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: