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..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. @@ -122,7 +181,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 +221,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", @@ -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/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/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: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs b/Packages/src/Editor/FirstPartyTools/HotReload/CompiledCallers/HotReloadCallSiteBackfill.cs index e66fae414..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,13 +58,19 @@ 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, 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) { @@ -86,7 +93,7 @@ internal void Start(IReadOnlyList dllPaths, string correlationId) _state = HotReloadCallSiteBackfillState.Running; _cancelledBy = null; _cancellation = new CancellationTokenSource(); - _completion = RunAllAsync(dllPaths, correlationId, _cancellation.Token); + _completion = RunAllAsync(dllPaths, findRefusedAgain, correlationId, _cancellation.Token); } } @@ -132,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) { @@ -205,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) @@ -216,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/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 3bee28e34..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,7 +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, correlationId); + _backfill.Start(_refusedDllPaths, FindRefusedAgainAsync, correlationId); } /// @@ -103,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, 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