From 45e831b09b5161759928d740da9b9d9304ee5897 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 12 Oct 2026 00:40:34 +0900 Subject: [PATCH 1/6] Capture the hot reload source snapshot on a pool thread behind a one-task gate The capture after each domain load took 1.9-2.3 s of main thread time, so the Editor and the first commands after a compile waited on it. The main thread now only reads the inputs Unity's APIs provide, and the file copy runs on the thread pool. - The gate keeps one in-flight capture task. Callers share it, a finished capture is never repeated, and a failed or cancelled one is left for the next caller. - get-compile-status holds the compile completion while a capture runs, capped at 30 s, so a command sent after uloop compile still sees the snapshot of the compiled sources. - hot-reload awaits the capture instead of blocking the main thread. A pause point re-armed on the first tick returns a live file the PDB confirms instead of waiting for the whole capture. - A compile start or an upcoming domain reload cancels the in-flight capture. - The capture logs started, captured, cancelled, failed, empty, and hold-cap entries. --- .../HotReloadCompositionRootTests.cs | 6 +- .../HotReload/HotReloadDefaultFilesTests.cs | 6 +- .../Editor/HotReload/HotReloadDomainTests.cs | 2 +- .../HotReload/HotReloadEditorStartupTests.cs | 25 +- .../HotReloadPatcherContractTests.cs | 2 +- .../Editor/HotReload/HotReloadPatcherTests.cs | 2 +- ...tReloadSourceSnapshotCaptureTestDoubles.cs | 98 ++++ ...adSourceSnapshotCaptureTestDoubles.cs.meta | 11 + .../HotReloadSourceSnapshotCaptureTests.cs | 440 +++++++++++++++--- .../HotReload/HotReloadSourceSnapshotTests.cs | 5 +- .../PausePointScriptPathFormTests.cs | 9 +- .../HotReload/HotReloadCompositionRoot.cs | 21 +- .../HotReload/HotReloadEditorStartup.cs | 14 +- .../HotReload/HotReloadTools.cs | 11 +- .../HotReload/HotReloadWarmUpEditorHooks.cs | 9 +- .../HotReload/Shared/HotReloadConstants.cs | 9 +- .../Shared/HotReloadSourceSnapshotCapture.cs | 264 ++++++++++- .../Shared/HotReloadSourceSnapshotter.cs | 11 +- 18 files changed, 810 insertions(+), 135 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs index 2707d8012..ed3ec1cdf 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs @@ -112,7 +112,7 @@ public void Install_PausePointSide_CapturesThroughTheInstalledGateWithThePausePo { VibeLogger.ClearMemoryLogs(); int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; @@ -213,7 +213,7 @@ private static HotReloadServices CreateServicesSharing(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), installed.WarmUp, installed.CompiledCallers); } @@ -228,7 +228,7 @@ private static HotReloadServices CreateServicesOwning(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), HotReloadWarmUpTestDoubles.CreateInert(), HotReloadCompiledCallers.CreateProduction()); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs index 77b610072..dc3189b1c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs @@ -358,7 +358,7 @@ public async Task ExecuteAsync_WhenFilesAreOmitted_CapturesTheSourceSnapshotBefo { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; })); int captureCountAtDetection = -1; using IDisposable detectorScope = HotReloadServicesTestScope.BeginWithChangeDetector( new HotReloadStubChangeDetector(() => @@ -387,7 +387,7 @@ public async Task ExecuteAsync_WithFiles_CapturesTheSourceSnapshotBeforeTheRun() { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; })); int captureCountAtRun = -1; using IDisposable orchestratorScope = HotReloadServicesTestScope.BeginWithOrchestrator( new HotReloadStubOrchestrator((files, ignoredCt) => @@ -409,7 +409,7 @@ public async Task ExecuteAsync_ForStatus_DoesNotCaptureTheSourceSnapshot() { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; })); HotReloadResponse response = await ExecuteAsync(new JObject { ["Status"] = true }); diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index 06638df51..6575ff9e7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -1078,7 +1078,7 @@ public void GetVerifiedSnapshotSource_WhileCaptureRuns_ReturnsTheLiveFileWhenItM /// makes the port wait for the capture once and read the snapshot instead. /// [Test] - public void GetVerifiedSnapshotSource_WhileCaptureRuns_WaitsWhenTheLiveFileDiffersFromThePdb() + public void GetVerifiedSnapshotSource_WhileCaptureRuns_WaitsWhenThePdbCannotConfirmTheLiveFile() { int ensureCount = 0; HotReloadPausePointPort port = new HotReloadPausePointPort( diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs index 4a0288dae..c6faac07b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -24,7 +24,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn { VibeLogger.ClearMemoryLogs(); int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; @@ -41,15 +41,16 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn } /// - /// What: a capture that throws before serving commands passes the exception on and stays - /// undone, so the first update tick's capture runs it again. + /// What: a capture that fails before serving commands does not throw into the domain load, + /// which never waits for it; it logs one failed entry and stays undone, so the first update + /// tick's capture runs it again. /// [Test] - public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_ThrowsAndLeavesItForTheNextCall() + public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureFails_LogsFailedAndLeavesItForTheNextCall() { VibeLogger.ClearMemoryLogs(); int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; if (captureCount == 1) @@ -62,14 +63,18 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - Assert.Throws( + Assert.DoesNotThrow( () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled)); + JObject failed = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptureFailed); + Assert.That((string)failed["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( - HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick, AllToolsEnabled); } Assert.That(captureCount, Is.EqualTo(2), "captures"); + Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(1), "captured entries"); } /// @@ -94,7 +99,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled)); Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0), "captured entries"); - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCapturedBlocking( HotReloadConstants.SourceSnapshotCaptureTriggerApply); } @@ -215,7 +220,7 @@ public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsEnabled_StartsOn private static HotReloadSourceSnapshotCapture CountingCapture(Action onCapture) { - return new HotReloadSourceSnapshotCapture(() => + return HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { onCapture(); return true; diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs index 18dd89378..6a7257e47 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs @@ -216,7 +216,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), HotReloadWarmUpTestDoubles.CreateInert(), HotReloadCompiledCallers.CreateProduction())); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs index 3f9b226e6..69fb92635 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs @@ -90,7 +90,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), HotReloadWarmUpTestDoubles.CreateInert(), HotReloadCompiledCallers.CreateProduction())); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs new file mode 100644 index 000000000..bba273944 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Ways to build the source snapshot capture gate for tests, so no test runs a capture on a + /// pool thread. + /// + internal static class HotReloadSourceSnapshotCaptureTestDoubles + { + /// + /// A gate that runs on the calling thread the moment it starts, so + /// the capture has finished when the call that started it returns. + /// + internal static HotReloadSourceSnapshotCapture CompletingSynchronously(Func capture) + { + return new HotReloadSourceSnapshotCapture( + FixedInputs, + (inputs, cancellationToken) => capture(), + RunNow, + () => 0); + } + + /// Inputs that no test capture reads. + internal static HotReloadSnapshotCaptureInputs FixedInputs() + { + return new HotReloadSnapshotCaptureInputs( + "project-root", + HotReloadCompileStart.Unknown, + Array.Empty(), + StringComparison.Ordinal, + Array.Empty(), + 0); + } + + // Why the exception goes into the task: Task.Run does the same, and the gate must see a + // failed task rather than a throwing start. + private static Task RunNow(Func work) + { + try + { + return Task.FromResult(work()); + } + catch (Exception exception) + { + return Task.FromException(exception); + } + } + } + + /// + /// A stand-in for the pool thread that holds the started work until the test runs it, so a + /// test controls when a capture is in flight and when it finishes. + /// + internal sealed class HeldBackgroundRun + { + private Func _work; + private TaskCompletionSource _completion; + + internal int StartCount { get; private set; } + + internal Task Run(Func work) + { + StartCount++; + _work = work; + _completion = new TaskCompletionSource(); + return _completion.Task; + } + + /// + /// Runs the held work on the calling thread and finishes its task with the outcome, the way + /// Task.Run would: a thrown exception, cancellation included, faults the task. + /// + internal void RunHeldWork() + { + NUnit.Framework.Assert.That(_work, NUnit.Framework.Is.Not.Null, "no work was started"); + Func work = _work; + TaskCompletionSource completion = _completion; + _work = null; + _completion = null; + bool result; + try + { + result = work(); + } + catch (Exception exception) + { + completion.SetException(exception); + return; + } + + completion.SetResult(result); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta new file mode 100644 index 000000000..cb41f6562 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8ffcd35b460eb4d97994e62b120fb217 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs index 1da3d54d1..196c9394d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json.Linq; @@ -10,42 +12,304 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// EditMode coverage for capturing the source snapshot once per domain. + /// EditMode coverage for the gate that captures the source snapshot once per domain off the + /// main thread. The pool thread is a held stand-in, so each test decides when a capture ends. /// public class HotReloadSourceSnapshotCaptureTests { + private const string Trigger = HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad; + private static readonly long HoldCapTicks = TimeSpan.FromSeconds(30).Ticks; + + private HeldBackgroundRun _background; + private int _inputReads; + private int _captureRuns; + private Func _captureBody; + private long _nowTicks; + + [SetUp] + public void SetUp() + { + VibeLogger.ClearMemoryLogs(); + _background = new HeldBackgroundRun(); + _inputReads = 0; + _captureRuns = 0; + _captureBody = _ => true; + _nowTicks = 0; + } + + /// + /// What: starting reads the inputs once on the calling thread, hands the capture to the + /// background, and returns before the capture runs, logging how long the main thread spent. + /// + [Test] + public void StartIfNeeded_NotStarted_ReadsInputsOnceAndReturnsBeforeTheCaptureFinishes() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + + Task task = gate.StartIfNeeded(Trigger); + + Assert.That(_inputReads, Is.EqualTo(1), "input reads"); + Assert.That(_captureRuns, Is.EqualTo(0), "captures run before the background ran them"); + Assert.That(task.IsCompleted, Is.False); + Assert.That(gate.IsInFlight, Is.True); + JObject started = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptureStarted); + Assert.That((string)started["trigger"], Is.EqualTo(Trigger)); + Assert.That(started["mainThreadMs"].Type, Is.EqualTo(JTokenType.Integer)); + + _background.RunHeldWork(); + + Assert.That(task.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(_captureRuns, Is.EqualTo(1)); + Assert.That(gate.IsInFlight, Is.False); + } + + /// + /// What: a second caller while a capture runs gets the running capture's task and starts no + /// second capture, so two captures never touch one snapshot directory at once. + /// + [Test] + public void StartIfNeeded_WhileInFlight_ReturnsTheSameTaskAndDoesNotStartAnother() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + + Task first = gate.StartIfNeeded(Trigger); + Task second = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(second, Is.SameAs(first)); + Assert.That(_background.StartCount, Is.EqualTo(1)); + Assert.That(_inputReads, Is.EqualTo(1)); + _background.RunHeldWork(); + } + + /// + /// What: after a capture that threw, the next caller starts a new one, and the failure is + /// logged with its exception type and leaves no captured entry. + /// + [Test] + public void StartIfNeeded_AfterAFailedCapture_StartsANewOne() + { + _captureBody = _ => throw new InvalidOperationException("The capture failed."); + HotReloadSourceSnapshotCapture gate = CreateGate(); + Task failed = gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + _captureBody = _ => true; + Task retried = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(failed.IsFaulted, Is.True); + Assert.That(retried, Is.Not.SameAs(failed)); + Assert.That(_background.StartCount, Is.EqualTo(2)); + JObject failure = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptureFailed); + Assert.That((string)failure["trigger"], Is.EqualTo(Trigger)); + Assert.That((string)failure["error"], Is.EqualTo(nameof(InvalidOperationException))); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + _background.RunHeldWork(); + } + + /// + /// What: after a capture that found no compiled assembly, the next caller starts a new one, + /// and the empty capture is logged as such rather than as captured. + /// + [Test] + public void StartIfNeeded_AfterAnEmptyCapture_StartsANewOne() + { + _captureBody = _ => false; + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(_background.StartCount, Is.EqualTo(2)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureEmpty), Is.EqualTo(1)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + _background.RunHeldWork(); + } + + /// + /// What: after a cancelled capture, the next caller starts a new one, and the cancellation + /// is logged as cancelled rather than as a failure. + /// + [Test] + public void StartIfNeeded_AfterACancelledCapture_StartsANewOne() + { + _captureBody = cancellationToken => + { + cancellationToken.ThrowIfCancellationRequested(); + return true; + }; + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + gate.CancelInFlight(); + _background.RunHeldWork(); + + gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(_background.StartCount, Is.EqualTo(2)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled), Is.EqualTo(1)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureFailed), Is.EqualTo(0)); + _background.RunHeldWork(); + } + + /// + /// What: once a capture succeeded, later callers get a completed task without reading the + /// inputs or starting anything, and the single captured entry keeps the first trigger. + /// + [Test] + public void StartIfNeeded_AfterSuccess_ReturnsACompletedTaskWithoutReadingInputs() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + Task later = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + + Assert.That(later.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(_inputReads, Is.EqualTo(1)); + Assert.That(_background.StartCount, Is.EqualTo(1)); + JObject captured = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)captured["trigger"], Is.EqualTo(Trigger)); + Assert.That(captured["captureMs"].Type, Is.EqualTo(JTokenType.Integer)); + } + + /// + /// What: an exception while reading the inputs reaches the caller, and the gate stays not + /// started, so neither a capture nor a hold is left behind and the next caller tries again. + /// + [Test] + public void StartIfNeeded_WhenReadingInputsThrows_RethrowsAndStaysNotStarted() + { + InvalidOperationException failure = new InvalidOperationException("The inputs could not be read."); + bool throwOnRead = true; + HotReloadSourceSnapshotCapture gate = new HotReloadSourceSnapshotCapture( + () => + { + _inputReads++; + if (throwOnRead) + { + throw failure; + } + + return HotReloadSourceSnapshotCaptureTestDoubles.FixedInputs(); + }, + RunCapture, + _background.Run, + () => _nowTicks); + + InvalidOperationException thrown = + Assert.Throws(() => gate.StartIfNeeded(Trigger)); + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(gate.IsInFlight, Is.False); + Assert.That(gate.IsHoldingCompileCompletion, Is.False); + Assert.That(_background.StartCount, Is.EqualTo(0)); + + throwOnRead = false; + gate.StartIfNeeded(Trigger); + Assert.That(_background.StartCount, Is.EqualTo(1)); + _background.RunHeldWork(); + } + + /// + /// What: an apply that arrives while a capture runs completes only after that capture does. + /// + [Test] + public async Task EnsureCapturedAsync_WhileInFlight_CompletesOnlyAfterTheCapture() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + + Task ensured = gate.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + Assert.That(ensured.IsCompleted, Is.False); + + _background.RunHeldWork(); + await ensured; + + Assert.That(ensured.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(_background.StartCount, Is.EqualTo(1)); + } + + /// + /// What: an apply waiting on a capture that throws gets that exception, and the gate is not + /// marked captured, so the next caller starts again. + /// + [Test] + public async Task EnsureCapturedAsync_WhenTheCaptureThrows_RethrowsAndLeavesItUnmarked() + { + InvalidOperationException failure = new InvalidOperationException("The capture failed."); + _captureBody = _ => throw failure; + HotReloadSourceSnapshotCapture gate = CreateGate(); + Task ensured = gate.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + + _background.RunHeldWork(); + Exception thrown = null; + try + { + await ensured; + } + catch (InvalidOperationException exception) + { + thrown = exception; + } + + Assert.That(thrown, Is.SameAs(failure)); + gate.StartIfNeeded(Trigger); + Assert.That(_background.StartCount, Is.EqualTo(2)); + _background.RunHeldWork(); + } + /// - /// What: once a capture has completed, a later call does not run it again. + /// What: a pause point that blocks after the capture finished returns without starting + /// another capture or reading the inputs again. /// [Test] - public void EnsureCaptured_CalledTwice_RunsTheCaptureOnce() + public void EnsureCapturedBlocking_AfterTheCaptureFinished_ReturnsWithoutStartingAnother() { - int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint); + + Assert.That(_background.StartCount, Is.EqualTo(1)); + Assert.That(_inputReads, Is.EqualTo(1)); + } + + /// + /// What: a pause point that finds no capture started starts one and returns once it ran. + /// + [Test] + public void EnsureCapturedBlocking_NotStarted_StartsAndWaits() + { + int captureRuns = 0; + HotReloadSourceSnapshotCapture gate = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { - captureCount++; + captureRuns++; return true; }); - capture.EnsureCaptured("test"); - capture.EnsureCaptured("test"); + gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint); - Assert.That(captureCount, Is.EqualTo(1)); + Assert.That(captureRuns, Is.EqualTo(1)); + Assert.That(gate.IsInFlight, Is.False); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(1)); } /// - /// What: a capture that throws passes the exception to the caller and is not marked done, so - /// the next call runs it again; once that run completes, later calls do not. + /// What: a blocking reader waits for the capture it starts and gets its exception; the + /// capture ran once and left the gate unmarked, so the next caller runs it again. /// [Test] - public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall() + public void EnsureCapturedBlocking_WhenTheCaptureThrows_RethrowsAndLeavesItUnmarked() { - int captureCount = 0; InvalidOperationException failure = new InvalidOperationException("The capture failed."); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + int captureRuns = 0; + HotReloadSourceSnapshotCapture gate = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { - captureCount++; - if (captureCount == 1) + captureRuns++; + if (captureRuns == 1) { throw failure; } @@ -53,96 +317,136 @@ public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall() return true; }); - InvalidOperationException thrown = - Assert.Throws(() => capture.EnsureCaptured("test")); - Assert.That(thrown, Is.SameAs(failure)); - - Assert.DoesNotThrow(() => capture.EnsureCaptured("test")); - Assert.That(captureCount, Is.EqualTo(2)); + InvalidOperationException thrown = Assert.Throws( + () => gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); - capture.EnsureCaptured("test"); - Assert.That(captureCount, Is.EqualTo(2)); + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(captureRuns, Is.EqualTo(1), "captures run by the blocking call"); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint); + Assert.That(captureRuns, Is.EqualTo(2), "captures after the failure"); } /// - /// What: a capture that runs to completion writes one captured entry naming its trigger and how long it took. + /// What: the compile completion is held while a capture has run for less than 30 seconds. /// [Test] - public void EnsureCaptured_WhenTheCaptureRuns_WritesOneCapturedEntryWithItsTrigger() + public void IsHoldingCompileCompletion_WithinTheCap_IsTrue() { - VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true); + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + _nowTicks = HoldCapTicks - 1; - JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); - Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); - Assert.That(context["captureMs"].Type, Is.EqualTo(JTokenType.Integer), "captureMs type"); - Assert.That((long)context["captureMs"], Is.GreaterThanOrEqualTo(0), "captureMs"); + Assert.That(gate.IsHoldingCompileCompletion, Is.True); + _background.RunHeldWork(); } /// - /// What: a call that finds the capture already done writes no second entry, so the entry keeps the first trigger. + /// What: once a capture has run for 30 seconds the compile completion is no longer held, and + /// that is logged once however often it is asked; the capture still counts as in flight. /// [Test] - public void EnsureCaptured_WhenAlreadyCaptured_WritesNoSecondEntry() + public void IsHoldingCompileCompletion_PastTheCap_IsFalseAndLogsOnce() { - VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true); + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + _nowTicks = HoldCapTicks; - JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); - Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + Assert.That(gate.IsHoldingCompileCompletion, Is.False); + Assert.That(gate.IsHoldingCompileCompletion, Is.False); + Assert.That(gate.IsInFlight, Is.True); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureHoldCapExceeded), Is.EqualTo(1)); + _background.RunHeldWork(); } /// - /// What: a capture that throws writes no captured entry. + /// What: nothing holds the compile completion before a capture starts, after one succeeded, + /// after one failed, or after one was cancelled. /// [Test] - public void EnsureCaptured_WhenTheCaptureThrows_WritesNoEntry() + public void IsHoldingCompileCompletion_WhenNothingRuns_IsFalse() { - VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => - { - throw new InvalidOperationException("The capture failed."); - }); + HotReloadSourceSnapshotCapture notStarted = CreateGate(); + Assert.That(notStarted.IsHoldingCompileCompletion, Is.False, "not started"); + + HotReloadSourceSnapshotCapture succeeded = CreateGate(); + succeeded.StartIfNeeded(Trigger); + _background.RunHeldWork(); + Assert.That(succeeded.IsHoldingCompileCompletion, Is.False, "succeeded"); - Assert.Throws( - () => capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + _captureBody = _ => throw new InvalidOperationException("The capture failed."); + HotReloadSourceSnapshotCapture failed = CreateGate(); + failed.StartIfNeeded(Trigger); + _background.RunHeldWork(); + Assert.That(failed.IsHoldingCompileCompletion, Is.False, "failed"); - Assert.That(CountCapturedEntries(), Is.EqualTo(0)); + _captureBody = cancellationToken => + { + cancellationToken.ThrowIfCancellationRequested(); + return true; + }; + HotReloadSourceSnapshotCapture cancelled = CreateGate(); + cancelled.StartIfNeeded(Trigger); + cancelled.CancelInFlight(); + _background.RunHeldWork(); + Assert.That(cancelled.IsHoldingCompileCompletion, Is.False, "cancelled"); } /// - /// What: a capture that saw no compilation assembly is not marked done and writes no entry, - /// so the next call runs it again and the entry names that call's trigger. + /// What: cancelling signals the running capture's token without waiting for it, and the + /// capture that stops leaves the gate not captured and not in flight. /// [Test] - public void EnsureCaptured_WhenTheCaptureSawNoAssembly_RunsItAgainOnTheNextCall() + public void CancelInFlight_CancelsTheTokenAndLeavesTheGateUnmarked() { - VibeLogger.ClearMemoryLogs(); - int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + CancellationToken seenToken = CancellationToken.None; + _captureBody = cancellationToken => { - captureCount++; - return captureCount > 1; - }); + seenToken = cancellationToken; + cancellationToken.ThrowIfCancellationRequested(); + return true; + }; + HotReloadSourceSnapshotCapture gate = CreateGate(); + Task task = gate.StartIfNeeded(Trigger); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); - Assert.That(CountCapturedEntries(), Is.EqualTo(0), "entries after the first call"); + gate.CancelInFlight(); + Assert.That(gate.IsInFlight, Is.True, "in flight until the capture stops"); + _background.RunHeldWork(); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + Assert.That(seenToken.IsCancellationRequested, Is.True); + // Faulted, not Canceled: the background runner is given no token, as Task.Run is not, + // so the token's exception faults the task the way it does in production. + Assert.That(task.IsFaulted, Is.True); + Assert.That(task.Exception.GetBaseException(), Is.InstanceOf()); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled), Is.EqualTo(1)); + Assert.That(gate.IsInFlight, Is.False); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + } - Assert.That(captureCount, Is.EqualTo(2), "captures"); - JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); - Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick)); + private HotReloadSourceSnapshotCapture CreateGate() + { + return new HotReloadSourceSnapshotCapture( + () => + { + _inputReads++; + return HotReloadSourceSnapshotCaptureTestDoubles.FixedInputs(); + }, + RunCapture, + _background.Run, + () => _nowTicks); + } + + private bool RunCapture(HotReloadSnapshotCaptureInputs inputs, CancellationToken cancellationToken) + { + _captureRuns++; + return _captureBody(cancellationToken); } - private static int CountCapturedEntries() + private static int CountEntries(string operation) { - return JArray.Parse(VibeLogger.GetLogsForAi(HotReloadConstants.VibeLogSourceSnapshotCaptured)).Count; + return JArray.Parse(VibeLogger.GetLogsForAi(operation)).Count; } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 52fd7ec15..0b922a8a6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Threading; using Mono.Cecil; using Mono.Cecil.Cil; @@ -180,7 +181,7 @@ public void LoadVerifiedSnapshotSource_ForPredefinedEditorAssembly_ReturnsOnDisk [Test] public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsTheVerifiedSource() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); string dllPath = Path.Combine( projectRoot, @@ -203,7 +204,7 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase() { VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown); string[] fields = diff --git a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs index e2f9dce99..11a13c817 100644 --- a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs +++ b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using NUnit.Framework; @@ -55,7 +56,7 @@ public void TearDown() [Test] public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedFileBasis() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); PausePointResponse response = Enable(FixtureAssetPath, SecondStatementLine); @@ -69,7 +70,7 @@ public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedF [Test] public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAssetPath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); PausePointResponse response = Enable(FixturePhysicalPath, SecondStatementLine); @@ -82,7 +83,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAss [Test] public void Enable_PackageSourceGivenByItsAbsolutePhysicalPath_ArmsTheSameMarkerAsTheAssetPath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); PausePointResponse response = Enable(ToAbsolutePath(FixturePhysicalPath), SecondStatementLine); @@ -130,7 +131,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPathWithAShiftedSnapshot_MapsT [Test] public void Enable_AssetsSourceGivenByItsAbsolutePath_ArmsTheSameMarkerAsTheRelativePath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); int line = FindLineNumber(File.ReadAllText(ToAbsolutePath(AssetsFixturePath)), AssetsFixtureStatementText); Assert.That(line, Is.GreaterThan(0)); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 2c0b0e4cc..953e825da 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using HarmonyLib; @@ -72,12 +73,22 @@ internal static HotReloadServices CreateProductionServicesWith( TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture( - () => HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread())), + CreateProductionSourceSnapshotCapture(), warmUp, compiledCallers); } + // Why only the inputs are read before the pool: they are the capture's only Unity calls, and + // Unity allows them on the main thread alone. The capture itself reads files and PDBs. + private static HotReloadSourceSnapshotCapture CreateProductionSourceSnapshotCapture() + { + return new HotReloadSourceSnapshotCapture( + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + HotReloadSourceSnapshotter.CaptureAfterDomainReload, + work => Task.Run(work), + () => DateTime.UtcNow.Ticks); + } + private static HotReloadWarmUp CreateProductionWarmUp(HotReloadCompiledCallers compiledCallers) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); @@ -273,11 +284,9 @@ private static void Install(HotReloadServices services) new HotReloadPausePointPort( domain, path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path), - () => services.SourceSnapshotCapture.EnsureCaptured( + () => services.SourceSnapshotCapture.EnsureCapturedBlocking( HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint), - // The capture still runs synchronously inside EnsureCaptured, so none is ever in - // flight when a pause point asks. - () => false); + () => services.SourceSnapshotCapture.IsInFlight); // 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/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index b3e05786d..8900c68eb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -20,9 +20,9 @@ public static void Initialize(Func isToolEnabled) // "Scripts have compiler errors" dialog never flushes delayCall again for the rest of // that process's lifetime, even for later registrations — while // EditorApplication.update keeps ticking (see SetupWizardWindow.cs:56-70). The - // snapshot is captured at the end of the domain load; this tick retries only when that - // capture threw or Unity listed no compilation assembly yet, and the gate does nothing - // when it already succeeded. + // capture starts at the end of the domain load; this tick starts another only when that + // one failed, found no compiled assembly, or was cancelled, and the gate does nothing + // when it is still running or already succeeded. void CaptureOnFirstUpdateTick() { EditorApplication.update -= CaptureOnFirstUpdateTick; @@ -63,6 +63,10 @@ void StartWarmUpOnFirstUpdateTick() // null delegate as "nothing to lose". HotReloadRuntimeChangeCoordination.GetActiveRuntimeChangeCount = () => HotReloadCompositionRoot.Services.Domain.CountActiveChanges().RuntimeChangeTotal; + // Why here, like the count above: get-compile-status reads it on every poll, which can + // come before anything in this assembly is touched. + HotReloadSnapshotCaptureCoordination.IsHoldingCompileCompletion = + () => HotReloadCompositionRoot.Services.SourceSnapshotCapture.IsHoldingCompileCompletion; // Why here, like the count above: the dynamic-code tool reads it after each server // reset, which can come before anything in this assembly is touched. string warmUpProjectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); @@ -128,7 +132,9 @@ internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Fu return; } - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(trigger); + // Why the task is not awaited: the domain load must not wait for the capture, and the + // gate logs how it ended. A caller that needs the snapshot waits for it itself. + _ = HotReloadCompositionRoot.Services.SourceSnapshotCapture.StartIfNeeded(trigger); } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 53db67b8e..3e51f9325 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -332,9 +332,14 @@ protected override async Task ExecuteAsync( // selection and response rows, and PackageInfo is main-thread only, which this path is. services.PackageRootCapture.CaptureCurrent(); // Why here: both the default selection below and the run read the snapshot of the compile - // the last reload loaded. The gate does nothing when the domain load captured it; it - // captures here when that capture threw or Unity listed no compilation assembly yet. - services.SourceSnapshotCapture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + // the last reload loaded. This waits for the capture the domain load started, or starts + // one when that capture failed, found no compiled assembly, or was cancelled. + await services.SourceSnapshotCapture.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + // Why switch explicitly: the capture finishes on a pool thread, and the selection below + // reads Unity's compilation assemblies, which only the main thread may. Returning there + // through the synchronization context would break silently if the wait ever gained a + // ConfigureAwait(false). + await MainThreadSwitcher.SwitchToMainThread(ct); HotReloadDefaultFileSelection selection = HotReloadDefaultFileSelector.Resolve( parameters.Files, services.ChangeDetector.Detect, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs index 5e67dc9f5..f96b1fd96 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs @@ -6,9 +6,10 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Stops the installed warm-up and the background reads of the compiled-caller analysis when - /// the Editor is about to reload the domain or starts a compile: they would read dlls that are - /// being rewritten, or work for a domain that is going away. + /// Stops the installed warm-up, the background reads of the compiled-caller analysis, and the + /// running source snapshot capture when the Editor is about to reload the domain or starts a + /// compile: they would read dlls that are being rewritten, or work for a domain that is going + /// away. /// internal static class HotReloadWarmUpEditorHooks { @@ -35,12 +36,14 @@ internal static void ShutdownForReload() { _ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload); _ = GetServices().CompiledCallers.StopBackgroundReadsAsync(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload); + GetServices().SourceSnapshotCapture.CancelInFlight(); } internal static void ShutdownForCompile(object context) { _ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); _ = GetServices().CompiledCallers.StopBackgroundReadsAsync(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); + GetServices().SourceSnapshotCapture.CancelInFlight(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 8d0f5547f..3630aa4fd 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -694,9 +694,16 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogWarmUpSkipped = "hot_reload_warm_up_skipped"; public const string VibeLogCallerNoteBackfillComplete = "hot_reload_caller_note_backfill_complete"; public const string VibeLogSourceSnapshotCaptured = "hot_reload_source_snapshot_captured"; + // The other entries of a source snapshot capture that runs off the main thread: its start, + // the ways it can end without capturing, and the compile completion giving up on it. + public const string VibeLogSourceSnapshotCaptureStarted = "hot_reload_source_snapshot_capture_started"; + public const string VibeLogSourceSnapshotCaptureCancelled = "hot_reload_source_snapshot_capture_cancelled"; + public const string VibeLogSourceSnapshotCaptureFailed = "hot_reload_source_snapshot_capture_failed"; + public const string VibeLogSourceSnapshotCaptureEmpty = "hot_reload_source_snapshot_capture_empty"; + public const string VibeLogSourceSnapshotCaptureHoldCapExceeded = "hot_reload_source_snapshot_capture_hold_cap_exceeded"; // What captured the source snapshot of a domain, as named in the trigger field of - // hot_reload_source_snapshot_captured. + // hot_reload_source_snapshot_captured and the other capture entries. public const string SourceSnapshotCaptureTriggerDomainLoad = "domain_load"; public const string SourceSnapshotCaptureTriggerFirstUpdateTick = "first_update_tick"; public const string SourceSnapshotCaptureTriggerApply = "apply"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs index bbeadc3ee..ee28fc06b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs @@ -1,5 +1,7 @@ using System; using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -8,54 +10,270 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Captures the source snapshot of the current compile once per domain, at the end of the - /// domain load. When that capture throws or Unity listed no compilation assembly yet, the - /// first update tick or the hot reload apply entry captures again. + /// Captures the source snapshot of the current compile once per domain, off the main thread. + /// The domain load starts the capture without waiting for it; a hot reload apply awaits it and + /// a pause point blocks on it. At most one capture runs at a time. A capture that throws, saw + /// no compiled assembly, or was cancelled leaves the domain uncaptured, so the next caller + /// starts a new one. /// internal sealed class HotReloadSourceSnapshotCapture { - private readonly Func _capture; + // Why a cap on the hold: if a capture never finishes, compile must still return. Readers + // keep waiting for the capture; only the compile completion stops waiting for it. + private static readonly long HoldCapTicks = TimeSpan.FromSeconds(30).Ticks; + + private readonly Func _readInputsOnMainThread; + private readonly Func _capture; + private readonly Func, Task> _runInBackground; + private readonly Func _utcNowTicks; + // Why a lock: the capture finishes on a pool thread while the main thread asks whether it + // runs, and a stale answer could start a second capture that collides with the first. + private readonly object _lock = new object(); private bool _captured; + private Task _inFlight; + private CancellationTokenSource _inFlightCancellation; + private long _inFlightStartedUtcTicks; + private bool _holdCapLogged; + /// Reads what the capture needs from Unity on the main thread. /// - /// Captures the snapshot and returns whether Unity listed at least one compilation assembly. + /// Captures the snapshot without touching Unity APIs and returns whether it found at least + /// one compiled assembly. /// - internal HotReloadSourceSnapshotCapture(Func capture) + /// Runs the capture off the main thread. + /// Reads the current UTC time, for the cap on the compile hold. + internal HotReloadSourceSnapshotCapture( + Func readInputsOnMainThread, + Func capture, + Func, Task> runInBackground, + Func utcNowTicks) { + Debug.Assert(readInputsOnMainThread != null, "readInputsOnMainThread must not be null."); Debug.Assert(capture != null, "capture must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + Debug.Assert(utcNowTicks != null, "utcNowTicks must not be null."); + + _readInputsOnMainThread = readInputsOnMainThread; _capture = capture; + _runInBackground = runInBackground; + _utcNowTicks = utcNowTicks; + } + + /// + /// Whether a capture is running now. + /// + internal bool IsInFlight + { + get + { + lock (_lock) + { + return _inFlight != null; + } + } } /// - /// Runs the capture unless it already ran to completion in this domain, and records - /// when this call completes it. Main thread only. A capture that - /// throws or saw no compilation assembly is not marked done, so the next caller runs it again. + /// Whether the compile completion should wait for the running capture. False once it has run + /// for 30 seconds, which is logged once per capture. Main thread only. /// - internal void EnsureCaptured(string trigger) + internal bool IsHoldingCompileCompletion + { + get + { + bool logCapExceeded; + lock (_lock) + { + if (_inFlight == null) + { + return false; + } + + if (_utcNowTicks() - _inFlightStartedUtcTicks < HoldCapTicks) + { + return true; + } + + logCapExceeded = !_holdCapLogged; + _holdCapLogged = true; + } + + if (logCapExceeded) + { + VibeLogger.LogWarning( + HotReloadConstants.VibeLogSourceSnapshotCaptureHoldCapExceeded, + "Hot reload stopped holding the compile completion for a source snapshot capture that has not finished.", + new { holdCapSeconds = TimeSpan.FromTicks(HoldCapTicks).TotalSeconds }); + } + + return false; + } + } + + /// + /// Returns a completed task when this domain is captured, the running capture's task when one + /// runs, and otherwise the task of a capture started now. Main thread only, because the + /// inputs are read from Unity here. An exception while reading them reaches the caller and + /// starts nothing. + /// + internal Task StartIfNeeded(string trigger) { Debug.Assert(!string.IsNullOrEmpty(trigger), "trigger must not be null or empty."); - if (_captured) + + lock (_lock) + { + if (_captured) + { + return Task.FromResult(true); + } + + if (_inFlight != null) + { + return _inFlight; + } + } + + Stopwatch mainThreadWatch = Stopwatch.StartNew(); + HotReloadSnapshotCaptureInputs inputs = _readInputsOnMainThread(); + CancellationTokenSource cancellation = new CancellationTokenSource(); + Stopwatch poolWatch = new Stopwatch(); + Task task = _runInBackground(() => + { + poolWatch.Start(); + try + { + return _capture(inputs, cancellation.Token); + } + finally + { + poolWatch.Stop(); + } + }); + Debug.Assert(task != null, "runInBackground must return a task."); + lock (_lock) + { + _inFlight = task; + _inFlightCancellation = cancellation; + _inFlightStartedUtcTicks = _utcNowTicks(); + _holdCapLogged = false; + } + + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptureStarted, + "Hot reload started the source snapshot capture of this domain.", + new { trigger, mainThreadMs = mainThreadWatch.ElapsedMilliseconds }); + // Why the continuation is attached after the task is published: a task that already + // finished then runs it right here, and it finds the published task to clear. + // Why ExecuteSynchronously on the default scheduler: the bookkeeping must not wait for the + // main thread, which a pause point may be blocking on this very task. + task.ContinueWith( + finished => OnFinished(finished, cancellation, trigger, poolWatch), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return task; + } + + /// + /// Completes when this domain's capture has run, passing on the exception of a capture that + /// failed. Main thread only. + /// + internal async Task EnsureCapturedAsync(string trigger) + { + // Why no ConfigureAwait(false): the caller continues with main-thread work. + await StartIfNeeded(trigger); + } + + /// + /// Blocks the main thread until this domain's capture has run, passing on the exception of a + /// capture that failed. For synchronous readers only. It cannot dead-lock, because the + /// capture and its bookkeeping never wait for the main thread. + /// + internal void EnsureCapturedBlocking(string trigger) + { + StartIfNeeded(trigger).GetAwaiter().GetResult(); + } + + /// + /// Asks the running capture to stop, without waiting for it. Its assemblies that were not + /// finished get no stamp, so the next capture redoes them. + /// + internal void CancelInFlight() + { + lock (_lock) + { + _inFlightCancellation?.Cancel(); + } + } + + private void OnFinished( + Task task, + CancellationTokenSource cancellation, + string trigger, + Stopwatch poolWatch) + { + // Why the exception is read on every path that has one: the domain load and the first + // update tick never await the task, and an exception nobody reads is reported later as + // an unobserved task exception. Why here and not in a log argument: the VibeLogger + // calls are conditional on ULOOP_DEBUG, so an ordinary build drops them with their + // arguments, and a read inside one would never run. + Exception failure = task.IsFaulted ? task.Exception.GetBaseException() : null; + bool cancelled = task.IsCanceled + || (failure is OperationCanceledException && cancellation.IsCancellationRequested); + bool captured = task.Status == TaskStatus.RanToCompletion && task.Result; + lock (_lock) + { + if (!ReferenceEquals(_inFlightCancellation, cancellation)) + { + return; + } + + _inFlight = null; + _inFlightCancellation = null; + // Marked only after the capture returns true: a capture that threw may have left + // assemblies without a snapshot, and one that found no compiled assembly ran while + // Unity was compiling. Marking either done would keep every later reader from + // capturing them. + _captured = captured; + } + + cancellation.Dispose(); + LogOutcome(trigger, captured, cancelled, failure, poolWatch.ElapsedMilliseconds); + } + + private static void LogOutcome(string trigger, bool captured, bool cancelled, Exception failure, long captureMs) + { + if (captured) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptured, + "Hot reload captured the source snapshot of this domain.", + new { trigger, captureMs }); + return; + } + + if (cancelled) { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled, + "Hot reload cancelled the source snapshot capture of this domain.", + new { trigger }); return; } - Stopwatch watch = Stopwatch.StartNew(); - // Marked only after the capture returns: a capture that threw may have left assemblies - // without a snapshot, and marking it done first would keep every later reader from - // capturing them. - bool sawAssemblies = _capture(); - // Why an empty list is not done: Unity lists none while it compiles, and a domain marked - // captured from an empty list would never be captured by any of its readers. - if (!sawAssemblies) + if (failure != null) { + VibeLogger.LogWarning( + HotReloadConstants.VibeLogSourceSnapshotCaptureFailed, + "Hot reload failed to capture the source snapshot of this domain.", + new { trigger, error = failure.GetType().Name, message = failure.Message }); return; } - _captured = true; VibeLogger.LogInfo( - HotReloadConstants.VibeLogSourceSnapshotCaptured, - "Hot reload captured the source snapshot of this domain.", - new { trigger, captureMs = watch.ElapsedMilliseconds }); + HotReloadConstants.VibeLogSourceSnapshotCaptureEmpty, + "Hot reload found no compiled assembly to capture the source snapshot from.", + new { trigger }); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index bdae7bfdc..40a45d574 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -27,12 +28,18 @@ internal static partial class HotReloadSourceSnapshotter /// baseline checks every copy against the PDB when it uses one. /// Logs how long each phase took, even when the list is empty. /// Returns false when the inputs list no compilation assembly (as while Unity compiles). - /// Asks Unity nothing beyond what carries. + /// Asks Unity nothing beyond what carries, so it may run on a pool + /// thread. Throws once + /// is cancelled. /// - internal static bool CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs inputs) + internal static bool CaptureAfterDomainReload( + HotReloadSnapshotCaptureInputs inputs, + CancellationToken cancellationToken) { Debug.Assert(inputs != null, "inputs must not be null."); + cancellationToken.ThrowIfCancellationRequested(); + System.Diagnostics.Stopwatch captureWatch = System.Diagnostics.Stopwatch.StartNew(); HotReloadSnapshotCaptureStats stats = CaptureAssemblies(inputs, HotReloadPdbDocumentIndex.Shared); captureWatch.Stop(); From d2a33549b3db523bbfff8c59a5be8d30d5b9970c Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 12 Oct 2026 00:40:51 +0900 Subject: [PATCH 2/6] Document the background source snapshot capture and record why in an ADR The vibe-logs reference lists the new capture entries, the hot reload doc says the capture runs off the main thread, and ADR 0014 records the considered options and when to revisit the decision. --- ...oad-source-snapshot-off-the-main-thread.md | 68 +++++++++++++++++++ docs/hot-reload.md | 5 ++ docs/vibe-logs.md | 32 ++++++--- 3 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md diff --git a/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md new file mode 100644 index 000000000..2c9f923b0 --- /dev/null +++ b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md @@ -0,0 +1,68 @@ +# Capture the Hot Reload Source Snapshot Off the Main Thread + +Date: 2026-10-11 + +## Decision + +After every domain reload, hot reload copies the sources of the compile into a snapshot so it can +later tell which methods were edited. That capture now runs on a pool thread: + +- The domain load reads on the main thread only the values the capture needs from Unity (the + project root, the compile start record, the package roots, the compiled assemblies). It hands + them to the pool and returns without waiting. +- At most one capture runs per domain. A hot reload run awaits it. A pause point blocks on it, + except while a capture runs and the file on disk matches the checksum the compiled assembly's + PDB recorded: those are the bytes the compiler read, so the pause point reads that file + instead of waiting. +- `get-compile-status` reports not ready while a capture runs, so `uloop compile` returns only + after the capture, up to 30 seconds. The hold lives in the internal bridge command, because + the CLI waits for a compile by polling it, outside the tool execution path. +- A domain reload or a compile start cancels the running capture without waiting. A capture that + failed, found no compiled assembly, or was cancelled leaves the domain uncaptured, and the + next reader starts a new one. + +## Context + +Issue #3222: on a large project the capture ran synchronously on the main thread after every +script compile and took about 1.9–2.3 s, writing 781 files (3.3 MB) for each compile, even when +hot reload was not used. Copying only the changed sources and skipping the capture when both +readers are disabled shortened it, but the main thread still waited on it, most of all on +listing the compilation assemblies from Unity. + +What the capture calls on Unity must stay on the main thread: `CompilationPipeline.GetAssemblies`, +`SessionState`, and the Package Manager. Everything else it does is file IO and PDB reads, which +the pool can do. + +The guarantee that an edit made right after `uloop compile` returns is never captured as +compiled source used to hold because the capture finished inside the domain load, before the +command listener opened. Holding `get-compile-status` keeps that guarantee. A source written +after the compile started is still checked against the PDB, so a capture that ends after the +30-second cap stays correct; only the timing guarantee of the compile response is lost. + +## Considered Options + +- **1. Keep `GetAssemblies` on the main thread and stop at the incremental copy.** Rejected: the + main thread still stalls. +- **2. Build only the capture's assembly list from the dlls and PDBs.** Not enough alone: the + cost of `GetAssemblies` only moves to the warm-up or the first run. Adopted together with 4. +- **3. Move the capture, the warm-up's reference graph, the default file selection, and the + patch target resolution all to lists built from dlls and PDBs.** Too large. Only the part that + keeps the warm-up from calling `GetAssemblies` at startup (rebuilding the list from a saved + one and the PDBs) was adopted. +- **4. Run the capture on a pool thread.** Not possible alone: `GetAssemblies` is main-thread + only, and while the capture moves the previous snapshot directory nothing is published, so + every reader has to wait on the capture. Adopted together with 2, with the gate below. +- **Waiting for the capture in the tool execution path.** Rejected: `get-compile-status` is an + internal bridge command that never enters it, so the compile response would not wait. The + hold is in its `Ready` instead. +- **Making the pause point wait for the capture in every case.** Rejected: a pause point + re-armed on the first update tick after a reload would block the main thread for the whole + capture, which is the stall this change removes. +- **Re-arming pause points after the capture instead.** Rejected: the re-arm might not finish + before Play Mode starts. + +## Revisit When + +- Unity makes `CompilationPipeline.GetAssemblies` safe to call off the main thread, or +- the way the CLI waits for a compile changes, so the hold in `get-compile-status` no longer + stops the compile response. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 86b0fc72e..720e58988 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -81,6 +81,11 @@ and the default selection, the sibling scan and the skip check treat a marked so changed; a capture moves the previous build's directory to the new MVID's and rewrites only the sources whose length or write time changed, that carry no unmarked manifest line, or that were written since the compile started; it deletes the copies of sources no longer in the assembly. +The capture runs on a pool thread after the domain load reads its inputs on the main thread, one +capture at a time; `uloop compile` returns only after it finishes (or after 30 seconds), a hot +reload run waits for it, and a pause point waits for it unless the file on disk matches the PDB +checksum, in which case it reads that file. A domain reload or a compile start cancels it, and +the next reader starts it again (`docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md`). 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 diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 3ca5cb287..2295b8ca7 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -90,19 +90,35 @@ command ran against: sent), `start_failed` or `request_failed` (with `failureReason`; the worker is stopped and the next compile starts it again), or `failed` (an exception, with `failureReason` set to its type and `exceptionMessage`, logged as a warning). - `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; `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 + The source snapshot capture reads its inputs on the main thread and then runs on a pool + thread, one capture at a time. Each start writes `hot_reload_source_snapshot_capture_started` + with its `trigger` and `mainThreadMs` (the time the main thread spent reading the inputs and + handing the capture over). How it ended follows as one of these, with the same `trigger`: + - `hot_reload_source_snapshot_captured`, once per domain, with `captureMs` (the time on the + pool thread). `trigger` is `domain_load` normally; `first_update_tick`, `apply` or + `pause_point` when the capture at domain load failed, found no compiled assembly, or was + cancelled, or when the captures at startup were skipped and a hot reload run or a pause point + is the first to read the snapshot. + - `hot_reload_source_snapshot_capture_failed` (a warning) with `error` (the exception type) and + `message`. + - `hot_reload_source_snapshot_capture_empty` when no compiled assembly was found, which is + normal while Unity compiles. + - `hot_reload_source_snapshot_capture_cancelled` when a domain reload or a compile start + stopped it. + After any of the last three, the next reader or the first update tick starts a new capture. + While a capture runs, `get-compile-status` reports not ready, so `uloop compile` returns only + after it. That hold gives up after 30 seconds and writes one + `hot_reload_source_snapshot_capture_hold_cap_exceeded` (a warning). Readers keep waiting for + the capture. `compile_status_query_received` carries `is_snapshot_capture_holding`. 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. `hot_reload_source_snapshot_breakdown` appears once each time the capture runs to the end, just before `hot_reload_source_snapshot_captured`. It also appears when Unity listed no - compilation assembly, in which case no captured entry follows, and it does not appear when the - capture threw. It says where the capture spent its time: + compilation assembly, in which case `hot_reload_source_snapshot_capture_empty` follows instead, + and it does not appear when the capture threw or was cancelled. It says where the capture spent + its time: - `getAssembliesMs`: listing the compilation assemblies from Unity, which happens while the capture's inputs are read on the main thread, before any copying starts. - `packageLookupMs`: reading whether each assembly's sources are immutable. The inputs decide From e55fe34fa48ba8922f8ee8989c199ed42300897b Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 12 Oct 2026 00:54:18 +0900 Subject: [PATCH 3/6] Await the snapshot capture with ConfigureAwait(false) The repository's ConfigureAwait guard rejects first-party awaits without it. The apply path already switches back to the main thread explicitly after the wait, so resuming on the pool changes nothing for it. --- .../Editor/FirstPartyTools/HotReload/HotReloadTools.cs | 10 +++++----- .../HotReload/Shared/HotReloadSourceSnapshotCapture.cs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 3e51f9325..dba27e1f1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -334,11 +334,11 @@ protected override async Task ExecuteAsync( // Why here: both the default selection below and the run read the snapshot of the compile // the last reload loaded. This waits for the capture the domain load started, or starts // one when that capture failed, found no compiled assembly, or was cancelled. - await services.SourceSnapshotCapture.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply); - // Why switch explicitly: the capture finishes on a pool thread, and the selection below - // reads Unity's compilation assemblies, which only the main thread may. Returning there - // through the synchronization context would break silently if the wait ever gained a - // ConfigureAwait(false). + await services.SourceSnapshotCapture.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply) + .ConfigureAwait(false); + // Why switch explicitly: the capture finishes on a pool thread and the wait does not return + // through the synchronization context, while the selection below reads Unity's compilation + // assemblies, which only the main thread may. await MainThreadSwitcher.SwitchToMainThread(ct); HotReloadDefaultFileSelection selection = HotReloadDefaultFileSelector.Resolve( parameters.Files, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs index ee28fc06b..d740988f0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs @@ -176,12 +176,12 @@ internal Task StartIfNeeded(string trigger) /// /// Completes when this domain's capture has run, passing on the exception of a capture that - /// failed. Main thread only. + /// failed. Main thread only. It may complete on a pool thread, so a caller that continues with + /// main-thread work switches back itself. /// internal async Task EnsureCapturedAsync(string trigger) { - // Why no ConfigureAwait(false): the caller continues with main-thread work. - await StartIfNeeded(trigger); + await StartIfNeeded(trigger).ConfigureAwait(false); } /// From 185f74958b7fa4b4114a5bf8d414368da9362349 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 12 Oct 2026 07:03:59 +0900 Subject: [PATCH 4/6] Forward the capture's cancellation token into the per-assembly loop A compile start or an upcoming domain reload now stops the capture between assemblies, not only before it starts. --- .../HotReload/Shared/HotReloadSourceSnapshotter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 5b9be9265..39172fb5a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -46,7 +46,7 @@ internal static bool CaptureAfterDomainReload( inputs, HotReloadPdbDocumentIndex.Shared, HotReloadCaptureAssemblyEnumerator.ReadSources, - CancellationToken.None); + cancellationToken); totalWatch.Stop(); VibeLogger.LogInfo( HotReloadConstants.VibeLogSourceSnapshotBreakdown, From aa71fac77b3230195109e0be941378746fa873fc Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 12 Oct 2026 07:07:07 +0900 Subject: [PATCH 5/6] Discard the gate's task explicitly in the async gate tests The un-awaited StartIfNeeded calls inside async test methods raised CS4014. --- .../Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs index 196c9394d..af2b5c0a6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs @@ -219,7 +219,7 @@ public void StartIfNeeded_WhenReadingInputsThrows_RethrowsAndStaysNotStarted() public async Task EnsureCapturedAsync_WhileInFlight_CompletesOnlyAfterTheCapture() { HotReloadSourceSnapshotCapture gate = CreateGate(); - gate.StartIfNeeded(Trigger); + _ = gate.StartIfNeeded(Trigger); Task ensured = gate.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply); Assert.That(ensured.IsCompleted, Is.False); @@ -255,7 +255,7 @@ public async Task EnsureCapturedAsync_WhenTheCaptureThrows_RethrowsAndLeavesItUn } Assert.That(thrown, Is.SameAs(failure)); - gate.StartIfNeeded(Trigger); + _ = gate.StartIfNeeded(Trigger); Assert.That(_background.StartCount, Is.EqualTo(2)); _background.RunHeldWork(); } From ce7e014dcf6d183058057aeb6b876c504d13dec4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 12 Oct 2026 07:31:41 +0900 Subject: [PATCH 6/6] Say the startup only starts the snapshot capture, and drop GetAssemblies from the ADR Since the capture moved to the pool, the startup hands it over without waiting, and the compile-response guarantee comes from get-compile-status holding the completion. The rename and comments say so. After #3283 the capture lists assemblies from the compiled dlls and PDBs, so ADR 0014 no longer names GetAssemblies among the main-thread inputs. --- .../HotReload/HotReloadEditorStartupTests.cs | 16 ++++++++-------- .../FirstPartyToolsEditorStartup.cs | 7 ++++--- .../HotReload/HotReloadEditorStartup.cs | 10 ++++++---- ...reload-source-snapshot-off-the-main-thread.md | 14 ++++++++------ 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs index c6faac07b..def3042a4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -20,7 +20,7 @@ public class HotReloadEditorStartupTests /// What: the capture before serving commands runs the installed capture once and records the domain-load trigger. /// [Test] - public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOnceWithTheDomainLoadTrigger() + public void StartSourceSnapshotCaptureBeforeServingCommands_RunsTheInstalledCaptureOnceWithTheDomainLoadTrigger() { VibeLogger.ClearMemoryLogs(); int captureCount = 0; @@ -32,7 +32,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(AllToolsEnabled); } Assert.That(captureCount, Is.EqualTo(1), "captures"); @@ -46,7 +46,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn /// tick's capture runs it again. /// [Test] - public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureFails_LogsFailedAndLeavesItForTheNextCall() + public void StartSourceSnapshotCaptureBeforeServingCommands_WhenTheCaptureFails_LogsFailedAndLeavesItForTheNextCall() { VibeLogger.ClearMemoryLogs(); int captureCount = 0; @@ -64,7 +64,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureFails_LogsF using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { Assert.DoesNotThrow( - () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled)); + () => HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(AllToolsEnabled)); JObject failed = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( HotReloadConstants.VibeLogSourceSnapshotCaptureFailed); Assert.That((string)failed["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); @@ -83,7 +83,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureFails_LogsF /// reader that runs later captures through the gate. /// [Test] - public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader() + public void StartSourceSnapshotCaptureBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader() { VibeLogger.ClearMemoryLogs(); int captureCount = 0; @@ -91,7 +91,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(NoToolEnabled); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(NoToolEnabled); Assert.That(captureCount, Is.EqualTo(0), "captures while both readers are disabled"); JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped); @@ -112,7 +112,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn /// [TestCase(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)] [TestCase(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT)] - public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool) + public void StartSourceSnapshotCaptureBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool) { VibeLogger.ClearMemoryLogs(); int captureCount = 0; @@ -120,7 +120,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(name => name == enabledTool); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(name => name == enabledTool); } Assert.That(captureCount, Is.EqualTo(1), "captures"); diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index 2bc77b757..0d2381369 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -40,8 +40,9 @@ public static void Initialize(Func isToolEnabled) } /// - /// 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. + /// Starts the first-party tool work that has to begin before this domain answers its first + /// uloop command: the source snapshot capture, which then runs in the background. 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(Func isToolEnabled) @@ -54,7 +55,7 @@ public static void PrepareBeforeServingCommands(Func isToolEnabled return; } - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(isToolEnabled); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(isToolEnabled); } public static void ResetServerScopedServices() diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 9cae8e425..49fa426fe 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -102,17 +102,19 @@ 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. + /// Starts the source snapshot capture of the current compile before this domain answers any + /// uloop command, without waiting for it. The guarantee that an edit made after the compile + /// returned is never captured as compiled source comes from get-compile-status holding the + /// compile completion while the capture runs (up to 30 seconds). /// - internal static void CaptureSourceSnapshotBeforeServingCommands(Func isToolEnabled) + internal static void StartSourceSnapshotCaptureBeforeServingCommands(Func isToolEnabled) { CaptureAtStartupUnlessReadersAreDisabled( HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad, isToolEnabled); } /// - /// Captures the source snapshot at a startup trigger unless both tools that read it are + /// Starts the source snapshot capture at a startup trigger unless both tools that read it are /// disabled in the tool settings. /// internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Func isToolEnabled) diff --git a/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md index 2c9f923b0..299626f03 100644 --- a/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md +++ b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md @@ -8,8 +8,8 @@ After every domain reload, hot reload copies the sources of the compile into a s later tell which methods were edited. That capture now runs on a pool thread: - The domain load reads on the main thread only the values the capture needs from Unity (the - project root, the compile start record, the package roots, the compiled assemblies). It hands - them to the pool and returns without waiting. + project root, the compile start record, and the package roots). It hands them to the pool and + returns without waiting. - At most one capture runs per domain. A hot reload run awaits it. A pause point blocks on it, except while a capture runs and the file on disk matches the checksum the compiled assembly's PDB recorded: those are the bytes the compiler read, so the pause point reads that file @@ -18,7 +18,7 @@ later tell which methods were edited. That capture now runs on a pool thread: after the capture, up to 30 seconds. The hold lives in the internal bridge command, because the CLI waits for a compile by polling it, outside the tool execution path. - A domain reload or a compile start cancels the running capture without waiting. A capture that - failed, found no compiled assembly, or was cancelled leaves the domain uncaptured, and the + failed, found no compiled dll with a PDB, or was cancelled leaves the domain uncaptured, and the next reader starts a new one. ## Context @@ -29,9 +29,11 @@ hot reload was not used. Copying only the changed sources and skipping the captu readers are disabled shortened it, but the main thread still waited on it, most of all on listing the compilation assemblies from Unity. -What the capture calls on Unity must stay on the main thread: `CompilationPipeline.GetAssemblies`, -`SessionState`, and the Package Manager. Everything else it does is file IO and PDB reads, which -the pool can do. +The capture used to list the assemblies with `CompilationPipeline.GetAssemblies`, which Unity +allows on the main thread only. #3283 made it list them from the compiled dlls and their PDBs +instead, so what stays on the main thread is the compile start record (`SessionState`) and the +package list (the Package Manager). Everything else the capture does is file IO and PDB reads, +which the pool can do. The guarantee that an edit made right after `uloop compile` returns is never captured as compiled source used to hold because the capture finished inside the domain load, before the