From bf51a4270283c1f133f42897f6f30ed15c7dbbee Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 12:53:27 +0900 Subject: [PATCH 1/7] test: Pin the caller-note backfill recomputing the dlls it must read The backfill reads only the dlls the run's note step refused at the first incomplete caller level, so the next level's dlls are found only by the next run. Pin the intended behavior before implementing it: after reading, the backfill recomputes the refused dlls (a function passed to Start), reads the new ones, skips ones it already tried, stops at the caller-depth round limit, and honors cancels before and inside the recompute. Start takes the recompute function now; the stub ignores it, so the new recompute tests fail while the guard tests (empty list, cancel before the recompute) pass. --- ...tReloadCallSiteBackfillEditorHooksTests.cs | 2 +- .../HotReloadCallSiteBackfillTestDoubles.cs | 38 ++++ .../HotReloadCallSiteBackfillTests.cs | 190 +++++++++++++++++- .../HotReloadCallerNoteBackfillE2ETests.cs | 4 +- .../HotReloadCompiledCallersTests.cs | 2 +- .../HotReloadCallSiteBackfill.cs | 6 +- .../HotReloadCompiledCallersRun.cs | 5 +- 7 files changed, 231 insertions(+), 16 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillEditorHooksTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillEditorHooksTests.cs index 16d87dbe4..4fb57f2be 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillEditorHooksTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillEditorHooksTests.cs @@ -64,7 +64,7 @@ public async Task ShutdownForCompile_StopsTheRunningBackfill() private async Task StopWith(Action hook) { - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, HotReloadCallSiteBackfillTestDoubles.FindNothingRefusedAgain, "corr"); Assert.That( _backfill.State, Is.EqualTo(HotReloadCallSiteBackfillState.Running), diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTestDoubles.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTestDoubles.cs index 995678a6d..7bce40783 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTestDoubles.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTestDoubles.cs @@ -76,4 +76,42 @@ private TaskCompletionSource GetOrCreate(string dllPath) return source; } } + + /// + /// A stand-in for the backfill's recompute of the refused dlls: it counts its calls and + /// answers each from a function of the call number (1-based) and the backfill's token. + /// + internal sealed class RefusedAgainFinder + { + private readonly Func> _answer; + + internal RefusedAgainFinder(Func> answer) + { + _answer = answer; + } + + internal int Calls { get; private set; } + + /// Answers the given lists in order, then empty lists. + internal static RefusedAgainFinder Answering(params string[][] answers) + { + return new RefusedAgainFinder( + (call, ct) => call <= answers.Length ? answers[call - 1] : Array.Empty()); + } + + internal Task> Find(CancellationToken ct) + { + Calls++; + return Task.FromResult(_answer(Calls, ct)); + } + } + + internal static class HotReloadCallSiteBackfillTestDoubles + { + /// A recompute that finds no refused dll, for tests that do not exercise it. + internal static Task> FindNothingRefusedAgain(CancellationToken ct) + { + return Task.FromResult>(Array.Empty()); + } + } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTests.cs index 919900cab..b0c13e792 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallSiteBackfillTests.cs @@ -24,12 +24,14 @@ public sealed class HotReloadCallSiteBackfillTests { private PendingDllLoader _loader; private HotReloadCallSiteBackfill _backfill; + private RefusedAgainFinder _finder; [SetUp] public void SetUp() { _loader = new PendingDllLoader(); _backfill = new HotReloadCallSiteBackfill(_loader.Load); + _finder = RefusedAgainFinder.Answering(); VibeLogger.ClearMemoryLogs(); } @@ -47,7 +49,7 @@ public void TearDown() [Test] public async Task Start_LoadsEachPathInOrder_AndLogsOneCompleteEntry() { - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, _finder.Find, "corr"); await _backfill.Completion; Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll", "b.dll" }), "paths read"); @@ -60,27 +62,194 @@ public async Task Start_LoadsEachPathInOrder_AndLogsOneCompleteEntry() } /// - /// What: an empty list starts nothing and leaves no entry behind. + /// What: an empty list starts nothing, recomputes nothing, and leaves no entry behind. /// [Test] - public void Start_WithNoPaths_LoadsNothingAndLogsNothing() + public void Start_WithNoDlls_DoesNotRunOrRecomputeAndLogsNothing() { - _backfill.Start(Array.Empty(), "corr"); + _backfill.Start(Array.Empty(), _finder.Find, "corr"); Assert.That(_backfill.Completion.IsCompleted, Is.True, "completion"); Assert.That(_loader.Ran, Is.Empty, "paths read"); + Assert.That(_finder.Calls, Is.EqualTo(0), "recomputes"); + Assert.That(CountCompleteEntries(), Is.EqualTo(0), "complete entries"); + } + + /// + /// What: after the refused dlls are read, the refused dlls are recomputed once; an empty + /// answer ends the backfill and the entry reports one round. + /// + [Test] + public async Task Start_WhenRecomputeFindsNothingNew_StopsAfterOneRound() + { + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + await _backfill.Completion; + + Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll" }), "paths read"); + Assert.That(_finder.Calls, Is.EqualTo(1), "recomputes"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((int)context["rounds"], Is.EqualTo(1), "rounds"); + } + + /// + /// What: a dll the recompute newly refuses (a caller one level further) is read too, after + /// the first ones, and the backfill recomputes again until nothing new is refused. + /// + [Test] + public async Task Start_WhenRecomputeRefusesANewDll_ReadsItToo() + { + _finder = RefusedAgainFinder.Answering(new[] { "b.dll" }); + + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + await _backfill.Completion; + + Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll", "b.dll" }), "paths read"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((int)context["rounds"], Is.EqualTo(2), "rounds"); + Assert.That((int)context["requested"], Is.EqualTo(2), "requested"); + Assert.That((int)context["loaded"], Is.EqualTo(2), "loaded"); + } + + /// + /// What: a dll that failed to read is refused again by the recompute but is not tried a + /// second time, so the backfill ends after one round. + /// + [Test] + public async Task Start_WhenRecomputeRefusesOnlyTriedDlls_DoesNotRetryThem() + { + _loader.ThrowFor.Add("a.dll"); + LogAssert.Expect(LogType.Warning, new Regex("caller-note backfill")); + _finder = new RefusedAgainFinder((call, ct) => new[] { "a.dll" }); + + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + await _backfill.Completion; + + Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll" }), "paths read"); + Assert.That(_finder.Calls, Is.EqualTo(1), "recomputes"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((int)context["requested"], Is.EqualTo(1), "requested"); + Assert.That((int)context["failed"], Is.EqualTo(1), "failed"); + } + + /// + /// What: a recompute that keeps refusing new dlls is stopped after the caller-depth limit + /// of rounds. The stand-in throws on the round past the limit, so a missing limit fails + /// the test instead of looping forever. + /// + [Test] + public async Task Start_WhenRecomputeKeepsFindingNewDlls_StopsAtTheRoundLimit() + { + int limit = HotReloadOneShotCallerClosure.MaxCallerDepth; + _finder = new RefusedAgainFinder((call, ct) => + { + if (call > limit) + { + throw new InvalidOperationException("test: recomputed past the round limit"); + } + + return new[] { "level" + call + ".dll" }; + }); + + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + await _backfill.Completion; + + Assert.That(_finder.Calls, Is.EqualTo(limit), "recomputes"); + Assert.That(_loader.Ran.Count, Is.EqualTo(limit + 1), "paths read"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((int)context["rounds"], Is.EqualTo(limit), "rounds"); + } + + /// + /// What: a cancel that lands while the last dll is read stops the backfill before the + /// recompute, so the run that cancelled waits for that dll only. + /// + [Test] + public async Task Start_WhenCancelledBeforeRecompute_DoesNotRecompute() + { + _loader.PendingFor.Add("a.dll"); + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + + Task yielded = _backfill.YieldToRunAsync(); + _loader.Release("a.dll"); + await yielded; + + Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll" }), "paths read"); + Assert.That(_finder.Calls, Is.EqualTo(0), "recomputes"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((string)context["cancelledBy"], Is.EqualTo(HotReloadConstants.WarmUpCancelledByRun), "cancelledBy"); + } + + /// + /// What: a recompute cancelled from inside (it throws OperationCanceledException on the + /// backfill's token) ends the backfill quietly: the completion does not fault, an entry names + /// what stopped it, and the backfill is idle again. + /// + [Test] + public async Task Start_WhenRecomputeIsCancelled_StopsQuietly() + { + _finder = new RefusedAgainFinder((call, ct) => + { + _backfill.Shutdown(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); + ct.ThrowIfCancellationRequested(); + return new[] { "b.dll" }; + }); + + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + await _backfill.Completion; + + Assert.That(_finder.Calls, Is.EqualTo(1), "recomputes"); + Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll" }), "paths read"); + Assert.That(_backfill.Completion.IsFaulted, Is.False, "completion faulted"); + Assert.That(_backfill.State, Is.EqualTo(HotReloadCallSiteBackfillState.Idle), "state"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((string)context["cancelledBy"], Is.EqualTo("compilationStarted"), "cancelledBy"); + } + + /// + /// What: a recompute answer that repeats a dll or names one already read reads each new + /// dll once. + /// + [Test] + public async Task Start_WhenRecomputeReturnsDuplicates_ReadsEachNewDllOnce() + { + _finder = RefusedAgainFinder.Answering(new[] { "b.dll", "a.dll", "b.dll" }); + + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + await _backfill.Completion; + + Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll", "b.dll" }), "paths read"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((int)context["requested"], Is.EqualTo(2), "requested"); + } + + /// + /// What: an unexpected exception from the recompute ends the backfill, is logged, and + /// leaves the completion task unfaulted, the backfill idle, and no complete entry behind. + /// + [Test] + public async Task Start_WhenRecomputeThrowsAnUnexpectedException_LogsItAndCompletionDoesNotFault() + { + _finder = new RefusedAgainFinder((call, ct) => throw new IOException("test: recompute failed")); + LogAssert.Expect(LogType.Exception, new Regex("IOException: test: recompute failed")); + + _backfill.Start(new[] { "a.dll" }, _finder.Find, "corr"); + await _backfill.Completion; + + Assert.That(_finder.Calls, Is.EqualTo(1), "recomputes"); + Assert.That(_backfill.Completion.IsFaulted, Is.False, "completion faulted"); + Assert.That(_backfill.State, Is.EqualTo(HotReloadCallSiteBackfillState.Idle), "state"); Assert.That(CountCompleteEntries(), Is.EqualTo(0), "complete entries"); } /// /// What: a run that yields while a dll is being read waits for that dll, and no later dll - /// is read; the entry names the run as what stopped the backfill. + /// is read nor recompute run; the entry names the run as what stopped the backfill. /// [Test] public async Task YieldToRunAsync_WhileADllIsInFlight_WaitsForItAndLoadsNoLaterOne() { _loader.PendingFor.Add("a.dll"); - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, _finder.Find, "corr"); Task yielded = _backfill.YieldToRunAsync(); Assert.That(yielded.IsCompleted, Is.False, "the yield must wait while a.dll is being read"); @@ -88,6 +257,7 @@ public async Task YieldToRunAsync_WhileADllIsInFlight_WaitsForItAndLoadsNoLaterO await yielded; Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll" }), "paths read"); + Assert.That(_finder.Calls, Is.EqualTo(0), "recomputes"); JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); Assert.That((int)context["loaded"], Is.EqualTo(1), "loaded"); Assert.That((string)context["cancelledBy"], Is.EqualTo(HotReloadConstants.WarmUpCancelledByRun), "cancelledBy"); @@ -125,7 +295,7 @@ public async Task YieldToRunAsync_WhileTheInFlightDllFails_CompletesAndCountsTha { _loader.PendingFor.Add("a.dll"); LogAssert.Expect(LogType.Warning, new Regex("caller-note backfill")); - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, _finder.Find, "corr"); Task yielded = _backfill.YieldToRunAsync(); _loader.Fail("a.dll", new IOException("test: cannot read a.dll")); @@ -146,7 +316,7 @@ public async Task YieldToRunAsync_WhileTheInFlightDllFails_CompletesAndCountsTha public async Task Shutdown_WhileADllIsInFlight_StopsBeforeTheNextOneAndRecordsTheTrigger() { _loader.PendingFor.Add("a.dll"); - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, _finder.Find, "corr"); Task stopped = _backfill.Shutdown(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); _loader.Release("a.dll"); @@ -167,7 +337,7 @@ public async Task Start_WhenALoadThrowsAnIoException_CountsItFailedAndLoadsTheNe _loader.ThrowFor.Add("a.dll"); LogAssert.Expect(LogType.Warning, new Regex("caller-note backfill")); - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, _finder.Find, "corr"); await _backfill.Completion; Assert.That(_loader.Ran, Is.EqualTo(new[] { "a.dll", "b.dll" }), "paths read"); @@ -186,7 +356,7 @@ public async Task Start_WhenALoadThrowsAnUnexpectedException_LogsItAndCompletion _loader.ThrowUnexpectedFor.Add("a.dll"); LogAssert.Expect(LogType.Exception, new Regex("InvalidOperationException: unexpected")); - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, _finder.Find, "corr"); await _backfill.Completion; Assert.That(_backfill.Completion.IsFaulted, Is.False, "completion faulted"); diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs index 04e547bf9..55c65ff7a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs @@ -122,7 +122,7 @@ public async Task Run_WhileTheBackfillOfThePreviousRunIsInFlight_WaitsForThatDll HotReloadCallSiteBackfill backfill = new HotReloadCallSiteBackfill(loader.Load); _scope = HotReloadDomainTestScope.WithCallSiteBackfill(backfill); await _scope.InstalledWarmUpStopped; - backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + backfill.Start(new[] { "a.dll", "b.dll" }, HotReloadCallSiteBackfillTestDoubles.FindNothingRefusedAgain, "corr"); string editedPath = HotReloadTestSourceWriter.WriteEditedSource( "CallerNoteBackfillE2E.cs", @@ -162,7 +162,7 @@ public async Task Run_WhileABackfillReadIsInFlight_CountsTheWaitInWarmUpYield() _scope = HotReloadDomainTestScope.WithCallSiteBackfill(backfill); await _scope.InstalledWarmUpStopped; VibeLogger.ClearMemoryLogs(); - backfill.Start(new[] { "a.dll" }, "corr"); + backfill.Start(new[] { "a.dll" }, HotReloadCallSiteBackfillTestDoubles.FindNothingRefusedAgain, "corr"); string editedPath = HotReloadTestSourceWriter.WriteEditedSource( "CallerNoteBackfillE2E.cs", diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs index 3add8991f..46140d5e7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs @@ -47,7 +47,7 @@ public void TearDown() public async Task BeginRunAsync_WhileABackgroundReadIsInFlight_TakesTheHoldOnlyAfterTheReadEnds() { _loader.PendingFor.Add("a.dll"); - _backfill.Start(new[] { "a.dll", "b.dll" }, "corr"); + _backfill.Start(new[] { "a.dll", "b.dll" }, HotReloadCallSiteBackfillTestDoubles.FindNothingRefusedAgain, "corr"); Task begin = _compiledCallers.BeginRunAsync(); Assert.That(begin.IsCompleted, Is.False, "the run waits for the read in flight"); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs index e66fae414..b042695ed 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs @@ -61,9 +61,13 @@ internal HotReloadCallSiteBackfillState State /// is running (the run calling this yielded to the previous one first). Nothing starts and /// nothing is logged for an empty list. /// - internal void Start(IReadOnlyList dllPaths, string correlationId) + internal void Start( + IReadOnlyList dllPaths, + Func>> findRefusedAgain, + string correlationId) { Debug.Assert(dllPaths != null, "dllPaths must not be null."); + Debug.Assert(findRefusedAgain != null, "findRefusedAgain must not be null."); Debug.Assert(!string.IsNullOrEmpty(correlationId), "correlationId must not be null or empty."); if (dllPaths.Count == 0) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs index 3bee28e34..837403b91 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs @@ -84,7 +84,10 @@ public void End(string correlationId) // Why the hold ends here, before the backfill starts: its reads belong to no run, so they // must evict as reads outside a run do rather than pile up under this run's hold. _hold.Dispose(); - _backfill.Start(_refusedDllPaths, correlationId); + _backfill.Start( + _refusedDllPaths, + ct => System.Threading.Tasks.Task.FromResult>(Array.Empty()), + correlationId); } /// From 4f3eba3e6e351def1673f70cdafb48fea004ca34 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 12:55:02 +0900 Subject: [PATCH 2/7] perf: The caller-note backfill also reads the dlls the next caller level needs The note step's caller closure stops at the first level with a refused dll, so the backfill only learned that level's dlls and the next run still read one dll and refused another. After reading, the backfill now calls the run's recompute function, reads the dlls it newly refuses, and repeats until nothing new is refused or the caller-depth limit of rounds is reached. Tried paths are remembered so a dll that fails to read is not retried, the cancel is checked before each recompute so a waiting run is not held up by it, and the complete entry gains rounds while requested now counts the distinct dlls tried. --- .../HotReloadCallSiteBackfill.cs | 79 +++++++++++++++---- docs/vibe-logs.md | 18 +++-- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs index b042695ed..f5eb3fe45 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -57,9 +58,11 @@ internal HotReloadCallSiteBackfillState State } /// - /// Starts reading on the main thread. Precondition: no backfill - /// is running (the run calling this yielded to the previous one first). Nothing starts and - /// nothing is logged for an empty list. + /// Starts reading on the main thread. Once they are read, + /// recomputes the dlls the run's notes would refuse now + /// and the new ones are read too, until none is new or the caller-depth round limit is + /// reached. Precondition: no backfill is running (the run calling this yielded to the + /// previous one first). Nothing starts and nothing is logged for an empty list. /// internal void Start( IReadOnlyList dllPaths, @@ -90,7 +93,7 @@ internal void Start( _state = HotReloadCallSiteBackfillState.Running; _cancelledBy = null; _cancellation = new CancellationTokenSource(); - _completion = RunAllAsync(dllPaths, correlationId, _cancellation.Token); + _completion = RunAllAsync(dllPaths, findRefusedAgain, correlationId, _cancellation.Token); } } @@ -136,40 +139,83 @@ private Task CancelRunningLocked(string cancelledBy) return _completion; } - private async Task RunAllAsync(IReadOnlyList dllPaths, string correlationId, CancellationToken ct) + private async Task RunAllAsync( + IReadOnlyList dllPaths, + Func>> findRefusedAgain, + string correlationId, + CancellationToken ct) { Stopwatch total = Stopwatch.StartNew(); + int requested = 0; int loaded = 0; int failed = 0; + int rounds = 0; + // Why remember every tried path: a dll that failed to read is refused again by the + // recompute, and trying it again would loop until the round limit. + HashSet attempted = new HashSet(StringComparer.Ordinal); + IReadOnlyList pending = dllPaths; try { try { - foreach (string dllPath in dllPaths) + while (true) { - // Why between dlls: a read cannot be interrupted, so a cancel takes effect - // before the next one, and the run that cancelled waits for this one only. + foreach (string dllPath in pending) + { + // Why between dlls: a read cannot be interrupted, so a cancel takes effect + // before the next one, and the run that cancelled waits for this one only. + if (ct.IsCancellationRequested) + { + break; + } + + if (!attempted.Add(dllPath)) + { + continue; + } + + requested++; + if (await TryLoadAsync(dllPath, ct).ConfigureAwait(false)) + { + loaded++; + } + else + { + failed++; + } + } + + // Why check before recomputing too: the run that cancelled waits for this + // backfill, so it must not also wait for a recompute nobody will use. if (ct.IsCancellationRequested) { break; } - if (await TryLoadAsync(dllPath, ct).ConfigureAwait(false)) + // Why a limit: each round reaches at most one more caller level and the + // notes walk no deeper than this; the attempted set already ends the loop. + if (rounds >= HotReloadOneShotCallerClosure.MaxCallerDepth) { - loaded++; + break; } - else + + rounds++; + // Why recompute rather than walk past the refused level: a refused dll's + // callers are unknown until it is read, so each read can reveal the next level. + IReadOnlyList refusedAgain = await findRefusedAgain(ct).ConfigureAwait(false); + pending = refusedAgain.Where(dllPath => !attempted.Contains(dllPath)).ToList(); + if (pending.Count == 0) { - failed++; + break; } } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - // A load cancelled before it started read nothing; it counts as neither. + // A load or recompute cancelled before it finished counts as neither. } - LogComplete(dllPaths.Count, loaded, failed, total.ElapsedMilliseconds, correlationId); + LogComplete(requested, loaded, failed, rounds, total.ElapsedMilliseconds, correlationId); } catch (Exception ex) { @@ -209,7 +255,8 @@ private static bool IsSkippableLoadException(Exception ex) return ex is IOException || ex is UnauthorizedAccessException || ex is BadImageFormatException; } - private void LogComplete(int requested, int loaded, int failed, long totalMs, string correlationId) + // requested counts the distinct dlls tried; rounds counts the recomputes. + private void LogComplete(int requested, int loaded, int failed, int rounds, long totalMs, string correlationId) { string cancelledBy; lock (_gate) @@ -220,7 +267,7 @@ private void LogComplete(int requested, int loaded, int failed, long totalMs, st VibeLogger.LogInfo( HotReloadConstants.VibeLogCallerNoteBackfillComplete, "Hot reload caller-note backfill finished.", - new { requested, loaded, failed, totalMs, cancelledBy }, + new { requested, loaded, failed, rounds, totalMs, cancelledBy }, correlationId); } } diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 25e7caf46..2cd813563 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -43,13 +43,17 @@ command ran against: only from one-shot lifecycle messages) reads at most one compiled assembly that is not in the call-site cache yet; when it would need more, the note is omitted and the run writes one `hot_reload_caller_note_load_budget_exhausted` with `budgetLoads` and the `refusedAssemblies` - it did not read. Right after the run the refused assemblies are read on a pool thread, and - when that finishes the run's correlation id gets one `hot_reload_caller_note_backfill_complete` - with `requested`, `loaded`, `failed` (dlls that could not be read; the next run reads them - itself), `totalMs` and `cancelledBy` (`run` when the next run came first, - `beforeAssemblyReload`, `compilationStarted`, `services_replaced`, or null). The next run finds - them cached, so a note that needs more than one assembly appears from the second run on; a - run's `warm_up_yield` step also covers the wait for a backfill's dll in flight. + it did not read. Right after the run the refused assemblies are read on a pool thread; then the + notes are recomputed without reading anything, and the assemblies that recompute would still + refuse (callers one level further, found only once the refused ones are read) are read too, + for at most four recomputes. When that finishes the run's correlation id gets one + `hot_reload_caller_note_backfill_complete` with `requested` (distinct dlls tried), `loaded`, + `failed` (dlls that could not be read; the next run reads them itself), `rounds` (recomputes + done), `totalMs` and `cancelledBy` (`run` when the next run came first, + `beforeAssemblyReload`, `compilationStarted`, `services_replaced`, or null). The recompute + writes no `hot_reload_caller_note_load_budget_exhausted`. The next run finds them cached, so a + note that needs more than one assembly appears from the second run on; a run's + `warm_up_yield` step also covers the wait for a backfill's dll in flight. The time inside the analysis and shim-compile phases is split by separate entries, never by `steps`, so `unaccountedMs` keeps its meaning. `hot_reload_worker_request_timing` (one per request to the transform worker, prepare and transform) has the gate wait, launch target From 6e378d16de88ac1b3f4ffb650420197a1c2a4417 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 12:58:32 +0900 Subject: [PATCH 3/7] perf: A run hands the backfill a zero-budget recompute of its caller notes The run remembers the project root and note requests, and on End passes the backfill a function that, on the main thread under a fresh cache hold, rebuilds the notes with a load budget of zero and returns the dlls it refused. Nothing is read by it and it writes no budget-exhausted entry, since no run's note was omitted. A run that built no notes returns an empty list without hopping threads. --- .../HotReloadCompiledCallers.cs | 2 +- .../HotReloadCompiledCallersRun.cs | 46 +++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs index 3649ac063..e4729bea5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs @@ -135,7 +135,7 @@ public async Task BeginRunAsync() // Why the whole run: the signature-change gate during analysis and the caller notes at // the end both scan callers through the shared cache, once per method they check, so // one hold keeps every dll they read until the run ends. - return new HotReloadCompiledCallersRun(_cache.HoldEntriesForRun(), _backfill); + return new HotReloadCompiledCallersRun(_cache.HoldEntriesForRun(), () => _cache.HoldEntriesForRun(), _backfill); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs index 837403b91..dc425c659 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using UnityEngine; @@ -15,16 +17,24 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools public sealed class HotReloadCompiledCallersRun : IDisposable { private readonly IDisposable _hold; + private readonly Func _holdEntries; private readonly HotReloadCallSiteBackfill _backfill; private IReadOnlyList _refusedDllPaths = Array.Empty(); + private string _projectRoot; + private IReadOnlyList _requests; private bool _notesBuilt; private bool _ended; - internal HotReloadCompiledCallersRun(IDisposable hold, HotReloadCallSiteBackfill backfill) + internal HotReloadCompiledCallersRun( + IDisposable hold, + Func holdEntries, + HotReloadCallSiteBackfill backfill) { Debug.Assert(hold != null, "hold must not be null."); + Debug.Assert(holdEntries != null, "holdEntries must not be null."); Debug.Assert(backfill != null, "backfill must not be null."); _hold = hold; + _holdEntries = holdEntries; _backfill = backfill; } @@ -45,6 +55,8 @@ public IReadOnlyList BuildOneShotCallerNotes( Debug.Assert(!string.IsNullOrEmpty(correlationId), "correlationId must not be null or empty."); Debug.Assert(!_notesBuilt, "one budget per run: notes are built once."); _notesBuilt = true; + _projectRoot = projectRoot; + _requests = requests; // Why one budget per run rather than per scan: the direct scan and every level of the // caller closure go through the same lambda, so one budget bounds the whole note step @@ -84,10 +96,7 @@ public void End(string correlationId) // Why the hold ends here, before the backfill starts: its reads belong to no run, so they // must evict as reads outside a run do rather than pile up under this run's hold. _hold.Dispose(); - _backfill.Start( - _refusedDllPaths, - ct => System.Threading.Tasks.Task.FromResult>(Array.Empty()), - correlationId); + _backfill.Start(_refusedDllPaths, FindRefusedAgainAsync, correlationId); } /// @@ -106,6 +115,33 @@ internal void RememberRefusedDllPathsForTesting(IReadOnlyList dllPaths) _refusedDllPaths = dllPaths; } + // Recomputes this run's notes without reading any dll and returns the dlls that recompute + // refuses: the ones the next run would refuse once the backfill read the earlier ones. + private async Task> FindRefusedAgainAsync(CancellationToken ct) + { + // A run that built no notes has nothing to recompute; that is a valid run, not a misuse. + if (_requests == null) + { + return Array.Empty(); + } + + // Why the main thread: the scan resolves compiled assemblies through Editor APIs. + await MainThreadSwitcher.SwitchToMainThread(ct); + // Why a hold only around the recompute: it keeps the dlls it scans from being evicted + // mid-scan, and is released before the backfill reads, which belong to no run. + using (IDisposable hold = _holdEntries()) + { + // Why a zero budget: the recompute only collects the dlls not read yet; the backfill + // reads them off the main thread. Its notes are discarded, and it logs no budget + // exhaustion because no run's note was omitted. + HotReloadCallSiteLoadBudget budget = new HotReloadCallSiteLoadBudget(0); + HotReloadOneShotCallerNoteEnricher.BuildNotes( + _requests, + (ignoredAssemblyName, identities) => HotReloadCallSiteScanner.FindCallSites(_projectRoot, identities, budget)); + return budget.RefusedDllPaths; + } + } + private static void LogLoadBudgetExhausted( int budgetLoads, IReadOnlyList refusedAssemblyNames, From fde83c4ebb290bb2eec0530ece1ed4c0a60319d5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 13:02:47 +0900 Subject: [PATCH 4/7] test: A cold run's backfill reads the next caller level so the next run reads nothing Add a two-level cross-assembly caller chain (host in the test assembly, relay in the cross-assembly fixture, caller in a new level-2 fixture assembly) and an E2E test: the cold run refuses only the relay's dll, the backfill reads it and then the level-2 dll found by its recompute, writes no extra budget-exhausted entry, and the next run reads no dll. The test reads the patched host through reflection: a direct call from the test assembly is itself a compiled caller, and the closure would stop at it before walking the relay's level. --- .../HotReloadCallerNoteBackfillE2ETests.cs | 72 ++++++++++++++++++- ...eloadCallerNoteBackfillLevel2E2EFixture.cs | 18 +++++ ...CallerNoteBackfillLevel2E2EFixture.cs.meta | 11 +++ ...tReloadCallerNoteBackfillLevel2E2ERelay.cs | 14 ++++ ...adCallerNoteBackfillLevel2E2ERelay.cs.meta | 11 +++ .../HotReloadCallSiteCrossAssemblyLevel2.meta | 8 +++ ...ReloadCallerNoteBackfillLevel2E2ECaller.cs | 14 ++++ ...dCallerNoteBackfillLevel2E2ECaller.cs.meta | 11 +++ ...tReload.CallSiteCrossAssemblyLevel2.asmdef | 18 +++++ ...ad.CallSiteCrossAssemblyLevel2.asmdef.meta | 7 ++ 10 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs create mode 100644 Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs.meta create mode 100644 Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2.meta create mode 100644 Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs create mode 100644 Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs.meta create mode 100644 Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef create mode 100644 Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs index 55c65ff7a..421a63f1a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillE2ETests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -18,12 +19,14 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// /// End-to-end EditMode coverage for the caller-note backfill around a hot reload run: a run - /// whose note step refused a dll reads it in the background afterwards, so the next run reads - /// nothing, and a run that comes while a backfill reads waits for that dll only. + /// whose note step refused a dll reads it, and the next caller level's dlls, in the background + /// afterwards, so the next run reads nothing, and a run that comes while a backfill reads + /// waits for that dll only. /// public class HotReloadCallerNoteBackfillE2ETests { private const string BackfillFixtureFileName = "HotReloadCallerNoteBackfillE2EFixture.cs"; + private const string Level2BackfillFixtureFileName = "HotReloadCallerNoteBackfillLevel2E2EFixture.cs"; private const string CacheFixtureFileName = "HotReloadCallSiteCacheE2EFixture.cs"; private const string CrossAssemblyName = "UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssembly"; private const string ReadBody = "return 1;"; @@ -107,6 +110,62 @@ public async Task Run_WhoseNoteStepRefusedADll_BackfillsItSoTheNextRunReadsNothi "The second run's budget refuses nothing."); } + /// + /// What: when the refused dll holds a caller whose own caller sits in a further uncached dll, + /// the cold run's backfill reads that dll too, so the next run reads no dll; the backfill's + /// recompute writes no budget-exhausted entry. + /// + [Test] + public async Task Run_WhoseNoteStepRefusedADllWithACallerChain_BackfillsTheNextLevelSoTheNextRunReadsNothing() + { + string fixturePath = FixturePath(Level2BackfillFixtureFileName); + string source = File.ReadAllText(fixturePath); + Assert.That(source, Does.Contain(ReadBody), "Precondition: the Read body anchor must exist."); + _scope = new HotReloadDomainTestScope(); + await _scope.InstalledWarmUpStopped; + // Why clear: entries cached by earlier tests would let the runs refuse nothing. + HotReloadCompiledCallSiteCache.Shared.Clear(); + int before = HotReloadCompiledCallSiteCache.Shared.LoadCount; + + HotReloadOrchestratorResult first = await RunWithReadReturningAsync( + fixturePath, + source, + 2, + ReadLevel2FixtureWithoutACallSite); + JArray exhausted = JArray.Parse(VibeLogger.GetLogsForAi(HotReloadConstants.VibeLogCallerNoteLoadBudgetExhausted)); + Assert.That(exhausted.Count, Is.EqualTo(1), "Precondition: the first run's budget must refuse a dll.\n" + FormatOutcomes(first)); + Assert.That( + exhausted[0]["context"]["refusedAssemblies"].ToObject(), + Is.EqualTo(new[] { CrossAssemblyName }), + "Precondition: the first run must refuse only the relay's dll; the next level is found later."); + + await HotReloadCompositionRoot.Services.CompiledCallers.Backfill.Completion; + int afterBackfill = HotReloadCompiledCallSiteCache.Shared.LoadCount; + Assert.That( + JArray.Parse(VibeLogger.GetLogsForAi(HotReloadConstants.VibeLogCallerNoteLoadBudgetExhausted)).Count, + Is.EqualTo(1), + "The backfill's recompute writes no budget-exhausted entry."); + + HotReloadOrchestratorResult second = await RunWithReadReturningAsync( + fixturePath, + source, + 3, + ReadLevel2FixtureWithoutACallSite); + Assert.That( + HotReloadCompiledCallSiteCache.Shared.LoadCount, + Is.EqualTo(afterBackfill), + "The second run reads no dll: the backfill already read the next caller level.\n" + FormatOutcomes(second)); + Assert.That( + JArray.Parse(VibeLogger.GetLogsForAi(HotReloadConstants.VibeLogCallerNoteLoadBudgetExhausted)).Count, + Is.EqualTo(1), + "The second run's budget refuses nothing."); + + JObject backfill = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCallerNoteBackfillComplete); + Assert.That((int)backfill["rounds"], Is.GreaterThanOrEqualTo(2), "rounds"); + Assert.That((int)backfill["loaded"], Is.EqualTo(2), "loaded: the relay's dll and the level-2 caller's dll"); + Assert.That(afterBackfill - before, Is.EqualTo(3), "the host's dll in the run, then the two dlls in the backfill"); + } + /// /// What: a run that comes while the previous run's backfill is reading a dll waits for /// that dll, applies, and the backfill reads no later dll; the entry names the run. @@ -215,6 +274,15 @@ private static async Task RunWithReadReturningAsync return result; } + // Why reflection: a direct call here would be a compiled caller of Read in this test + // assembly, and the caller closure would stop at it before walking the relay's level. + private static int ReadLevel2FixtureWithoutACallSite() + { + MethodInfo read = typeof(HotReloadCallerNoteBackfillLevel2E2EFixture).GetMethod( + nameof(HotReloadCallerNoteBackfillLevel2E2EFixture.Read)); + return (int)read.Invoke(new HotReloadCallerNoteBackfillLevel2E2EFixture(), null); + } + private static async Task AwaitRunAsync(Task run) { HotReloadOrchestratorResult result = null; diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs new file mode 100644 index 000000000..d728de873 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Compiled host whose one method the level-2 caller-note backfill E2E edits; its caller chain + /// runs through the cross-assembly relay to the level-2 fixture assembly, so a cold run's + /// backfill must read both dlls before the next run reads none. + /// + public class HotReloadCallerNoteBackfillLevel2E2EFixture + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int Read() + { + return 1; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs.meta new file mode 100644 index 000000000..8f817f245 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCallerNoteBackfillLevel2E2EFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d6dba7ff3e06f407d9f137a32efe323d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs b/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs new file mode 100644 index 000000000..a0ab1a2a7 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Compiled relay between the level-2 E2E host and its caller in the level-2 fixture assembly, + /// so the caller-note closure walks two cross-assembly levels. + /// + public static class HotReloadCallerNoteBackfillLevel2E2ERelay + { + public static int Call() + { + return new HotReloadCallerNoteBackfillLevel2E2EFixture().Read(); + } + } +} diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs.meta b/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs.meta new file mode 100644 index 000000000..1ffec0615 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssembly/HotReloadCallerNoteBackfillLevel2E2ERelay.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1abe54291482248428a5c95e48e91290 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2.meta b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2.meta new file mode 100644 index 000000000..45ae4d2c0 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7981b9bdadaf74538bd9ab32c694ed65 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs new file mode 100644 index 000000000..91a777fe2 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Compiled caller one level above the cross-assembly relay, so the caller-note closure of the + /// level-2 E2E host's method reaches a second dll only once the relay's dll has been read. + /// + public static class HotReloadCallerNoteBackfillLevel2E2ECaller + { + public static int Call() + { + return HotReloadCallerNoteBackfillLevel2E2ERelay.Call(); + } + } +} diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs.meta b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs.meta new file mode 100644 index 000000000..89157047a --- /dev/null +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/HotReloadCallerNoteBackfillLevel2E2ECaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d1278768d949446a0920598ba578135b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef new file mode 100644 index 000000000..ef86a7542 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef @@ -0,0 +1,18 @@ +{ + "name": "UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2", + "rootNamespace": "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload", + "references": [ + "GUID:fc86d7bfb642b4032b71b989e24c5fe8" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef.meta b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef.meta new file mode 100644 index 000000000..9221e2a50 --- /dev/null +++ b/Assets/Tests/Editor/HotReloadCallSiteCrossAssemblyLevel2/UnityCLILoop.Tests.Editor.HotReload.CallSiteCrossAssemblyLevel2.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 87f5d615d36a148948da107d69157f70 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From e8ec29d095a6c8cada590991e6d0e2becc3418cc Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 13:09:34 +0900 Subject: [PATCH 5/7] refactor: Move the group state snapshot out of the group processor Move Function only: SnapshotGroupState becomes HotReloadGroupStateSnapshot.Capture(domain, playMode, files) with the same body. It depends only on the domain and the play-mode query, and moving it gives HotReloadGroupProcessor (493 -> 468 SLOC) room under the 500 SLOC limit before the compiled-callers run is threaded through it. --- .../HotReload/HotReloadGroupProcessor.cs | 34 +------------ .../HotReload/HotReloadGroupStateSnapshot.cs | 51 +++++++++++++++++++ .../HotReloadGroupStateSnapshot.cs.meta | 11 ++++ 3 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs index 9bd7eb7e6..9aeca8aee 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs @@ -72,7 +72,7 @@ internal async Task> ProcessGroupAsync using (timing.MeasureDetail(HotReloadConstants.TimingDetailStepSnapshotGroupState)) { - SnapshotGroupState(files); + HotReloadGroupStateSnapshot.Capture(_domain, _collaborators.PlayMode, files); } // Why here and not at the leave-out decision: the ledgers need the main thread, which @@ -610,37 +610,5 @@ internal async Task RevalidateBeforeRevertAsync( revertUnchangedPatches(); return true; } - - private void SnapshotGroupState(IReadOnlyList files) - { - foreach (HotReloadGroupFile file in files) - { - // Why snapshot at the group's apply entry: runs process groups sequentially, and - // RevertUnchangedPatches / BeginFileGeneration mutate ledgers after the worker. - // The worker itself does not. - file.SnapshotLabels = HotReloadAppliedSourceLifecycle.CollectActiveLabelsForFile( - _domain, - file.ProjectRelativePath); - file.SnapshotAddedLabels = new HashSet( - _domain.ListActiveAddedMethodKeys(file.ProjectRelativePath), - StringComparer.Ordinal); - file.SnapshotForwardedUnityMessageLabels = _collaborators.PlayMode.IsPlaying - ? HotReloadDeactivatedUnityMessageNote.CollectForwardedLabels( - _domain.DescribeAddedMembersOfFile(file.ProjectRelativePath)) - : new HashSet(StringComparer.Ordinal); - // Why projectRelativePath (not workerSourcePath): contentPathOverride E2E copies - // live under Library/UloopHotReload/TestSources/ and are absent from the PDB - // document list. Assembly resolution already computed the on-disk path. - file.SnapshotSource = HotReloadSourceBaseline.LoadVerifiedSnapshotSource( - file.ProjectRelativePath, - file.TargetDllPath); - file.SnapshotMissReason = file.SnapshotSource == null - ? HotReloadSourceBaseline.DescribeSnapshotMiss( - file.ProjectRelativePath, - file.TargetDllPath, - file.NewSourceMembershipEvidence) - : HotReloadSnapshotMissReason.None; - } - } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs new file mode 100644 index 000000000..5f66c5aa7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Takes the per-file snapshot a group compares its apply against. + /// + internal static class HotReloadGroupStateSnapshot + { + /// + /// Records on each file of the group what the domain holds for it before the group + /// applies: active labels, added methods, forwarded Unity messages, and the verified + /// source baseline (or why there is none). Call on the Unity main thread. + /// + internal static void Capture( + HotReloadDomain domain, + IHotReloadPlayModeQuery playMode, + IReadOnlyList files) + { + foreach (HotReloadGroupFile file in files) + { + // Why snapshot at the group's apply entry: runs process groups sequentially, and + // RevertUnchangedPatches / BeginFileGeneration mutate ledgers after the worker. + // The worker itself does not. + file.SnapshotLabels = HotReloadAppliedSourceLifecycle.CollectActiveLabelsForFile( + domain, + file.ProjectRelativePath); + file.SnapshotAddedLabels = new HashSet( + domain.ListActiveAddedMethodKeys(file.ProjectRelativePath), + StringComparer.Ordinal); + file.SnapshotForwardedUnityMessageLabels = playMode.IsPlaying + ? HotReloadDeactivatedUnityMessageNote.CollectForwardedLabels( + domain.DescribeAddedMembersOfFile(file.ProjectRelativePath)) + : new HashSet(StringComparer.Ordinal); + // Why projectRelativePath (not workerSourcePath): contentPathOverride E2E copies + // live under Library/UloopHotReload/TestSources/ and are absent from the PDB + // document list. Assembly resolution already computed the on-disk path. + file.SnapshotSource = HotReloadSourceBaseline.LoadVerifiedSnapshotSource( + file.ProjectRelativePath, + file.TargetDllPath); + file.SnapshotMissReason = file.SnapshotSource == null + ? HotReloadSourceBaseline.DescribeSnapshotMiss( + file.ProjectRelativePath, + file.TargetDllPath, + file.NewSourceMembershipEvidence) + : HotReloadSnapshotMissReason.None; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs.meta new file mode 100644 index 000000000..8b345cf26 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStateSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b7d746a1cec3742b4a908fa41e333abc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From f462d4129b9b7780bef0fea8f6e387bd91fb430d Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 13:23:55 +0900 Subject: [PATCH 6/7] refactor: Caller scans go through the compiled-callers run, so they cannot run outside one FindCallSites reads every compiled assembly it needs and was safe only while a run held the cache, a contract the facade stated in a comment alone. It moves from HotReloadCompiledCallers onto HotReloadCompiledCallersRun, which the orchestrator threads through the group processor into HotReloadApplyContext; the signature-change gate scans through context.CompiledCallersRun. Holding a run is now the precondition, and a call after End or Dispose asserts. HotReloadGroupStageCollaborators loses its now unused CompiledCallers. No behavior change. Hand-built test contexts get a run on an isolated cache (no shared hold, no Dispose); tests that call ProcessGroupAsync directly open a real run around it. --- .../HotReloadCompiledCallersTestRuns.cs | 31 ++++++++++++++ .../HotReloadCompiledCallersTestRuns.cs.meta | 11 +++++ .../HotReloadCompiledCallersTests.cs | 21 ++++++++++ .../HotReloadGroupProcessorLeaveOutTests.cs | 15 ++++--- .../HotReload/HotReloadGroupProcessorTests.cs | 40 ++++++++++++------- .../HotReloadIntroducedTypeE2ETestBase.cs | 3 +- .../HotReload/HotReloadOrchestratorTests.cs | 3 +- .../HotReloadCompiledCallers.cs | 11 ----- .../HotReloadCompiledCallersRun.cs | 14 +++++++ .../HotReload/HotReloadApplyContext.cs | 9 ++++- .../HotReload/HotReloadCompositionRoot.cs | 3 +- .../HotReload/HotReloadGroupProcessor.cs | 15 +++++-- .../HotReloadGroupStageCollaborators.cs | 7 +--- .../HotReload/HotReloadOrchestrator.cs | 8 ++-- .../HotReload/HotReloadSignatureChangeGate.cs | 2 +- 15 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs new file mode 100644 index 000000000..40b592607 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Compiled-caller runs for tests that build an apply context by hand, outside any hot reload run. + /// + internal static class HotReloadCompiledCallersTestRuns + { + /// + /// Opens a run on compiled callers of its own: a fresh cache and a backfill that reads + /// nothing. Its hold is taken on that fresh cache, never on the shared one, so it keeps no + /// shared entry alive and needs no Dispose; the caller scans it does still read the shared + /// cache without a hold, as gate tests did before scans moved onto the run. The backfill is + /// idle, so the run begins synchronously and is read without blocking. + /// + internal static HotReloadCompiledCallersRun OpenIsolated() + { + HotReloadCompiledCallers compiledCallers = new HotReloadCompiledCallers( + new HotReloadCompiledCallSiteCache(HotReloadCompiledCallSiteCache.DefaultBudgetBytes), + new HotReloadCallSiteBackfill((dllPath, ct) => Task.CompletedTask)); + Task begin = compiledCallers.BeginRunAsync(); + Assert.That(begin.IsCompleted, Is.True, "Precondition: a run on an idle backfill begins synchronously."); + return begin.Result; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs.meta new file mode 100644 index 000000000..c59a245e6 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTestRuns.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 43582fde9e890428e9bef0a545db5708 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs index 46140d5e7..6cbedd035 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs @@ -1,9 +1,14 @@ +using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -104,5 +109,21 @@ public async Task Dispose_WithoutEnd_ReleasesTheHoldAndStartsNoBackgroundRead() Assert.That(_loader.Ran, Is.Empty, "no background read starts"); Assert.That(_backfill.State, Is.EqualTo(HotReloadCallSiteBackfillState.Idle)); } + + /// + /// What: a caller scan asked of a run that already ended is reported, since its reads are + /// no longer held against eviction. + /// + [Test] + public async Task FindCallSites_AfterTheRunEnded_Asserts() + { + HotReloadCompiledCallersRun run = await _compiledCallers.BeginRunAsync(); + run.End("corr"); + LogAssert.Expect(LogType.Assert, "FindCallSites is called only while the run is open."); + + run.FindCallSites(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), Array.Empty()); + + run.Dispose(); + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs index 07e0f1dac..df72056bc 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorLeaveOutTests.cs @@ -289,11 +289,16 @@ private async Task> RunGroupAsync( collaborators, context, compileResult, entriesToPatch), collaborators.EntryApplier.ApplyPreparedEntries))) { - return await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( - files, - "leave-out-test", - new HotReloadRunTiming(), - CancellationToken.None); + using (HotReloadCompiledCallersRun compiledCallersRun = + await HotReloadCompositionRoot.Services.CompiledCallers.BeginRunAsync()) + { + return await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( + files, + compiledCallersRun, + "leave-out-test", + new HotReloadRunTiming(), + CancellationToken.None); + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs index 2455fe8df..befbc164a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs @@ -639,11 +639,16 @@ public async Task ProcessGroupAsync_WhenTheWorkerFails_BuildsUnappliedResultsWit collaborators, context, compileResult, entriesToPatch), collaborators.EntryApplier.ApplyPreparedEntries))) { - results = await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( - new[] { file }, - "worker-failure-test", - new HotReloadRunTiming(), - CancellationToken.None); + using (HotReloadCompiledCallersRun compiledCallersRun = + await HotReloadCompositionRoot.Services.CompiledCallers.BeginRunAsync()) + { + results = await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( + new[] { file }, + compiledCallersRun, + "worker-failure-test", + new HotReloadRunTiming(), + CancellationToken.None); + } } Assert.That(results, Has.Count.EqualTo(1)); @@ -749,13 +754,18 @@ private static async Task RunGroupWithPhaseStubsAsync( { try { - total.Start(); - await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( - new[] { stubs.GroupFile }, - "phase-timing-test", - timing, - CancellationToken.None); - total.Stop(); + using (HotReloadCompiledCallersRun compiledCallersRun = + await HotReloadCompositionRoot.Services.CompiledCallers.BeginRunAsync()) + { + total.Start(); + await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( + new[] { stubs.GroupFile }, + compiledCallersRun, + "phase-timing-test", + timing, + CancellationToken.None); + total.Stop(); + } } catch (OperationCanceledException exception) { @@ -1821,7 +1831,8 @@ private static HotReloadApplyContext CreateContext( workerOutput, new[] { callerFile, targetFile }, null, - new HotReloadCompileFailureNoteSources(workerOutput.skipped, Array.Empty())); + new HotReloadCompileFailureNoteSources(workerOutput.skipped, Array.Empty()), + HotReloadCompiledCallersTestRuns.OpenIsolated()); } private static HotReloadApplyContext CreateEmptyEntriesContext( @@ -1848,7 +1859,8 @@ private static HotReloadApplyContext CreateEmptyEntriesContext( emptyWorkerOutput, context.Files, null, - new HotReloadCompileFailureNoteSources(emptyWorkerOutput.skipped, Array.Empty())); + new HotReloadCompileFailureNoteSources(emptyWorkerOutput.skipped, Array.Empty()), + context.CompiledCallersRun); } private static Assembly FindCompilationAssembly() diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs index 1f4d37377..e96a7887d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeE2ETestBase.cs @@ -102,8 +102,7 @@ private protected static HotReloadGroupStageCollaborators WithInternalAccessGran collaborators.EditorStateSnapshotCapture, collaborators.CommitPolicy, collaborators.PlayMode, - internalAccessGrant, - collaborators.CompiledCallers); + internalAccessGrant); } private protected static Task RunReloadAsync( diff --git a/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs index 3c6c1987f..f9c8cf418 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadOrchestratorTests.cs @@ -3710,7 +3710,8 @@ private static HotReloadApplyContext CreateApplyContext( workerOutput, new[] { file }, null, - new HotReloadCompileFailureNoteSources(workerOutput.skipped, Array.Empty())); + new HotReloadCompileFailureNoteSources(workerOutput.skipped, Array.Empty()), + HotReloadCompiledCallersTestRuns.OpenIsolated()); } private static HotReloadOrchestratorResult ToOrchestratorResult( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs index e4729bea5..7785aacae 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallers.cs @@ -41,17 +41,6 @@ public static HotReloadCompiledCallers CreateProduction() return new HotReloadCompiledCallers(cache, backfill); } - /// - /// Finds the compiled call and ldftn sites that reference any of , - /// reading every compiled assembly it needs. Call it only while a run begun with - /// is open: outside one, a read on another thread may evict and - /// dispose a cached assembly this is still reading. - /// - public List FindCallSites(string projectRoot, HotReloadCompiledMethodIdentity[] targets) - { - return HotReloadCallSiteScanner.FindCallSites(projectRoot, targets).Hits; - } - /// The compiled dlls of the assemblies that reference . public IReadOnlyList CollectReferencingDllPaths(string projectRoot, string assemblyName) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs index dc425c659..7a306c6cc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCompiledCallersRun.cs @@ -24,6 +24,7 @@ public sealed class HotReloadCompiledCallersRun : IDisposable private IReadOnlyList _requests; private bool _notesBuilt; private bool _ended; + private bool _disposed; internal HotReloadCompiledCallersRun( IDisposable hold, @@ -38,6 +39,18 @@ internal HotReloadCompiledCallersRun( _backfill = backfill; } + /// + /// Finds the compiled call and ldftn sites that reference any of , + /// reading every compiled assembly it needs. Call before the run ends: the assemblies it + /// reads stay cached only while the run is open, so another thread cannot evict and + /// dispose one this is still reading. + /// + public List FindCallSites(string projectRoot, HotReloadCompiledMethodIdentity[] targets) + { + Debug.Assert(!_ended && !_disposed, "FindCallSites is called only while the run is open."); + return HotReloadCallSiteScanner.FindCallSites(projectRoot, targets).Hits; + } + /// /// Builds the one-shot lifecycle note of each request: one entry per request, in the same /// order, null where no note can be proven. Reads at most the configured number of compiled @@ -105,6 +118,7 @@ public void End(string correlationId) /// public void Dispose() { + _disposed = true; _hold.Dispose(); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyContext.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyContext.cs index 9913911cc..80ceb1b94 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyContext.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyContext.cs @@ -28,7 +28,8 @@ internal HotReloadApplyContext( TransformWorkerOutputDto workerOutput, IReadOnlyList files, HotReloadPreparedIntroducedTypes preparedIntroducedTypes, - HotReloadCompileFailureNoteSources compileFailureNoteSources) + HotReloadCompileFailureNoteSources compileFailureNoteSources, + HotReloadCompiledCallersRun compiledCallersRun) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be empty."); @@ -39,6 +40,7 @@ internal HotReloadApplyContext( Debug.Assert(workerOutput != null, "workerOutput must not be null."); Debug.Assert(files != null && files.Count > 0, "A group must hold a file."); Debug.Assert(compileFailureNoteSources != null, "compileFailureNoteSources must not be null."); + Debug.Assert(compiledCallersRun != null, "compiledCallersRun must not be null."); ProjectRoot = projectRoot; AssemblyName = assemblyName; @@ -51,6 +53,7 @@ internal HotReloadApplyContext( Files = files; PreparedIntroducedTypes = preparedIntroducedTypes; CompileFailureNoteSources = compileFailureNoteSources; + CompiledCallersRun = compiledCallersRun; List<(string ProjectRelativePath, string AssemblyResolvePath)> filePaths = new List<(string ProjectRelativePath, string AssemblyResolvePath)>(files.Count); @@ -101,6 +104,10 @@ internal HotReloadApplyContext( // What a shim compile failure of this group can be explained with. internal HotReloadCompileFailureNoteSources CompileFailureNoteSources { get; } + // The compiled-caller analysis of the run this group belongs to; caller scans go through it + // so they read only while the run keeps the cached assemblies alive. + internal HotReloadCompiledCallersRun CompiledCallersRun { get; } + // The edited files of this group, in the order they were sent to the worker. internal IReadOnlyList Files { get; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 0297501ec..10676d4b2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -152,8 +152,7 @@ internal static HotReloadServices CreateServices( editorStateSnapshotCapture, new HotReloadGroupCommitPolicy(domain, fileEntryApplier), playMode, - HotReloadMonoInternalAccessGrant.ForThisProcess(), - compiledCallers); + HotReloadMonoInternalAccessGrant.ForThisProcess()); // A factory, not a built value: the stages are bound to the collaborators built here, // and a caller that built them from the installed services would bind a replacement's // group run back to the domain that was installed when it called. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs index 9aeca8aee..bd52a5172 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs @@ -48,11 +48,13 @@ internal HotReloadGroupProcessor( internal async Task> ProcessGroupAsync( IReadOnlyList files, + HotReloadCompiledCallersRun compiledCallersRun, string correlationId, HotReloadRunTiming timing, CancellationToken ct) { Debug.Assert(files != null && files.Count > 0, "A group must hold a file."); + Debug.Assert(compiledCallersRun != null, "compiledCallersRun must not be null."); Debug.Assert(timing != null, "timing must not be null."); HotReloadGroupFile firstFile = files[0]; @@ -159,7 +161,7 @@ internal async Task> ProcessGroupAsync if (preparation.Prepared == null) { - return await TransformAndApplyGroupAsync(files, workerInput, null, refusedTypes, activePaths, correlationId, timing, ct) + return await TransformAndApplyGroupAsync(files, workerInput, null, refusedTypes, activePaths, compiledCallersRun, correlationId, timing, ct) .ConfigureAwait(false); } @@ -169,6 +171,7 @@ internal async Task> ProcessGroupAsync preparation.Prepared, refusedTypes, activePaths, + compiledCallersRun, correlationId, timing, ct).ConfigureAwait(false); @@ -194,6 +197,7 @@ private async Task> TransformAndApplyP HotReloadPreparedIntroducedTypes prepared, IReadOnlyList refusedTypes, HashSet activePaths, + HotReloadCompiledCallersRun compiledCallersRun, string correlationId, HotReloadRunTiming timing, CancellationToken ct) @@ -227,6 +231,7 @@ private async Task> TransformAndApplyP prepared, refusedTypes, activePaths, + compiledCallersRun, correlationId, timing, ct).ConfigureAwait(false); @@ -255,6 +260,7 @@ private async Task> TransformAndApplyG HotReloadPreparedIntroducedTypes prepared, IReadOnlyList refusedTypes, HashSet activePaths, + HotReloadCompiledCallersRun compiledCallersRun, string correlationId, HotReloadRunTiming timing, CancellationToken ct) @@ -281,7 +287,7 @@ private async Task> TransformAndApplyG if (leftOutPaths.Count == 0) { - return await ApplyTransformedGroupAsync(files, workerInput, workerOutput, prepared, refusedTypes, correlationId, timing, ct) + return await ApplyTransformedGroupAsync(files, workerInput, workerOutput, prepared, refusedTypes, compiledCallersRun, correlationId, timing, ct) .ConfigureAwait(false); } @@ -306,6 +312,7 @@ private async Task> TransformAndApplyG retryResult.Output, prepared, refusedTypes, + compiledCallersRun, correlationId, timing, ct).ConfigureAwait(false) @@ -366,6 +373,7 @@ private async Task> ApplyTransformedGr TransformWorkerOutputDto workerOutput, HotReloadPreparedIntroducedTypes prepared, IReadOnlyList refusedTypes, + HotReloadCompiledCallersRun compiledCallersRun, string correlationId, HotReloadRunTiming timing, CancellationToken ct) @@ -424,7 +432,8 @@ private async Task> ApplyTransformedGr workerOutput, files, prepared, - new HotReloadCompileFailureNoteSources(workerOutput.skipped, refusedTypes)); + new HotReloadCompileFailureNoteSources(workerOutput.skipped, refusedTypes), + compiledCallersRun); } Stopwatch gateWatch = Stopwatch.StartNew(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStageCollaborators.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStageCollaborators.cs index a9c5e9aae..f4390284b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStageCollaborators.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupStageCollaborators.cs @@ -24,8 +24,7 @@ internal HotReloadGroupStageCollaborators( IHotReloadEditorStateSnapshotCapture editorStateSnapshotCapture, HotReloadGroupCommitPolicy commitPolicy, IHotReloadPlayModeQuery playMode, - IHotReloadInternalAccessGrant internalAccessGrant, - HotReloadCompiledCallers compiledCallers) + IHotReloadInternalAccessGrant internalAccessGrant) { Debug.Assert(domain != null, "domain must not be null."); Debug.Assert(patcher != null, "patcher must not be null."); @@ -38,7 +37,6 @@ internal HotReloadGroupStageCollaborators( Debug.Assert(commitPolicy != null, "commitPolicy must not be null."); Debug.Assert(playMode != null, "playMode must not be null."); Debug.Assert(internalAccessGrant != null, "internalAccessGrant must not be null."); - Debug.Assert(compiledCallers != null, "compiledCallers must not be null."); Domain = domain; Patcher = patcher; FileEntryApplier = fileEntryApplier; @@ -49,7 +47,6 @@ internal HotReloadGroupStageCollaborators( CommitPolicy = commitPolicy; PlayMode = playMode; InternalAccessGrant = internalAccessGrant; - CompiledCallers = compiledCallers; } internal HotReloadDomain Domain { get; } @@ -73,7 +70,5 @@ internal HotReloadGroupStageCollaborators( // Why one grant per services graph: its availability is probed once when it is built, and // every artifact of the domain these collaborators serve is granted through that answer. internal IHotReloadInternalAccessGrant InternalAccessGrant { get; } - - internal HotReloadCompiledCallers CompiledCallers { get; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs index 97b64015d..4072ad520 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOrchestrator.cs @@ -206,7 +206,8 @@ await ProcessPlannedGroupAsync( contentPathOverrideByFile, isLastChangedGroup, extraResults, - run) + run, + compiledCallersRun) .ConfigureAwait(false); } @@ -322,7 +323,8 @@ private async Task ProcessPlannedGroupAsync( IReadOnlyDictionary contentPathOverrideByFile, bool isLastGroupOfAssembly, List<(string Path, HotReloadFileProcessResult Result)> extraResults, - HotReloadRunAccumulator run) + HotReloadRunAccumulator run, + HotReloadCompiledCallersRun compiledCallersRun) { Debug.Assert(inputIndexes != null && inputIndexes.Count > 0, "A group must hold a file."); List filesOfGroup = new List(inputIndexes.Count); @@ -351,7 +353,7 @@ private async Task ProcessPlannedGroupAsync( // ProcessGroupAsync switches back via MainThreadSwitcher (EditorApplication.update // queue) before any main-thread-only editor API or Harmony patch. IReadOnlyList groupResults = - await _groupProcessor.ProcessGroupAsync(filesOfGroup, correlationId, timing, ct) + await _groupProcessor.ProcessGroupAsync(filesOfGroup, compiledCallersRun, correlationId, timing, ct) .ConfigureAwait(false); Debug.Assert( groupResults.Count == filesOfGroup.Count, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSignatureChangeGate.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSignatureChangeGate.cs index b3e614afa..8905fac86 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSignatureChangeGate.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSignatureChangeGate.cs @@ -42,7 +42,7 @@ internal static async Task TryApplySignatureChangeGat context.WorkerOutput.unchangedMethods ?? Array.Empty(), context.WorkerOutput.skipped ?? Array.Empty(), removedSignatures); - List hits = collaborators.CompiledCallers.FindCallSites(context.ProjectRoot, targets); + List hits = context.CompiledCallersRun.FindCallSites(context.ProjectRoot, targets); Dictionary> uncoveredCallersByTarget = CollectInitialUncoveredCallers(context.AssemblyName, entries, hits, deletedCallerExemptions); From dc187aaed1fa6eafceac460332945ea28029ca5c Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 13 Oct 2026 13:34:37 +0900 Subject: [PATCH 7/7] test: Pin the run's caller scan asserting after a Dispose without End The existing test covered only a scan after End, so dropping the disposed flag left every test passing. A run that threw is disposed without End and releases its hold just the same, so a scan after it must be reported too. --- .../HotReload/HotReloadCompiledCallersTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs index 6cbedd035..5c04a7bfa 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledCallersTests.cs @@ -125,5 +125,19 @@ public async Task FindCallSites_AfterTheRunEnded_Asserts() run.Dispose(); } + + /// + /// What: a caller scan asked of a run that was disposed without End (a run that threw) is + /// reported too, since its hold is released as well. + /// + [Test] + public async Task FindCallSites_AfterTheRunWasDisposedWithoutEnd_Asserts() + { + HotReloadCompiledCallersRun run = await _compiledCallers.BeginRunAsync(); + run.Dispose(); + LogAssert.Expect(LogType.Assert, "FindCallSites is called only while the run is open."); + + run.FindCallSites(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), Array.Empty()); + } } }