diff --git a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs index 5a4b1ac00..5ac4bb8f5 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/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index d2a11ac07..06638df51 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 000000000..a90bc6559 --- /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 000000000..985182960 --- /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 000000000..fdeebfa4b --- /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 000000000..a63fbe0b7 --- /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 11663b5e3..5c376c8d8 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 c6ad53482..f1dfa4963 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 7fe4f34c8..5f052ad6e 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 diff --git a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs index 68e9a04fd..049b75d01 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 000000000..62f16cddd --- /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 000000000..5b7e6eb86 --- /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: