From 80fc9c4024090cf24ee1cfd6ace2359bbf0b35be Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 13:08:19 +0900 Subject: [PATCH 1/4] Capture the source snapshot through the gate before a pause point reads it A pause point read the snapshot without asking the gate, so once the startup captures can be skipped it could read a snapshot that was never taken in this domain. The port now asks the gate first; a capture that throws reaches the pause-point command, as it does for a hot reload run, and the gate stays unmarked so the next read tries again. --- .../Editor/HotReload/HotReloadDomainTests.cs | 53 ++++++++++++++++++- .../HotReload/HotReloadCompositionRoot.cs | 4 +- .../Patching/HotReloadPausePointPort.cs | 15 +++++- .../HotReload/Shared/HotReloadConstants.cs | 1 + 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index 8a85bc8644..d2a11ac072 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -1004,6 +1004,52 @@ public void FindUnappliedRowForMethod_MethodWithAConstructedGenericParameter_Fin Assert.That(row.Label, Is.EqualTo(workerLabel)); } + /// + /// What: reading a verified snapshot source asks the gate to capture the snapshot first, so + /// a pause point set after both tools were disabled at domain load still has one to read. + /// + [Test] + public void GetVerifiedSnapshotSource_EnsuresTheSnapshotIsCaptured() + { + int ensureCount = 0; + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++); + + port.GetVerifiedSnapshotSource(FileOne, typeof(HotReloadDomainTests).Assembly.Location); + + Assert.That(ensureCount, Is.EqualTo(1)); + } + + /// + /// What: an empty path is answered with no source before the gate is asked to capture. + /// + [Test] + public void GetVerifiedSnapshotSource_WithAnEmptyPath_DoesNotEnsureTheCapture() + { + int ensureCount = 0; + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++); + + string source = port.GetVerifiedSnapshotSource(string.Empty, typeof(HotReloadDomainTests).Assembly.Location); + + Assert.That(source, Is.Null); + Assert.That(ensureCount, Is.EqualTo(0)); + } + + /// + /// What: a capture that throws reaches the pause point command instead of being turned into + /// a missing snapshot. + /// + [Test] + public void GetVerifiedSnapshotSource_WhenTheCaptureThrows_PassesTheExceptionOn() + { + InvalidOperationException captureFailure = new InvalidOperationException("The capture failed."); + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => throw captureFailure); + + InvalidOperationException thrown = Assert.Throws( + () => port.GetVerifiedSnapshotSource(FileOne, typeof(HotReloadDomainTests).Assembly.Location)); + + Assert.That(thrown, Is.SameAs(captureFailure)); + } + // Why a generated name: an artifact assembly is compiled under a name of its own, so a // fixture that reused a project assembly's name would not resolve the way production does. private static Assembly CreateIntroducedTypeAssembly() @@ -1025,7 +1071,12 @@ private static void AssertRow( private HotReloadPausePointPort CreatePort(Func readSourceContentHashOrNull) { - return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull); + return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull, () => { }); + } + + private HotReloadPausePointPort CreatePortWithSnapshotCapture(Action ensureSnapshotCaptured) + { + return new HotReloadPausePointPort(_access.Domain, _ => RecordedSourceHash, ensureSnapshotCaptured); } // Records a partially applied reload of FileOne that read RecordedWorkerSourcePath. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 6828ece287..11663b5e3d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -271,7 +271,9 @@ private static void Install(HotReloadServices services) HotReloadPausePointCoordination.HotReloadSide = new HotReloadPausePointPort( domain, - path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path)); + path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path), + () => services.SourceSnapshotCapture.EnsureCaptured( + HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); // Attaching last keeps the invariant across the gap: the resolver only starts // answering binds once every gateway already points at the domain behind it. domain.IntroducedTypeResolver.Resume(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs index 75c0009293..c6ad534821 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Reflection; using System.Reflection.Emit; @@ -16,13 +17,22 @@ internal sealed class HotReloadPausePointPort : IHotReloadPausePointPort { private readonly HotReloadDomain _domain; private readonly Func _readSourceContentHashOrNull; + private readonly Action _ensureSnapshotCaptured; // Why the hash reader is passed in: the hasher lives in the apply pipeline's assembly, // which this one may not reference, and the check must hash the way the worker did. - public HotReloadPausePointPort(HotReloadDomain domain, Func readSourceContentHashOrNull) + // Why the capture is passed in: the gate that captures the source snapshot belongs to the + // services the composition root installs, which this assembly does not reference. + public HotReloadPausePointPort( + HotReloadDomain domain, + Func readSourceContentHashOrNull, + Action ensureSnapshotCaptured) { + Debug.Assert(ensureSnapshotCaptured != null, "ensureSnapshotCaptured must not be null."); + _domain = domain; _readSourceContentHashOrNull = readSourceContentHashOrNull; + _ensureSnapshotCaptured = ensureSnapshotCaptured; } public MethodBase GetActiveShimForMethod(MethodBase method) @@ -54,6 +64,9 @@ public string GetVerifiedSnapshotSource(string projectRelativeFile, string dllPa return null; } + // Why: with both tools disabled at domain load nothing was captured, and a pause point + // is the first reader once the pause-point tool is enabled. + _ensureSnapshotCaptured(); return HotReloadSourceBaseline.LoadVerifiedSnapshotSource(projectRelativeFile, dllPath); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 1d552fa219..8c77b7338e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -695,6 +695,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string SourceSnapshotCaptureTriggerDomainLoad = "domain_load"; public const string SourceSnapshotCaptureTriggerFirstUpdateTick = "first_update_tick"; public const string SourceSnapshotCaptureTriggerApply = "apply"; + public const string SourceSnapshotCaptureTriggerPausePoint = "pause_point"; // What stopped a running warm-up or caller-note backfill, as named in the cancelledBy field // of hot_reload_warm_up_complete and hot_reload_caller_note_backfill_complete. From 3d16cb2d3446789fdb343d0c4303b663a519abaa Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 13:08:25 +0900 Subject: [PATCH 2/4] Skip the startup source snapshot capture and warm-up when no tool reads them The capture after a domain reload blocks the main thread on every compile even when the user disabled both tools that read the snapshot. The two startup captures now ask the tool settings and log a skip when hot-reload and pause-point are both disabled, leaving the gate unmarked so the first run or pause point after a tool is enabled captures. The warm-up is skipped when hot-reload is disabled. The compile-start record and the artifact sweep still run, because a later capture needs the compile start to tell compiled sources from edits made during the compile. --- .../HotReload/HotReloadEditorStartupTests.cs | 160 +++++++++++++++++- .../UnityCliLoopEditorBootstrapper.cs | 10 +- .../FirstPartyToolsEditorStartup.cs | 17 +- .../HotReload/HotReloadEditorStartup.cs | 66 +++++++- .../HotReload/Shared/HotReloadConstants.cs | 7 + 5 files changed, 244 insertions(+), 16 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs index 7cb294de6e..4a0288dae7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Newtonsoft.Json.Linq; @@ -31,7 +32,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(); + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled); } Assert.That(captureCount, Is.EqualTo(1), "captures"); @@ -62,7 +63,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { Assert.Throws( - () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands()); + () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled)); HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); @@ -70,5 +71,160 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro Assert.That(captureCount, Is.EqualTo(2), "captures"); } + + /// + /// What: with both tools that read the snapshot disabled, the capture before serving + /// commands is skipped and logged, and the skip leaves the capture undone, so the first + /// reader that runs later captures through the gate. + /// + [Test] + public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(NoToolEnabled); + + Assert.That(captureCount, Is.EqualTo(0), "captures while both readers are disabled"); + JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped); + Assert.That((string)skipped["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled)); + Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0), "captured entries"); + + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( + HotReloadConstants.SourceSnapshotCaptureTriggerApply); + } + + Assert.That(captureCount, Is.EqualTo(1), "captures once a reader asks the gate"); + } + + /// + /// What: one enabled tool that reads the snapshot is enough for the capture before serving + /// commands to run. + /// + [TestCase(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)] + [TestCase(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT)] + public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool) + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(name => name == enabledTool); + } + + Assert.That(captureCount, Is.EqualTo(1), "captures"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + } + + /// + /// What: the startup capture asks about both reader tools by the names the tool settings + /// store them under, hot-reload first. + /// + [Test] + public void CaptureAtStartupUnlessReadersAreDisabled_WhenNeitherIsEnabled_AsksBothReaderToolsBySettingsName() + { + VibeLogger.ClearMemoryLogs(); + List askedNames = new List(); + HotReloadSourceSnapshotCapture capture = CountingCapture(() => { }); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad, + name => + { + askedNames.Add(name); + return false; + }); + } + + Assert.That( + askedNames, + Is.EqualTo(new[] { UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT })); + } + + /// + /// What: the first update tick's capture is skipped the same way when both reader tools are + /// disabled, and the skip names that trigger. + /// + [Test] + public void CaptureAtStartupUnlessReadersAreDisabled_AtTheFirstUpdateTick_WhenNeitherIsEnabled_SkipsWithThatTrigger() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick, + NoToolEnabled); + } + + Assert.That(captureCount, Is.EqualTo(0), "captures"); + JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped); + Assert.That((string)skipped["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick)); + } + + /// + /// What: with the hot-reload tool disabled the warm-up is not started, and the skip is logged + /// with its reason. + /// + [Test] + public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsDisabled_DoesNotStartAndLogsTheReason() + { + VibeLogger.ClearMemoryLogs(); + int startCount = 0; + + HotReloadEditorStartup.StartWarmUpUnlessHotReloadIsDisabled( + name => name != UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, + () => startCount++); + + Assert.That(startCount, Is.EqualTo(0), "warm-up starts"); + JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpSkipped); + Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.WarmUpSkipReasonToolDisabled)); + } + + /// + /// What: with the hot-reload tool enabled the warm-up is started once and no skip is logged. + /// + [Test] + public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsEnabled_StartsOnce() + { + VibeLogger.ClearMemoryLogs(); + int startCount = 0; + + HotReloadEditorStartup.StartWarmUpUnlessHotReloadIsDisabled( + name => name == UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, + () => startCount++); + + Assert.That(startCount, Is.EqualTo(1), "warm-up starts"); + Assert.That(CountVibeEntries(HotReloadConstants.VibeLogWarmUpSkipped), Is.EqualTo(0), "skipped entries"); + } + + private static bool AllToolsEnabled(string toolName) => true; + + private static bool NoToolEnabled(string toolName) => false; + + private static HotReloadSourceSnapshotCapture CountingCapture(Action onCapture) + { + return new HotReloadSourceSnapshotCapture(() => + { + onCapture(); + return true; + }); + } + + private static int CountVibeEntries(string operation) + { + return JArray.Parse(VibeLogger.GetLogsForAi(operation)).Count; + } } } diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs index e811db4752..619394bea4 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs @@ -1,3 +1,5 @@ +using System; + using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Infrastructure; @@ -27,7 +29,8 @@ internal void Initialize() UnityCliLoopApplicationServices applicationServices = _applicationRegistration.Register(); ApplicationEditorStartup.Initialize(applicationServices.DomainReloadDetectionService); EditorRuntimeStateSnapshotSubscriber.InitializeForEditorStartup(); - FirstPartyToolsEditorStartup.Initialize(); + Func isToolEnabled = applicationServices.ToolSettingsUseCase.IsToolEnabled; + FirstPartyToolsEditorStartup.Initialize(isToolEnabled); InfrastructureEditorStartup.Initialize(applicationServices.EditorSettingsPort); PresentationEditorStartup.Initialize( applicationServices.EditorSettingsPort, @@ -45,8 +48,9 @@ internal void Initialize() // afterAssemblyReload, and every command except get-editor-status runs on the main // thread's update or tick, so no command of this domain is answered before this // returns — an edit made after `uloop compile` returns can no longer be captured as - // compiled source. - FirstPartyToolsEditorStartup.PrepareBeforeServingCommands(); + // compiled source. When both the hot-reload and pause-point tools are disabled, the + // capture is skipped here and runs on the first use of either tool instead. + FirstPartyToolsEditorStartup.PrepareBeforeServingCommands(isToolEnabled); } } } diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index 7a279d88b1..2bc77b7572 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -1,3 +1,5 @@ +using System; + using UnityEditor; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -8,14 +10,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public static class FirstPartyToolsEditorStartup { - public static void Initialize() + /// + /// Initializes every first-party tool's Editor startup work. + /// answers whether the user enabled a tool in the tool settings, by its settings name. + /// + public static void Initialize(Func isToolEnabled) { DomainReloadDisableScopeRecovery.RestoreForEditorStartup(); ExternalSceneChangeTracker.Initialize(); ControlPlayModeEditorStartup.Initialize(); PausePointEditorStartup.Initialize(); CompileEditorStartup.Initialize(); - HotReloadEditorStartup.Initialize(); + HotReloadEditorStartup.Initialize(isToolEnabled); WatchEditorStartup.Initialize(); ExecuteDynamicCodeEditorStartup.Initialize(); GetLogsEditorStartup.Initialize(); @@ -35,9 +41,10 @@ public static void Initialize() /// /// Runs the first-party tool work that has to finish before this domain answers its first - /// uloop command. Called once, at the end of the Editor startup. + /// uloop command. Called once, at the end of the Editor startup. + /// answers whether the user enabled a tool in the tool settings, by its settings name. /// - public static void PrepareBeforeServingCommands() + public static void PrepareBeforeServingCommands(Func isToolEnabled) { // Why not in an import worker: it never serves commands (the server does not start // there) and it shares Library with the main Editor, so a capture there would only @@ -47,7 +54,7 @@ public static void PrepareBeforeServingCommands() return; } - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(); + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(isToolEnabled); } public static void ResetServerScopedServices() diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 881fd704b6..b3e05786d1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using UnityEditor; @@ -11,8 +12,10 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools // root only depends on the bundled-tool facade. internal static class HotReloadEditorStartup { - public static void Initialize() + public static void Initialize(Func isToolEnabled) { + Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + // Why not EditorApplication.delayCall: a cold-start session that hits Unity's native // "Scripts have compiler errors" dialog never flushes delayCall again for the rest of // that process's lifetime, even for later registrations — while @@ -23,7 +26,8 @@ public static void Initialize() void CaptureOnFirstUpdateTick() { EditorApplication.update -= CaptureOnFirstUpdateTick; - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick, isToolEnabled); } // Why a callback of its own rather than a line in the capture above: an exception in @@ -42,7 +46,8 @@ void SweepArtifactsOnFirstUpdateTick() void StartWarmUpOnFirstUpdateTick() { EditorApplication.update -= StartWarmUpOnFirstUpdateTick; - HotReloadCompositionRoot.Services.WarmUp.Start(); + StartWarmUpUnlessHotReloadIsDisabled( + isToolEnabled, () => HotReloadCompositionRoot.Services.WarmUp.Start()); } // The services are rebuilt here rather than on first use because the introduced type @@ -91,10 +96,59 @@ void StartWarmUpOnFirstUpdateTick() /// Captures the source snapshot of the current compile before this domain answers any uloop /// command, so an edit made after the compile returned is never captured as compiled source. /// - internal static void CaptureSourceSnapshotBeforeServingCommands() + internal static void CaptureSourceSnapshotBeforeServingCommands(Func isToolEnabled) + { + CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad, isToolEnabled); + } + + /// + /// Captures the source snapshot at a startup trigger unless both tools that read it are + /// disabled in the tool settings. + /// + internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Func isToolEnabled) { - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( - HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + Debug.Assert(!string.IsNullOrEmpty(trigger), "trigger must not be null or empty."); + Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + + // Why only the startup triggers ask: a reader that runs (a hot reload run, a pause point) + // belongs to an enabled tool and captures through the gate itself. A capture that comes + // late stays correct, because a source written after the compile started is checked + // against the PDB. + bool anyReaderEnabled = isToolEnabled(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD) + || isToolEnabled(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT); + if (!anyReaderEnabled) + { + // Why the gate is not marked: a tool enabled later in this domain must still + // capture through it before its first read. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotSkipped, + "Hot reload source snapshot capture skipped.", + new { trigger, reason = HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled }); + return; + } + + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(trigger); + } + + /// + /// Starts the warm-up unless the hot-reload tool is disabled in the tool settings. + /// + internal static void StartWarmUpUnlessHotReloadIsDisabled(Func isToolEnabled, Action startWarmUp) + { + Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + Debug.Assert(startWarmUp != null, "startWarmUp must not be null."); + + if (!isToolEnabled(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogWarmUpSkipped, + "Hot reload warm-up skipped.", + new { reason = HotReloadConstants.WarmUpSkipReasonToolDisabled }); + return; + } + + startWarmUp(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 8c77b7338e..da09f64c42 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -697,6 +697,13 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string SourceSnapshotCaptureTriggerApply = "apply"; public const string SourceSnapshotCaptureTriggerPausePoint = "pause_point"; + // Why the capture at a startup trigger did not run, as logged in + // hot_reload_source_snapshot_skipped; and the warm-up's matching reason in + // hot_reload_warm_up_skipped. + public const string VibeLogSourceSnapshotSkipped = "hot_reload_source_snapshot_skipped"; + public const string SourceSnapshotSkipReasonToolsDisabled = "tools_disabled"; + public const string WarmUpSkipReasonToolDisabled = "tool_disabled"; + // What stopped a running warm-up or caller-note backfill, as named in the cancelledBy field // of hot_reload_warm_up_complete and hot_reload_caller_note_backfill_complete. public const string WarmUpCancelledByRun = "run"; From ea7b82ca2950e70e1d0c019a49893a404c9434f4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 13:08:29 +0900 Subject: [PATCH 3/4] Document the skipped source snapshot capture and its log entries --- docs/hot-reload.md | 5 ++++- docs/vibe-logs.md | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 26352e61cc..8e8fcc05f9 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -78,7 +78,10 @@ records each copied source's length and write time, so a run after a domain relo unchanged sibling by a stat instead of reading it; a source written since the compile started whose bytes do not match the PDB checksum carries a mark that it was edited after the compile, and the default selection, the sibling scan and the skip check treat a marked source as -changed), +changed. When both the hot-reload and pause-point tools are disabled in the tool settings, the +capture after a domain reload is skipped; the first hot reload run or pause point after either +tool is enabled captures it, and any source written since the compile started is then checked +against the PDB), `Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the first run after a domain reload does not walk the PDB again while they still match), and diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 60d022fedd..5aedbe7d6b 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -93,12 +93,18 @@ command ran against: `hot_reload_source_snapshot_captured` appears once per domain when the source snapshot capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or `apply` only when the capture at domain load threw or Unity listed no compilation assembly - yet) and `captureMs`. + yet; `apply` or `pause_point` when the captures at startup were skipped and a hot reload run + or a pause point is the first to read the snapshot) and `captureMs`. When both the hot-reload + and pause-point tools are disabled in the tool settings, the captures at `domain_load` and + `first_update_tick` each write `hot_reload_source_snapshot_skipped` with that `trigger` and + `reason` `tools_disabled` instead, and the first hot reload run or pause point after either + tool is enabled captures it. After a domain reload, the warm-up writes one `hot_reload_warm_up_complete` with the target assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy` (`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one `hot_reload_warm_up_skipped` with its `reason` (`no_targets`, `compiling`, `updating`, - `no_compiled_assembly`, `run_started_first`). `cancelled` covers both an item that never started + `no_compiled_assembly`, `run_started_first`, `tool_disabled` when the hot-reload tool is + disabled in the tool settings). `cancelled` covers both an item that never started (`ms` 0) and one stopped between its units (one dll or one transform worker request each) with units left, with the time it ran. The items run in the order `publicized_targets`, `call_sites`, `referenced_method_sets`, `transform_worker`, `pdb_documents`. The warm-up's transform worker From 3fec3aed6c4137922e3ae4f71e7a20b5dde977a3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 14:11:12 +0900 Subject: [PATCH 4/4] Pin that the installed pause point side captures through the gate with its own trigger The port tests hand the capture in directly, so replacing the composition root's capture with a no-op, or passing another trigger, passed every test. --- .../HotReloadCompositionRootTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs index 73c2a6fe6e..2707d8012b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs @@ -4,6 +4,8 @@ using HarmonyLib; +using Newtonsoft.Json.Linq; + using NUnit.Framework; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -101,6 +103,35 @@ public void UninstallInstalledServices_LeavesTheSiblingToolsReadingNoDomainInsta Assert.That(HotReloadPausePointCoordination.HotReloadSide, Is.Not.SameAs(installedPort)); } + /// + /// The pause point side the composition root installs captures the source snapshot + /// through the installed gate, under the pause_point trigger, before it reads the snapshot. + /// + [Test] + public void Install_PausePointSide_CapturesThroughTheInstalledGateWithThePausePointTrigger() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + captureCount++; + return true; + }); + string source; + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + source = HotReloadPausePointCoordination.HotReloadSide.GetVerifiedSnapshotSource( + "Assets/NoSuchSource.cs", + "Library/ScriptAssemblies/NoSuchAssembly.dll"); + } + + Assert.That(captureCount, Is.EqualTo(1), "captures"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); + Assert.That(source, Is.Null, "no snapshot exists for a dll that does not exist"); + } + /// /// The copies that swap one stage keep the same wired-value persistence and restore /// refresh, so a wiring recorded before the swap can still be restored after it and an