From 08504b977a374a1c30338943cc3b882d13aa0596 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 17:39:53 +0900 Subject: [PATCH 1/2] Let the hot reload snapshot capture hold back the compile completion get-compile-status now stays not ready while a coordination delegate the hot-reload startup can set reports a running source snapshot capture. Once the capture moves off the main thread, the CLI could otherwise return from compile before it finishes, and an edit made right after could be captured as the source the compiler read. Nothing sets the delegate yet, so the behaviour is unchanged. --- .../Editor/CompileStatusBridgeCommandTests.cs | 37 +++++++++++++++++++ .../Api/CompileStatusBridgeCommand.cs | 14 +++++-- .../HotReloadSnapshotCaptureCoordination.cs | 20 ++++++++++ ...tReloadSnapshotCaptureCoordination.cs.meta | 11 ++++++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs create mode 100644 Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta diff --git a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs index 5a4b1ac007..5ac4bb8f58 100644 --- a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs +++ b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs @@ -52,6 +52,7 @@ public void BuildResponse_WhenUnityIsIdleAndResultMatches_ReturnsReadyResult() isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -77,6 +78,7 @@ public void BuildResponse_WhenUnityIsStillCompiling_ReturnsNotReadyWithStoredRes isCompiling: true, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -101,6 +103,7 @@ public void BuildResponse_WhenRequestIdDiffers_DoesNotReturnStaleResult() isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -124,6 +127,7 @@ public void BuildResponse_WhenUnityIsIdleAndPendingRequestHasNoReloadSignal_Wait isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -152,6 +156,7 @@ public void BuildResponse_WhenUnityIsIdleAndPendingRequestObservedReload_Returns isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -182,6 +187,37 @@ public void BuildResponse_WhenUnityIsBusyAndPendingRequestHasNoResult_WaitsForRe isCompiling: true, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, + _compileSessionLifecycleService, + _compileResultSessionRepository, + _pendingCompileSessionRepository); + + Assert.That(response.Ready, Is.False); + Assert.That(response.HasResult, Is.False); + Assert.That(response.Result, Is.Null); + Assert.That( + UnityCliLoopEditorSessionStateTestFactory.GetSinglePendingCompileRequest( + _pendingCompileSessionRepository).HasRequest, + Is.True); + } + + [Test] + public void BuildResponse_WhileTheSnapshotCaptureHolds_IsNotReadyAndDoesNotRecoverAPendingResult() + { + // Verifies an idle Unity still withholds the compile completion, and leaves the pending + // request for a later poll, while the hot reload source snapshot capture is running. + _compileSessionLifecycleService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: false, + markedAtUtc: System.DateTime.UtcNow); + _pendingCompileSessionRepository.MarkPendingCompileRequestReloadObserved(); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: false, + isUpdating: false, + isDomainReloadInProgress: false, + isSnapshotCaptureHolding: true, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -210,6 +246,7 @@ public void BuildResponse_WhenForceCompilePendingRequestHasNoResult_ReturnsExpla isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); diff --git a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs index 68e9a04fd6..049b75d011 100644 --- a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs @@ -36,15 +36,18 @@ public static GetCompileStatusResponse Execute(JToken paramsToken) bool isDomainReloadInProgress = sessionFlagsRepository.GetIsDomainReloadInProgress() || DomainReloadStateRegistry.IsDomainReloadInProgress(); + bool isSnapshotCaptureHolding = + HotReloadSnapshotCaptureCoordination.IsHoldingCompileCompletion?.Invoke() ?? false; GetCompileStatusResponse response = BuildResponse( requestId, isCompiling, isUpdating, isDomainReloadInProgress, + isSnapshotCaptureHolding, compileSessionLifecycleService, compileResultSessionRepository, pendingCompileSessionRepository); - LogCompileStatusQueryReceived(requestId, response); + LogCompileStatusQueryReceived(requestId, response, isSnapshotCaptureHolding); return response; } @@ -53,6 +56,7 @@ internal static GetCompileStatusResponse BuildResponse( bool isCompiling, bool isUpdating, bool isDomainReloadInProgress, + bool isSnapshotCaptureHolding, UnityCliLoopCompileSessionLifecycleService compileSessionLifecycleService, ICompileResultSessionRepository compileResultSessionRepository, IPendingCompileSessionRepository pendingCompileSessionRepository) @@ -61,7 +65,9 @@ internal static GetCompileStatusResponse BuildResponse( Debug.Assert(compileResultSessionRepository != null, "compileResultSessionRepository must not be null"); Debug.Assert(pendingCompileSessionRepository != null, "pendingCompileSessionRepository must not be null"); - bool ready = !isCompiling && !isUpdating && !isDomainReloadInProgress; + // The hot reload snapshot capture must finish before compile returns, or an edit made + // right after the CLI returns could be captured as the source the compiler read. + bool ready = !isCompiling && !isUpdating && !isDomainReloadInProgress && !isSnapshotCaptureHolding; compileSessionLifecycleService.ClearExpiredPendingCompileRequest(DateTime.UtcNow); UnityCliLoopStoredCompileResult storedResult = string.IsNullOrWhiteSpace(requestId) ? UnityCliLoopStoredCompileResult.None() @@ -100,7 +106,8 @@ private static string ReadRequestId(JToken paramsToken) private static void LogCompileStatusQueryReceived( string requestId, - GetCompileStatusResponse response) + GetCompileStatusResponse response, + bool isSnapshotCaptureHolding) { Debug.Assert(response != null, "response must not be null"); @@ -115,6 +122,7 @@ private static void LogCompileStatusQueryReceived( is_compiling = response.IsCompiling, is_updating = response.IsUpdating, is_domain_reload_in_progress = response.IsDomainReloadInProgress, + is_snapshot_capture_holding = isSnapshotCaptureHolding, message = response.Message }, requestId); diff --git a/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs new file mode 100644 index 0000000000..62f16cdddb --- /dev/null +++ b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs @@ -0,0 +1,20 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Editor-domain coordination point that lets the compile status report wait for the hot + /// reload source snapshot capture. The hot-reload tool lives in a sibling assembly the + /// server must not reference, so it publishes the hold through a delegate wired at its own + /// startup. + /// + public static class HotReloadSnapshotCaptureCoordination + { + // Set by the hot-reload startup. Null means the hot-reload tool has not initialized in + // this domain, so nothing holds the compile completion - callers treat null as false. + // While it returns true, the compile completion is not reported: if the CLI returned + // from compile before the capture finished, an edit made right after could be captured + // as the source the compiler read. + public static Func IsHoldingCompileCompletion { get; set; } + } +} diff --git a/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta new file mode 100644 index 0000000000..5b7e6eb861 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d901119d9fa444fe90222d442d4bd21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From e3b2b4a1b5a6b2a50cf2c02c21745e35059b6663 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 17:39:55 +0900 Subject: [PATCH 2/2] Let a pause point read a PDB-confirmed live source while a capture runs The pause point port now takes an in-flight check. While a source snapshot capture runs, it first reads the file on disk and returns it when the PDB checksum confirms those are the bytes the compiler read, so a pause point re-armed on the first update tick after a reload need not wait for the whole capture. Otherwise it ensures the capture and reads the snapshot as before. The live file and the snapshot share one decoder so a byte order mark is handled the same way. The composition root passes a check that is always false until the capture moves to a pool thread. --- .../Editor/HotReload/HotReloadDomainTests.cs | 75 ++++++++- .../HotReloadLiveSourceBomFixture.cs | 14 ++ .../HotReloadLiveSourceBomFixture.cs.meta | 11 ++ .../HotReloadLiveSourceVerificationTests.cs | 148 ++++++++++++++++++ ...tReloadLiveSourceVerificationTests.cs.meta | 11 ++ .../HotReload/HotReloadCompositionRoot.cs | 5 +- .../Patching/HotReloadPausePointPort.cs | 24 ++- .../Shared/HotReloadSourceBaseline.cs | 99 +++++++++++- 8 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index d2a11ac072..06638df512 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -22,6 +22,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload public class HotReloadDomainTests { private const string FileOne = "Assets/Tests/Editor/HotReload/DomainFileOne.cs"; + // Real sources of this test assembly: one its PDB has a document for, one it has none for. + private const string CompiledFixtureFile = "Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs"; + private const string BodylessFixtureFile = "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; private const string FileTwo = "Assets/Tests/Editor/HotReload/DomainFileTwo.cs"; private const string AddedMethodKey = "DomainHost.AddedPing(System.Int32)"; private const string OtherAddedMethodKey = "DomainHost.AddedPong()"; @@ -1050,6 +1053,74 @@ public void GetVerifiedSnapshotSource_WhenTheCaptureThrows_PassesTheExceptionOn( Assert.That(thrown, Is.SameAs(captureFailure)); } + /// + /// What: while a capture runs, a live file the PDB confirms is returned without waiting for + /// the capture, so a pause point re-armed on the first update tick does not stall on it. + /// + [Test] + public void GetVerifiedSnapshotSource_WhileCaptureRuns_ReturnsTheLiveFileWhenItMatchesThePdbWithoutWaiting() + { + int ensureCount = 0; + HotReloadPausePointPort port = new HotReloadPausePointPort( + _access.Domain, + _ => RecordedSourceHash, + () => ensureCount++, + () => true); + + string source = port.GetVerifiedSnapshotSource(CompiledFixtureFile, TestAssemblyDllPath()); + + Assert.That(source, Is.EqualTo(File.ReadAllText(Path.Combine(ProjectRoot(), CompiledFixtureFile)))); + Assert.That(ensureCount, Is.EqualTo(0)); + } + + /// + /// What: while a capture runs, a live file the PDB cannot confirm (here it has no document) + /// makes the port wait for the capture once and read the snapshot instead. + /// + [Test] + public void GetVerifiedSnapshotSource_WhileCaptureRuns_WaitsWhenTheLiveFileDiffersFromThePdb() + { + int ensureCount = 0; + HotReloadPausePointPort port = new HotReloadPausePointPort( + _access.Domain, + _ => RecordedSourceHash, + () => ensureCount++, + () => true); + + port.GetVerifiedSnapshotSource(BodylessFixtureFile, TestAssemblyDllPath()); + + Assert.That(ensureCount, Is.EqualTo(1)); + } + + /// + /// What: with no capture running, the port waits for the capture and reads the snapshot even + /// when the live file matches the PDB, so the answer is the one it gave before captures ran + /// in the background. + /// + [Test] + public void GetVerifiedSnapshotSource_WhenNoCaptureRuns_EnsuresTheCaptureEvenIfTheLiveFileMatches() + { + int ensureCount = 0; + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++); + + port.GetVerifiedSnapshotSource(CompiledFixtureFile, TestAssemblyDllPath()); + + Assert.That(ensureCount, Is.EqualTo(1)); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string TestAssemblyDllPath() + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + typeof(HotReloadDomainTests).Assembly.GetName().Name + HotReloadConstants.CompiledAssemblyExtension); + } + // Why a generated name: an artifact assembly is compiled under a name of its own, so a // fixture that reused a project assembly's name would not resolve the way production does. private static Assembly CreateIntroducedTypeAssembly() @@ -1071,12 +1142,12 @@ private static void AssertRow( private HotReloadPausePointPort CreatePort(Func readSourceContentHashOrNull) { - return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull, () => { }); + return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull, () => { }, () => false); } private HotReloadPausePointPort CreatePortWithSnapshotCapture(Action ensureSnapshotCaptured) { - return new HotReloadPausePointPort(_access.Domain, _ => RecordedSourceHash, ensureSnapshotCaptured); + return new HotReloadPausePointPort(_access.Domain, _ => RecordedSourceHash, ensureSnapshotCaptured, () => false); } // Records a partially applied reload of FileOne that read RecordedWorkerSourcePath. diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs new file mode 100644 index 0000000000..a90bc65594 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Fixture saved with a UTF-8 byte order mark, so the live-source tests can check that the live + /// file and the snapshot decode the mark the same way. Its method body gives it a PDB document. + /// + internal static class HotReloadLiveSourceBomFixture + { + internal static int Answer() + { + return 42; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta new file mode 100644 index 0000000000..9851829600 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10bdf23520ce04bd28b441d24256fe64 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs new file mode 100644 index 0000000000..fdeebfa4bf --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs @@ -0,0 +1,148 @@ +using System; +using System.IO; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for reading a source file on disk only when the compiled assembly's PDB + /// confirms it holds the bytes the compiler read. + /// + public class HotReloadLiveSourceVerificationTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string BomFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs"; + private const string BodylessFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; + + private string _temporaryRoot; + + [SetUp] + public void SetUp() + { + _temporaryRoot = Path.Combine(Path.GetTempPath(), "uloop-live-source-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_temporaryRoot)) + { + Directory.Delete(_temporaryRoot, recursive: true); + } + } + + /// + /// What: a live file and a snapshot holding the same bytes, a byte order mark included, decode + /// to the same text, and the mark is not part of it. + /// + [Test] + public void LoadVerifiedLiveSourceOrNull_ReturnsTheSameTextAsTheSnapshotForTheSameBytes() + { + string dllPath = TestAssemblyDllPath(); + byte[] compiledBytes = File.ReadAllBytes(Path.Combine(ProjectRoot(), BomFixtureProjectRelativePath)); + Assert.That(compiledBytes[0], Is.EqualTo(0xEF), "Precondition: the fixture must start with a byte order mark."); + WriteLiveFile(BomFixtureProjectRelativePath, compiledBytes); + string snapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory( + _temporaryRoot, + TestAssemblyName, + HotReloadAssemblyMvid.Read(dllPath)); + Directory.CreateDirectory(snapshotDirectory); + File.WriteAllBytes( + HotReloadSourceSnapshotLayout.SourcePath(snapshotDirectory, BomFixtureProjectRelativePath), + compiledBytes); + + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + dllPath, + HotReloadPdbDocumentIndex.Shared); + string snapshot = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + dllPath, + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Not.Null); + Assert.That(live, Is.EqualTo(snapshot)); + Assert.That(live[0], Is.Not.EqualTo('')); + } + + /// + /// What: a live file whose bytes differ from the checksum the PDB recorded is not returned. + /// + [Test] + public void LoadVerifiedLiveSourceAt_WhenTheLiveBytesDifferFromThePdb_ReturnsNull() + { + byte[] editedBytes = File.ReadAllBytes(Path.Combine(ProjectRoot(), BomFixtureProjectRelativePath)); + editedBytes[editedBytes.Length - 1] = (byte)(editedBytes[editedBytes.Length - 1] ^ 0xFF); + WriteLiveFile(BomFixtureProjectRelativePath, editedBytes); + + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + TestAssemblyDllPath(), + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Null); + } + + /// + /// What: a source that is not on disk is not returned. + /// + [Test] + public void LoadVerifiedLiveSourceAt_WhenTheFileIsMissing_ReturnsNull() + { + Directory.CreateDirectory(_temporaryRoot); + + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + TestAssemblyDllPath(), + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Null); + } + + /// + /// What: a source the PDB has no document for is not returned, because nothing confirms it. + /// + [Test] + public void LoadVerifiedLiveSourceAt_WhenThePdbHasNoDocument_ReturnsNull() + { + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + ProjectRoot(), + BodylessFixtureProjectRelativePath, + TestAssemblyDllPath(), + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Null); + } + + private void WriteLiveFile(string projectRelativePath, byte[] bytes) + { + string livePath = Path.Combine(_temporaryRoot, projectRelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(livePath)); + File.WriteAllBytes(livePath, bytes); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string TestAssemblyDllPath() + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta new file mode 100644 index 0000000000..a63fbe0b78 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3307c460a59a4e558157a13346e02d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 11663b5e3d..5c376c8d83 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -273,7 +273,10 @@ private static void Install(HotReloadServices services) domain, path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path), () => services.SourceSnapshotCapture.EnsureCaptured( - HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); + HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint), + // The capture still runs synchronously inside EnsureCaptured, so none is ever in + // flight when a pause point asks. + () => false); // Attaching last keeps the invariant across the gap: the resolver only starts // answering binds once every gateway already points at the domain behind it. domain.IntroducedTypeResolver.Resume(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs index c6ad534821..f1dfa4963e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs @@ -18,21 +18,26 @@ internal sealed class HotReloadPausePointPort : IHotReloadPausePointPort private readonly HotReloadDomain _domain; private readonly Func _readSourceContentHashOrNull; private readonly Action _ensureSnapshotCaptured; + private readonly Func _isCaptureInFlight; // Why the hash reader is passed in: the hasher lives in the apply pipeline's assembly, // which this one may not reference, and the check must hash the way the worker did. // Why the capture is passed in: the gate that captures the source snapshot belongs to the - // services the composition root installs, which this assembly does not reference. + // services the composition root installs, which this assembly does not reference, and the + // in-flight check is passed in for the same reason. public HotReloadPausePointPort( HotReloadDomain domain, Func readSourceContentHashOrNull, - Action ensureSnapshotCaptured) + Action ensureSnapshotCaptured, + Func isCaptureInFlight) { Debug.Assert(ensureSnapshotCaptured != null, "ensureSnapshotCaptured must not be null."); + Debug.Assert(isCaptureInFlight != null, "isCaptureInFlight must not be null."); _domain = domain; _readSourceContentHashOrNull = readSourceContentHashOrNull; _ensureSnapshotCaptured = ensureSnapshotCaptured; + _isCaptureInFlight = isCaptureInFlight; } public MethodBase GetActiveShimForMethod(MethodBase method) @@ -64,6 +69,21 @@ public string GetVerifiedSnapshotSource(string projectRelativeFile, string dllPa return null; } + // Why the live file is tried while a capture runs: a pause point re-armed on the first + // update tick after a reload would otherwise wait for the whole capture. A live file the + // PDB confirms holds the bytes the compiler read, which is what the snapshot will hold. + // Why only while a capture runs: otherwise the snapshot is ready or about to be captured + // here, and reading it keeps the answer unchanged from before the capture moved off the + // main thread. + if (_isCaptureInFlight()) + { + string liveSource = HotReloadSourceBaseline.LoadVerifiedLiveSourceOrNull(projectRelativeFile, dllPath); + if (liveSource != null) + { + return liveSource; + } + } + // Why: with both tools disabled at domain load nothing was captured, and a pause point // is the first reader once the pause-point tool is enabled. _ensureSnapshotCaptured(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs index 7fe4f34c86..5f052ad6e1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -130,12 +131,104 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( return comparison; } - using MemoryStream memoryStream = new MemoryStream(snapshotBytes, writable: false); - using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - source = reader.ReadToEnd(); + source = DecodeVerifiedSource(snapshotBytes); return HotReloadSnapshotMissReason.None; } + /// + /// Returns the text of the file on disk when its bytes match the checksum the compiled + /// assembly's PDB recorded for it, or null when the file is missing or unreadable, the PDB has + /// no document for it, or the checksums differ. Matching bytes are the bytes the compiler read, + /// so the text equals what the snapshot holds for the same assembly. Main thread only, because + /// the checksum lookup asks the Package Manager for the source's physical path. + /// + public static string LoadVerifiedLiveSourceOrNull(string projectRelativeSourcePath, string targetDllPath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativeSourcePath), "projectRelativeSourcePath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); + + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return LoadVerifiedLiveSourceAt( + projectRoot, + projectRelativeSourcePath, + targetDllPath, + HotReloadPdbDocumentIndex.Shared); + } + + // projectRoot is injectable so EditMode tests can point at a copy of a source under a + // temporary root, the same way they do for the snapshot. + internal static string LoadVerifiedLiveSourceAt( + string projectRoot, + string projectRelativeSourcePath, + string targetDllPath, + HotReloadPdbDocumentIndex documentIndex) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRelativeSourcePath), "projectRelativeSourcePath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); + + string pdbPath = Path.ChangeExtension(targetDllPath, ".pdb"); + if (!File.Exists(targetDllPath) || !File.Exists(pdbPath)) + { + return null; + } + + // Why the same path the snapshot capture reads: the live file must be the file the + // capture would have copied, so a match here means the snapshot would hold these bytes. + string slashNormalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); + string absoluteSourcePath = Path.Combine( + projectRoot, + slashNormalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); + byte[] liveBytes = ReadAllBytesOrNull(HotReloadFileSystemPath.GetFileSystemPath(absoluteSourcePath)); + if (liveBytes == null) + { + return null; + } + + HotReloadSnapshotMissReason comparison = CompareWithCompiledDocument( + projectRoot, + slashNormalizedRelativePath, + targetDllPath, + pdbPath, + HotReloadAssemblyMvid.Read(targetDllPath), + liveBytes, + documentIndex); + return comparison == HotReloadSnapshotMissReason.None ? DecodeVerifiedSource(liveBytes) : null; + } + + // Why an unreadable file is a miss rather than an error: the caller falls back to waiting for + // the snapshot, which answers for that file the same way it does today. + private static byte[] ReadAllBytesOrNull(string fileSystemPath) + { + if (!File.Exists(fileSystemPath)) + { + return null; + } + + try + { + return File.ReadAllBytes(fileSystemPath); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + // Why one decoder for the snapshot and the live file: a pause point maps lines in whichever + // text it gets, so both must treat a byte order mark the same way. + private static string DecodeVerifiedSource(byte[] verifiedBytes) + { + using MemoryStream memoryStream = new MemoryStream(verifiedBytes, writable: false); + using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + /// /// Answers whether the bytes match the checksum the compiled assembly's PDB recorded for the /// source: , or why they could not be confirmed