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/CecilPdbRewrite.cs b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs
new file mode 100644
index 000000000..71ae47ea0
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs
@@ -0,0 +1,67 @@
+using System;
+using System.IO;
+
+using Mono.Cecil;
+using Mono.Cecil.Cil;
+
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Writes a copy of a compiled assembly of this project the way an IL post-processor does, for
+ /// tests that need a PDB which lost the documents no sequence point refers to.
+ ///
+ internal static class CecilPdbRewrite
+ {
+ // Writes the rewritten copy into a new temporary directory and returns the dll's path; the
+ // caller deletes the directory. Fails the test unless the copy kept the compiler's source
+ // count and lost a document, which every case that uses it relies on.
+ internal static string RewriteIntoNewDirectoryLosingDocuments(string projectRoot, string assemblyName)
+ {
+ string directory = Path.Combine(Path.GetTempPath(), "uloop-cecil-pdb-rewrite-" + Guid.NewGuid().ToString("N"));
+ string dllPath = Path.Combine(directory, HotReloadConstants.ScriptAssembliesRelativeDirectory, assemblyName + ".dll");
+ Directory.CreateDirectory(Path.GetDirectoryName(dllPath));
+ Rewrite(projectRoot, assemblyName, directory, dllPath);
+ HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb"));
+ Assert.That(rewritten.SourceFileCount, Is.Not.Null, "Precondition: the rewritten PDB must keep the compiler's source count.");
+ Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount.Value), "Precondition: the rewrite must drop some document.");
+ return dllPath;
+ }
+
+ // The temporary directory RewriteIntoNewDirectoryLosingDocuments created for the dll.
+ internal static string RootOf(string dllPath)
+ {
+ return Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(dllPath)));
+ }
+
+ // Reads the assembly with its symbols and writes both again, as an IL post-processor does.
+ // Cecil keeps only the documents a method refers to. workDirectory receives the copy that is
+ // read, so the compiled files are never opened for writing.
+ internal static void Rewrite(string projectRoot, string assemblyName, string workDirectory, string outputDllPath)
+ {
+ CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(projectRoot);
+ string sourceDll = Path.Combine(workDirectory, "Source", assemblyName + ".dll");
+ Directory.CreateDirectory(Path.GetDirectoryName(sourceDll));
+ File.Copy(real.DllPath(assemblyName), sourceDll);
+ File.Copy(real.PdbPath(assemblyName), Path.ChangeExtension(sourceDll, ".pdb"));
+ using (DefaultAssemblyResolver resolver = new DefaultAssemblyResolver())
+ {
+ resolver.AddSearchDirectory(real.CompiledAssembliesDirectory);
+ resolver.AddSearchDirectory(Path.GetDirectoryName(typeof(object).Assembly.Location));
+ using (ModuleDefinition module = ModuleDefinition.ReadModule(
+ sourceDll,
+ new ReaderParameters { ReadSymbols = true, AssemblyResolver = resolver }))
+ {
+ module.Write(outputDllPath, new WriterParameters
+ {
+ WriteSymbols = true,
+ SymbolWriterProvider = new PortablePdbWriterProvider(),
+ });
+ }
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs.meta b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs.meta
new file mode 100644
index 000000000..0eae96ec2
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7c9825d04ecc5417392d4c02a5494bf8
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs
new file mode 100644
index 000000000..c40bc6de8
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs
@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+using NUnit.Framework;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Covers listing the compiled assemblies from the dlls and PDBs on disk, and, on this project's
+ /// real assemblies, that the listing and the sources read from each PDB agree with what Unity's
+ /// compilation pipeline and the Package Manager report.
+ ///
+ public sealed class HotReloadCaptureAssemblyEnumeratorTests
+ {
+ private const int MaxListedMismatches = 5;
+ private const string RewrittenAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
+
+ private string _projectRoot;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path.Combine(_projectRoot, "Assets"));
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_projectRoot))
+ {
+ Directory.Delete(_projectRoot, true);
+ }
+ }
+
+ ///
+ /// Verifies a dll without a PDB beside it is not listed, and the dlls that have one are listed
+ /// in ordinal order with their paths.
+ ///
+ [Test]
+ public void ListCompiled_DllWithoutPdb_IsNotListed()
+ {
+ // Why not Path.Combine with the "Library/ScriptAssemblies" constant: on Windows that keeps
+ // the "/" inside the constant, while the listing returns paths with "\" throughout.
+ string directory = CompiledAssemblyLayout.Resolve(_projectRoot).CompiledAssembliesDirectory;
+ Directory.CreateDirectory(directory);
+ WriteEmptyFiles(directory, "b.dll", "b.pdb", "A.dll", "A.pdb", "NoPdb.dll", "notes.txt");
+
+ IReadOnlyList candidates =
+ HotReloadCaptureAssemblyEnumerator.ListCompiled(_projectRoot);
+
+ Assert.That(candidates.Select(candidate => candidate.Name), Is.EqualTo(new[] { "A", "b" }));
+ Assert.That(candidates[0].DllPath, Is.EqualTo(Path.Combine(directory, "A.dll")));
+ Assert.That(candidates[0].PdbPath, Is.EqualTo(Path.Combine(directory, "A.pdb")));
+ }
+
+ ///
+ /// Verifies a project whose compiled assemblies directory does not exist lists nothing.
+ ///
+ [Test]
+ public void ListCompiled_WithoutTheCompiledAssembliesDirectory_ListsNothing()
+ {
+ IReadOnlyList candidates =
+ HotReloadCaptureAssemblyEnumerator.ListCompiled(_projectRoot);
+
+ Assert.That(candidates, Is.Empty);
+ }
+
+ ///
+ /// Verifies, on this project, that the listed assemblies are the compilation assemblies Unity
+ /// lists with a PDB beside their dll, that each read-only classification is the Package
+ /// Manager's, and that for every one, read-only or not, the sources read are Unity's source
+ /// files, including where a rewrite left the PDB beside the dll without some documents and
+ /// the compiler's own PDB is read instead.
+ ///
+ [Test]
+ public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAssemblies()
+ {
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot);
+ HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread();
+ StringComparer comparer = HotReloadSourcePathNormalizer.ProjectRelativePathComparer();
+ Dictionary fromUnity = new Dictionary(StringComparer.Ordinal);
+ foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current())
+ {
+ if (File.Exists(layout.DllPath(assembly.name)) && File.Exists(layout.PdbPath(assembly.name)))
+ {
+ fromUnity[assembly.name] = assembly;
+ }
+ }
+
+ IReadOnlyList candidates = HotReloadCaptureAssemblyEnumerator.ListCompiled(projectRoot);
+ List mismatches = new List();
+ int immutableAssemblies = 0;
+ int comparedSources = 0;
+ int lostDocuments = 0;
+ int fromCompilerPdb = 0;
+ foreach (HotReloadCaptureAssemblyCandidate candidate in candidates)
+ {
+ if (!fromUnity.TryGetValue(candidate.Name, out UnityCompilationAssembly unityAssembly))
+ {
+ mismatches.Add(candidate.Name + ": listed from disk but not by Unity.");
+ continue;
+ }
+
+ HotReloadPdbCompilationInfo beside = HotReloadPdbCompilationInfo.Read(candidate.PdbPath);
+ lostDocuments += beside.DocumentCount < beside.SourceFileCount ? 1 : 0;
+ HotReloadCaptureAssembly assembly = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, inputs);
+ fromCompilerPdb += assembly.SourcesFromCompilerOutput ? 1 : 0;
+ string[] sourceFiles = unityAssembly.sourceFiles;
+ bool expectedImmutable = sourceFiles.Length > 0
+ && HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles);
+ immutableAssemblies += expectedImmutable ? 1 : 0;
+ if (assembly.IsImmutablePackageAssembly != expectedImmutable)
+ {
+ mismatches.Add(candidate.Name + ": read-only is " + assembly.IsImmutablePackageAssembly
+ + " but the Package Manager says " + expectedImmutable + ".");
+ continue;
+ }
+
+ comparedSources += assembly.SourceAssetPaths.Count;
+ HashSet fromPdb = new HashSet(assembly.SourceAssetPaths, comparer);
+ if (!fromPdb.SetEquals(sourceFiles.Select(HotReloadSourcePathNormalizer.ToForwardSlashes)))
+ {
+ mismatches.Add(candidate.Name + ": the sources differ from Unity's.");
+ }
+ }
+
+ foreach (string name in fromUnity.Keys)
+ {
+ if (!candidates.Any(candidate => candidate.Name == name))
+ {
+ mismatches.Add(name + ": listed by Unity but not from disk.");
+ }
+ }
+
+ string summary = "Listed " + candidates.Count + " assemblies (Unity: " + fromUnity.Count + "), "
+ + immutableAssemblies + " read-only, " + comparedSources + " sources, " + fromCompilerPdb + " from the compiler's PDB of "
+ + lostDocuments + " whose PDB lost documents; " + mismatches.Count + " mismatches";
+ TestContext.WriteLine(summary + ".");
+ Debug.Log("[cross-check] " + summary);
+ Assert.That(lostDocuments, Is.GreaterThan(0), "Precondition: some assembly's PDB must have lost sources, so the compiler's PDB is read.");
+ Assert.That(candidates.Count, Is.GreaterThan(0), "No compiled assembly with a PDB was listed.");
+ Assert.That(immutableAssemblies, Is.GreaterThan(0), "No read-only package assembly was found to compare.");
+ Assert.That(
+ mismatches,
+ Is.Empty,
+ summary + ":\n" + string.Join("\n", mismatches.Take(MaxListedMismatches)));
+ }
+
+ ///
+ /// What: a PDB that lists every source is used as it is, even when the compiler's output of the
+ /// same build sits under Library/Bee and would be used if it were read.
+ ///
+ [Test]
+ public void ReadSources_CompletePdb_DoesNotReadTheCompilerOutput()
+ {
+ CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(RealProjectRoot());
+ string dllPath = ScriptAssembliesDllPathInTemporaryProject();
+ File.Copy(real.DllPath(RewrittenAssemblyName), dllPath);
+ File.Copy(real.PdbPath(RewrittenAssemblyName), Path.ChangeExtension(dllPath, ".pdb"));
+ PutTheRealCompilerOutputInBee();
+
+ HotReloadCaptureAssembly assembly = ReadSourcesOf(dllPath);
+
+ Assert.That(assembly.HasEverySource, Is.True);
+ Assert.That(assembly.SourcesFromCompilerOutput, Is.False);
+ }
+
+ ///
+ /// What: when the PDB beside the dll lost documents in a rewrite, the sources are read from the
+ /// compiler's PDB of the same build under Library/Bee, and are the sources the complete PDB lists.
+ ///
+ [Test]
+ public void ReadSources_PdbThatLostDocuments_ListsEverySourceFromTheCompilersPdb()
+ {
+ string dllPath = RewriteIntoTemporaryProject();
+ PutTheRealCompilerOutputInBee();
+
+ HotReloadCaptureAssembly assembly = ReadSourcesOf(dllPath);
+
+ Assert.That(assembly.HasEverySource, Is.True);
+ Assert.That(assembly.SourcesFromCompilerOutput, Is.True);
+ Assert.That(assembly.SourceAssetPaths, Is.EquivalentTo(ReadRealSources().SourceAssetPaths));
+ }
+
+ ///
+ /// What: when the PDB beside the dll lost documents and no compiler output stands in, the sources
+ /// are what that PDB lists, fewer than the complete PDB's, and are marked as not every source.
+ ///
+ [Test]
+ public void ReadSources_PdbThatLostDocumentsWithoutTheCompilerOutput_ListsWhatThePdbHas()
+ {
+ string dllPath = RewriteIntoTemporaryProject();
+
+ HotReloadCaptureAssembly assembly = ReadSourcesOf(dllPath);
+
+ Assert.That(assembly.HasEverySource, Is.False);
+ Assert.That(assembly.SourcesFromCompilerOutput, Is.False);
+ Assert.That(assembly.SourceAssetPaths.Count, Is.LessThan(ReadRealSources().SourceAssetPaths.Count));
+ }
+
+ private static string RealProjectRoot()
+ {
+ return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ }
+
+ private string ScriptAssembliesDllPathInTemporaryProject()
+ {
+ string directory = CompiledAssemblyLayout.Resolve(_projectRoot).CompiledAssembliesDirectory;
+ Directory.CreateDirectory(directory);
+ return Path.Combine(directory, RewrittenAssemblyName + ".dll");
+ }
+
+ // Writes, beside the temporary project's Library, a copy of the assembly the way an IL
+ // post-processor does, and checks that it lost documents while keeping the MVID.
+ private string RewriteIntoTemporaryProject()
+ {
+ string projectRoot = RealProjectRoot();
+ string dllPath = ScriptAssembliesDllPathInTemporaryProject();
+ CecilPdbRewrite.Rewrite(projectRoot, RewrittenAssemblyName, Path.Combine(_projectRoot, "Work"), dllPath);
+ Assert.That(
+ HotReloadAssemblyMetadataMvid.Read(dllPath),
+ Is.EqualTo(HotReloadAssemblyMetadataMvid.Read(CompiledAssemblyLayout.Resolve(projectRoot).DllPath(RewrittenAssemblyName))),
+ "Precondition: the rewrite must keep the MVID.");
+ HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb"));
+ Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount), "Precondition: the rewrite must drop some document.");
+ return dllPath;
+ }
+
+ private void PutTheRealCompilerOutputInBee()
+ {
+ CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(RealProjectRoot());
+ string dag = Path.Combine(_projectRoot, "Library", "Bee", "artifacts", "test.dag");
+ Directory.CreateDirectory(dag);
+ File.Copy(real.DllPath(RewrittenAssemblyName), Path.Combine(dag, RewrittenAssemblyName + ".dll"));
+ File.Copy(real.PdbPath(RewrittenAssemblyName), Path.Combine(dag, RewrittenAssemblyName + ".pdb"));
+ }
+
+ private static HotReloadCaptureAssembly ReadSourcesOf(string dllPath)
+ {
+ return HotReloadCaptureAssemblyEnumerator.ReadSources(
+ new HotReloadCaptureAssemblyCandidate(RewrittenAssemblyName, dllPath, Path.ChangeExtension(dllPath, ".pdb")),
+ HotReloadSnapshotCaptureInputs.ReadOnMainThread());
+ }
+
+ private static HotReloadCaptureAssembly ReadRealSources()
+ {
+ CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(RealProjectRoot());
+ HotReloadCaptureAssembly assembly = ReadSourcesOf(real.DllPath(RewrittenAssemblyName));
+ Assert.That(assembly.HasEverySource, Is.True, "Precondition: the compiled test assembly's PDB must list every source.");
+ return assembly;
+ }
+
+ private static void WriteEmptyFiles(string directory, params string[] fileNames)
+ {
+ foreach (string fileName in fileNames)
+ {
+ File.WriteAllBytes(Path.Combine(directory, fileName), Array.Empty());
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta
new file mode 100644
index 000000000..68ad3f3e4
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 97fe28077814042f3851e56cd050e57f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs
index ad0795215..bfeced9d9 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs
@@ -58,6 +58,24 @@ public void Current_CalledTwice_ReturnsSameInstance()
Assert.That(second, Is.SameAs(first));
}
+ ///
+ /// Verifies IsKept answers whether a list is kept right now: true once a caller asked for
+ /// it, false after the list is dropped, so the warm-ups skip instead of asking Unity.
+ ///
+ [Test]
+ public void IsKept_FollowsWhetherAListIsKept()
+ {
+ Assert.That(HotReloadCompilationAssemblies.Current(), Is.Not.Empty);
+ Assert.That(HotReloadCompilationAssemblies.IsKept(), Is.True, "after the list was asked for");
+
+ HotReloadCompilationAssemblies.DropForScriptSetChange();
+ bool keptAfterDrop = HotReloadCompilationAssemblies.IsKept();
+ // Why ask again: later tests in this domain expect the shared list to be kept.
+ HotReloadCompilationAssemblies.Current();
+
+ Assert.That(keptAfterDrop, Is.False, "after the list was dropped");
+ }
+
// Why: CompilationPipeline stores handlers on Delegate fields, but EditorApplication.projectChanged
// lives on EventWithPerformanceTracker. A Delegate-only scan cannot see it.
private static bool StaticEventHasHandler(Type eventOwner, Func isMatch)
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs
index b26b95ea2..6c5f6840e 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs
@@ -154,6 +154,57 @@ public void FindByName_UnknownName_ReturnsNull()
Assert.That(found, Is.Null);
}
+ ///
+ /// Verifies that a list built off the main thread is kept, without asking Unity, when nothing
+ /// is kept and no invalidation happened since its generation was read.
+ ///
+ [Test]
+ public void TryFill_WhenEmptyAtTheSameGeneration_KeepsTheRebuiltList()
+ {
+ UnityCompilationAssembly[] rebuilt = { CreateAssembly("Rebuilt") };
+ HotReloadCompilationAssemblyCache cache = CreateCache();
+
+ bool filled = cache.TryFill(rebuilt, cache.Generation);
+
+ Assert.That(filled, Is.True);
+ Assert.That(cache.Current(), Is.SameAs(rebuilt));
+ Assert.That(_fetchCount, Is.EqualTo(0));
+ }
+
+ ///
+ /// Verifies that a list Unity already answered wins over a rebuilt one.
+ ///
+ [Test]
+ public void TryFill_WhenAlreadyFilled_KeepsTheFetchedList()
+ {
+ UnityCompilationAssembly[] fetched = { CreateAssembly("Fetched") };
+ _answers.Enqueue(fetched);
+ HotReloadCompilationAssemblyCache cache = CreateCache();
+ cache.Current();
+
+ bool filled = cache.TryFill(new[] { CreateAssembly("Rebuilt") }, cache.Generation);
+
+ Assert.That(filled, Is.False);
+ Assert.That(cache.Current(), Is.SameAs(fetched));
+ }
+
+ ///
+ /// Verifies that a list built before an invalidation is refused even though nothing is kept,
+ /// because the script set may have changed while it was built.
+ ///
+ [Test]
+ public void TryFill_AfterAnInvalidation_Refuses()
+ {
+ HotReloadCompilationAssemblyCache cache = CreateCache();
+ int generationBeforeBuild = cache.Generation;
+ cache.Invalidate();
+
+ bool filled = cache.TryFill(new[] { CreateAssembly("Rebuilt") }, generationBeforeBuild);
+
+ Assert.That(filled, Is.False);
+ Assert.That(cache.IsFilled, Is.False);
+ }
+
private HotReloadCompilationAssemblyCache CreateCache()
{
return new HotReloadCompilationAssemblyCache(Fetch);
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs
new file mode 100644
index 000000000..08e354a40
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs
@@ -0,0 +1,275 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+
+using NUnit.Framework;
+
+using UnityEditor.Compilation;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Contract of when the compilation assembly list is saved for the next domain (only the first
+ /// fetch of a domain without an invalidation and without a failed compile, and only when no
+ /// invalidation happened before the file is published, because only then does the list
+ /// describe the dlls on disk) and of telling a missing saved list from an unusable one when it
+ /// is loaded.
+ ///
+ public sealed class HotReloadCompilationAssemblyListStoreTests
+ {
+ private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
+ private const string MissingReferencePath = "Packages/com.example.missing/Plugins/Missing.dll";
+ private const string PreviousFileContent = "previous";
+
+ // A complete saved file: one assembly whose only reference is the one reference entry.
+ // Each unreadable case below changes exactly one part of it.
+ private const string CompleteFile =
+ "{\"formatVersion\":1,"
+ + "\"assemblies\":[{\"name\":\"A\",\"outputPath\":\"Library/ScriptAssemblies/A.dll\",\"sourceFiles\":[],"
+ + "\"defines\":[],\"assemblyReferences\":[],\"compiledAssemblyReferenceIndexes\":[0],\"dllStamp\":null}],"
+ + "\"references\":[{\"path\":\"Plugins/R.dll\",\"stamp\":{\"length\":1,\"lastWriteTicks\":2},"
+ + "\"mvid\":\"0123456789abcdef0123456789abcdef\"}]}";
+
+ private string _projectRoot;
+ private string _filePath;
+ private string _referencePath;
+ private bool _isCompilationFailed;
+ private int _currentGeneration;
+ private int _backgroundRuns;
+ private Action _deferredWork;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-compilation-list-store-" + Guid.NewGuid().ToString("N"));
+ string scriptAssemblies = Path.Combine(_projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory);
+ Directory.CreateDirectory(scriptAssemblies);
+ File.WriteAllText(Path.Combine(scriptAssemblies, "Saved" + HotReloadConstants.CompiledAssemblyExtension), "dll");
+ _filePath = Path.Combine(_projectRoot, HotReloadConstants.CompilationAssemblyListRelativePath);
+ _referencePath = CompiledAssemblyLayout.Resolve(RealProjectRoot()).DllPath(TestAssemblyName);
+ _isCompilationFailed = false;
+ _currentGeneration = 0;
+ _backgroundRuns = 0;
+ _deferredWork = null;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_projectRoot))
+ {
+ Directory.Delete(_projectRoot, true);
+ }
+ }
+
+ ///
+ /// What: a clean first fetch is saved with the read fields and the dll stamp. Precompiled
+ /// references are saved once in the references table, with stamp and MVID, or with neither
+ /// when the file is missing, and assemblies point at them by index in Unity's order.
+ ///
+ [Test]
+ public void Store_SavesTheFirstFetchOfACleanDomain()
+ {
+ HotReloadCompilationAssemblyListStore store = CreateStore();
+
+ store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot);
+
+ HotReloadSavedCompilationListLoad load = store.Load();
+ Assert.That(load.FailureReason, Is.Null);
+ HotReloadSavedCompilationList saved = load.List;
+ Assert.That(saved.assemblies.Count, Is.EqualTo(2));
+ HotReloadSavedCompilationAssembly entry = saved.assemblies[1];
+ Assert.That(entry.name, Is.EqualTo("Saved"));
+ Assert.That(entry.sourceFiles, Is.EqualTo(new[] { "Assets/Saved/A.cs", "Assets/Saved/B.cs" }));
+ Assert.That(entry.defines, Is.EqualTo(new[] { "SECOND", "FIRST" }));
+ Assert.That(entry.assemblyReferences, Is.EqualTo(new[] { "Referenced" }));
+ Assert.That(entry.compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 1, 0 }));
+ Assert.That(saved.assemblies[0].compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 0 }));
+ Assert.That(entry.dllStamp, Is.Not.Null);
+ Assert.That(saved.assemblies[0].dllStamp, Is.Null, "Referenced has no dll in the temporary project.");
+ Assert.That(saved.references.Count, Is.EqualTo(2));
+ Assert.That(saved.references[0].path, Is.EqualTo(_referencePath));
+ Assert.That(saved.references[0].mvid, Is.EqualTo(HotReloadAssemblyMvid.Read(_referencePath)));
+ Assert.That(saved.references[1].path, Is.EqualTo(MissingReferencePath));
+ Assert.That(saved.references[1].stamp, Is.Null);
+ Assert.That(saved.references[1].mvid, Is.Null);
+ }
+
+ ///
+ /// What: a fetch after an invalidation of the domain (a compile start, or a script set change
+ /// without a reload) is not saved.
+ ///
+ [Test]
+ public void Store_DoesNotSaveAFetchAfterAnInvalidation()
+ {
+ HotReloadCompilationAssemblyListStore store = CreateStore();
+
+ store.SaveInBackgroundIfClean(CreateFetchedList(), 1, _projectRoot);
+
+ Assert.That(File.Exists(_filePath), Is.False);
+ Assert.That(_backgroundRuns, Is.EqualTo(0));
+ }
+
+ ///
+ /// What: a fetch while the last compile failed is not saved, since the list can name sources
+ /// the dlls were not built from.
+ ///
+ [Test]
+ public void Store_DoesNotSaveWhileCompilationFailed()
+ {
+ _isCompilationFailed = true;
+ HotReloadCompilationAssemblyListStore store = CreateStore();
+
+ store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot);
+
+ Assert.That(File.Exists(_filePath), Is.False);
+ Assert.That(_backgroundRuns, Is.EqualTo(0));
+ }
+
+ ///
+ /// What: when an invalidation happens after the hand-off but before the background write
+ /// publishes, the earlier saved file is left as it was and no temporary file remains.
+ ///
+ [Test]
+ public void Store_WhenInvalidatedBeforeTheWriteFinishes_KeepsThePreviousFile()
+ {
+ WriteSavedFile(PreviousFileContent);
+ HotReloadCompilationAssemblyListStore store = CreateDeferringStore();
+ store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot);
+
+ _currentGeneration = 1;
+ _deferredWork();
+
+ Assert.That(File.ReadAllText(_filePath), Is.EqualTo(PreviousFileContent));
+ Assert.That(File.Exists(_filePath + ".tmp"), Is.False);
+ }
+
+ ///
+ /// What: without a saved file, the load names no_saved_list.
+ ///
+ [Test]
+ public void Load_WithoutASavedFile_ReportsNoSavedList()
+ {
+ HotReloadSavedCompilationListLoad load = CreateStore().Load();
+
+ Assert.That(load.List, Is.Null);
+ Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonNoSavedList));
+ }
+
+ ///
+ /// What: a saved file with every required field, in-range indexes and the current format
+ /// version loads. The unreadable cases below each differ from it in one way.
+ ///
+ [Test]
+ public void Load_CompleteFileOfTheCurrentVersion_ReturnsTheList()
+ {
+ WriteSavedFile(CompleteFile);
+
+ HotReloadSavedCompilationListLoad load = CreateStore().Load();
+
+ Assert.That(load.FailureReason, Is.Null);
+ Assert.That(load.List.assemblies[0].compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 0 }));
+ }
+
+ ///
+ /// What: a torn file loads as saved_list_unreadable instead of throwing.
+ ///
+ [Test]
+ public void Load_TornFile_ReportsUnreadable()
+ {
+ WriteSavedFile(CompleteFile.Substring(0, CompleteFile.Length / 2));
+
+ HotReloadSavedCompilationListLoad load = CreateStore().Load();
+
+ Assert.That(load.List, Is.Null);
+ Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonSavedListUnreadable));
+ }
+
+ ///
+ /// What: a file of another format version, an assembly without a name, a reference index
+ /// outside the table and a reference with a stamp but no MVID all load as
+ /// saved_list_unreadable instead of failing later in the rebuild.
+ ///
+ [TestCase("\"formatVersion\":1", "\"formatVersion\":2", TestName = "Load_OtherFormatVersion_ReportsUnreadable")]
+ [TestCase("\"name\":\"A\",", "", TestName = "Load_AssemblyWithoutAName_ReportsUnreadable")]
+ [TestCase("\"compiledAssemblyReferenceIndexes\":[0]", "\"compiledAssemblyReferenceIndexes\":[1]", TestName = "Load_ReferenceIndexOutOfRange_ReportsUnreadable")]
+ [TestCase(",\"mvid\":\"0123456789abcdef0123456789abcdef\"", "", TestName = "Load_ReferenceWithAStampButNoMvid_ReportsUnreadable")]
+ public void Load_IncompleteFile_ReportsUnreadable(string completePart, string replacement)
+ {
+ Assert.That(CompleteFile, Does.Contain(completePart), "The case must change the complete file.");
+ WriteSavedFile(CompleteFile.Replace(completePart, replacement));
+
+ HotReloadSavedCompilationListLoad load = CreateStore().Load();
+
+ Assert.That(load.List, Is.Null);
+ Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonSavedListUnreadable));
+ }
+
+ private void WriteSavedFile(string json)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(_filePath));
+ File.WriteAllText(_filePath, json);
+ }
+
+ private HotReloadCompilationAssemblyListStore CreateStore()
+ {
+ // Why inline: EditMode tests must not start pool work, so the "background" runs here.
+ return new HotReloadCompilationAssemblyListStore(
+ _filePath,
+ () => _isCompilationFailed,
+ () => _currentGeneration,
+ work =>
+ {
+ _backgroundRuns++;
+ work();
+ return Task.CompletedTask;
+ });
+ }
+
+ private HotReloadCompilationAssemblyListStore CreateDeferringStore()
+ {
+ // Keeps the write so the test can change the generation between the hand-off and the write.
+ return new HotReloadCompilationAssemblyListStore(
+ _filePath,
+ () => _isCompilationFailed,
+ () => _currentGeneration,
+ work =>
+ {
+ _deferredWork = work;
+ return Task.CompletedTask;
+ });
+ }
+
+ private UnityCompilationAssembly[] CreateFetchedList()
+ {
+ UnityCompilationAssembly referenced = new UnityCompilationAssembly(
+ "Referenced",
+ HotReloadConstants.ScriptAssembliesRelativeDirectory + "/Referenced" + HotReloadConstants.CompiledAssemblyExtension,
+ new[] { "Assets/Referenced/R.cs" },
+ Array.Empty(),
+ Array.Empty(),
+ new[] { _referencePath },
+ AssemblyFlags.EditorAssembly);
+ UnityCompilationAssembly saved = new UnityCompilationAssembly(
+ "Saved",
+ HotReloadConstants.ScriptAssembliesRelativeDirectory + "/Saved" + HotReloadConstants.CompiledAssemblyExtension,
+ new[] { "Assets/Saved/A.cs", "Assets/Saved/B.cs" },
+ new[] { "SECOND", "FIRST" },
+ new[] { referenced },
+ new[] { MissingReferencePath, _referencePath },
+ AssemblyFlags.EditorAssembly);
+ return new[] { referenced, saved };
+ }
+
+ private static string RealProjectRoot()
+ {
+ return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta
new file mode 100644
index 000000000..2e4bec536
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a13938635d42e496b82fb97fdf46f182
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs
new file mode 100644
index 000000000..530417021
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs
@@ -0,0 +1,257 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+using NUnit.Framework;
+
+using UnityEditor.Compilation;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Cross-checks the rebuilt compilation assembly list against Unity's on every assembly of this
+ /// project, once from the saved entries and once with every assembly rebuilt from its PDB.
+ /// Some packages' PDBs are rewritten after the compile and lose the documents no sequence point
+ /// refers to; those must be rebuilt from the compiler's own PDB under Library/Bee, not with
+ /// fewer sources.
+ ///
+ public sealed class HotReloadCompilationAssemblyReconstructionCrossCheckTests
+ {
+ private string _directory;
+ private string _filePath;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _directory = Path.Combine(Path.GetTempPath(), "uloop-compilation-list-cross-check-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_directory);
+ _filePath = Path.Combine(_directory, "CompilationAssemblies.json");
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, true);
+ }
+ }
+
+ ///
+ /// What: the list rebuilt from a clean save with every dll unchanged equals
+ /// CompilationPipeline.GetAssemblies() in every field hot reload reads, in order.
+ ///
+ [Test]
+ public void ReconstructedList_MatchesGetAssembliesForEveryField()
+ {
+ UnityCompilationAssembly[] expected = CompilationPipeline.GetAssemblies();
+ HotReloadCompilationAssemblyListStore store = SaveClean(expected);
+ HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread();
+
+ HotReloadCompilationListReconstruction reconstruction =
+ HotReloadCompilationAssemblyReconstruction.Reconstruct(store, inputs, CancellationToken.None);
+
+ Assert.That(reconstruction.FailureReason, Is.Null, "failure");
+ Assert.That(Mismatches(expected, reconstruction.Assemblies), Is.Empty);
+ }
+
+ ///
+ /// What: rebuilding every compiled assembly from its PDB gives Unity's fields, in order, for
+ /// every assembly, including those whose PDB a rewrite left without some documents, which
+ /// are rebuilt from the compiler's own PDB; none is refused.
+ ///
+ [Test]
+ public void EveryAssemblyRebuiltFromItsPdb_MatchesGetAssemblies()
+ {
+ UnityCompilationAssembly[] expected = CompilationPipeline.GetAssemblies();
+ HotReloadSavedCompilationList saved = SaveClean(expected).Load().List;
+ HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread();
+ HotReloadCompilationAssemblyRebuilder rebuilder = new HotReloadCompilationAssemblyRebuilder(saved, inputs);
+ Dictionary expectedByName = new Dictionary();
+ foreach (UnityCompilationAssembly assembly in expected)
+ {
+ expectedByName[assembly.name] = assembly;
+ }
+
+ List mismatches = new List();
+ List refused = new List();
+ int lostDocuments = 0;
+ int rebuilt = 0;
+ foreach (HotReloadCaptureAssemblyCandidate candidate in HotReloadCaptureAssemblyEnumerator.ListCompiled(inputs.ProjectRoot))
+ {
+ if (!expectedByName.TryGetValue(candidate.Name, out UnityCompilationAssembly want))
+ {
+ continue;
+ }
+
+ HotReloadPdbCompilationInfo beside = HotReloadPdbCompilationInfo.Read(candidate.PdbPath);
+ lostDocuments += beside.DocumentCount < beside.SourceFileCount ? 1 : 0;
+ HotReloadSavedCompilationAssembly savedAssembly = saved.assemblies.Find(assembly => assembly.name == candidate.Name);
+ string reason = rebuilder.TryFromPdb(savedAssembly, candidate, out HotReloadRebuiltAssemblyEntry entry);
+ if (reason != null)
+ {
+ refused.Add(candidate.Name + ": " + reason + " (" + DescribeCompilerOutput(candidate) + ")");
+ continue;
+ }
+
+ rebuilt++;
+ AddEntryMismatches(mismatches, want, entry);
+ }
+
+ Debug.Log("[cross-check] rebuilt " + rebuilt + ", from compiler pdb " + lostDocuments + ", refused " + refused.Count);
+ Assert.That(lostDocuments, Is.GreaterThan(0), "Precondition: some assembly's PDB must have lost sources, so the compiler's PDB is read.");
+ Assert.That(refused, Is.Empty, "refused assemblies:\n" + string.Join("\n", refused));
+ Assert.That(rebuilt, Is.GreaterThan(0), "Precondition: some assembly must be rebuilt.");
+ Assert.That(mismatches, Is.Empty, "rebuilt entries");
+ }
+
+ private HotReloadCompilationAssemblyListStore SaveClean(UnityCompilationAssembly[] fetched)
+ {
+ HotReloadCompilationAssemblyListStore store = CreateStore();
+ store.SaveInBackgroundIfClean(fetched, 0, Path.GetFullPath(Path.Combine(Application.dataPath, "..")));
+ Assert.That(File.Exists(_filePath), Is.True, "Precondition: the list must be saved.");
+ return store;
+ }
+
+ // Says, per dag, why the compiler's own output under Library/Bee did not stand in for this
+ // assembly's PDB, so a refusal on CI can be told apart from the log alone: no dag of the same
+ // build, or one whose PDB lacks documents too. The last case gives the counts either way.
+ private static string DescribeCompilerOutput(HotReloadCaptureAssemblyCandidate candidate)
+ {
+ string library = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(candidate.DllPath)));
+ string artifacts = Path.Combine(library, "Bee", "artifacts");
+ if (!Directory.Exists(artifacts))
+ {
+ return "no Bee artifacts";
+ }
+
+ Guid mvid = HotReloadAssemblyMetadataMvid.Read(candidate.DllPath);
+ string[] dags = Directory.GetDirectories(artifacts, "*.dag");
+ Array.Sort(dags, StringComparer.Ordinal);
+ List seen = new List();
+ foreach (string dag in dags)
+ {
+ seen.Add(Path.GetFileName(dag) + ": " + DescribeDag(dag, Path.GetFileName(candidate.DllPath), mvid));
+ }
+
+ return seen.Count == 0 ? "no dag" : string.Join("; ", seen);
+ }
+
+ private static string DescribeDag(string dag, string fileName, Guid mvid)
+ {
+ string dll = Path.Combine(dag, fileName);
+ string pdb = Path.ChangeExtension(dll, ".pdb");
+ if (!File.Exists(dll))
+ {
+ return "no compiler dll";
+ }
+
+ if (HotReloadAssemblyMetadataMvid.Read(dll) != mvid)
+ {
+ return "compiler dll of another build";
+ }
+
+ if (!File.Exists(pdb))
+ {
+ return "no compiler PDB";
+ }
+
+ List documents = HotReloadPdbDocumentTable.TryRead(dll, pdb, out int? count);
+ if (documents == null)
+ {
+ return "compiler PDB of another dll";
+ }
+
+ return "compiler PDB documents " + documents.Count + "/" + (count.HasValue ? count.Value.ToString() : "no count");
+ }
+
+ private static void AddEntryMismatches(List mismatches, UnityCompilationAssembly want, HotReloadRebuiltAssemblyEntry got)
+ {
+ AddIfDifferent(mismatches, want.name, "outputPath", new[] { want.outputPath }, new[] { got.OutputPath });
+ AddIfDifferent(mismatches, want.name, "sourceFiles", want.sourceFiles, got.SourceFiles);
+ AddIfDifferent(mismatches, want.name, "defines", want.defines, got.Defines);
+ AddIfDifferent(mismatches, want.name, "assemblyReferences", NamesOf(want.assemblyReferences), got.ProjectReferences.ToArray());
+ AddIfDifferent(mismatches, want.name, "compiledAssemblyReferences", want.compiledAssemblyReferences, got.CompiledReferences.ToArray());
+ }
+
+ private HotReloadCompilationAssemblyListStore CreateStore()
+ {
+ // Why inline: EditMode tests must not start pool work, so the "background" runs here.
+ return new HotReloadCompilationAssemblyListStore(
+ _filePath,
+ () => false,
+ () => 0,
+ work =>
+ {
+ work();
+ return Task.CompletedTask;
+ });
+ }
+
+ private static List Mismatches(UnityCompilationAssembly[] expected, UnityCompilationAssembly[] actual)
+ {
+ List mismatches = new List();
+ if (actual == null || actual.Length != expected.Length)
+ {
+ mismatches.Add("count " + expected.Length + " != " + (actual == null ? "null" : actual.Length.ToString()));
+ return mismatches;
+ }
+
+ for (int index = 0; index < expected.Length; index++)
+ {
+ UnityCompilationAssembly want = expected[index];
+ UnityCompilationAssembly got = actual[index];
+ AddIfDifferent(mismatches, want.name, "name", new[] { want.name }, new[] { got.name });
+ AddIfDifferent(mismatches, want.name, "outputPath", new[] { want.outputPath }, new[] { got.outputPath });
+ AddIfDifferent(mismatches, want.name, "sourceFiles", want.sourceFiles, got.sourceFiles);
+ AddIfDifferent(mismatches, want.name, "defines", want.defines, got.defines);
+ AddIfDifferent(mismatches, want.name, "assemblyReferences", NamesOf(want.assemblyReferences), NamesOf(got.assemblyReferences));
+ AddIfDifferent(mismatches, want.name, "compiledAssemblyReferences", want.compiledAssemblyReferences, got.compiledAssemblyReferences);
+ AddIfDifferent(mismatches, want.name, "allReferences", want.allReferences, got.allReferences);
+ }
+
+ return mismatches;
+ }
+
+ private static string[] NamesOf(UnityCompilationAssembly[] assemblies)
+ {
+ string[] names = new string[assemblies.Length];
+ for (int index = 0; index < assemblies.Length; index++)
+ {
+ names[index] = assemblies[index].name;
+ }
+
+ return names;
+ }
+
+ private static void AddIfDifferent(List mismatches, string assemblyName, string field, string[] want, string[] got)
+ {
+ if (want.Length == got.Length && Array.TrueForAll(Indexes(want.Length), index => want[index] == got[index]))
+ {
+ return;
+ }
+
+ bool sameSet = new HashSet(want).SetEquals(got);
+ mismatches.Add(assemblyName + "." + field + (sameSet ? " (order only)" : " (content)"));
+ }
+
+ private static int[] Indexes(int count)
+ {
+ int[] indexes = new int[count];
+ for (int index = 0; index < count; index++)
+ {
+ indexes[index] = index;
+ }
+
+ return indexes;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs.meta
new file mode 100644
index 000000000..2de810fe0
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1d0a6db33e0084c7d89ef013a5ac23eb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs
new file mode 100644
index 000000000..11a442efd
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs
@@ -0,0 +1,656 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+using NUnit.Framework;
+
+using UnityEditor.Compilation;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Contract of rebuilding the compilation assembly list from the saved list and the compiled
+ /// files: a saved entry is used as it is only while its dll is unchanged, a compiled-again
+ /// assembly is rebuilt from its PDB, and anything that cannot be shown to match the files on
+ /// disk gives no list at all. Each case saves this project's real list, then changes the one
+ /// part of the saved file the case is about; the dlls on disk are never touched.
+ ///
+ public sealed class HotReloadCompilationAssemblyReconstructionTests
+ {
+ private const string TargetAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
+ private const string TargetReferenceFileName = "nunit.framework.dll";
+ private const string SavedOnlyDefine = "SAVED_ONLY_DEFINE";
+ private const string RemovedAssemblyName = "Removed.Assembly";
+
+ private string _directory;
+ private string _filePath;
+ private string _projectRoot;
+ private int _backgroundRuns;
+ private bool _isCompilationFailed;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _directory = Path.Combine(Path.GetTempPath(), "uloop-compilation-list-rebuild-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_directory);
+ _filePath = Path.Combine(_directory, "CompilationAssemblies.json");
+ _projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ _backgroundRuns = 0;
+ _isCompilationFailed = false;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, true);
+ }
+ }
+
+ ///
+ /// What: without a saved list the rebuild gives no list and passes on why the load failed.
+ ///
+ [Test]
+ public void Reconstruct_WithoutAUsableSavedList_ReturnsNull()
+ {
+ HotReloadCompilationListReconstruction result = Reconstruct(CreateStore());
+
+ Assert.That(result.Assemblies, Is.Null, "list");
+ Assert.That(result.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonNoSavedList));
+ }
+
+ ///
+ /// What: a saved list that names a dll no longer compiled, or lacks one that is, gives no list.
+ ///
+ [TestCase(true, TestName = "Reconstruct_WhenTheAssemblySetChanged_ReturnsNull(added)")]
+ [TestCase(false, TestName = "Reconstruct_WhenTheAssemblySetChanged_ReturnsNull(removed)")]
+ public void Reconstruct_WhenTheAssemblySetChanged_ReturnsNull(bool savedHasAnExtraDll)
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved =>
+ {
+ if (savedHasAnExtraDll)
+ {
+ HotReloadSavedCompilationAssembly extra = JsonConvert.DeserializeObject(
+ JsonConvert.SerializeObject(Target(saved)));
+ extra.name = RemovedAssemblyName;
+ saved.assemblies.Add(extra);
+ return;
+ }
+
+ Target(saved).dllStamp = null;
+ });
+
+ HotReloadCompilationListReconstruction result = Reconstruct(store);
+
+ Assert.That(result.Assemblies, Is.Null, "list");
+ Assert.That(result.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonAssemblySetChanged));
+ }
+
+ ///
+ /// What: when no dll changed, every entry is the saved one, even where the PDB says otherwise.
+ ///
+ [Test]
+ public void Reconstruct_AllStampsUnchanged_RebuildsTheSavedList()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => Target(saved).defines = new[] { SavedOnlyDefine });
+
+ HotReloadCompilationListReconstruction result = Reconstruct(store);
+
+ Assert.That(result.FailureReason, Is.Null, "failure");
+ Assert.That(Find(result.Assemblies, TargetAssemblyName).defines, Is.EqualTo(new[] { SavedOnlyDefine }));
+ }
+
+ ///
+ /// What: an assembly whose dll changed takes its sources, defines, project references and
+ /// precompiled references from its PDB, dropping what the saved entry had but the PDB no
+ /// longer records, so it equals Unity's entry field by field and in order.
+ ///
+ [Test]
+ public void Reconstruct_RecompiledAssembly_TakesSourcesDefinesAndReferencesFromItsPdb()
+ {
+ UnityCompilationAssembly expected = CompilationPipeline.GetAssemblies()[IndexOf(TargetAssemblyName)];
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved =>
+ {
+ HotReloadSavedCompilationAssembly target = MarkRecompiled(saved);
+ target.defines = new[] { SavedOnlyDefine };
+ target.sourceFiles = Append(target.sourceFiles, "Assets/NotOnDisk/Removed.cs");
+ target.assemblyReferences = Append(target.assemblyReferences, RemovedAssemblyName);
+ target.compiledAssemblyReferenceIndexes = AppendIndex(
+ target.compiledAssemblyReferenceIndexes, IndexOfReferenceNotIn(saved, target));
+ });
+
+ HotReloadCompilationListReconstruction result = Reconstruct(store);
+
+ Assert.That(result.FailureReason, Is.Null, "failure");
+ UnityCompilationAssembly rebuilt = Find(result.Assemblies, TargetAssemblyName);
+ Assert.That(rebuilt.sourceFiles, Is.EqualTo(expected.sourceFiles), "sourceFiles");
+ Assert.That(rebuilt.defines, Is.EqualTo(expected.defines), "defines");
+ Assert.That(NamesOf(rebuilt.assemblyReferences), Is.EqualTo(NamesOf(expected.assemblyReferences)), "assemblyReferences");
+ Assert.That(rebuilt.compiledAssemblyReferences, Is.EqualTo(expected.compiledAssemblyReferences), "compiledAssemblyReferences");
+ }
+
+ ///
+ /// What: a reference the PDB records under a file name no saved path has gives no list.
+ ///
+ [Test]
+ public void Reconstruct_UnresolvedReference_ReturnsNull()
+ {
+ AssertRecompiledTargetGives(
+ HotReloadConstants.CompilationListReasonUnresolvedReference,
+ reference => reference.path = reference.path.Replace(TargetReferenceFileName, "renamed.dll"));
+ }
+
+ ///
+ /// What: a reference whose saved path no longer exists gives no list.
+ ///
+ [Test]
+ public void Reconstruct_MissingReferenceFile_ReturnsNull()
+ {
+ AssertRecompiledTargetGives(
+ HotReloadConstants.CompilationListReasonReferenceMissing,
+ reference =>
+ {
+ reference.path = "Library/NotOnDisk/" + TargetReferenceFileName;
+ reference.stamp = null;
+ reference.mvid = null;
+ });
+ }
+
+ ///
+ /// What: a reference whose file holds another MVID than the PDB records gives no list. The
+ /// saved MVID stands for the file because its stamp is unchanged.
+ ///
+ [Test]
+ public void Reconstruct_ReferenceWithAnotherMvid_ReturnsNull()
+ {
+ AssertRecompiledTargetGives(
+ HotReloadConstants.CompilationListReasonReferenceChanged,
+ reference => reference.mvid = Guid.NewGuid().ToString("N"));
+ }
+
+ ///
+ /// What: a recompiled assembly whose PDB was rewritten after the compile and lost the
+ /// documents no sequence point refers to (here by a Cecil round trip, as an IL
+ /// post-processor does) is refused, instead of being rebuilt with fewer sources.
+ ///
+ [Test]
+ public void TryFromPdb_PdbThatLostSourcesInARewrite_IsRefused()
+ {
+ HotReloadSavedCompilationList saved = SaveCleanThenEdit(edit => { }).Load().List;
+ string scriptAssemblies = Path.Combine(_directory, "Project", HotReloadConstants.ScriptAssembliesRelativeDirectory);
+ Directory.CreateDirectory(scriptAssemblies);
+ string dllPath = Path.Combine(scriptAssemblies, TargetAssemblyName + ".dll");
+ string pdbPath = Path.ChangeExtension(dllPath, ".pdb");
+ CecilPdbRewrite.Rewrite(_projectRoot, TargetAssemblyName, _directory, dllPath);
+ HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(pdbPath);
+ Assert.That(rewritten.SourceFileCount, Is.Not.Null, "Precondition: the rewritten PDB must keep the compiler's source count.");
+ Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount.Value), "Precondition: the rewrite must drop some document.");
+ HotReloadCompilationAssemblyRebuilder rebuilder =
+ new HotReloadCompilationAssemblyRebuilder(saved, HotReloadSnapshotCaptureInputs.ReadOnMainThread());
+
+ string reason = rebuilder.TryFromPdb(
+ Target(saved),
+ new HotReloadCaptureAssemblyCandidate(TargetAssemblyName, dllPath, pdbPath),
+ out HotReloadRebuiltAssemblyEntry entry);
+
+ Assert.That(reason, Is.EqualTo(HotReloadConstants.CompilationListReasonPdbDocumentsIncomplete));
+ Assert.That(entry, Is.Null, "entry");
+ }
+
+ ///
+ /// What: a recompiled assembly whose PDB lost documents in a rewrite is rebuilt from the
+ /// compiler's own PDB of the same build under Library/Bee, with Unity's source files in
+ /// Unity's order.
+ ///
+ [Test]
+ public void TryFromPdb_PdbThatLostSourcesInARewrite_IsRebuiltFromTheCompilersPdb()
+ {
+ HotReloadSavedCompilationList saved = SaveCleanThenEdit(edit => { }).Load().List;
+ string scriptAssemblies = Path.Combine(_directory, "Project", HotReloadConstants.ScriptAssembliesRelativeDirectory);
+ Directory.CreateDirectory(scriptAssemblies);
+ string dllPath = Path.Combine(scriptAssemblies, TargetAssemblyName + ".dll");
+ string pdbPath = Path.ChangeExtension(dllPath, ".pdb");
+ CecilPdbRewrite.Rewrite(_projectRoot, TargetAssemblyName, _directory, dllPath);
+ CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(_projectRoot);
+ Assert.That(
+ HotReloadAssemblyMetadataMvid.Read(dllPath),
+ Is.EqualTo(HotReloadAssemblyMetadataMvid.Read(real.DllPath(TargetAssemblyName))),
+ "Precondition: the rewrite must keep the MVID.");
+ HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(pdbPath);
+ Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount), "Precondition: the rewrite must drop some document.");
+ string dag = Path.Combine(_directory, "Project", "Library", "Bee", "artifacts", "test.dag");
+ Directory.CreateDirectory(dag);
+ File.Copy(real.DllPath(TargetAssemblyName), Path.Combine(dag, TargetAssemblyName + ".dll"));
+ File.Copy(real.PdbPath(TargetAssemblyName), Path.Combine(dag, TargetAssemblyName + ".pdb"));
+ HotReloadCompilationAssemblyRebuilder rebuilder =
+ new HotReloadCompilationAssemblyRebuilder(saved, HotReloadSnapshotCaptureInputs.ReadOnMainThread());
+
+ string reason = rebuilder.TryFromPdb(
+ Target(saved),
+ new HotReloadCaptureAssemblyCandidate(TargetAssemblyName, dllPath, pdbPath),
+ out HotReloadRebuiltAssemblyEntry entry);
+
+ Assert.That(reason, Is.Null);
+ Assert.That(entry.SourceFiles, Is.EqualTo(CompilationPipeline.GetAssemblies()[IndexOf(TargetAssemblyName)].sourceFiles));
+ }
+
+ ///
+ /// What: a recompiled assembly whose PDB belongs to another build of the dll gives no list.
+ ///
+ [Test]
+ public void Reconstruct_PdbOfAnotherBuild_ReturnsNull()
+ {
+ string scriptAssemblies = Path.Combine(_directory, "Project", HotReloadConstants.ScriptAssembliesRelativeDirectory);
+ Directory.CreateDirectory(scriptAssemblies);
+ CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(_projectRoot);
+ File.Copy(real.DllPath(TargetAssemblyName), Path.Combine(scriptAssemblies, TargetAssemblyName + ".dll"));
+ File.Copy(real.PdbPath("UnityCLILoop.ToolContracts"), Path.Combine(scriptAssemblies, TargetAssemblyName + ".pdb"));
+ File.WriteAllText(_filePath, JsonConvert.SerializeObject(new HotReloadSavedCompilationList
+ {
+ formatVersion = HotReloadCompilationAssemblyListStore.FormatVersion,
+ assemblies = new List
+ {
+ new HotReloadSavedCompilationAssembly
+ {
+ name = TargetAssemblyName,
+ outputPath = "Library/ScriptAssemblies/" + TargetAssemblyName + ".dll",
+ sourceFiles = Array.Empty(),
+ defines = Array.Empty(),
+ assemblyReferences = Array.Empty(),
+ compiledAssemblyReferenceIndexes = Array.Empty(),
+ dllStamp = new HotReloadSavedFileStamp { length = 1, lastWriteTicks = 1 },
+ },
+ },
+ references = new List(),
+ }));
+ HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs(
+ Path.Combine(_directory, "Project"),
+ default(HotReloadCompileStart),
+ Array.Empty(),
+ StringComparison.Ordinal);
+
+ HotReloadCompilationListReconstruction result =
+ HotReloadCompilationAssemblyReconstruction.Reconstruct(CreateStore(), inputs, CancellationToken.None);
+
+ Assert.That(result.Assemblies, Is.Null, "list");
+ Assert.That(result.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonPdbMismatch));
+ }
+
+ ///
+ /// What: a rebuilt list that fills the empty cache is saved again as the next domain's
+ /// baseline, and Unity is not asked.
+ ///
+ [Test]
+ public void ReconstructAndFill_WhenFilled_SavesTheRebuiltListAsTheNewBaseline()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ int savesBefore = _backgroundRuns;
+ int fetches = 0;
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(() =>
+ {
+ fetches++;
+ return Array.Empty();
+ });
+
+ Task refill = CreateRefill(store, cache).RunAsync();
+
+ Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status");
+ Assert.That(refill.Result, Is.True, "kept");
+ Assert.That(cache.IsFilled, Is.True, "cache filled");
+ Assert.That(_backgroundRuns, Is.EqualTo(savesBefore + 1), "saves");
+ Assert.That(store.Load().List, Is.Not.Null, "saved list readable");
+ cache.Current();
+ Assert.That(fetches, Is.EqualTo(0), "Unity asked");
+ }
+
+ ///
+ /// What: when a caller already filled the cache from Unity, the rebuilt list is neither kept
+ /// nor saved.
+ ///
+ [Test]
+ public void ReconstructAndFill_WhenTheCacheIsAlreadyFilled_KeepsAndSavesNothing()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ int savesBefore = _backgroundRuns;
+ UnityCompilationAssembly[] fetched = CompilationPipeline.GetAssemblies();
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(() => fetched);
+ cache.Current();
+
+ Task refill = CreateRefill(store, cache).RunAsync();
+
+ Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status");
+ Assert.That(refill.Result, Is.False, "kept");
+ Assert.That(cache.Current(), Is.SameAs(fetched), "cache content");
+ Assert.That(_backgroundRuns, Is.EqualTo(savesBefore), "saves");
+ }
+
+ ///
+ /// What: a rebuild cancelled while it runs in the background keeps nothing, saves nothing and
+ /// reports the cancellation instead of faulting.
+ ///
+ [Test]
+ public async Task ReconstructAndFill_CancelledWhileRebuilding_KeepsNothing()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ int savesBefore = _backgroundRuns;
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty);
+ TaskCompletionSource held =
+ new TaskCompletionSource();
+ Func rebuild = null;
+ HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill(
+ store,
+ cache,
+ HotReloadSnapshotCaptureInputs.ReadOnMainThread,
+ () => _isCompilationFailed,
+ work =>
+ {
+ rebuild = work;
+ return held.Task;
+ });
+ VibeLogger.ClearMemoryLogs();
+
+ Task run = refill.RunAsync();
+ HotReloadCompilationListReconstruction rebuilt = rebuild();
+ Assert.That(rebuilt.Assemblies, Is.Not.Null, "Precondition: the rebuild must give a list.");
+ refill.Cancel();
+ held.SetResult(rebuilt);
+ // Why awaited: the rest of the run resumes off this thread after the held rebuild completes.
+ bool kept = await run;
+
+ Assert.That(run.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status");
+ Assert.That(kept, Is.False, "kept");
+ Assert.That(cache.IsFilled, Is.False, "cache filled");
+ Assert.That(_backgroundRuns, Is.EqualTo(savesBefore), "saves");
+ Assert.That(
+ (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed)["reason"],
+ Is.EqualTo(HotReloadConstants.CompilationListReasonCancelled));
+ }
+
+ ///
+ /// What: after a failed compile the rebuilt list is neither kept nor saved, because the dlls
+ /// are older than the sources Unity now lists.
+ ///
+ [Test]
+ public void ReconstructAndFill_WhileCompilationFailed_KeepsAndSavesNothing()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ int savesBefore = _backgroundRuns;
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty);
+ _isCompilationFailed = true;
+ VibeLogger.ClearMemoryLogs();
+
+ Task refill = CreateRefill(store, cache).RunAsync();
+
+ Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status");
+ Assert.That(refill.Result, Is.False, "kept");
+ Assert.That(cache.IsFilled, Is.False, "cache filled");
+ Assert.That(_backgroundRuns, Is.EqualTo(savesBefore), "saves");
+ Assert.That(
+ (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed)["reason"],
+ Is.EqualTo(HotReloadConstants.CompilationListReasonCompilationFailed));
+ }
+
+ ///
+ /// What: a rebuild that fills the cache logs both the background time and the main thread
+ /// wait, and together they fit within the whole time.
+ ///
+ [Test]
+ public void ReconstructAndFill_WhenFilled_LogsTheBackgroundAndMainThreadWaitTimes()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty);
+ VibeLogger.ClearMemoryLogs();
+
+ Task refill = CreateRefill(store, cache).RunAsync();
+
+ Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status");
+ JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed);
+ Assert.That((bool)context["filled"], Is.True, "filled");
+ Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Integer), "backgroundMs");
+ Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Integer), "mainThreadWaitMs");
+ long backgroundMs = (long)context["backgroundMs"];
+ long mainThreadWaitMs = (long)context["mainThreadWaitMs"];
+ Assert.That(backgroundMs, Is.GreaterThanOrEqualTo(0), "backgroundMs");
+ Assert.That(mainThreadWaitMs, Is.GreaterThanOrEqualTo(0), "mainThreadWaitMs");
+ Assert.That(backgroundMs + mainThreadWaitMs, Is.LessThanOrEqualTo((long)context["reconstructMs"]), "sum");
+ }
+
+ ///
+ /// What: a rebuild cancelled after the background work finished logs the background time and
+ /// leaves the main thread wait null, because the switch to the main thread never completed.
+ ///
+ [Test]
+ public async Task ReconstructAndFill_CancelledBeforeTheMainThread_LogsOnlyTheBackgroundTime()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty);
+ TaskCompletionSource held =
+ new TaskCompletionSource();
+ Func rebuild = null;
+ HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill(
+ store,
+ cache,
+ HotReloadSnapshotCaptureInputs.ReadOnMainThread,
+ () => _isCompilationFailed,
+ work =>
+ {
+ rebuild = work;
+ return held.Task;
+ });
+ VibeLogger.ClearMemoryLogs();
+
+ Task run = refill.RunAsync();
+ HotReloadCompilationListReconstruction rebuilt = rebuild();
+ refill.Cancel();
+ held.SetResult(rebuilt);
+ // Why awaited: the rest of the run resumes off this thread after the held rebuild completes.
+ await run;
+
+ JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed);
+ Assert.That((string)context["reason"], Is.EqualTo(HotReloadConstants.CompilationListReasonCancelled), "reason");
+ Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Integer), "backgroundMs");
+ Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Null), "mainThreadWaitMs");
+ }
+
+ ///
+ /// What: a rebuild cancelled while the background work runs logs neither time as a number,
+ /// because the work threw before its time was taken.
+ ///
+ [Test]
+ public async Task ReconstructAndFill_CancelledDuringTheRebuild_LogsNeitherTime()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty);
+ TaskCompletionSource held =
+ new TaskCompletionSource();
+ Func rebuild = null;
+ HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill(
+ store,
+ cache,
+ HotReloadSnapshotCaptureInputs.ReadOnMainThread,
+ () => _isCompilationFailed,
+ work =>
+ {
+ rebuild = work;
+ return held.Task;
+ });
+ VibeLogger.ClearMemoryLogs();
+
+ Task run = refill.RunAsync();
+ refill.Cancel();
+ OperationCanceledException thrown = Assert.Throws(() => rebuild());
+ held.SetException(thrown);
+ // Why awaited: the rest of the run resumes off this thread after the held rebuild completes.
+ await run;
+
+ JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed);
+ Assert.That((string)context["reason"], Is.EqualTo(HotReloadConstants.CompilationListReasonCancelled), "reason");
+ Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Null), "backgroundMs");
+ Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Null), "mainThreadWaitMs");
+ }
+
+ ///
+ /// What: a rebuild whose background work does not finish logs neither time as a number, so a
+ /// step that never ran is not reported as taking 0 ms.
+ ///
+ [Test]
+ public void ReconstructAndFill_WhenTheRebuildFails_LogsNeitherTime()
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { });
+ HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty);
+ HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill(
+ store,
+ cache,
+ HotReloadSnapshotCaptureInputs.ReadOnMainThread,
+ () => _isCompilationFailed,
+ work => Task.FromException(new InvalidOperationException("test")));
+ VibeLogger.ClearMemoryLogs();
+
+ Task run = refill.RunAsync();
+
+ Assert.That(run.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status");
+ Assert.That(run.Result, Is.False, "kept");
+ JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed);
+ Assert.That((string)context["reason"], Is.EqualTo(nameof(InvalidOperationException)), "reason");
+ Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Null), "backgroundMs");
+ Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Null), "mainThreadWaitMs");
+ }
+
+ private void AssertRecompiledTargetGives(string reason, Action editTargetReference)
+ {
+ HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved =>
+ {
+ MarkRecompiled(saved);
+ int edited = 0;
+ foreach (HotReloadSavedReference reference in saved.references)
+ {
+ if (Path.GetFileName(reference.path) == TargetReferenceFileName)
+ {
+ editTargetReference(reference);
+ edited++;
+ }
+ }
+
+ Assert.That(edited, Is.GreaterThan(0), "Precondition: the saved list must reference " + TargetReferenceFileName);
+ });
+
+ HotReloadCompilationListReconstruction result = Reconstruct(store);
+
+ Assert.That(result.Assemblies, Is.Null, "list");
+ Assert.That(result.FailureReason, Is.EqualTo(reason));
+ }
+
+ private HotReloadCompilationListRefill CreateRefill(HotReloadCompilationAssemblyListStore store, HotReloadCompilationAssemblyCache cache)
+ {
+ // Why inline: EditMode tests must not start pool work.
+ return new HotReloadCompilationListRefill(
+ store,
+ cache,
+ HotReloadSnapshotCaptureInputs.ReadOnMainThread,
+ () => _isCompilationFailed,
+ rebuild => Task.FromResult(rebuild()));
+ }
+
+ private HotReloadCompilationListReconstruction Reconstruct(HotReloadCompilationAssemblyListStore store)
+ {
+ return HotReloadCompilationAssemblyReconstruction.Reconstruct(
+ store, HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None);
+ }
+
+ private HotReloadCompilationAssemblyListStore SaveCleanThenEdit(Action edit)
+ {
+ HotReloadCompilationAssemblyListStore store = CreateStore();
+ store.SaveInBackgroundIfClean(CompilationPipeline.GetAssemblies(), 0, _projectRoot);
+ HotReloadSavedCompilationList saved = store.Load().List;
+ Assert.That(saved, Is.Not.Null, "Precondition: the list must be saved.");
+ edit(saved);
+ File.WriteAllText(_filePath, JsonConvert.SerializeObject(saved));
+ return store;
+ }
+
+ private HotReloadCompilationAssemblyListStore CreateStore()
+ {
+ // Why inline: EditMode tests must not start pool work, so the "background" runs here.
+ return new HotReloadCompilationAssemblyListStore(
+ _filePath,
+ () => false,
+ () => 0,
+ work =>
+ {
+ _backgroundRuns++;
+ work();
+ return Task.CompletedTask;
+ });
+ }
+
+ private static HotReloadSavedCompilationAssembly Target(HotReloadSavedCompilationList saved)
+ {
+ return saved.assemblies.Find(assembly => assembly.name == TargetAssemblyName);
+ }
+
+ // A stamp no file has, so the target goes through its PDB.
+ private static HotReloadSavedCompilationAssembly MarkRecompiled(HotReloadSavedCompilationList saved)
+ {
+ HotReloadSavedCompilationAssembly target = Target(saved);
+ target.dllStamp.lastWriteTicks = 1;
+ return target;
+ }
+
+ private static int IndexOfReferenceNotIn(HotReloadSavedCompilationList saved, HotReloadSavedCompilationAssembly target)
+ {
+ HashSet targetFileNames = new HashSet();
+ foreach (int index in target.compiledAssemblyReferenceIndexes)
+ {
+ targetFileNames.Add(Path.GetFileName(saved.references[index].path));
+ }
+
+ int found = saved.references.FindIndex(reference => !targetFileNames.Contains(Path.GetFileName(reference.path)));
+ Assert.That(found, Is.GreaterThanOrEqualTo(0), "Precondition: some reference must be outside the target's.");
+ return found;
+ }
+
+ private static int IndexOf(string assemblyName)
+ {
+ return Array.FindIndex(CompilationPipeline.GetAssemblies(), assembly => assembly.name == assemblyName);
+ }
+
+ private static UnityCompilationAssembly Find(UnityCompilationAssembly[] assemblies, string assemblyName)
+ {
+ return Array.Find(assemblies, assembly => assembly.name == assemblyName);
+ }
+
+ private static string[] NamesOf(UnityCompilationAssembly[] assemblies)
+ {
+ return Array.ConvertAll(assemblies, assembly => assembly.name);
+ }
+
+ private static string[] Append(string[] values, string value)
+ {
+ string[] appended = new string[values.Length + 1];
+ values.CopyTo(appended, 0);
+ appended[values.Length] = value;
+ return appended;
+ }
+
+ private static int[] AppendIndex(int[] values, int value)
+ {
+ int[] appended = new int[values.Length + 1];
+ values.CopyTo(appended, 0);
+ appended[values.Length] = value;
+ return appended;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs.meta
new file mode 100644
index 000000000..b089533b1
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 48b17e4b0e06f4ddd96a82c16cdac1f2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs
index 46fd9aa9c..5d694f9ea 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs
@@ -50,7 +50,7 @@ public void FromDocuments_MatchesTheSourceFilesUnityListsForEveryCompiledAssembl
}
readTime.Start();
- List documents = HotReloadPdbDocumentTable.Read(dllPath, pdbPath);
+ List documents = HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _);
readTime.Stop();
HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments(
documents,
@@ -100,7 +100,8 @@ public void FromDocuments_IncludesTheBodylessFixtures()
CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot());
List documents = HotReloadPdbDocumentTable.Read(
layout.DllPath(TestAssemblyName),
- layout.PdbPath(TestAssemblyName));
+ layout.PdbPath(TestAssemblyName),
+ out int? _);
HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments(
documents,
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs
new file mode 100644
index 000000000..1a2650bef
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+using NUnit.Framework;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Contract of reading a rewritten PDB's documents from the compiler's own output under
+ /// Library/Bee/artifacts: only a compiler dll with the same MVID whose own PDB lists every source
+ /// stands in, whichever dag holds it, and anything else gives null. Each case lays out a
+ /// temporary Library with copies of this project's compiled files.
+ ///
+ public sealed class HotReloadCompilerOutputDocumentsTests
+ {
+ private const string TargetAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
+
+ private string _projectRoot;
+ private string _directory;
+ private string _targetDllPath;
+ private string _targetPdbPath;
+ private string _otherDllPath;
+ private string _otherPdbPath;
+ private string _scriptAssembliesDllPath;
+ private string _artifactsDirectory;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot);
+ _targetDllPath = layout.DllPath(TargetAssemblyName);
+ _targetPdbPath = layout.PdbPath(TargetAssemblyName);
+ string otherAssemblyName = typeof(HotReloadSourceBaseline).Assembly.GetName().Name;
+ _otherDllPath = layout.DllPath(otherAssemblyName);
+ _otherPdbPath = layout.PdbPath(otherAssemblyName);
+
+ HotReloadPdbCompilationInfo target = HotReloadPdbCompilationInfo.Read(_targetPdbPath);
+ Assert.That(target.DocumentCount, Is.EqualTo(target.SourceFileCount), "Precondition: the compiled test assembly's PDB must list every source.");
+ HotReloadPdbCompilationInfo other = HotReloadPdbCompilationInfo.Read(_otherPdbPath);
+ Assert.That(other.DocumentCount, Is.EqualTo(other.SourceFileCount), "Precondition: the other assembly's PDB must list every source, so a guard that let its files through would return documents.");
+
+ _directory = Path.Combine(Path.GetTempPath(), "uloop-compiler-output-documents-" + Guid.NewGuid().ToString("N"));
+ _scriptAssembliesDllPath = Path.Combine(
+ _directory, HotReloadConstants.ScriptAssembliesRelativeDirectory, TargetAssemblyName + ".dll");
+ Directory.CreateDirectory(Path.GetDirectoryName(_scriptAssembliesDllPath));
+ File.Copy(_targetDllPath, _scriptAssembliesDllPath);
+ _artifactsDirectory = Path.Combine(_directory, "Library", "Bee", "artifacts");
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, true);
+ }
+ }
+
+ ///
+ /// What: without a Library/Bee/artifacts folder beside the dll's Library there is nothing to read.
+ ///
+ [Test]
+ public void TryReadEveryDocument_NoBeeFolder_ReturnsNull()
+ {
+ Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null);
+ }
+
+ ///
+ /// What: a dag that holds no dll of the assembly's name gives null.
+ ///
+ [Test]
+ public void TryReadEveryDocument_NoCompilerDllOfThatName_ReturnsNull()
+ {
+ Dag("test.dag");
+
+ Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null);
+ }
+
+ ///
+ /// What: a compiler dll of the same name but another MVID, with a complete PDB of its own, is not used.
+ ///
+ [Test]
+ public void TryReadEveryDocument_CompilerDllOfAnotherBuild_ReturnsNull()
+ {
+ PutOtherBuild(Dag("test.dag"));
+
+ Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null);
+ }
+
+ ///
+ /// What: a compiler dll with the same MVID but no PDB beside it gives null.
+ ///
+ [Test]
+ public void TryReadEveryDocument_NoCompilerPdb_ReturnsNull()
+ {
+ File.Copy(_targetDllPath, Path.Combine(Dag("test.dag"), TargetAssemblyName + ".dll"));
+
+ Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null);
+ }
+
+ ///
+ /// What: a compiler dll with the same MVID whose PDB belongs to another dll gives null instead of throwing.
+ ///
+ [Test]
+ public void TryReadEveryDocument_CompilerPdbOfAnotherDll_ReturnsNull()
+ {
+ string dag = Dag("test.dag");
+ File.Copy(_targetDllPath, Path.Combine(dag, TargetAssemblyName + ".dll"));
+ File.Copy(_otherPdbPath, Path.Combine(dag, TargetAssemblyName + ".pdb"));
+
+ Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null);
+ }
+
+ ///
+ /// What: a compiler dll with the same MVID whose own PDB also lost documents gives null.
+ ///
+ [Test]
+ public void TryReadEveryDocument_CompilerPdbAlsoLostDocuments_ReturnsNull()
+ {
+ string compilerDllPath = Path.Combine(Dag("test.dag"), TargetAssemblyName + ".dll");
+ CecilPdbRewrite.Rewrite(_projectRoot, TargetAssemblyName, Path.Combine(_directory, "Work"), compilerDllPath);
+ Assert.That(
+ HotReloadAssemblyMetadataMvid.Read(compilerDllPath),
+ Is.EqualTo(HotReloadAssemblyMetadataMvid.Read(_targetDllPath)),
+ "Precondition: the rewrite must keep the MVID.");
+ HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(compilerDllPath, ".pdb"));
+ Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount), "Precondition: the rewrite must drop some document.");
+
+ Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null);
+ }
+
+ ///
+ /// What: a compiler dll with the same MVID and a complete PDB of its own gives that PDB's documents in table order.
+ ///
+ [Test]
+ public void TryReadEveryDocument_CompilerOutputOfTheSameBuild_ReturnsEveryDocument()
+ {
+ PutSameBuild(Dag("test.dag"));
+
+ List documents = HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath);
+
+ Assert.That(documents, Is.Not.Null);
+ Assert.That(documents.Select(document => document.Url), Is.EqualTo(ExpectedUrls()));
+ }
+
+ ///
+ /// What: when the first dag holds another build and the second the same one, the second dag's documents are returned.
+ ///
+ [Test]
+ public void TryReadEveryDocument_SecondDagHoldsTheSameBuild_ReturnsItsDocuments()
+ {
+ PutOtherBuild(Dag("a.dag"));
+ PutSameBuild(Dag("b.dag"));
+
+ List documents = HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath);
+
+ Assert.That(documents, Is.Not.Null);
+ Assert.That(documents.Select(document => document.Url), Is.EqualTo(ExpectedUrls()));
+ }
+
+ private string Dag(string name)
+ {
+ string dag = Path.Combine(_artifactsDirectory, name);
+ Directory.CreateDirectory(dag);
+ return dag;
+ }
+
+ private void PutSameBuild(string dag)
+ {
+ File.Copy(_targetDllPath, Path.Combine(dag, TargetAssemblyName + ".dll"));
+ File.Copy(_targetPdbPath, Path.Combine(dag, TargetAssemblyName + ".pdb"));
+ }
+
+ private void PutOtherBuild(string dag)
+ {
+ File.Copy(_otherDllPath, Path.Combine(dag, TargetAssemblyName + ".dll"));
+ File.Copy(_otherPdbPath, Path.Combine(dag, TargetAssemblyName + ".pdb"));
+ }
+
+ private IEnumerable ExpectedUrls()
+ {
+ return HotReloadPdbDocumentTable.Read(_targetDllPath, _targetPdbPath, out int? _)
+ .Select(document => document.Url);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs.meta
new file mode 100644
index 000000000..fe5ea2b07
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 518dc09b1ed4d4dc282c1c9d3bc19bfc
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs
index 2707d8012..ed3ec1cdf 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs
@@ -112,7 +112,7 @@ public void Install_PausePointSide_CapturesThroughTheInstalledGateWithThePausePo
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
+ HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() =>
{
captureCount++;
return true;
@@ -213,7 +213,7 @@ private static HotReloadServices CreateServicesSharing(HotReloadDomain domain)
TransformWorkerHost.Shared,
HotReloadGroupProcessorDependencies.CreateProduction,
new HotReloadApplicationPlayModeQuery(),
- new HotReloadSourceSnapshotCapture(() => true),
+ HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true),
installed.WarmUp,
installed.CompiledCallers);
}
@@ -228,7 +228,7 @@ private static HotReloadServices CreateServicesOwning(HotReloadDomain domain)
TransformWorkerHost.Shared,
HotReloadGroupProcessorDependencies.CreateProduction,
new HotReloadApplicationPlayModeQuery(),
- new HotReloadSourceSnapshotCapture(() => true),
+ HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true),
HotReloadWarmUpTestDoubles.CreateInert(),
HotReloadCompiledCallers.CreateProduction());
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs
index 77b610072..dc3189b1c 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs
@@ -358,7 +358,7 @@ public async Task ExecuteAsync_WhenFilesAreOmitted_CapturesTheSourceSnapshotBefo
{
int captureCount = 0;
using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(
- new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; }));
+ HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; }));
int captureCountAtDetection = -1;
using IDisposable detectorScope = HotReloadServicesTestScope.BeginWithChangeDetector(
new HotReloadStubChangeDetector(() =>
@@ -387,7 +387,7 @@ public async Task ExecuteAsync_WithFiles_CapturesTheSourceSnapshotBeforeTheRun()
{
int captureCount = 0;
using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(
- new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; }));
+ HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; }));
int captureCountAtRun = -1;
using IDisposable orchestratorScope = HotReloadServicesTestScope.BeginWithOrchestrator(
new HotReloadStubOrchestrator((files, ignoredCt) =>
@@ -409,7 +409,7 @@ public async Task ExecuteAsync_ForStatus_DoesNotCaptureTheSourceSnapshot()
{
int captureCount = 0;
using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(
- new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; }));
+ HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; }));
HotReloadResponse response = await ExecuteAsync(new JObject { ["Status"] = true });
diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs
index d2a11ac07..6575ff9e7 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_WaitsWhenThePdbCannotConfirmTheLiveFile()
+ {
+ 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/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs
index 4a0288dae..804a40dda 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
@@ -20,11 +21,11 @@ public class HotReloadEditorStartupTests
/// What: the capture before serving commands runs the installed capture once and records the domain-load trigger.
///
[Test]
- public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOnceWithTheDomainLoadTrigger()
+ public void StartSourceSnapshotCaptureBeforeServingCommands_RunsTheInstalledCaptureOnceWithTheDomainLoadTrigger()
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
+ HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() =>
{
captureCount++;
return true;
@@ -32,7 +33,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn
using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
- HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled);
+ HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(AllToolsEnabled);
}
Assert.That(captureCount, Is.EqualTo(1), "captures");
@@ -41,15 +42,16 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn
}
///
- /// What: a capture that throws before serving commands passes the exception on and stays
- /// undone, so the first update tick's capture runs it again.
+ /// What: a capture that fails before serving commands does not throw into the domain load,
+ /// which never waits for it; it logs one failed entry and stays undone, so the first update
+ /// tick's capture runs it again.
///
[Test]
- public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_ThrowsAndLeavesItForTheNextCall()
+ public void StartSourceSnapshotCaptureBeforeServingCommands_WhenTheCaptureFails_LogsFailedAndLeavesItForTheNextCall()
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
+ HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() =>
{
captureCount++;
if (captureCount == 1)
@@ -62,14 +64,18 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro
using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
- Assert.Throws(
- () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled));
+ Assert.DoesNotThrow(
+ () => HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(AllToolsEnabled));
+ JObject failed = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(
+ HotReloadConstants.VibeLogSourceSnapshotCaptureFailed);
+ Assert.That((string)failed["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad));
- HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(
- HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
+ HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled(
+ HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick, AllToolsEnabled);
}
Assert.That(captureCount, Is.EqualTo(2), "captures");
+ Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(1), "captured entries");
}
///
@@ -78,7 +84,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro
/// reader that runs later captures through the gate.
///
[Test]
- public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader()
+ public void StartSourceSnapshotCaptureBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader()
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
@@ -86,7 +92,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn
using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
- HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(NoToolEnabled);
+ HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(NoToolEnabled);
Assert.That(captureCount, Is.EqualTo(0), "captures while both readers are disabled");
JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped);
@@ -94,7 +100,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn
Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled));
Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0), "captured entries");
- HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(
+ HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCapturedBlocking(
HotReloadConstants.SourceSnapshotCaptureTriggerApply);
}
@@ -107,7 +113,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn
///
[TestCase(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)]
[TestCase(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT)]
- public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool)
+ public void StartSourceSnapshotCaptureBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool)
{
VibeLogger.ClearMemoryLogs();
int captureCount = 0;
@@ -115,7 +121,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEn
using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture))
{
- HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(name => name == enabledTool);
+ HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(name => name == enabledTool);
}
Assert.That(captureCount, Is.EqualTo(1), "captures");
@@ -209,13 +215,95 @@ public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsEnabled_StartsOn
Assert.That(CountVibeEntries(HotReloadConstants.VibeLogWarmUpSkipped), Is.EqualTo(0), "skipped entries");
}
+ ///
+ /// What: with the hot-reload tool disabled the list is not rebuilt even though the
+ /// pause-point tool is enabled, because only hot reload reads the list.
+ ///
+ [Test]
+ public void ReconstructAndFill_WhenHotReloadIsDisabled_DoesNotStartEvenIfPausePointIsEnabled()
+ {
+ VibeLogger.ClearMemoryLogs();
+ int startCount = 0;
+
+ Task refill = HotReloadEditorStartup.RefillCompilationListUnlessHotReloadIsDisabled(
+ name => name == UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT,
+ () =>
+ {
+ startCount++;
+ return Task.FromResult(true);
+ });
+
+ Assert.That(startCount, Is.EqualTo(0), "rebuilds started");
+ Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status");
+ Assert.That(refill.Result, Is.False, "kept");
+ JObject logged = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed);
+ Assert.That((string)logged["reason"], Is.EqualTo(HotReloadConstants.CompilationListReasonToolDisabled));
+ }
+
+ ///
+ /// What: with the hot-reload tool enabled the rebuild is started once.
+ ///
+ [Test]
+ public void ReconstructAndFill_WhenHotReloadIsEnabled_StartsOnce()
+ {
+ int startCount = 0;
+
+ HotReloadEditorStartup.RefillCompilationListUnlessHotReloadIsDisabled(
+ name => name == UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD,
+ () =>
+ {
+ startCount++;
+ return Task.FromResult(true);
+ });
+
+ Assert.That(startCount, Is.EqualTo(1), "rebuilds started");
+ }
+
private static bool AllToolsEnabled(string toolName) => true;
private static bool NoToolEnabled(string toolName) => false;
+ ///
+ /// What: the warm-up does not start, so neither chooses its targets nor runs an item, until
+ /// the domain's attempt to rebuild the compilation assembly list has ended; then it starts once.
+ ///
+ [Test]
+ public async Task StartWarmUpOnceTheCompilationListIsReady_StartsOnlyAfterTheRebuildEnded()
+ {
+ TaskCompletionSource ready = new TaskCompletionSource();
+ int startCount = 0;
+
+ Task started = HotReloadEditorStartup.StartWarmUpOnceTheCompilationListIsReady(ready.Task, () => startCount++);
+
+ Assert.That(startCount, Is.EqualTo(0), "started before the rebuild ended");
+ Assert.That(started.IsCompleted, Is.False, "completed before the rebuild ended");
+ ready.SetResult(false);
+ await started;
+ Assert.That(started.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(startCount, Is.EqualTo(1));
+ }
+
+ ///
+ /// What: when the rebuild ends on a pool thread, as a cancelled one does, the warm-up still
+ /// starts on the main thread, where it reads Unity state and the kept list.
+ ///
+ [Test]
+ public async Task StartWarmUpOnceTheCompilationListIsReady_WhenTheRebuildEndsOnAPoolThread_StartsOnTheMainThread()
+ {
+ List startedOnMainThread = new List();
+
+ // Why a delay: its completion runs on a timer thread, so a missing switch would start there.
+ Task started = HotReloadEditorStartup.StartWarmUpOnceTheCompilationListIsReady(
+ Task.Delay(1), () => startedOnMainThread.Add(MainThreadSwitcher.IsMainThread));
+ await started;
+
+ Assert.That(started.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(startedOnMainThread, Is.EqualTo(new[] { true }));
+ }
+
private static HotReloadSourceSnapshotCapture CountingCapture(Action onCapture)
{
- return new HotReloadSourceSnapshotCapture(() =>
+ return HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() =>
{
onCapture();
return true;
diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
index e054e1806..dedae6b7e 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
@@ -4,18 +4,15 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
+using System.Threading;
using NUnit.Framework;
-using UnityEditor.Compilation;
-
using UnityEngine;
using UnityEngine.TestTools;
using io.github.hatayama.UnityCliLoop.FirstPartyTools;
-using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
-
namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
///
@@ -525,21 +522,20 @@ private HotReloadSnapshotCaptureStats CaptureIncrementally(HotReloadSnapshotSour
return stats;
}
+ // Why a reader that names the sources: the planted PDB is this test assembly's, whose sources
+ // live under the real project, so none of them maps to an asset path under the temporary root.
private HotReloadSnapshotCaptureStats CaptureAssemblies(params string[] sources)
{
- UnityCompilationAssembly assembly = new UnityCompilationAssembly(
- AssemblyName,
- HotReloadConstants.ScriptAssembliesRelativeDirectory + "/" + AssemblyName + HotReloadConstants.CompiledAssemblyExtension,
- sources,
- Array.Empty(),
- Array.Empty(),
- Array.Empty(),
- AssemblyFlags.EditorAssembly);
- return HotReloadSourceSnapshotter.CaptureAssemblies(
+ HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs(
_root,
- new[] { assembly },
HotReloadCompileStart.Unknown,
- new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")));
+ Array.Empty(),
+ StringComparison.Ordinal);
+ return HotReloadSourceSnapshotter.CaptureAssemblies(
+ inputs,
+ new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")),
+ (candidate, capturedInputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false, hasEverySource: true, sourcesFromCompilerOutput: false),
+ CancellationToken.None);
}
// A check whose suspect window starts at the planted compile start, against the given build.
@@ -550,7 +546,9 @@ private HotReloadSnapshotSourceCheck CheckAgainst(string dllPath, string pdbPath
dllPath,
pdbPath,
HotReloadAssemblyMvid.Read(dllPath),
- new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")));
+ new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")),
+ HotReloadSnapshotPackageRoots.ReadCurrent(),
+ HotReloadSnapshotCaptureInputs.CurrentPathComparison());
}
// A check whose suspect window never opens, so no copy is checked against a PDB.
@@ -561,7 +559,9 @@ private HotReloadSnapshotSourceCheck SuspectsNothing()
"unused.dll",
"unused.pdb",
"unused",
- new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")));
+ new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")),
+ Array.Empty(),
+ StringComparison.Ordinal);
}
// Copies this test assembly's own DLL and PDB, so the fixture has a real image for Cecil to read.
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/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs
index 18dd89378..6a7257e47 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs
@@ -216,7 +216,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony)
TransformWorkerHost.Shared,
HotReloadGroupProcessorDependencies.CreateProduction,
new HotReloadApplicationPlayModeQuery(),
- new HotReloadSourceSnapshotCapture(() => true),
+ HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true),
HotReloadWarmUpTestDoubles.CreateInert(),
HotReloadCompiledCallers.CreateProduction()));
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs
index 3f9b226e6..69fb92635 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs
@@ -90,7 +90,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony)
TransformWorkerHost.Shared,
HotReloadGroupProcessorDependencies.CreateProduction,
new HotReloadApplicationPlayModeQuery(),
- new HotReloadSourceSnapshotCapture(() => true),
+ HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true),
HotReloadWarmUpTestDoubles.CreateInert(),
HotReloadCompiledCallers.CreateProduction()));
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs
new file mode 100644
index 000000000..a70a0deef
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+using Mono.Cecil;
+using Mono.Cecil.Cil;
+
+using NUnit.Framework;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Contract of reading the defines and metadata references a portable PDB records, and a
+ /// cross-check of both against what Unity reports for every compiled assembly of this project.
+ /// Whether they agree decides if the compilation list can be rebuilt from PDBs at startup.
+ ///
+ public sealed class HotReloadPdbCompilationInfoTests
+ {
+ private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
+ private const string ReferenceAssemblySuffix = ".ref.dll";
+ private const string AssemblySuffix = ".dll";
+ private const int MaxListedDifferences = 10;
+
+ ///
+ /// What: the test assembly's own PDB yields the editor and test defines it was compiled with,
+ /// and references whose file names and MVIDs are filled in.
+ ///
+ [Test]
+ public void Read_ReturnsTheDefinesAndReferencesOfTheTestAssembly()
+ {
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot());
+
+ HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(layout.PdbPath(TestAssemblyName));
+
+ CollectionAssert.Contains(info.Defines, "UNITY_EDITOR");
+ CollectionAssert.Contains(info.Defines, "UNITY_INCLUDE_TESTS");
+ Assert.That(info.References, Is.Not.Empty);
+ Assert.That(info.References.Select(reference => reference.FileName), Has.Some.EqualTo("nunit.framework.dll"));
+ Assert.That(info.References.Select(reference => reference.FileName), Has.All.EndsWith(AssemblySuffix));
+ Assert.That(info.References.Select(reference => reference.Mvid), Has.None.EqualTo(Guid.Empty));
+ }
+
+ ///
+ /// What: for every compiled assembly with a PDB, the PDB's defines equal Unity's defines in
+ /// order, and its reference file names equal the file names of Unity's references, counting a
+ /// project assembly's X.ref.dll as X.dll. Each precompiled reference also carries the MVID of
+ /// the dll Unity lists for it, which is what the PDB's MVID is later compared with.
+ ///
+ [Test]
+ public void PdbCompilationInfo_MatchesGetAssembliesForEveryCompiledAssembly()
+ {
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot());
+ List mismatches = new List();
+ Dictionary mvidByReferencePath = new Dictionary(StringComparer.Ordinal);
+ int checkedAssemblies = 0;
+
+ foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current())
+ {
+ string pdbPath = layout.PdbPath(assembly.name);
+ if (!File.Exists(layout.DllPath(assembly.name)) || !File.Exists(pdbPath))
+ {
+ continue;
+ }
+
+ HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(pdbPath);
+ checkedAssemblies++;
+ AddDefineMismatch(assembly, info, mismatches);
+ AddReferenceMismatch(assembly, info, mismatches);
+ AddMvidMismatches(assembly, info, mvidByReferencePath, mismatches);
+ }
+
+ Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compiled assembly with a PDB was found.");
+ Assert.That(mismatches, Is.Empty, string.Join("\n", mismatches));
+ }
+
+ ///
+ /// What: a portable PDB written without Roslyn's compilation records (here by Cecil) is
+ /// refused, instead of reading as an assembly with no defines and no references.
+ ///
+ [Test]
+ public void Read_WhenThePdbHasNoCompilationRecords_Throws()
+ {
+ string directory = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-pdb-compilation-info-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ try
+ {
+ string dllPath = Path.Combine(directory, "NoRecords.dll");
+ using (ModuleDefinition module = ModuleDefinition.CreateModule("NoRecords", ModuleKind.Dll))
+ {
+ module.Write(dllPath, new WriterParameters
+ {
+ WriteSymbols = true,
+ SymbolWriterProvider = new PortablePdbWriterProvider(),
+ });
+ }
+
+ string pdbPath = Path.ChangeExtension(dllPath, ".pdb");
+ Assert.That(File.Exists(pdbPath), Is.True, "Cecil did not write the portable PDB.");
+
+ Assert.Throws(() => HotReloadPdbCompilationInfo.Read(pdbPath));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ private static void AddDefineMismatch(
+ UnityCompilationAssembly assembly,
+ HotReloadPdbCompilationInfo info,
+ List mismatches)
+ {
+ string[] fromUnity = assembly.defines ?? Array.Empty();
+ if (info.Defines.SequenceEqual(fromUnity, StringComparer.Ordinal))
+ {
+ return;
+ }
+
+ // The order question is reported separately from a content difference.
+ bool sameSet = new HashSet(info.Defines, StringComparer.Ordinal).SetEquals(fromUnity);
+ mismatches.Add(
+ assembly.name + ": defines differ " + (sameSet ? "in order only" : "in content")
+ + DescribeDifference(info.Defines, fromUnity));
+ }
+
+ private static void AddReferenceMismatch(
+ UnityCompilationAssembly assembly,
+ HotReloadPdbCompilationInfo info,
+ List mismatches)
+ {
+ HashSet fromPdb = new HashSet(
+ info.References.Select(reference => ToImplementationFileName(reference.FileName)),
+ StringComparer.Ordinal);
+ HashSet fromUnity = new HashSet(
+ (assembly.allReferences ?? Array.Empty()).Select(Path.GetFileName),
+ StringComparer.Ordinal);
+ if (fromPdb.SetEquals(fromUnity))
+ {
+ return;
+ }
+
+ mismatches.Add(assembly.name + ": reference file names differ" + DescribeDifference(fromPdb, fromUnity));
+ }
+
+ private static void AddMvidMismatches(
+ UnityCompilationAssembly assembly,
+ HotReloadPdbCompilationInfo info,
+ Dictionary mvidByReferencePath,
+ List mismatches)
+ {
+ Dictionary pathByFileName = new Dictionary(StringComparer.Ordinal);
+ foreach (string referencePath in assembly.compiledAssemblyReferences ?? Array.Empty())
+ {
+ pathByFileName[Path.GetFileName(referencePath)] = referencePath;
+ }
+
+ List wrong = new List();
+ foreach (HotReloadPdbReferencedAssembly reference in info.References)
+ {
+ if (!pathByFileName.TryGetValue(reference.FileName, out string referencePath))
+ {
+ continue;
+ }
+
+ if (!mvidByReferencePath.TryGetValue(referencePath, out string expected))
+ {
+ expected = HotReloadAssemblyMvid.Read(Path.GetFullPath(Path.Combine(ProjectRoot(), referencePath)));
+ mvidByReferencePath[referencePath] = expected;
+ }
+
+ if (reference.Mvid.ToString("N") != expected)
+ {
+ wrong.Add(reference.FileName);
+ }
+ }
+
+ if (wrong.Count == 0)
+ {
+ return;
+ }
+
+ mismatches.Add(
+ assembly.name + ": " + wrong.Count + " reference MVIDs differ from the dll ("
+ + string.Join(", ", wrong.Take(MaxListedDifferences)) + ")");
+ }
+
+ private static string DescribeDifference(IEnumerable fromPdb, IEnumerable fromUnity)
+ {
+ List onlyInPdb = fromPdb.Except(fromUnity, StringComparer.Ordinal).ToList();
+ List onlyInUnity = fromUnity.Except(fromPdb, StringComparer.Ordinal).ToList();
+ StringBuilder message = new StringBuilder();
+ message.Append(" (only in PDB: ").Append(onlyInPdb.Count).Append(' ');
+ message.Append(string.Join(", ", onlyInPdb.Take(MaxListedDifferences)));
+ message.Append("; only in Unity: ").Append(onlyInUnity.Count).Append(' ');
+ message.Append(string.Join(", ", onlyInUnity.Take(MaxListedDifferences)));
+ message.Append(')');
+ return message.ToString();
+ }
+
+ private static string ToImplementationFileName(string fileName)
+ {
+ if (!fileName.EndsWith(ReferenceAssemblySuffix, StringComparison.Ordinal))
+ {
+ return fileName;
+ }
+
+ return fileName.Substring(0, fileName.Length - ReferenceAssemblySuffix.Length) + AssemblySuffix;
+ }
+
+ private static string ProjectRoot()
+ {
+ return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta
new file mode 100644
index 000000000..2910bab90
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: eaa911ed77928402a93d83a195b80e97
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs
index 9c66b97cb..a15d5a9d6 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs
@@ -548,6 +548,68 @@ private void AssertRewrittenListIsNotUsed(Func rewrite)
Assert.That(second.PersistedLoadCount, Is.EqualTo(0));
}
+ ///
+ /// What: the compiler's own PDB, which has a document for every source it emitted, is not
+ /// reported as having dropped documents.
+ ///
+ [Test]
+ public void DropsDocuments_CompilerPdb_IsFalse()
+ {
+ string dllPath = DllPath(TestAssemblyName);
+ HotReloadPdbCompilationInfo compiled = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb"));
+ Assert.That(compiled.SourceFileCount, Is.Not.Null, "Precondition: the compiler's PDB must record its source count.");
+ Assert.That(compiled.DocumentCount, Is.GreaterThanOrEqualTo(compiled.SourceFileCount.Value), "Precondition: the compiler's PDB must drop nothing.");
+
+ bool drops = DropsDocuments(_index, dllPath);
+
+ Assert.That(drops, Is.False);
+ }
+
+ ///
+ /// What: a PDB rewritten the way an IL post-processor does, which lost the documents no
+ /// sequence point refers to, is reported as having dropped documents.
+ ///
+ [Test]
+ public void DropsDocuments_PdbThatLostDocumentsInARewrite_IsTrue()
+ {
+ string dllPath = CecilPdbRewrite.RewriteIntoNewDirectoryLosingDocuments(ProjectRoot(), TestAssemblyName);
+ try
+ {
+ bool drops = DropsDocuments(_index, dllPath);
+
+ Assert.That(drops, Is.True);
+ }
+ finally
+ {
+ Directory.Delete(CecilPdbRewrite.RootOf(dllPath), recursive: true);
+ }
+ }
+
+ ///
+ /// What: a new index on the same directory answers that a rewritten PDB dropped documents
+ /// from the persisted list, without reading the dll and the PDB.
+ ///
+ [Test]
+ public void DropsDocuments_NewIndexOnTheSameDirectory_AnswersFromThePersistedList()
+ {
+ string dllPath = CecilPdbRewrite.RewriteIntoNewDirectoryLosingDocuments(ProjectRoot(), TestAssemblyName);
+ try
+ {
+ DropsDocuments(_index, dllPath);
+ HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory);
+
+ bool drops = DropsDocuments(second, dllPath);
+
+ Assert.That(drops, Is.True);
+ Assert.That(second.LoadCount, Is.EqualTo(0));
+ Assert.That(second.PersistedLoadCount, Is.EqualTo(1));
+ }
+ finally
+ {
+ Directory.Delete(CecilPdbRewrite.RootOf(dllPath), recursive: true);
+ }
+ }
+
///
/// What: the list read from the PDB holds the same documents, with the same url, hash algorithm
/// and hash, as a Cecil walk over the dll's types, methods and sequence points, and as many.
@@ -699,6 +761,11 @@ private static bool Find(
return index.TryFindDocument(dllPath, pdbPath, moduleVersionId, projectRelativePath, out document);
}
+ private static bool DropsDocuments(HotReloadPdbDocumentIndex index, string dllPath)
+ {
+ return index.DropsDocuments(dllPath, Path.ChangeExtension(dllPath, ".pdb"), HotReloadAssemblyMvid.Read(dllPath));
+ }
+
// Copies the assembly's dll and PDB into a new temp directory and returns the dll's path.
// The caller deletes the directory.
private static string CopyAssemblyToTemp(string assemblyName)
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs
index a1442d3e9..0e17f50c8 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs
@@ -39,7 +39,7 @@ public void Read_ListsSourcesWithoutAMethodBody()
string dllPath = DllPath(TestAssemblyName);
string pdbPath = Path.ChangeExtension(dllPath, ".pdb");
- List table = HotReloadPdbDocumentTable.Read(dllPath, pdbPath);
+ List table = HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _);
List withSequencePoints = HotReloadPdbDocumentIndex.ReadDocuments(dllPath, pdbPath);
foreach (string assetPath in BodylessFixtureAssetPaths)
@@ -72,7 +72,7 @@ public void Read_WhenThePdbBelongsToAnotherBuild_Throws()
File.Copy(DllPath(TestAssemblyName), dllPath);
File.Copy(Path.ChangeExtension(DllPath(OtherAssemblyName), ".pdb"), pdbPath);
- Assert.Throws(() => HotReloadPdbDocumentTable.Read(dllPath, pdbPath));
+ Assert.Throws(() => HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _));
}
finally
{
@@ -80,6 +80,55 @@ public void Read_WhenThePdbBelongsToAnotherBuild_Throws()
}
}
+ ///
+ /// What: the non-throwing read returns null, rather than documents, for a PDB of another
+ /// assembly than the dll.
+ ///
+ [Test]
+ public void TryRead_PdbOfAnotherBuild_ReturnsNull()
+ {
+ string directory = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-pdb-document-table-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ try
+ {
+ string dllPath = Path.Combine(directory, "X.dll");
+ string pdbPath = Path.Combine(directory, "X.pdb");
+ File.Copy(DllPath(TestAssemblyName), dllPath);
+ File.Copy(Path.ChangeExtension(DllPath(OtherAssemblyName), ".pdb"), pdbPath);
+
+ List documents = HotReloadPdbDocumentTable.TryRead(dllPath, pdbPath, out int? sourceFileCount);
+
+ Assert.That(documents, Is.Null);
+ Assert.That(sourceFileCount, Is.Null);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ ///
+ /// What: for a PDB the compiler wrote, the non-throwing read returns the same documents as
+ /// Read, and the source count it records equals the number of documents.
+ ///
+ [Test]
+ public void TryRead_CompilerPdb_ReturnsEveryDocumentAndTheSourceCount()
+ {
+ string dllPath = DllPath(TestAssemblyName);
+ string pdbPath = Path.ChangeExtension(dllPath, ".pdb");
+
+ List documents = HotReloadPdbDocumentTable.TryRead(dllPath, pdbPath, out int? sourceFileCount);
+ List read = HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _);
+
+ Assert.That(documents, Is.Not.Null);
+ Assert.That(sourceFileCount, Is.EqualTo(documents.Count));
+ Assert.That(
+ documents.Select(document => document.Url),
+ Is.EqualTo(read.Select(document => document.Url)));
+ }
+
private static bool EndsWithAssetPath(HotReloadPdbDocument document, string assetPath)
{
return document.Url.Replace('\\', '/').EndsWith("/" + assetPath, StringComparison.Ordinal);
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs
index 29983e55a..90eee7acc 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs
@@ -190,7 +190,8 @@ private static HotReloadWarmUpContext ContextFor(string assemblyName)
dllPath,
Path.ChangeExtension(dllPath, ".pdb"),
Array.Empty())
- });
+ },
+ true);
}
// The home a run uses for the target right after a reload.
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs
index 6203bfd66..7a310eee3 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs
@@ -94,7 +94,9 @@ public async Task CollectAsync_ListsWhatTheShimCompileBindsAsItIs()
HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource(
projectRoot,
_ => new[] { TestAssemblyName },
- HotReloadCompilationAssemblies.FindByName);
+ HotReloadCompilationAssemblies.FindByName,
+ Task.CompletedTask,
+ () => true);
IReadOnlyList listed = await source.CollectAsync(CancellationToken.None);
@@ -150,7 +152,9 @@ public async Task CollectAsync_SkipsANameWithNoDllWithoutAskingUnity()
}
return HotReloadCompilationAssemblies.FindByName(name);
- });
+ },
+ Task.CompletedTask,
+ () => true);
IReadOnlyList listed = await source.CollectAsync(CancellationToken.None);
@@ -168,7 +172,9 @@ public async Task CollectAsync_SkipsANameWhoseCompilationAssemblyIsGone()
HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource(
ProjectRoot(),
_ => new[] { TestAssemblyName },
- _ => null);
+ _ => null,
+ Task.CompletedTask,
+ () => true);
IReadOnlyList listed = await source.CollectAsync(CancellationToken.None);
@@ -189,7 +195,9 @@ public async Task CollectAsync_WithAnEmptyLedger_ReturnsEmptyWithoutAskingUnity(
{
finderCalls++;
return HotReloadCompilationAssemblies.FindByName(name);
- });
+ },
+ Task.CompletedTask,
+ () => true);
IReadOnlyList listed = await source.CollectAsync(CancellationToken.None);
@@ -236,13 +244,103 @@ public async Task CollectAsync_AsksUnityOnTheMainThreadWhenCalledFromAPoolThread
{
askedOnMainThread.Add(MainThreadSwitcher.IsMainThread);
return HotReloadCompilationAssemblies.FindByName(name);
- });
+ },
+ Task.CompletedTask,
+ () => true);
await Task.Run(() => source.CollectAsync(CancellationToken.None));
Assert.That(askedOnMainThread, Is.EqualTo(new[] { true }));
}
+ ///
+ /// Verifies nothing is looked up until the domain's attempt to rebuild the compilation
+ /// assembly list has ended, and the kept list is used after it.
+ ///
+ [Test]
+ public async Task CollectAsync_WaitsForTheCompilationListRebuildToEnd()
+ {
+ CountingCompilationListFetch fetch = new CountingCompilationListFetch();
+ HotReloadCompilationAssemblyCache cache = fetch.CreateFilledCache();
+ TaskCompletionSource ready = new TaskCompletionSource();
+ int keptChecks = 0;
+ int ledgerReads = 0;
+ HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource(
+ ProjectRoot(),
+ _ =>
+ {
+ ledgerReads++;
+ return new[] { TestAssemblyName };
+ },
+ cache.FindByName,
+ ready.Task,
+ () =>
+ {
+ keptChecks++;
+ return cache.IsFilled;
+ });
+
+ Task> collect = source.CollectAsync(CancellationToken.None);
+
+ Assert.That(collect.IsCompleted, Is.False, "completed before the rebuild ended");
+ Assert.That(keptChecks + ledgerReads, Is.EqualTo(0), "looked at the list or the ledger before the rebuild ended");
+ ready.SetResult(true);
+ IReadOnlyList listed = await collect;
+ Assert.That(collect.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(listed, Is.EqualTo(await CollectFor(new[] { TestAssemblyName })));
+ Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list");
+ }
+
+ ///
+ /// Verifies that without a kept list nothing is listed and Unity is not asked for the list.
+ ///
+ [Test]
+ public async Task CollectAsync_WithoutAKeptList_ReturnsEmptyWithoutAskingUnity()
+ {
+ CountingCompilationListFetch fetch = new CountingCompilationListFetch();
+ HotReloadCompilationAssemblyCache cache = fetch.CreateEmptyCache();
+ HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource(
+ ProjectRoot(),
+ _ => new[] { TestAssemblyName },
+ cache.FindByName,
+ Task.CompletedTask,
+ () => cache.IsFilled);
+
+ Task> collect = source.CollectAsync(CancellationToken.None);
+ IReadOnlyList listed = await collect;
+
+ Assert.That(collect.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(listed, Is.Empty);
+ Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list");
+ }
+
+ ///
+ /// Verifies the list is checked on the main thread when the rebuild ends on a pool thread, as
+ /// a cancelled one does.
+ ///
+ [Test]
+ public async Task CollectAsync_WhenTheRebuildEndsOnAPoolThread_ChecksTheListOnTheMainThread()
+ {
+ List checkedOnMainThread = new List();
+ // Why a delay: its completion runs on a timer thread, so a missing switch would check off it.
+ HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource(
+ ProjectRoot(),
+ _ => new[] { TestAssemblyName },
+ _ => null,
+ Task.Delay(1),
+ () =>
+ {
+ checkedOnMainThread.Add(MainThreadSwitcher.IsMainThread);
+ return false;
+ });
+
+ Task> collect = source.CollectAsync(CancellationToken.None);
+ await collect;
+
+ Assert.That(collect.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(checkedOnMainThread, Is.EqualTo(new[] { true }));
+ }
+
///
/// Verifies the hot-reload startup publishes its reference list to the dynamic-code tool in every domain.
///
@@ -257,7 +355,9 @@ private static Task> CollectFor(IReadOnlyList ledg
HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource(
ProjectRoot(),
_ => ledger,
- HotReloadCompilationAssemblies.FindByName);
+ HotReloadCompilationAssemblies.FindByName,
+ Task.CompletedTask,
+ () => true);
return source.CollectAsync(CancellationToken.None);
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs
index 035bb8f09..0ba2eaf34 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
+using System.Threading;
using NUnit.Framework;
@@ -65,11 +66,7 @@ public void CaptureAssemblies_WithoutStamp_CapturesSourcesRemovesStaleSnapshotAn
string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(staleDirectory);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
Assert.That(Directory.Exists(staleDirectory), Is.False);
@@ -87,11 +84,7 @@ public void CaptureAssemblies_WhenStampMatches_SkipsCapture()
string stamp = ExpectedStamp(dllPath, mvid);
File.WriteAllText(StampPath("Fixture"), stamp);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(Path.Combine(_snapshotRoot, "Fixture-" + mvid)), Is.False);
Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(stamp));
@@ -111,11 +104,7 @@ public void CaptureAssemblies_WhenMvidDirectoryExists_KeepsSnapshotsAndWritesSta
string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(staleDirectory);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.GetFiles(currentDirectory), Is.Empty);
Assert.That(Directory.Exists(staleDirectory), Is.True);
@@ -153,11 +142,7 @@ public void CaptureAssemblies_WhenStampIsMalformedOrDiffers_CapturesAgain(string
.Replace("{length}", length.ToString(CultureInfo.InvariantCulture));
File.WriteAllText(StampPath("Fixture"), stamp);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.True);
Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(ExpectedStamp(dllPath, mvid)));
@@ -183,11 +168,7 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories()
Directory.CreateDirectory(sibling);
Directory.CreateDirectory(nonMvid);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(orphanTemporary), Is.False);
Assert.That(Directory.Exists(stale), Is.False);
@@ -197,7 +178,9 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories()
}
///
- /// Verifies an assembly without sources is skipped before anything is written: no snapshot directory and no stamp.
+ /// Verifies an assembly whose PDB names no source that maps to an asset path is skipped before
+ /// anything is written: no snapshot directory, no stamp and no marker. The planted PDB is this
+ /// test assembly's, whose sources live under the real project and so map to nothing here.
///
[Test]
public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing()
@@ -205,14 +188,132 @@ public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing()
string dllPath = PlantCompiledAssembly("NoSources");
string mvid = HotReloadAssemblyMvid.Read(dllPath);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssemblyWithSources("NoSources", Array.Empty()) },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, HotReloadCaptureAssemblyEnumerator.ReadSources);
Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "NoSources", mvid)), Is.False);
Assert.That(File.Exists(StampPath("NoSources")), Is.False);
+ Assert.That(File.Exists(MarkerPath("NoSources")), Is.False);
+ }
+
+ ///
+ /// Verifies a read-only package's marker that matches the compiled dll skips the assembly
+ /// without reading its PDB, and counts it as read-only.
+ ///
+ [Test]
+ public void CaptureAssemblies_ImmutableMarkerMatchingTheDll_SkipsWithoutReadingThePdb()
+ {
+ string dllPath = PlantCompiledAssembly("Fixture");
+ File.WriteAllText(MarkerPath("Fixture"), DllStampText("immutable", dllPath, 0));
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats stats = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, false));
+
+ Assert.That(reads, Is.EqualTo(0), "PDB reads");
+ Assert.That(stats.AssembliesImmutable, Is.EqualTo(1), "immutable");
+ Assert.That(File.Exists(StampPath("Fixture")), Is.False);
+ }
+
+ ///
+ /// Verifies a stamp that matches the compiled dll skips the assembly without reading its PDB.
+ ///
+ [Test]
+ public void CaptureAssemblies_UnchangedStamp_SkipsWithoutReadingThePdb()
+ {
+ string dllPath = PlantCompiledAssembly("Fixture");
+ File.WriteAllText(StampPath("Fixture"), ExpectedStamp(dllPath, HotReloadAssemblyMvid.Read(dllPath)));
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats stats = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, false));
+
+ Assert.That(reads, Is.EqualTo(0), "PDB reads");
+ Assert.That(stats.AssembliesUnchanged, Is.EqualTo(1), "unchanged");
+ }
+
+ ///
+ /// Verifies an assembly whose PDB says it is a read-only package's gets a marker recording its
+ /// dll and nothing else: no snapshot directory and no stamp. The next capture of the same dll
+ /// does not read the PDB again.
+ ///
+ [Test]
+ public void CaptureAssemblies_ImmutablePackageAssembly_WritesTheMarkerAndCopiesNothing()
+ {
+ string dllPath = PlantCompiledAssembly("Fixture");
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats first = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, true));
+ HotReloadSnapshotCaptureStats second = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, true));
+
+ Assert.That(File.ReadAllText(MarkerPath("Fixture")), Is.EqualTo(DllStampText("immutable", dllPath, 0)));
+ Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty);
+ Assert.That(File.Exists(StampPath("Fixture")), Is.False);
+ Assert.That(first.AssembliesImmutable, Is.EqualTo(1), "first: immutable");
+ Assert.That(second.AssembliesImmutable, Is.EqualTo(1), "second: immutable");
+ Assert.That(reads, Is.EqualTo(1), "PDB reads over both captures");
+ }
+
+ ///
+ /// Verifies a marker left from an earlier dll, as when a package was made editable and its
+ /// assembly rebuilt, does not skip the assembly: its PDB is read and its sources are captured.
+ ///
+ [Test]
+ public void CaptureAssemblies_StaleImmutableMarker_IsIgnoredWhenTheDllChanged()
+ {
+ string dllPath = PlantCompiledAssembly("Fixture");
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+ File.WriteAllText(MarkerPath("Fixture"), DllStampText("immutable", dllPath, -1));
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats stats = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, false));
+
+ Assert.That(reads, Is.EqualTo(1), "PDB reads");
+ Assert.That(stats.AssembliesCaptured, Is.EqualTo(1), "captured");
+ Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
+ }
+
+ ///
+ /// Verifies an assembly whose PDB belongs to another build of its dll, as while a compile
+ /// replaces them, is skipped and counted without a stamp, while the assembly after it is captured.
+ ///
+ [Test]
+ public void CaptureAssemblies_PdbOfAnotherBuild_SkipsThatAssemblyAndCapturesTheRest()
+ {
+ string otherBuildDll = PlantCompiledAssembly("Another");
+ string otherAssemblyPdb = Path.ChangeExtension(typeof(HotReloadSourceSnapshotter).Assembly.Location, ".pdb");
+ File.Copy(otherAssemblyPdb, Path.ChangeExtension(otherBuildDll, ".pdb"), true);
+ string dllPath = PlantCompiledAssembly("Fixture");
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+
+ // Why the real document table is read before the sources are named: it is what checks
+ // that the PDB belongs to the dll.
+ HotReloadSnapshotCaptureStats stats = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ (candidate, inputs) =>
+ {
+ HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath, out int? _);
+ return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, false, hasEverySource: true, sourcesFromCompilerOutput: false);
+ });
+
+ Assert.That(stats.AssembliesSkippedPdbMismatch, Is.EqualTo(1), "skipped for another build's PDB");
+ Assert.That(File.Exists(StampPath("Another")), Is.False);
+ Assert.That(Directory.GetDirectories(_snapshotRoot, "Another-*"), Is.Empty);
+ Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
+ Assert.That(File.Exists(StampPath("Fixture")), Is.True);
}
///
@@ -225,11 +326,7 @@ public void CaptureAssemblies_WhenCompiledOutputIsMissing_WritesNothingAndDoesNo
string dllPath = PlantCompiledAssembly("Fixture");
File.Delete(Path.ChangeExtension(dllPath, missingExtension));
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty);
Assert.That(File.Exists(StampPath("Fixture")), Is.False);
@@ -251,11 +348,7 @@ public void CaptureAssemblies_WhenStampMatches_DoesNotOpenTheCompiledAssembly()
string stamp = ExpectedStamp(dllPath, mvid);
File.WriteAllText(StampPath("Fixture"), stamp);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.False);
Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(stamp));
@@ -279,11 +372,7 @@ public void CaptureAssemblies_WhenOneAssemblyCannotBeRead_WarnsAndCapturesTheRes
string mvid = HotReloadAssemblyMvid.Read(dllPath);
LogAssert.Expect(LogType.Warning, new Regex("Snapshot capture failed for assembly Broken"));
- HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Broken"), CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(File.Exists(StampPath("Broken")), Is.False);
Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
@@ -342,11 +431,7 @@ public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSna
File.SetLastWriteTimeUtc(playerFixturePath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
string mvid = HotReloadAssemblyMvid.Read(dllPath);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- playerRoot,
- new[] { CreateAssembly("Fixture") },
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ Capture(playerRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
string playerSnapshotSource = HotReloadSourceSnapshotLayout.SourcePath(
HotReloadSourceSnapshotLayout.AssemblyDirectory(playerRoot, "Fixture", mvid),
@@ -411,21 +496,11 @@ public void CaptureAssemblies_ReturnsWhatItCopiedAndWhatItSkipped()
PlantCompiledAssembly("Fixture");
WriteSourceWrittenBeforeTheCompile("Assets/Three.cs", "abc");
WriteSourceWrittenBeforeTheCompile("Assets/Five.cs", "abcde");
- UnityCompilationAssembly[] assemblies =
- {
- CreateAssemblyWithSources("Fixture", new[] { "Assets/Three.cs", "Assets/Five.cs", "Assets/Missing.cs" })
- };
+ Func assemblies =
+ ReadsSources("Assets/Three.cs", "Assets/Five.cs", "Assets/Missing.cs");
- HotReloadSnapshotCaptureStats first = HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- assemblies,
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
- HotReloadSnapshotCaptureStats second = HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- assemblies,
- HotReloadCompileStart.Unknown,
- CreateDocumentIndex());
+ HotReloadSnapshotCaptureStats first = Capture(_projectRoot, HotReloadCompileStart.Unknown, assemblies);
+ HotReloadSnapshotCaptureStats second = Capture(_projectRoot, HotReloadCompileStart.Unknown, assemblies);
Assert.That(first.Assemblies, Is.EqualTo(1), "first: assemblies");
Assert.That(first.AssembliesCaptured, Is.EqualTo(1), "first: captured");
@@ -451,11 +526,7 @@ private string CaptureWithSourceSavedBetweenStartAndDll(
new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
string mvid = HotReloadAssemblyMvid.Read(dllPath);
- stats = HotReloadSourceSnapshotter.CaptureAssemblies(
- _projectRoot,
- new[] { CreateAssembly("Fixture") },
- compileStart,
- CreateDocumentIndex());
+ stats = Capture(_projectRoot, compileStart, ReadsSources(SourceRelativePath));
return Path.Combine(_snapshotRoot, "Fixture-" + mvid);
}
@@ -511,6 +582,55 @@ private static UnityCompilationAssembly CreateAssemblyWithSources(string assembl
AssemblyFlags.EditorAssembly);
}
+ // Captures the compiled assemblies planted under the root, with a PDB source reader the test
+ // chooses. No package is registered under a temporary root, so every path reads as editable.
+ private HotReloadSnapshotCaptureStats Capture(
+ string projectRoot,
+ HotReloadCompileStart compileStart,
+ Func readSources)
+ {
+ HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs(
+ projectRoot,
+ compileStart,
+ Array.Empty(),
+ StringComparison.Ordinal);
+ return HotReloadSourceSnapshotter.CaptureAssemblies(inputs, CreateDocumentIndex(), readSources, CancellationToken.None);
+ }
+
+ // A PDB source reader that names the given sources for every assembly. Why not the real one:
+ // the planted PDB is this test assembly's, whose sources live under the real project, so
+ // none of them maps to an asset path under a temporary root.
+ private static Func ReadsSources(
+ params string[] sources)
+ {
+ return (candidate, inputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false, hasEverySource: true, sourcesFromCompilerOutput: false);
+ }
+
+ // A PDB source reader that counts its calls and names the fixture source, with the read-only
+ // classification the test chooses.
+ private static Func CountingReads(
+ Action onRead,
+ bool isImmutablePackageAssembly)
+ {
+ return (candidate, inputs) =>
+ {
+ onRead();
+ return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, isImmutablePackageAssembly, hasEverySource: true, sourcesFromCompilerOutput: false);
+ };
+ }
+
+ private string MarkerPath(string assemblyName)
+ {
+ return HotReloadSourceSnapshotLayout.ImmutableMarkerPath(_projectRoot, assemblyName);
+ }
+
+ // The stamp form for the dll: the tag, then its write time shifted by the given ticks, then its length.
+ private static string DllStampText(string tag, string dllPath, long mtimeOffsetTicks)
+ {
+ FileInfo dllInfo = new FileInfo(dllPath);
+ return tag + "," + (dllInfo.LastWriteTimeUtc.Ticks + mtimeOffsetTicks) + "," + dllInfo.Length;
+ }
+
private string SnapshotSourcePath(string assemblyName, string mvid)
{
return HotReloadSourceSnapshotLayout.SourcePath(
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs
new file mode 100644
index 000000000..804fe5b8c
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs
@@ -0,0 +1,29 @@
+using System.IO;
+
+using NUnit.Framework;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Covers the values the snapshot capture reads from Unity on the main thread before it starts.
+ ///
+ public sealed class HotReloadSnapshotCaptureInputsTests
+ {
+ ///
+ /// Verifies the inputs read on the main thread name this project's root and carry the
+ /// registered packages.
+ ///
+ [Test]
+ public void ReadOnMainThread_CarriesTheProjectRootAndThePackageRoots()
+ {
+ HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread();
+
+ Assert.That(inputs.ProjectRoot, Is.EqualTo(Path.GetFullPath(Path.Combine(Application.dataPath, ".."))));
+ Assert.That(inputs.PackageRoots, Is.Not.Empty);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs.meta
new file mode 100644
index 000000000..df92ac9fd
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1ee49cc5e8e1b42da9117502651883ab
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs
index e0699649e..09abcf338 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
@@ -23,6 +24,12 @@ public sealed class HotReloadSnapshotEditedDuringCompileTests
// A file without method bodies, so the PDB has no document for it.
private const string BodylessFixtureProjectRelativePath =
"Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs";
+ private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime";
+ // A file of this repository's embedded package, whose folder differs from its package name,
+ // so its asset path and the path its PDB records differ.
+ private const string PackageSourceAssetPath =
+ "Packages/io.github.hatayama.uloopmcp/Runtime/PausePoints/UloopPausePoint.cs";
+ private const string PackageSourcePhysicalPath = "Packages/src/Runtime/PausePoints/UloopPausePoint.cs";
private static readonly long CompileStartUtcTicks = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;
private static readonly DateTime SinceTheStartUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
@@ -59,13 +66,36 @@ public void CaptureAtomically_ASourceWrittenSinceTheStartThatMatchesThePdb_Recor
{
PlantSource(FixtureProjectRelativePath, RealBytes(FixtureProjectRelativePath), SinceTheStartUtc);
- HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath);
+ HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent());
string snapshotFileName = SnapshotFileName(FixtureProjectRelativePath);
Assert.That(manifest.IsEditedAfterCompile(snapshotFileName), Is.False);
Assert.That(manifest.TryGetStamp(snapshotFileName, out long _, out long _), Is.True);
}
+ ///
+ /// Verifies that a source of an embedded package written since the compile started whose bytes
+ /// match the PDB gets an unmarked stamp: the check names it by the folder behind the package
+ /// path, as the PDB records it, from the package roots it was given. The PDB records the
+ /// package's sources relative to the compiling project, so the package root is moved under
+ /// the temporary root, as the real one lies under the real project.
+ ///
+ [Test]
+ public void CaptureAtomically_APackageSourceWrittenSinceTheStartThatMatchesThePdb_RecordsAnUnmarkedStamp()
+ {
+ byte[] realBytes = File.ReadAllBytes(Path.Combine(_realProjectRoot, PackageSourcePhysicalPath));
+ PlantSource(PackageSourceAssetPath, realBytes, SinceTheStartUtc);
+
+ HotReloadSourceStampManifest manifest = Capture(
+ PackageSourceAssetPath,
+ PausePointsRuntimeAssemblyName,
+ PackageRootsMovedUnderTheTemporaryRoot());
+
+ string snapshotFileName = SnapshotFileName(PackageSourceAssetPath);
+ Assert.That(manifest.IsEditedAfterCompile(snapshotFileName), Is.False);
+ Assert.That(manifest.TryGetStamp(snapshotFileName, out long _, out long _), Is.True);
+ }
+
///
/// Verifies that a source written since the compile started whose bytes differ from the PDB is
/// marked, and that its copy is still written with the edited bytes.
@@ -76,7 +106,7 @@ public void CaptureAtomically_ASourceWrittenSinceTheStartThatDiffersFromThePdb_M
byte[] editedBytes = EditedBytes(FixtureProjectRelativePath);
PlantSource(FixtureProjectRelativePath, editedBytes, SinceTheStartUtc);
- HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath);
+ HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent());
string snapshotFileName = SnapshotFileName(FixtureProjectRelativePath);
string snapshotPath = Path.Combine(_snapshotDirectory, snapshotFileName);
@@ -94,7 +124,7 @@ public void CaptureAtomically_ASourceWrittenBeforeTheStart_IsNotCheckedAgainstTh
{
PlantSource(FixtureProjectRelativePath, EditedBytes(FixtureProjectRelativePath), BeforeTheStartUtc);
- HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath);
+ HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent());
Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(FixtureProjectRelativePath)), Is.False);
}
@@ -111,35 +141,60 @@ public void CaptureAtomically_ASourceThePdbHasNoDocumentFor_MarksIt()
RealBytes(BodylessFixtureProjectRelativePath),
SinceTheStartUtc);
- HotReloadSourceStampManifest manifest = Capture(BodylessFixtureProjectRelativePath);
+ HotReloadSourceStampManifest manifest = Capture(BodylessFixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent());
Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(BodylessFixtureProjectRelativePath)), Is.True);
}
- private HotReloadSourceStampManifest Capture(string projectRelativePath)
+ private HotReloadSourceStampManifest Capture(
+ string projectRelativePath,
+ string assemblyName,
+ IReadOnlyList packageRoots)
{
string dllPath = Path.Combine(
_realProjectRoot,
HotReloadConstants.ScriptAssembliesRelativeDirectory,
- TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension);
+ assemblyName + HotReloadConstants.CompiledAssemblyExtension);
HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck(
CompileStartUtcTicks,
dllPath,
Path.ChangeExtension(dllPath, ".pdb"),
HotReloadAssemblyMvid.Read(dllPath),
- new HotReloadPdbDocumentIndex(Path.Combine(_tempRoot, "PdbDocuments")));
+ new HotReloadPdbDocumentIndex(Path.Combine(_tempRoot, "PdbDocuments")),
+ packageRoots,
+ HotReloadSnapshotCaptureInputs.CurrentPathComparison());
HotReloadSourceSnapshotCopier.CaptureAtomically(
_tempRoot,
_snapshotDirectory,
new[] { projectRelativePath },
- TestAssemblyName,
+ assemblyName,
check,
new HotReloadSnapshotCaptureStats());
return HotReloadSourceStampManifest.Load(_snapshotDirectory);
}
+ // The registered packages, with every folder under the real project moved to the same place
+ // under the temporary root.
+ private List PackageRootsMovedUnderTheTemporaryRoot()
+ {
+ string realRootPrefix = _realProjectRoot.Replace('\\', '/').TrimEnd('/') + "/";
+ List roots = new List();
+ foreach (HotReloadSnapshotPackageRoot root in HotReloadSnapshotPackageRoots.ReadCurrent())
+ {
+ string resolvedPath = root.ResolvedPath.Replace('\\', '/');
+ if (resolvedPath.StartsWith(realRootPrefix, StringComparison.Ordinal))
+ {
+ resolvedPath = Path.Combine(_tempRoot, resolvedPath.Substring(realRootPrefix.Length));
+ }
+
+ roots.Add(new HotReloadSnapshotPackageRoot(resolvedPath, root.AssetPath, root.IsImmutable));
+ }
+
+ return roots;
+ }
+
private byte[] RealBytes(string projectRelativePath)
{
return File.ReadAllBytes(Path.Combine(_realProjectRoot, projectRelativePath));
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs
new file mode 100644
index 000000000..2cd8e835c
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+using NUnit.Framework;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Covers naming a source the way its PDB records it from package roots read beforehand, and,
+ /// on this project's real sources, that the name equals the one the Package Manager gives.
+ ///
+ public sealed class HotReloadSnapshotLookupPathTests
+ {
+ private const int MaxListedMismatches = 5;
+
+ private static readonly HotReloadSnapshotPackageRoot[] Packages =
+ {
+ new HotReloadSnapshotPackageRoot("/proj/Packages/src", "Packages/com.example.embedded", false),
+ new HotReloadSnapshotPackageRoot("/elsewhere/LocalPkg", "Packages/com.example.local", false),
+ };
+
+ ///
+ /// Verifies a source of a package inside the project is named relative to the project, one
+ /// outside it by its absolute path, and an Assets source by its asset path.
+ ///
+ [TestCase("Packages/com.example.embedded/Editor/A.cs", "Packages/src/Editor/A.cs")]
+ [TestCase("Packages/com.example.local/A.cs", "/elsewhere/LocalPkg/A.cs")]
+ [TestCase("Assets/A.cs", "Assets/A.cs")]
+ public void ToPdbLookupPath_NamesTheFileTheWayThePdbRecordsIt(string assetPath, string expected)
+ {
+ string lookupPath = HotReloadSnapshotLookupPath.ToPdbLookupPath("/proj", assetPath, Packages, StringComparison.Ordinal);
+
+ Assert.That(lookupPath, Is.EqualTo(expected));
+ }
+
+ ///
+ /// Verifies, for every source of every compilation assembly of this project, that the name
+ /// built from the package roots equals the one ScriptPackageRoots.ToPhysicalPath builds by
+ /// asking the Package Manager.
+ ///
+ [Test]
+ public void PdbLookupPath_FromPackageRoots_MatchesThePackageManagerAnswerForEverySource()
+ {
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot;
+ IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent();
+ StringComparison comparison = HotReloadSnapshotCaptureInputs.CurrentPathComparison();
+ List mismatches = new List();
+ int checkedSources = 0;
+ int packageSources = 0;
+
+ foreach (UnityCompilationAssembly compilationAssembly in HotReloadCompilationAssemblies.Current())
+ {
+ foreach (string sourceFile in compilationAssembly.sourceFiles ?? Array.Empty())
+ {
+ string assetPath = sourceFile.Replace('\\', '/');
+ string expected = ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, assetPath);
+ string actual = HotReloadSnapshotLookupPath.ToPdbLookupPath(
+ compiledProjectRoot,
+ assetPath,
+ packageRoots,
+ comparison);
+ checkedSources++;
+ packageSources += expected == assetPath ? 0 : 1;
+ if (!string.Equals(actual, expected, StringComparison.Ordinal))
+ {
+ mismatches.Add(assetPath + ": package roots give " + actual + ", the Package Manager gives " + expected + ".");
+ }
+ }
+ }
+
+ TestContext.WriteLine(
+ "Checked " + checkedSources + " sources, " + packageSources + " renamed by a package; "
+ + mismatches.Count + " mismatches.");
+ Assert.That(checkedSources, Is.GreaterThan(0), "No source was found.");
+ Assert.That(packageSources, Is.GreaterThan(0), "No source behind a package folder was found to compare.");
+ Assert.That(
+ mismatches,
+ Is.Empty,
+ "Checked " + checkedSources + " sources, " + packageSources + " renamed by a package; "
+ + mismatches.Count + " mismatches:\n"
+ + string.Join("\n", mismatches.GetRange(0, Math.Min(MaxListedMismatches, mismatches.Count))));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs.meta
new file mode 100644
index 000000000..c628c599e
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5847b035ba7a34ddc867e05ea4877d82
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs
new file mode 100644
index 000000000..2dd24a4f0
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Threading.Tasks;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Ways to build the source snapshot capture gate for tests, so no test runs a capture on a
+ /// pool thread.
+ ///
+ internal static class HotReloadSourceSnapshotCaptureTestDoubles
+ {
+ ///
+ /// A gate that runs on the calling thread the moment it starts, so
+ /// the capture has finished when the call that started it returns.
+ ///
+ internal static HotReloadSourceSnapshotCapture CompletingSynchronously(Func capture)
+ {
+ return new HotReloadSourceSnapshotCapture(
+ FixedInputs,
+ (inputs, cancellationToken) => capture(),
+ RunNow,
+ () => 0);
+ }
+
+ /// Inputs that no test capture reads.
+ internal static HotReloadSnapshotCaptureInputs FixedInputs()
+ {
+ return new HotReloadSnapshotCaptureInputs(
+ "project-root",
+ HotReloadCompileStart.Unknown,
+ Array.Empty(),
+ StringComparison.Ordinal);
+ }
+
+ // Why the exception goes into the task: Task.Run does the same, and the gate must see a
+ // failed task rather than a throwing start.
+ private static Task RunNow(Func work)
+ {
+ try
+ {
+ return Task.FromResult(work());
+ }
+ catch (Exception exception)
+ {
+ return Task.FromException(exception);
+ }
+ }
+ }
+
+ ///
+ /// A stand-in for the pool thread that holds the started work until the test runs it, so a
+ /// test controls when a capture is in flight and when it finishes.
+ ///
+ internal sealed class HeldBackgroundRun
+ {
+ private Func _work;
+ private TaskCompletionSource _completion;
+
+ internal int StartCount { get; private set; }
+
+ internal Task Run(Func work)
+ {
+ StartCount++;
+ _work = work;
+ _completion = new TaskCompletionSource();
+ return _completion.Task;
+ }
+
+ ///
+ /// Runs the held work on the calling thread and finishes its task with the outcome, the way
+ /// Task.Run would: a thrown exception, cancellation included, faults the task.
+ ///
+ internal void RunHeldWork()
+ {
+ NUnit.Framework.Assert.That(_work, NUnit.Framework.Is.Not.Null, "no work was started");
+ Func work = _work;
+ TaskCompletionSource completion = _completion;
+ _work = null;
+ _completion = null;
+ bool result;
+ try
+ {
+ result = work();
+ }
+ catch (Exception exception)
+ {
+ completion.SetException(exception);
+ return;
+ }
+
+ completion.SetResult(result);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta
new file mode 100644
index 000000000..cb41f6562
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8ffcd35b460eb4d97994e62b120fb217
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs
index 1da3d54d1..af2b5c0a6 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs
@@ -1,4 +1,6 @@
using System;
+using System.Threading;
+using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
@@ -10,42 +12,304 @@
namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
///
- /// EditMode coverage for capturing the source snapshot once per domain.
+ /// EditMode coverage for the gate that captures the source snapshot once per domain off the
+ /// main thread. The pool thread is a held stand-in, so each test decides when a capture ends.
///
public class HotReloadSourceSnapshotCaptureTests
{
+ private const string Trigger = HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad;
+ private static readonly long HoldCapTicks = TimeSpan.FromSeconds(30).Ticks;
+
+ private HeldBackgroundRun _background;
+ private int _inputReads;
+ private int _captureRuns;
+ private Func _captureBody;
+ private long _nowTicks;
+
+ [SetUp]
+ public void SetUp()
+ {
+ VibeLogger.ClearMemoryLogs();
+ _background = new HeldBackgroundRun();
+ _inputReads = 0;
+ _captureRuns = 0;
+ _captureBody = _ => true;
+ _nowTicks = 0;
+ }
+
+ ///
+ /// What: starting reads the inputs once on the calling thread, hands the capture to the
+ /// background, and returns before the capture runs, logging how long the main thread spent.
+ ///
+ [Test]
+ public void StartIfNeeded_NotStarted_ReadsInputsOnceAndReturnsBeforeTheCaptureFinishes()
+ {
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+
+ Task task = gate.StartIfNeeded(Trigger);
+
+ Assert.That(_inputReads, Is.EqualTo(1), "input reads");
+ Assert.That(_captureRuns, Is.EqualTo(0), "captures run before the background ran them");
+ Assert.That(task.IsCompleted, Is.False);
+ Assert.That(gate.IsInFlight, Is.True);
+ JObject started = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(
+ HotReloadConstants.VibeLogSourceSnapshotCaptureStarted);
+ Assert.That((string)started["trigger"], Is.EqualTo(Trigger));
+ Assert.That(started["mainThreadMs"].Type, Is.EqualTo(JTokenType.Integer));
+
+ _background.RunHeldWork();
+
+ Assert.That(task.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(_captureRuns, Is.EqualTo(1));
+ Assert.That(gate.IsInFlight, Is.False);
+ }
+
+ ///
+ /// What: a second caller while a capture runs gets the running capture's task and starts no
+ /// second capture, so two captures never touch one snapshot directory at once.
+ ///
+ [Test]
+ public void StartIfNeeded_WhileInFlight_ReturnsTheSameTaskAndDoesNotStartAnother()
+ {
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+
+ Task first = gate.StartIfNeeded(Trigger);
+ Task second = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
+
+ Assert.That(second, Is.SameAs(first));
+ Assert.That(_background.StartCount, Is.EqualTo(1));
+ Assert.That(_inputReads, Is.EqualTo(1));
+ _background.RunHeldWork();
+ }
+
+ ///
+ /// What: after a capture that threw, the next caller starts a new one, and the failure is
+ /// logged with its exception type and leaves no captured entry.
+ ///
+ [Test]
+ public void StartIfNeeded_AfterAFailedCapture_StartsANewOne()
+ {
+ _captureBody = _ => throw new InvalidOperationException("The capture failed.");
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ Task failed = gate.StartIfNeeded(Trigger);
+ _background.RunHeldWork();
+
+ _captureBody = _ => true;
+ Task retried = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
+
+ Assert.That(failed.IsFaulted, Is.True);
+ Assert.That(retried, Is.Not.SameAs(failed));
+ Assert.That(_background.StartCount, Is.EqualTo(2));
+ JObject failure = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(
+ HotReloadConstants.VibeLogSourceSnapshotCaptureFailed);
+ Assert.That((string)failure["trigger"], Is.EqualTo(Trigger));
+ Assert.That((string)failure["error"], Is.EqualTo(nameof(InvalidOperationException)));
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0));
+ _background.RunHeldWork();
+ }
+
+ ///
+ /// What: after a capture that found no compiled assembly, the next caller starts a new one,
+ /// and the empty capture is logged as such rather than as captured.
+ ///
+ [Test]
+ public void StartIfNeeded_AfterAnEmptyCapture_StartsANewOne()
+ {
+ _captureBody = _ => false;
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ gate.StartIfNeeded(Trigger);
+ _background.RunHeldWork();
+
+ gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
+
+ Assert.That(_background.StartCount, Is.EqualTo(2));
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureEmpty), Is.EqualTo(1));
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0));
+ _background.RunHeldWork();
+ }
+
+ ///
+ /// What: after a cancelled capture, the next caller starts a new one, and the cancellation
+ /// is logged as cancelled rather than as a failure.
+ ///
+ [Test]
+ public void StartIfNeeded_AfterACancelledCapture_StartsANewOne()
+ {
+ _captureBody = cancellationToken =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return true;
+ };
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ gate.StartIfNeeded(Trigger);
+ gate.CancelInFlight();
+ _background.RunHeldWork();
+
+ gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
+
+ Assert.That(_background.StartCount, Is.EqualTo(2));
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled), Is.EqualTo(1));
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureFailed), Is.EqualTo(0));
+ _background.RunHeldWork();
+ }
+
+ ///
+ /// What: once a capture succeeded, later callers get a completed task without reading the
+ /// inputs or starting anything, and the single captured entry keeps the first trigger.
+ ///
+ [Test]
+ public void StartIfNeeded_AfterSuccess_ReturnsACompletedTaskWithoutReadingInputs()
+ {
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ gate.StartIfNeeded(Trigger);
+ _background.RunHeldWork();
+
+ Task later = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerApply);
+
+ Assert.That(later.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(_inputReads, Is.EqualTo(1));
+ Assert.That(_background.StartCount, Is.EqualTo(1));
+ JObject captured = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(
+ HotReloadConstants.VibeLogSourceSnapshotCaptured);
+ Assert.That((string)captured["trigger"], Is.EqualTo(Trigger));
+ Assert.That(captured["captureMs"].Type, Is.EqualTo(JTokenType.Integer));
+ }
+
+ ///
+ /// What: an exception while reading the inputs reaches the caller, and the gate stays not
+ /// started, so neither a capture nor a hold is left behind and the next caller tries again.
+ ///
+ [Test]
+ public void StartIfNeeded_WhenReadingInputsThrows_RethrowsAndStaysNotStarted()
+ {
+ InvalidOperationException failure = new InvalidOperationException("The inputs could not be read.");
+ bool throwOnRead = true;
+ HotReloadSourceSnapshotCapture gate = new HotReloadSourceSnapshotCapture(
+ () =>
+ {
+ _inputReads++;
+ if (throwOnRead)
+ {
+ throw failure;
+ }
+
+ return HotReloadSourceSnapshotCaptureTestDoubles.FixedInputs();
+ },
+ RunCapture,
+ _background.Run,
+ () => _nowTicks);
+
+ InvalidOperationException thrown =
+ Assert.Throws(() => gate.StartIfNeeded(Trigger));
+ Assert.That(thrown, Is.SameAs(failure));
+ Assert.That(gate.IsInFlight, Is.False);
+ Assert.That(gate.IsHoldingCompileCompletion, Is.False);
+ Assert.That(_background.StartCount, Is.EqualTo(0));
+
+ throwOnRead = false;
+ gate.StartIfNeeded(Trigger);
+ Assert.That(_background.StartCount, Is.EqualTo(1));
+ _background.RunHeldWork();
+ }
+
+ ///
+ /// What: an apply that arrives while a capture runs completes only after that capture does.
+ ///
+ [Test]
+ public async Task EnsureCapturedAsync_WhileInFlight_CompletesOnlyAfterTheCapture()
+ {
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ _ = gate.StartIfNeeded(Trigger);
+
+ Task ensured = gate.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply);
+ Assert.That(ensured.IsCompleted, Is.False);
+
+ _background.RunHeldWork();
+ await ensured;
+
+ Assert.That(ensured.Status, Is.EqualTo(TaskStatus.RanToCompletion));
+ Assert.That(_background.StartCount, Is.EqualTo(1));
+ }
+
+ ///
+ /// What: an apply waiting on a capture that throws gets that exception, and the gate is not
+ /// marked captured, so the next caller starts again.
+ ///
+ [Test]
+ public async Task EnsureCapturedAsync_WhenTheCaptureThrows_RethrowsAndLeavesItUnmarked()
+ {
+ InvalidOperationException failure = new InvalidOperationException("The capture failed.");
+ _captureBody = _ => throw failure;
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ Task ensured = gate.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply);
+
+ _background.RunHeldWork();
+ Exception thrown = null;
+ try
+ {
+ await ensured;
+ }
+ catch (InvalidOperationException exception)
+ {
+ thrown = exception;
+ }
+
+ Assert.That(thrown, Is.SameAs(failure));
+ _ = gate.StartIfNeeded(Trigger);
+ Assert.That(_background.StartCount, Is.EqualTo(2));
+ _background.RunHeldWork();
+ }
+
///
- /// What: once a capture has completed, a later call does not run it again.
+ /// What: a pause point that blocks after the capture finished returns without starting
+ /// another capture or reading the inputs again.
///
[Test]
- public void EnsureCaptured_CalledTwice_RunsTheCaptureOnce()
+ public void EnsureCapturedBlocking_AfterTheCaptureFinished_ReturnsWithoutStartingAnother()
{
- int captureCount = 0;
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ gate.StartIfNeeded(Trigger);
+ _background.RunHeldWork();
+
+ gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint);
+
+ Assert.That(_background.StartCount, Is.EqualTo(1));
+ Assert.That(_inputReads, Is.EqualTo(1));
+ }
+
+ ///
+ /// What: a pause point that finds no capture started starts one and returns once it ran.
+ ///
+ [Test]
+ public void EnsureCapturedBlocking_NotStarted_StartsAndWaits()
+ {
+ int captureRuns = 0;
+ HotReloadSourceSnapshotCapture gate = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() =>
{
- captureCount++;
+ captureRuns++;
return true;
});
- capture.EnsureCaptured("test");
- capture.EnsureCaptured("test");
+ gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint);
- Assert.That(captureCount, Is.EqualTo(1));
+ Assert.That(captureRuns, Is.EqualTo(1));
+ Assert.That(gate.IsInFlight, Is.False);
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(1));
}
///
- /// What: a capture that throws passes the exception to the caller and is not marked done, so
- /// the next call runs it again; once that run completes, later calls do not.
+ /// What: a blocking reader waits for the capture it starts and gets its exception; the
+ /// capture ran once and left the gate unmarked, so the next caller runs it again.
///
[Test]
- public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall()
+ public void EnsureCapturedBlocking_WhenTheCaptureThrows_RethrowsAndLeavesItUnmarked()
{
- int captureCount = 0;
InvalidOperationException failure = new InvalidOperationException("The capture failed.");
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
+ int captureRuns = 0;
+ HotReloadSourceSnapshotCapture gate = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() =>
{
- captureCount++;
- if (captureCount == 1)
+ captureRuns++;
+ if (captureRuns == 1)
{
throw failure;
}
@@ -53,96 +317,136 @@ public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall()
return true;
});
- InvalidOperationException thrown =
- Assert.Throws(() => capture.EnsureCaptured("test"));
- Assert.That(thrown, Is.SameAs(failure));
-
- Assert.DoesNotThrow(() => capture.EnsureCaptured("test"));
- Assert.That(captureCount, Is.EqualTo(2));
+ InvalidOperationException thrown = Assert.Throws(
+ () => gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint));
- capture.EnsureCaptured("test");
- Assert.That(captureCount, Is.EqualTo(2));
+ Assert.That(thrown, Is.SameAs(failure));
+ Assert.That(captureRuns, Is.EqualTo(1), "captures run by the blocking call");
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0));
+ gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint);
+ Assert.That(captureRuns, Is.EqualTo(2), "captures after the failure");
}
///
- /// What: a capture that runs to completion writes one captured entry naming its trigger and how long it took.
+ /// What: the compile completion is held while a capture has run for less than 30 seconds.
///
[Test]
- public void EnsureCaptured_WhenTheCaptureRuns_WritesOneCapturedEntryWithItsTrigger()
+ public void IsHoldingCompileCompletion_WithinTheCap_IsTrue()
{
- VibeLogger.ClearMemoryLogs();
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true);
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ gate.StartIfNeeded(Trigger);
- capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad);
+ _nowTicks = HoldCapTicks - 1;
- JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured);
- Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad));
- Assert.That(context["captureMs"].Type, Is.EqualTo(JTokenType.Integer), "captureMs type");
- Assert.That((long)context["captureMs"], Is.GreaterThanOrEqualTo(0), "captureMs");
+ Assert.That(gate.IsHoldingCompileCompletion, Is.True);
+ _background.RunHeldWork();
}
///
- /// What: a call that finds the capture already done writes no second entry, so the entry keeps the first trigger.
+ /// What: once a capture has run for 30 seconds the compile completion is no longer held, and
+ /// that is logged once however often it is asked; the capture still counts as in flight.
///
[Test]
- public void EnsureCaptured_WhenAlreadyCaptured_WritesNoSecondEntry()
+ public void IsHoldingCompileCompletion_PastTheCap_IsFalseAndLogsOnce()
{
- VibeLogger.ClearMemoryLogs();
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true);
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ gate.StartIfNeeded(Trigger);
- capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad);
- capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
+ _nowTicks = HoldCapTicks;
- JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured);
- Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad));
+ Assert.That(gate.IsHoldingCompileCompletion, Is.False);
+ Assert.That(gate.IsHoldingCompileCompletion, Is.False);
+ Assert.That(gate.IsInFlight, Is.True);
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureHoldCapExceeded), Is.EqualTo(1));
+ _background.RunHeldWork();
}
///
- /// What: a capture that throws writes no captured entry.
+ /// What: nothing holds the compile completion before a capture starts, after one succeeded,
+ /// after one failed, or after one was cancelled.
///
[Test]
- public void EnsureCaptured_WhenTheCaptureThrows_WritesNoEntry()
+ public void IsHoldingCompileCompletion_WhenNothingRuns_IsFalse()
{
- VibeLogger.ClearMemoryLogs();
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
- {
- throw new InvalidOperationException("The capture failed.");
- });
+ HotReloadSourceSnapshotCapture notStarted = CreateGate();
+ Assert.That(notStarted.IsHoldingCompileCompletion, Is.False, "not started");
+
+ HotReloadSourceSnapshotCapture succeeded = CreateGate();
+ succeeded.StartIfNeeded(Trigger);
+ _background.RunHeldWork();
+ Assert.That(succeeded.IsHoldingCompileCompletion, Is.False, "succeeded");
- Assert.Throws(
- () => capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad));
+ _captureBody = _ => throw new InvalidOperationException("The capture failed.");
+ HotReloadSourceSnapshotCapture failed = CreateGate();
+ failed.StartIfNeeded(Trigger);
+ _background.RunHeldWork();
+ Assert.That(failed.IsHoldingCompileCompletion, Is.False, "failed");
- Assert.That(CountCapturedEntries(), Is.EqualTo(0));
+ _captureBody = cancellationToken =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return true;
+ };
+ HotReloadSourceSnapshotCapture cancelled = CreateGate();
+ cancelled.StartIfNeeded(Trigger);
+ cancelled.CancelInFlight();
+ _background.RunHeldWork();
+ Assert.That(cancelled.IsHoldingCompileCompletion, Is.False, "cancelled");
}
///
- /// What: a capture that saw no compilation assembly is not marked done and writes no entry,
- /// so the next call runs it again and the entry names that call's trigger.
+ /// What: cancelling signals the running capture's token without waiting for it, and the
+ /// capture that stops leaves the gate not captured and not in flight.
///
[Test]
- public void EnsureCaptured_WhenTheCaptureSawNoAssembly_RunsItAgainOnTheNextCall()
+ public void CancelInFlight_CancelsTheTokenAndLeavesTheGateUnmarked()
{
- VibeLogger.ClearMemoryLogs();
- int captureCount = 0;
- HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() =>
+ CancellationToken seenToken = CancellationToken.None;
+ _captureBody = cancellationToken =>
{
- captureCount++;
- return captureCount > 1;
- });
+ seenToken = cancellationToken;
+ cancellationToken.ThrowIfCancellationRequested();
+ return true;
+ };
+ HotReloadSourceSnapshotCapture gate = CreateGate();
+ Task task = gate.StartIfNeeded(Trigger);
- capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad);
- Assert.That(CountCapturedEntries(), Is.EqualTo(0), "entries after the first call");
+ gate.CancelInFlight();
+ Assert.That(gate.IsInFlight, Is.True, "in flight until the capture stops");
+ _background.RunHeldWork();
- capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick);
+ Assert.That(seenToken.IsCancellationRequested, Is.True);
+ // Faulted, not Canceled: the background runner is given no token, as Task.Run is not,
+ // so the token's exception faults the task the way it does in production.
+ Assert.That(task.IsFaulted, Is.True);
+ Assert.That(task.Exception.GetBaseException(), Is.InstanceOf());
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled), Is.EqualTo(1));
+ Assert.That(gate.IsInFlight, Is.False);
+ Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0));
+ }
- Assert.That(captureCount, Is.EqualTo(2), "captures");
- JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured);
- Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick));
+ private HotReloadSourceSnapshotCapture CreateGate()
+ {
+ return new HotReloadSourceSnapshotCapture(
+ () =>
+ {
+ _inputReads++;
+ return HotReloadSourceSnapshotCaptureTestDoubles.FixedInputs();
+ },
+ RunCapture,
+ _background.Run,
+ () => _nowTicks);
+ }
+
+ private bool RunCapture(HotReloadSnapshotCaptureInputs inputs, CancellationToken cancellationToken)
+ {
+ _captureRuns++;
+ return _captureBody(cancellationToken);
}
- private static int CountCapturedEntries()
+ private static int CountEntries(string operation)
{
- return JArray.Parse(VibeLogger.GetLogsForAi(HotReloadConstants.VibeLogSourceSnapshotCaptured)).Count;
+ return JArray.Parse(VibeLogger.GetLogsForAi(operation)).Count;
}
}
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs
index 860c5ccd7..2dc7bee99 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs
@@ -4,6 +4,7 @@
using System.Linq;
using System.Security.Cryptography;
using System.Text;
+using System.Threading;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -180,7 +181,7 @@ public void LoadVerifiedSnapshotSource_ForPredefinedEditorAssembly_ReturnsOnDisk
[Test]
public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsTheVerifiedSource()
{
- HotReloadSourceSnapshotter.CaptureAfterDomainReload();
+ HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None);
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
string dllPath = Path.Combine(
projectRoot,
@@ -188,7 +189,7 @@ public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsT
PausePointsRuntimeAssemblyName + HotReloadConstants.CompiledAssemblyExtension);
string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSource(PackageSourceAssetPath, dllPath);
- HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMiss(PackageSourceAssetPath, dllPath);
+ HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMiss(PackageSourceAssetPath, dllPath, null);
Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.None));
Assert.That(loaded, Is.EqualTo(File.ReadAllText(Path.Combine(projectRoot, PackageSourcePhysicalPath))));
@@ -203,14 +204,14 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase()
{
VibeLogger.ClearMemoryLogs();
- HotReloadSourceSnapshotter.CaptureAfterDomainReload();
+ HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None);
JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown);
string[] fields =
{
- "getAssembliesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs",
+ "listingMs", "pdbSourcesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs",
"pdbCheckMs", "publishMs", "cleanupMs", "totalMs", "assemblies", "assembliesImmutable",
- "assembliesUnchanged", "assembliesCaptured", "filesCopied", "bytesCopied", "filesChecked"
+ "assembliesUnchanged", "assembliesCaptured", "assembliesSkippedPdbMismatch", "assembliesFromCompilerPdb", "filesCopied", "bytesCopied", "filesChecked"
};
foreach (string field in fields)
{
@@ -262,7 +263,8 @@ public void LoadVerifiedSnapshotSourceAt_VirtualPlayerRootAndPackageSource_Finds
playerRoot,
PackageSourceAssetPath,
dllPath,
- documentIndex);
+ documentIndex,
+ null);
string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt(
playerRoot,
PackageSourceAssetPath,
@@ -358,7 +360,8 @@ public void DescribeSnapshotMiss_ForAFileWithoutMethodBodies_ReportsNoDocumentIn
projectRoot,
BodylessFixtureProjectRelativePath,
TestAssemblyDllPath(projectRoot),
- HotReloadPdbDocumentIndex.Shared);
+ HotReloadPdbDocumentIndex.Shared,
+ null);
Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb));
}
@@ -376,11 +379,158 @@ public void DescribeSnapshotMiss_WithoutASnapshotFile_ReportsNoSnapshotFile()
emptyRoot,
FixtureProjectRelativePath,
TestAssemblyDllPath(projectRoot),
- HotReloadPdbDocumentIndex.Shared);
+ HotReloadPdbDocumentIndex.Shared,
+ null);
+
+ Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile));
+ }
+
+ ///
+ /// What: a listed file without a snapshot in an assembly whose PDB dropped nothing reports
+ /// the missing snapshot a compile will write, even when no sequence point refers to it: such
+ /// a file is one the last compile did not build yet, as after a rename the compile has not
+ /// caught up with.
+ ///
+ [Test]
+ public void DescribeSnapshotMiss_ListedFileWithoutASnapshotInAPdbThatDroppedNothing_ReportsNoSnapshotFile()
+ {
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ string emptyRoot = Path.Combine(Path.GetTempPath(), "uloop-hot-reload-snapshot-empty-" + Guid.NewGuid().ToString("N"));
+ string dllPath = TestAssemblyDllPath(projectRoot);
+ HotReloadPdbCompilationInfo compiled = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb"));
+ Assert.That(compiled.SourceFileCount, Is.Not.Null, "Precondition: the compiler's PDB must record its source count.");
+ Assert.That(compiled.DocumentCount, Is.GreaterThanOrEqualTo(compiled.SourceFileCount.Value), "Precondition: the compiler's PDB must drop nothing.");
+
+ try
+ {
+ // Why a new index: the shared one answers from a list persisted by an earlier domain,
+ // which would hide a change to how the drop is worked out.
+ HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt(
+ emptyRoot,
+ BodylessFixtureProjectRelativePath,
+ dllPath,
+ new HotReloadPdbDocumentIndex(Path.Combine(emptyRoot, "PdbDocuments")),
+ null);
+
+ Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile));
+ }
+ finally
+ {
+ if (Directory.Exists(emptyRoot))
+ {
+ Directory.Delete(emptyRoot, recursive: true);
+ }
+ }
+ }
+
+ ///
+ /// What: in an assembly whose PDB an IL post-processor rewrote and dropped documents from, a
+ /// listed file without a snapshot that no sequence point refers to, which the capture could
+ /// not name from that PDB, reports that no compile gives it a baseline.
+ ///
+ [Test]
+ public void DescribeSnapshotMiss_ListedBodylessFileWithoutASnapshotInAPdbThatDroppedDocuments_ReportsNoDocumentInPdb()
+ {
+ HotReloadSnapshotMissReason reason = DescribeMissInARewrittenTestAssembly(
+ BodylessFixtureProjectRelativePath,
+ withNewSourceEvidence: false);
+
+ Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb));
+ }
+
+ ///
+ /// What: in an assembly whose PDB dropped documents, a listed file with method bodies and no
+ /// snapshot still reports the missing snapshot a compile will write.
+ ///
+ [Test]
+ public void DescribeSnapshotMiss_ListedFileWithBodiesWithoutASnapshotInAPdbThatDroppedDocuments_ReportsNoSnapshotFile()
+ {
+ HotReloadSnapshotMissReason reason = DescribeMissInARewrittenTestAssembly(
+ FixtureProjectRelativePath,
+ withNewSourceEvidence: false);
+
+ Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile));
+ }
+
+ ///
+ /// What: a file the compilation assemblies do not list yet, placed in the assembly by new
+ /// source evidence, reports the missing snapshot a compile will write even in an assembly
+ /// whose PDB dropped documents, whether or not it has method bodies.
+ ///
+ [TestCase(BodylessFixtureProjectRelativePath)]
+ [TestCase("Assets/Tests/Editor/HotReload/HotReloadAddedSinceTheCompile.cs")]
+ public void DescribeSnapshotMiss_FileWithNewSourceEvidenceInAPdbThatDroppedDocuments_ReportsNoSnapshotFile(
+ string projectRelativePath)
+ {
+ HotReloadSnapshotMissReason reason = DescribeMissInARewrittenTestAssembly(
+ projectRelativePath,
+ withNewSourceEvidence: true);
Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile));
}
+ // Asks why a file has no snapshot in a copy of this test assembly whose PDB a Cecil round
+ // trip rewrote and dropped documents from, under a root with no snapshot tree.
+ private static HotReloadSnapshotMissReason DescribeMissInARewrittenTestAssembly(
+ string projectRelativePath,
+ bool withNewSourceEvidence)
+ {
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ string dllPath = CecilPdbRewrite.RewriteIntoNewDirectoryLosingDocuments(projectRoot, TestAssemblyName);
+ string rewriteRoot = CecilPdbRewrite.RootOf(dllPath);
+ try
+ {
+ return HotReloadSourceBaseline.DescribeSnapshotMissAt(
+ rewriteRoot,
+ projectRelativePath,
+ dllPath,
+ new HotReloadPdbDocumentIndex(Path.Combine(rewriteRoot, "PdbDocuments")),
+ withNewSourceEvidence ? NewSourceEvidence(projectRelativePath, dllPath) : null);
+ }
+ finally
+ {
+ Directory.Delete(rewriteRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// What: a PDB of another build beside the dll is rejected the same way for a listed file
+ /// without a snapshot as for one with a snapshot.
+ ///
+ [TestCase(true)]
+ [TestCase(false)]
+ public void DescribeSnapshotMiss_PdbOfAnotherBuild_ThrowsWithOrWithoutASnapshot(bool withSnapshot)
+ {
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ string realDllPath = TestAssemblyDllPath(projectRoot);
+ string fakeRoot = Path.Combine(Path.GetTempPath(), "uloop-hot-reload-snapshot-other-build-" + Guid.NewGuid().ToString("N"));
+ string dllPath = TestAssemblyDllPath(fakeRoot);
+ Directory.CreateDirectory(Path.GetDirectoryName(dllPath));
+ File.Copy(realDllPath, dllPath);
+ File.Copy(
+ Path.ChangeExtension(typeof(HotReloadSourceBaseline).Assembly.Location, ".pdb"),
+ Path.ChangeExtension(dllPath, ".pdb"));
+ if (withSnapshot)
+ {
+ CopyRealSnapshot(projectRoot, fakeRoot, realDllPath, FixtureProjectRelativePath);
+ }
+
+ HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(Path.Combine(fakeRoot, "PdbDocuments"));
+ try
+ {
+ Assert.Throws(() => HotReloadSourceBaseline.DescribeSnapshotMissAt(
+ fakeRoot,
+ FixtureProjectRelativePath,
+ dllPath,
+ index,
+ null));
+ }
+ finally
+ {
+ Directory.Delete(fakeRoot, recursive: true);
+ }
+ }
+
///
/// What: snapshot bytes that fail the PDB checksum report a hash mismatch.
///
@@ -397,7 +547,8 @@ public void DescribeSnapshotMiss_WhenSnapshotBytesTampered_ReportsHashMismatch()
fakeRoot,
FixtureProjectRelativePath,
dllPath,
- HotReloadPdbDocumentIndex.Shared);
+ HotReloadPdbDocumentIndex.Shared,
+ null);
Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.HashMismatch));
}
finally
@@ -418,7 +569,8 @@ public void DescribeSnapshotMiss_ForAVerifiedSnapshot_ReportsNone()
projectRoot,
FixtureProjectRelativePath,
TestAssemblyDllPath(projectRoot),
- HotReloadPdbDocumentIndex.Shared);
+ HotReloadPdbDocumentIndex.Shared,
+ null);
Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.None));
}
@@ -478,7 +630,8 @@ public void DescribeSnapshotMissAt_AfterALoadThatFoundNoDocument_DoesNotReadTheP
projectRoot,
BodylessFixtureProjectRelativePath,
TestAssemblyDllPath(projectRoot),
- index);
+ index,
+ null);
Assert.That(loaded, Is.Null);
Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb));
@@ -501,6 +654,18 @@ private static string TestAssemblyDllPath(string projectRoot)
TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension);
}
+ // Evidence of the kind a run carries for a file Unity's compilation assemblies do not list.
+ private static HotReloadNewSourceMembershipEvidence NewSourceEvidence(string projectRelativePath, string dllPath)
+ {
+ return new HotReloadNewSourceMembershipEvidence(
+ projectRelativePath,
+ TestAssemblyName,
+ dllPath,
+ HotReloadAssemblyMvid.Read(dllPath),
+ "Assets/Tests/Editor/HotReload/UnityCLILoop.Tests.Editor.HotReload.asmdef",
+ Array.Empty());
+ }
+
// No source is written at or after the end of time, so the capture never reads a compiled assembly.
private static HotReloadSnapshotSourceCheck SuspectsNothing(string root)
{
@@ -509,7 +674,25 @@ private static HotReloadSnapshotSourceCheck SuspectsNothing(string root)
"unused.dll",
"unused.pdb",
"unused",
- new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments")));
+ new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments")),
+ Array.Empty(),
+ StringComparison.Ordinal);
+ }
+
+ // Copies the real snapshot of the file, unchanged, under another root for the same assembly build.
+ private static void CopyRealSnapshot(string projectRoot, string targetRoot, string dllPath, string projectRelativePath)
+ {
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+ string realSnapshotPath = HotReloadSourceSnapshotLayout.SourcePath(
+ HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, TestAssemblyName, mvid),
+ projectRelativePath);
+ Assert.That(File.Exists(realSnapshotPath), Is.True, "Precondition: the real snapshot must exist.");
+
+ string targetPath = HotReloadSourceSnapshotLayout.SourcePath(
+ HotReloadSourceSnapshotLayout.AssemblyDirectory(targetRoot, TestAssemblyName, mvid),
+ projectRelativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(targetPath));
+ File.Copy(realSnapshotPath, targetPath);
}
// Copies the fixture's real snapshot under a temporary root with its first byte flipped.
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs
index 49203054f..aa472aaf8 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs
@@ -239,7 +239,9 @@ private static HotReloadSnapshotSourceCheck SuspectsNothing(string root)
"unused.dll",
"unused.pdb",
"unused",
- new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments")));
+ new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments")),
+ Array.Empty(),
+ StringComparison.Ordinal);
}
// Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while
diff --git a/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs
index 748811d41..dd989042e 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs
@@ -371,7 +371,7 @@ private static HotReloadWarmUpContext ContextFor(string projectRoot, params stri
Array.Empty()));
}
- return new HotReloadWarmUpContext(projectRoot, targets);
+ return new HotReloadWarmUpContext(projectRoot, targets, true);
}
// Records each request and its token, and answers it with what the test chose for its
diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs
index 092711dbc..ebb88d293 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs
@@ -130,15 +130,44 @@ public void Capture_PutsThePathsAndTheReferencingDllsIntoTheTarget()
HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource(
_projectRoot,
new HotReloadStubEditorStateSnapshotCapture(() => _state),
- name => name == "A" ? referencing : Array.Empty());
+ name => name == "A" ? referencing : Array.Empty(),
+ () => true);
- HotReloadWarmUpTarget target = source.Capture().Context.Targets[0];
+ HotReloadWarmUpContext context = source.Capture().Context;
+ HotReloadWarmUpTarget target = context.Targets[0];
+ Assert.That(context.CompilationListKept, Is.True, "kept");
Assert.That(target.DllPath, Is.EqualTo(CompiledPath("A.dll")), "dll");
Assert.That(target.PdbPath, Is.EqualTo(CompiledPath("A.pdb")), "pdb");
Assert.That(target.ReferencingDllPaths, Is.EqualTo(referencing), "referencing");
}
+ ///
+ /// What: without a kept compilation assembly list the referencing dlls are not collected,
+ /// since collecting them asks Unity for the list, and the context says no list was kept.
+ ///
+ [Test]
+ public void Capture_WithoutAKeptList_CollectsNoReferencingDlls()
+ {
+ RecordCompiled("A");
+ int collected = 0;
+ HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource(
+ _projectRoot,
+ new HotReloadStubEditorStateSnapshotCapture(() => _state),
+ name =>
+ {
+ collected++;
+ return new[] { "referencing.dll" };
+ },
+ () => false);
+
+ HotReloadWarmUpContext context = source.Capture().Context;
+
+ Assert.That(collected, Is.EqualTo(0), "collector calls");
+ Assert.That(context.Targets[0].ReferencingDllPaths, Is.Empty, "referencing");
+ Assert.That(context.CompilationListKept, Is.False, "kept");
+ }
+
///
/// What: a failed last compile does not skip, because the loaded dlls are the last good build.
///
@@ -156,7 +185,8 @@ private HotReloadWarmUpCapture Capture()
HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource(
_projectRoot,
new HotReloadStubEditorStateSnapshotCapture(() => _state),
- name => Array.Empty());
+ name => Array.Empty(),
+ () => true);
return source.Capture();
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs
index 0a00841a1..96a1c227f 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs
@@ -250,7 +250,7 @@ private async Task BeginScope(HotReloadWarmUp warmUp)
private HotReloadWarmUp CreateWarmUp(params IHotReloadWarmUpItem[] items)
{
- return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), items);
+ return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(true)), items);
}
private static HotReloadWarmUpCapture CreateTestAssemblyCapture(string testDll)
@@ -266,7 +266,8 @@ private static HotReloadWarmUpCapture CreateTestAssemblyCapture(string testDll)
testDll,
Path.ChangeExtension(testDll, ".pdb"),
HotReloadCallSiteScanner.CollectReferencingDllPaths(root, TestAssemblyName()))
- }));
+ },
+ true));
}
private static async Task RunWithReadReturningAsync(int value)
diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs
index eb5b6e30d..b1ebe750a 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs
@@ -28,7 +28,7 @@ public void SetUp()
_ran = new List();
_pending = new PendingWarmUpItem("a", _ran);
_warmUp = new HotReloadWarmUp(
- new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()),
+ new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(true)),
new IHotReloadWarmUpItem[] { _pending, new RecordingWarmUpItem("b", _ran) });
_scope = HotReloadDomainTestScope.WithWarmUp(_warmUp);
HotReloadWarmUpEditorHooks.GetServices = () => HotReloadCompositionRoot.Services;
diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs
index f37a279a4..551238f3a 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs
@@ -29,5 +29,26 @@ public void CreateProduction_ReturnsPublicizedTargetsThenCallSitesThenReferenced
names,
Is.EqualTo(new[] { "publicized_targets", "call_sites", "referenced_method_sets", "transform_worker", "pdb_documents" }));
}
+
+ ///
+ /// What: exactly the items that read the compilation assembly list say they need it: the
+ /// publicized copies and the transform worker look their target up in it, and the
+ /// referenced-method sets read the referencing dlls only it names. These are the items
+ /// skipped when no list was kept.
+ ///
+ [Test]
+ public void CreateProduction_MarksTheItemsThatReadTheCompilationList()
+ {
+ List needing = new List();
+ foreach (IHotReloadWarmUpItem item in HotReloadWarmUpItems.CreateProduction(HotReloadCompiledCallers.CreateProduction()))
+ {
+ if (item.NeedsCompilationList)
+ {
+ needing.Add(item.Name);
+ }
+ }
+
+ Assert.That(needing, Is.EqualTo(new[] { "publicized_targets", "referenced_method_sets", "transform_worker" }));
+ }
}
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs
index 37c40e37b..f7997d987 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs
@@ -5,9 +5,13 @@
using Newtonsoft.Json.Linq;
+using UnityEditor.Compilation;
+
using io.github.hatayama.UnityCliLoop.FirstPartyTools;
using io.github.hatayama.UnityCliLoop.ToolContracts;
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
///
@@ -26,7 +30,7 @@ internal static HotReloadWarmUp CreateInert()
Array.Empty());
}
- internal static HotReloadWarmUpCapture CreateReadyCapture()
+ internal static HotReloadWarmUpCapture CreateReadyCapture(bool compilationListKept)
{
return HotReloadWarmUpCapture.Ready(
new HotReloadWarmUpContext(
@@ -38,7 +42,8 @@ internal static HotReloadWarmUpCapture CreateReadyCapture()
TargetAssemblyName + ".dll",
TargetAssemblyName + ".pdb",
Array.Empty())
- }));
+ },
+ compilationListKept));
}
/// The context of the only entry of in the in-memory vibe log.
@@ -95,6 +100,8 @@ internal RecordingWarmUpItem(string name, List ran)
public string Name { get; }
+ public bool NeedsCompilationList => false;
+
public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
{
_ran.Add(Name);
@@ -115,6 +122,8 @@ internal PendingWarmUpItem(string name, List ran)
public string Name { get; }
+ public bool NeedsCompilationList => false;
+
internal TaskCompletionSource Release { get; } = new TaskCompletionSource();
public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
@@ -141,6 +150,8 @@ internal TwoUnitWarmUpItem(string name, List ran)
public string Name { get; }
+ public bool NeedsCompilationList => false;
+
internal TaskCompletionSource BetweenUnits { get; } = new TaskCompletionSource();
public async Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
@@ -152,6 +163,61 @@ public async Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
}
}
+ ///
+ /// An item that needs the compilation assembly list: it looks the target up through the given
+ /// lookup and appends its name when it runs.
+ ///
+ internal sealed class ListLookupWarmUpItem : IHotReloadWarmUpItem
+ {
+ private readonly List _ran;
+ private readonly Func _findByName;
+
+ internal ListLookupWarmUpItem(string name, List ran, Func findByName)
+ {
+ Name = name;
+ _ran = ran;
+ _findByName = findByName;
+ }
+
+ public string Name { get; }
+
+ public bool NeedsCompilationList => true;
+
+ public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
+ {
+ _findByName(HotReloadWarmUpTestDoubles.TargetAssemblyName);
+ _ran.Add(Name);
+ return Task.CompletedTask;
+ }
+ }
+
+ ///
+ /// The fetch of a compilation assembly cache, counting the times the cache asks Unity for the list.
+ ///
+ internal sealed class CountingCompilationListFetch
+ {
+ internal int Count { get; private set; }
+
+ internal HotReloadCompilationAssemblyCache CreateEmptyCache()
+ {
+ return new HotReloadCompilationAssemblyCache(Fetch);
+ }
+
+ /// A cache that already keeps Unity's list, as after a rebuild that was kept.
+ internal HotReloadCompilationAssemblyCache CreateFilledCache()
+ {
+ HotReloadCompilationAssemblyCache cache = CreateEmptyCache();
+ NUnit.Framework.Assert.That(cache.TryFill(CompilationPipeline.GetAssemblies(), cache.Generation), NUnit.Framework.Is.True);
+ return cache;
+ }
+
+ private UnityCompilationAssembly[] Fetch()
+ {
+ Count++;
+ return CompilationPipeline.GetAssemblies();
+ }
+ }
+
/// An item whose task faults with the given exception.
internal sealed class ThrowingWarmUpItem : IHotReloadWarmUpItem
{
@@ -165,6 +231,8 @@ internal ThrowingWarmUpItem(string name, Exception exception)
public string Name { get; }
+ public bool NeedsCompilationList => false;
+
public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
{
return Task.FromException(_exception);
diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs
index 6ef56bcd2..2499e38dd 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs
@@ -279,9 +279,64 @@ public async Task Shutdown_BeforeStart_DoesNothing_AndStartAfterwardsRuns()
Assert.That(_ran, Is.EqualTo(new[] { "a" }), "items run");
}
+ ///
+ /// What: without a kept compilation assembly list, the items that look assemblies up in it
+ /// are reported skipped with compilation_list_unavailable and never ask Unity for the
+ /// list, while the other items still run.
+ ///
+ [Test]
+ public async Task WarmUp_WhenTheListIsUnavailable_SkipsTheItemsThatNeedItWithoutFetching()
+ {
+ CountingCompilationListFetch fetch = new CountingCompilationListFetch();
+ HotReloadCompilationAssemblyCache cache = fetch.CreateEmptyCache();
+ HotReloadWarmUp warmUp = new HotReloadWarmUp(
+ new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(false)),
+ new IHotReloadWarmUpItem[]
+ {
+ new ListLookupWarmUpItem("needs_list", _ran, cache.FindByName),
+ new RecordingWarmUpItem("other", _ran)
+ });
+
+ warmUp.Start();
+ await warmUp.Completion;
+
+ Assert.That(_ran, Is.EqualTo(new[] { "other" }), "items run");
+ Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list");
+ Assert.That(
+ HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(),
+ Is.EqualTo(new[] { "needs_list:skipped", "other:done" }));
+ JObject skipped = (JObject)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete)["items"][0];
+ Assert.That(
+ (string)skipped["detail"],
+ Is.EqualTo(HotReloadConstants.WarmUpItemSkipReasonCompilationListUnavailable));
+ }
+
+ ///
+ /// What: with a kept compilation assembly list, every item runs, including those that look
+ /// assemblies up in it, and none asks Unity for the list.
+ ///
+ [Test]
+ public async Task WarmUp_AfterTheListIsFilled_RunsEveryItem()
+ {
+ CountingCompilationListFetch fetch = new CountingCompilationListFetch();
+ HotReloadCompilationAssemblyCache cache = fetch.CreateFilledCache();
+ HotReloadWarmUp warmUp = CreateReady(
+ new ListLookupWarmUpItem("needs_list", _ran, cache.FindByName),
+ new RecordingWarmUpItem("other", _ran));
+
+ warmUp.Start();
+ await warmUp.Completion;
+
+ Assert.That(_ran, Is.EqualTo(new[] { "needs_list", "other" }), "items run");
+ Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list");
+ Assert.That(
+ HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(),
+ Is.EqualTo(new[] { "needs_list:done", "other:done" }));
+ }
+
private static HotReloadWarmUp CreateReady(params IHotReloadWarmUpItem[] items)
{
- return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), items);
+ return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(true)), items);
}
}
}
diff --git a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs
index 205d93bd2..11a13c817 100644
--- a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs
+++ b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Threading;
using NUnit.Framework;
@@ -55,7 +56,7 @@ public void TearDown()
[Test]
public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedFileBasis()
{
- HotReloadSourceSnapshotter.CaptureAfterDomainReload();
+ HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None);
PausePointResponse response = Enable(FixtureAssetPath, SecondStatementLine);
@@ -69,7 +70,7 @@ public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedF
[Test]
public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAssetPath()
{
- HotReloadSourceSnapshotter.CaptureAfterDomainReload();
+ HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None);
PausePointResponse response = Enable(FixturePhysicalPath, SecondStatementLine);
@@ -82,7 +83,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAss
[Test]
public void Enable_PackageSourceGivenByItsAbsolutePhysicalPath_ArmsTheSameMarkerAsTheAssetPath()
{
- HotReloadSourceSnapshotter.CaptureAfterDomainReload();
+ HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None);
PausePointResponse response = Enable(ToAbsolutePath(FixturePhysicalPath), SecondStatementLine);
@@ -130,7 +131,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPathWithAShiftedSnapshot_MapsT
[Test]
public void Enable_AssetsSourceGivenByItsAbsolutePath_ArmsTheSameMarkerAsTheRelativePath()
{
- HotReloadSourceSnapshotter.CaptureAfterDomainReload();
+ HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None);
int line = FindLineNumber(File.ReadAllText(ToAbsolutePath(AssetsFixturePath)), AssetsFixtureStatementText);
Assert.That(line, Is.GreaterThan(0));
diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs
index 2bc77b757..0d2381369 100644
--- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs
+++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs
@@ -40,8 +40,9 @@ public static void Initialize(Func isToolEnabled)
}
///
- /// Runs the first-party tool work that has to finish before this domain answers its first
- /// uloop command. Called once, at the end of the Editor startup.
+ /// Starts the first-party tool work that has to begin before this domain answers its first
+ /// uloop command: the source snapshot capture, which then runs in the background. Called
+ /// once, at the end of the Editor startup.
/// answers whether the user enabled a tool in the tool settings, by its settings name.
///
public static void PrepareBeforeServingCommands(Func isToolEnabled)
@@ -54,7 +55,7 @@ public static void PrepareBeforeServingCommands(Func isToolEnabled
return;
}
- HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(isToolEnabled);
+ HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(isToolEnabled);
}
public static void ResetServerScopedServices()
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs
index eb7bc0b67..89cfec676 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs
@@ -22,6 +22,8 @@ internal HotReloadCallSiteWarmUpItem(HotReloadCompiledCallers compiledCallers)
public string Name => "call_sites";
+ public bool NeedsCompilationList => false;
+
public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
{
List dllPaths = new List();
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs
index 11663b5e3..0297501ec 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Threading.Tasks;
using HarmonyLib;
@@ -72,11 +73,22 @@ internal static HotReloadServices CreateProductionServicesWith(
TransformWorkerHost.Shared,
HotReloadGroupProcessorDependencies.CreateProduction,
new HotReloadApplicationPlayModeQuery(),
- new HotReloadSourceSnapshotCapture(HotReloadSourceSnapshotter.CaptureAfterDomainReload),
+ CreateProductionSourceSnapshotCapture(),
warmUp,
compiledCallers);
}
+ // Why only the inputs are read before the pool: they are the capture's only Unity calls, and
+ // Unity allows them on the main thread alone. The capture itself reads files and PDBs.
+ private static HotReloadSourceSnapshotCapture CreateProductionSourceSnapshotCapture()
+ {
+ return new HotReloadSourceSnapshotCapture(
+ HotReloadSnapshotCaptureInputs.ReadOnMainThread,
+ HotReloadSourceSnapshotter.CaptureAfterDomainReload,
+ work => Task.Run(work),
+ () => DateTime.UtcNow.Ticks);
+ }
+
private static HotReloadWarmUp CreateProductionWarmUp(HotReloadCompiledCallers compiledCallers)
{
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
@@ -84,7 +96,8 @@ private static HotReloadWarmUp CreateProductionWarmUp(HotReloadCompiledCallers c
new HotReloadWarmUpContextSource(
projectRoot,
new HotReloadEditorStateSnapshotCapture(),
- assemblyName => compiledCallers.CollectReferencingDllPaths(projectRoot, assemblyName)),
+ assemblyName => compiledCallers.CollectReferencingDllPaths(projectRoot, assemblyName),
+ HotReloadCompilationAssemblies.IsKept),
HotReloadWarmUpItems.CreateProduction(compiledCallers));
}
@@ -272,8 +285,9 @@ private static void Install(HotReloadServices services)
new HotReloadPausePointPort(
domain,
path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path),
- () => services.SourceSnapshotCapture.EnsureCaptured(
- HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint));
+ () => services.SourceSnapshotCapture.EnsureCapturedBlocking(
+ HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint),
+ () => services.SourceSnapshotCapture.IsInFlight);
// 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/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
index b3e05786d..d5fabec26 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
@@ -16,13 +17,23 @@ public static void Initialize(Func isToolEnabled)
{
Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null.");
+ // Why first: the snapshot capture no longer touches the compilation assembly list, so
+ // the list's static constructor, which subscribes to compilationStarted, would otherwise
+ // run for whichever caller touches the list first, which need not be on the main thread.
+ HotReloadCompilationAssemblies.EnsureInitialized();
+ // Why at domain load: the rebuild then runs while the Editor finishes loading, so the
+ // first caller of the domain finds the list kept instead of asking Unity on the main thread.
+ // The warm-ups wait for it, and then skip what needs a list when none was kept.
+ Task compilationListReady = RefillCompilationListUnlessHotReloadIsDisabled(
+ isToolEnabled, HotReloadCompilationAssemblies.RefillInBackground);
+
// Why not EditorApplication.delayCall: a cold-start session that hits Unity's native
// "Scripts have compiler errors" dialog never flushes delayCall again for the rest of
// that process's lifetime, even for later registrations — while
// EditorApplication.update keeps ticking (see SetupWizardWindow.cs:56-70). The
- // snapshot is captured at the end of the domain load; this tick retries only when that
- // capture threw or Unity listed no compilation assembly yet, and the gate does nothing
- // when it already succeeded.
+ // capture starts at the end of the domain load; this tick starts another only when that
+ // one failed, found no compiled assembly, or was cancelled, and the gate does nothing
+ // when it is still running or already succeeded.
void CaptureOnFirstUpdateTick()
{
EditorApplication.update -= CaptureOnFirstUpdateTick;
@@ -46,8 +57,10 @@ void SweepArtifactsOnFirstUpdateTick()
void StartWarmUpOnFirstUpdateTick()
{
EditorApplication.update -= StartWarmUpOnFirstUpdateTick;
- StartWarmUpUnlessHotReloadIsDisabled(
- isToolEnabled, () => HotReloadCompositionRoot.Services.WarmUp.Start());
+ _ = StartWarmUpOnceTheCompilationListIsReady(
+ compilationListReady,
+ () => StartWarmUpUnlessHotReloadIsDisabled(
+ isToolEnabled, () => HotReloadCompositionRoot.Services.WarmUp.Start()));
}
// The services are rebuilt here rather than on first use because the introduced type
@@ -63,13 +76,19 @@ void StartWarmUpOnFirstUpdateTick()
// null delegate as "nothing to lose".
HotReloadRuntimeChangeCoordination.GetActiveRuntimeChangeCount =
() => HotReloadCompositionRoot.Services.Domain.CountActiveChanges().RuntimeChangeTotal;
+ // Why here, like the count above: get-compile-status reads it on every poll, which can
+ // come before anything in this assembly is touched.
+ HotReloadSnapshotCaptureCoordination.IsHoldingCompileCompletion =
+ () => HotReloadCompositionRoot.Services.SourceSnapshotCapture.IsHoldingCompileCompletion;
// Why here, like the count above: the dynamic-code tool reads it after each server
// reset, which can come before anything in this assembly is touched.
string warmUpProjectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
HotReloadSharedCompilerWarmUpReferenceSource warmUpReferences = new HotReloadSharedCompilerWarmUpReferenceSource(
warmUpProjectRoot,
HotReloadWarmUpTargetLedger.Read,
- HotReloadCompilationAssemblies.FindByName);
+ HotReloadCompilationAssemblies.FindByName,
+ compilationListReady,
+ HotReloadCompilationAssemblies.IsKept);
SharedCompilerWarmUpCoordination.CollectWarmUpReferencePaths = warmUpReferences.CollectAsync;
// Why the same shape for these two: both run from Editor callbacks that take no
// argument, so they have to read whichever services are installed when they fire.
@@ -93,17 +112,19 @@ void StartWarmUpOnFirstUpdateTick()
}
///
- /// Captures the source snapshot of the current compile before this domain answers any uloop
- /// command, so an edit made after the compile returned is never captured as compiled source.
+ /// Starts the source snapshot capture of the current compile before this domain answers any
+ /// uloop command, without waiting for it. The guarantee that an edit made after the compile
+ /// returned is never captured as compiled source comes from get-compile-status holding the
+ /// compile completion while the capture runs (up to 30 seconds).
///
- internal static void CaptureSourceSnapshotBeforeServingCommands(Func isToolEnabled)
+ internal static void StartSourceSnapshotCaptureBeforeServingCommands(Func isToolEnabled)
{
CaptureAtStartupUnlessReadersAreDisabled(
HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad, isToolEnabled);
}
///
- /// Captures the source snapshot at a startup trigger unless both tools that read it are
+ /// Starts the source snapshot capture at a startup trigger unless both tools that read it are
/// disabled in the tool settings.
///
internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Func isToolEnabled)
@@ -128,7 +149,50 @@ internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Fu
return;
}
- HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(trigger);
+ // Why the task is not awaited: the domain load must not wait for the capture, and the
+ // gate logs how it ended. A caller that needs the snapshot waits for it itself.
+ _ = HotReloadCompositionRoot.Services.SourceSnapshotCapture.StartIfNeeded(trigger);
+ }
+
+ ///
+ /// Starts rebuilding the compilation assembly list unless the hot-reload tool is disabled in
+ /// the tool settings. Only hot reload reads the list, so the pause-point tool does not count.
+ /// Completes with whether the rebuilt list was kept.
+ ///
+ internal static Task RefillCompilationListUnlessHotReloadIsDisabled(
+ Func isToolEnabled,
+ Func> startRefill)
+ {
+ Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null.");
+ Debug.Assert(startRefill != null, "startRefill must not be null.");
+
+ if (!isToolEnabled(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD))
+ {
+ VibeLogger.LogInfo(
+ HotReloadConstants.VibeLogCompilationListReconstructed,
+ "Hot reload did not rebuild the compilation assembly list.",
+ new { filled = false, reason = HotReloadConstants.CompilationListReasonToolDisabled });
+ return Task.FromResult(false);
+ }
+
+ return startRefill();
+ }
+
+ ///
+ /// Starts the warm-up on the main thread once the domain's attempt to rebuild the compilation
+ /// assembly list has ended, kept or not, so the warm-up sees the list the rebuild kept
+ /// instead of asking Unity for it. Never faults.
+ ///
+ internal static async Task StartWarmUpOnceTheCompilationListIsReady(Task compilationListReady, Action startWarmUp)
+ {
+ Debug.Assert(compilationListReady != null, "compilationListReady must not be null.");
+ Debug.Assert(startWarmUp != null, "startWarmUp must not be null.");
+
+ // Why ConfigureAwait(false) and then a switch: a rebuild cancelled while it runs ends on a
+ // pool thread, and the warm-up reads Unity state when it starts.
+ await compilationListReady.ConfigureAwait(false);
+ await MainThreadSwitcher.SwitchToMainThread();
+ startWarmUp();
}
///
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs
index c2ba888cb..9bd7eb7e6 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs
@@ -635,7 +635,10 @@ private void SnapshotGroupState(IReadOnlyList files)
file.ProjectRelativePath,
file.TargetDllPath);
file.SnapshotMissReason = file.SnapshotSource == null
- ? HotReloadSourceBaseline.DescribeSnapshotMiss(file.ProjectRelativePath, file.TargetDllPath)
+ ? HotReloadSourceBaseline.DescribeSnapshotMiss(
+ file.ProjectRelativePath,
+ file.TargetDllPath,
+ file.NewSourceMembershipEvidence)
: HotReloadSnapshotMissReason.None;
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs
index caaa4ee04..9bdfecc5f 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs
@@ -12,6 +12,8 @@ internal sealed class HotReloadPdbDocumentWarmUpItem : IHotReloadWarmUpItem
{
public string Name => "pdb_documents";
+ public bool NeedsCompilationList => false;
+
public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
{
// Why taken here: the Shared instance reads Application.dataPath when it is first
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs
index dd24d885e..48803f3e6 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs
@@ -17,6 +17,8 @@ internal sealed class HotReloadPublicizedTargetWarmUpItem : IHotReloadWarmUpItem
{
public string Name => "publicized_targets";
+ public bool NeedsCompilationList => true;
+
public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
{
// Why gather every target here first: the compilation assembly lookup may only run on
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs
index 4a294b9f4..4b9b2acd3 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs
@@ -22,6 +22,9 @@ internal HotReloadReferencedMethodSetWarmUpItem(HotReloadCompiledCallers compile
public string Name => "referenced_method_sets";
+ // Why: its dlls are the referencing ones, which only the compilation assembly list names.
+ public bool NeedsCompilationList => true;
+
public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
{
List dllPaths = CollectDllPaths(context.Targets);
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs
index 38426aa93..1a821cc51 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs
@@ -22,28 +22,48 @@ internal sealed class HotReloadSharedCompilerWarmUpReferenceSource
private readonly string _projectRoot;
private readonly Func> _readLedger;
private readonly Func _findCompilationAssembly;
+ private readonly Task _compilationListReady;
+ private readonly Func _isCompilationListKept;
internal HotReloadSharedCompilerWarmUpReferenceSource(
string projectRoot,
Func> readLedger,
- Func findCompilationAssembly)
+ Func findCompilationAssembly,
+ Task compilationListReady,
+ Func isCompilationListKept)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty.");
Debug.Assert(readLedger != null, "readLedger must not be null.");
Debug.Assert(findCompilationAssembly != null, "findCompilationAssembly must not be null.");
+ Debug.Assert(compilationListReady != null, "compilationListReady must not be null.");
+ Debug.Assert(isCompilationListKept != null, "isCompilationListKept must not be null.");
_projectRoot = projectRoot;
_readLedger = readLedger;
_findCompilationAssembly = findCompilationAssembly;
+ _compilationListReady = compilationListReady;
+ _isCompilationListKept = isCompilationListKept;
}
///
- /// Any thread. Empty when the ledger names no assembly whose dll exists and whose
- /// compilation assembly Unity lists (none is listed while the Editor compiles).
+ /// Any thread. Waits until the domain's attempt to rebuild the compilation assembly list has
+ /// ended. Empty when no list is kept then, or when the ledger names no assembly whose dll
+ /// exists and whose compilation assembly the list holds.
///
internal async Task> CollectAsync(CancellationToken ct)
{
- // Why the main thread: the compilation pipeline answers only there.
+ // Why wait: this runs at the server start right after a domain load, before the rebuild
+ // has usually ended, and returning empty then would leave the worker cold.
+ // Why ConfigureAwait(false) and then a switch: a cancelled rebuild ends on a pool thread.
+ await _compilationListReady.ConfigureAwait(false);
+ // Why the main thread: the kept list is read only there.
await MainThreadSwitcher.SwitchToMainThread(ct);
+ // Why not ask Unity instead: GetAssemblies stops the main thread for hundreds of
+ // milliseconds on a large project, more than this warm-up saves.
+ if (!_isCompilationListKept())
+ {
+ return Array.Empty();
+ }
+
IReadOnlyList names = _readLedger(_projectRoot);
CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot);
int count = Math.Min(names.Count, HotReloadWarmUpContextSource.MaxWarmedTargets);
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs
index 53db67b8e..dba27e1f1 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs
@@ -332,9 +332,14 @@ protected override async Task ExecuteAsync(
// selection and response rows, and PackageInfo is main-thread only, which this path is.
services.PackageRootCapture.CaptureCurrent();
// Why here: both the default selection below and the run read the snapshot of the compile
- // the last reload loaded. The gate does nothing when the domain load captured it; it
- // captures here when that capture threw or Unity listed no compilation assembly yet.
- services.SourceSnapshotCapture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerApply);
+ // the last reload loaded. This waits for the capture the domain load started, or starts
+ // one when that capture failed, found no compiled assembly, or was cancelled.
+ await services.SourceSnapshotCapture.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply)
+ .ConfigureAwait(false);
+ // Why switch explicitly: the capture finishes on a pool thread and the wait does not return
+ // through the synchronization context, while the selection below reads Unity's compilation
+ // assemblies, which only the main thread may.
+ await MainThreadSwitcher.SwitchToMainThread(ct);
HotReloadDefaultFileSelection selection = HotReloadDefaultFileSelector.Resolve(
parameters.Files,
services.ChangeDetector.Detect,
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs
index b20e21655..1bdc3c1d6 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs
@@ -29,6 +29,8 @@ internal HotReloadTransformWorkerWarmUpItem(
public string Name => ItemName;
+ public bool NeedsCompilationList => true;
+
public async Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct)
{
HotReloadTransformWorkerWarmUpRequest request = GatherOnMainThread(context);
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs
index 033cfa584..30ec51a57 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs
@@ -165,6 +165,15 @@ private async Task RunAllAsync(CancellationToken ct)
continue;
}
+ // Why skip rather than look up: without a kept list the lookup asks Unity on the
+ // main thread, which is the wait the rebuilt list exists to avoid.
+ if (item.NeedsCompilationList && !capture.Context.CompilationListKept)
+ {
+ outcomes.Add(HotReloadWarmUpItemOutcome.Skipped(
+ item.Name, HotReloadConstants.WarmUpItemSkipReasonCompilationListUnavailable));
+ continue;
+ }
+
// Why ConfigureAwait(false): the next item switches to the main thread itself,
// and the entry after the last item must not wait for a main thread that a
// reload may never give back.
@@ -280,6 +289,8 @@ private static string ToOutcomeName(HotReloadWarmUpItemOutcomeKind kind)
return "cancelled";
case HotReloadWarmUpItemOutcomeKind.Failed:
return "failed";
+ case HotReloadWarmUpItemOutcomeKind.Skipped:
+ return "skipped";
default:
throw new ArgumentOutOfRangeException(nameof(kind), kind, null);
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs
index 0db3c1a4f..a2601d436 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs
@@ -5,20 +5,31 @@
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
///
- /// What one warm-up loads: the project root and at least one target assembly.
+ /// What one warm-up loads: the project root, at least one target assembly, and whether the
+ /// compilation assembly list was kept when the targets were chosen.
///
internal sealed class HotReloadWarmUpContext
{
- internal HotReloadWarmUpContext(string projectRoot, IReadOnlyList targets)
+ internal HotReloadWarmUpContext(
+ string projectRoot,
+ IReadOnlyList targets,
+ bool compilationListKept)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty.");
Debug.Assert(targets != null && targets.Count > 0, "targets must hold at least one target.");
ProjectRoot = projectRoot;
Targets = targets;
+ CompilationListKept = compilationListKept;
}
internal string ProjectRoot { get; }
internal IReadOnlyList Targets { get; }
+
+ ///
+ /// False when no compilation assembly list was kept: the items that look assemblies up in
+ /// it are then skipped, so the warm-up never asks Unity for the list on the main thread.
+ ///
+ internal bool CompilationListKept { get; }
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs
index 5ccdc81c5..6e424b859 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs
@@ -8,7 +8,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
///
/// Chooses what the warm-up loads: the most recent assemblies in the project's ledger whose
- /// compiled dll and PDB exist, with the dlls that reference each of them.
+ /// compiled dll and PDB exist, with the dlls that reference each of them. The referencing dlls
+ /// are read only from a kept compilation assembly list; without one each target has none.
///
internal sealed class HotReloadWarmUpContextSource : IHotReloadWarmUpContextSource
{
@@ -19,18 +20,22 @@ internal sealed class HotReloadWarmUpContextSource : IHotReloadWarmUpContextSour
private readonly string _projectRoot;
private readonly IHotReloadEditorStateSnapshotCapture _editorState;
private readonly Func> _collectReferencingDllPaths;
+ private readonly Func _isCompilationListKept;
internal HotReloadWarmUpContextSource(
string projectRoot,
IHotReloadEditorStateSnapshotCapture editorState,
- Func> collectReferencingDllPaths)
+ Func> collectReferencingDllPaths,
+ Func isCompilationListKept)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty.");
Debug.Assert(editorState != null, "editorState must not be null.");
Debug.Assert(collectReferencingDllPaths != null, "collectReferencingDllPaths must not be null.");
+ Debug.Assert(isCompilationListKept != null, "isCompilationListKept must not be null.");
_projectRoot = projectRoot;
_editorState = editorState;
_collectReferencingDllPaths = collectReferencingDllPaths;
+ _isCompilationListKept = isCompilationListKept;
}
///
@@ -57,16 +62,18 @@ public HotReloadWarmUpCapture Capture()
return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonNoTargets);
}
- List targets = CollectTargets(names);
+ // Read once: the items decide from the same answer the referencing dlls came from.
+ bool compilationListKept = _isCompilationListKept();
+ List targets = CollectTargets(names, compilationListKept);
if (targets.Count == 0)
{
return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonNoCompiledAssembly);
}
- return HotReloadWarmUpCapture.Ready(new HotReloadWarmUpContext(_projectRoot, targets));
+ return HotReloadWarmUpCapture.Ready(new HotReloadWarmUpContext(_projectRoot, targets, compilationListKept));
}
- private List CollectTargets(IReadOnlyList names)
+ private List CollectTargets(IReadOnlyList names, bool compilationListKept)
{
CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot);
List targets = new List();
@@ -83,7 +90,12 @@ private List CollectTargets(IReadOnlyList names)
continue;
}
- targets.Add(new HotReloadWarmUpTarget(name, dllPath, pdbPath, _collectReferencingDllPaths(name)));
+ // Why not collected without a kept list: the referencing assemblies come from the
+ // list, and asking Unity for it here would stop the main thread.
+ IReadOnlyList referencingDllPaths = compilationListKept
+ ? _collectReferencingDllPaths(name)
+ : Array.Empty();
+ targets.Add(new HotReloadWarmUpTarget(name, dllPath, pdbPath, referencingDllPaths));
}
return targets;
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs
index 5e67dc9f5..f96b1fd96 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs
@@ -6,9 +6,10 @@
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
///
- /// Stops the installed warm-up and the background reads of the compiled-caller analysis when
- /// the Editor is about to reload the domain or starts a compile: they would read dlls that are
- /// being rewritten, or work for a domain that is going away.
+ /// Stops the installed warm-up, the background reads of the compiled-caller analysis, and the
+ /// running source snapshot capture when the Editor is about to reload the domain or starts a
+ /// compile: they would read dlls that are being rewritten, or work for a domain that is going
+ /// away.
///
internal static class HotReloadWarmUpEditorHooks
{
@@ -35,12 +36,14 @@ internal static void ShutdownForReload()
{
_ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload);
_ = GetServices().CompiledCallers.StopBackgroundReadsAsync(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload);
+ GetServices().SourceSnapshotCapture.CancelInFlight();
}
internal static void ShutdownForCompile(object context)
{
_ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted);
_ = GetServices().CompiledCallers.StopBackgroundReadsAsync(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted);
+ GetServices().SourceSnapshotCapture.CancelInFlight();
}
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs
index a16f7e6be..2aaae5085 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs
@@ -24,7 +24,9 @@ private HotReloadWarmUpItemOutcome(string name, long ms, HotReloadWarmUpItemOutc
internal HotReloadWarmUpItemOutcomeKind Kind { get; }
- /// The exception's type and message for a failed item; empty otherwise.
+ ///
+ /// The exception's type and message for a failed item, the reason for a skipped one; empty otherwise.
+ ///
internal string Detail { get; }
internal static HotReloadWarmUpItemOutcome Done(string name, long ms)
@@ -38,6 +40,13 @@ internal static HotReloadWarmUpItemOutcome Cancelled(string name, long ms)
return new HotReloadWarmUpItemOutcome(name, ms, HotReloadWarmUpItemOutcomeKind.Cancelled, string.Empty);
}
+ /// An item that was not run because what it needs is unavailable.
+ internal static HotReloadWarmUpItemOutcome Skipped(string name, string reason)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(reason), "reason must not be null or empty.");
+ return new HotReloadWarmUpItemOutcome(name, 0, HotReloadWarmUpItemOutcomeKind.Skipped, reason);
+ }
+
internal static HotReloadWarmUpItemOutcome Failed(string name, long ms, Exception exception)
{
Debug.Assert(exception != null, "exception must not be null.");
@@ -53,6 +62,7 @@ internal enum HotReloadWarmUpItemOutcomeKind
{
Done,
Cancelled,
- Failed
+ Failed,
+ Skipped
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs
index c78b0b625..78f937f51 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs
@@ -122,8 +122,8 @@ private static void AppendBaselineNotices(
///
/// Why this file has no verified snapshot. A file declaring a type hot reload introduced
/// gets its own kind: for it a missing baseline is the normal state rather than something
- /// a compile has yet to establish. So does a file with no compiled method body, which the
- /// PDB never lists and so no compile can give a baseline.
+ /// a compile has yet to establish. So does a file with no compiled method body, which no
+ /// sequence point of the PDB refers to and so no compile can give a baseline.
///
private static HotReloadMissingBaselineKind ChooseMissingBaselineKind(
HotReloadSnapshotMissReason snapshotMissReason,
@@ -170,7 +170,7 @@ private static string ChooseNoVerifiedSourceSnapshotWarningFormat(
}
// Why no other reason comes here: a file with a verified snapshot gets no warning, and
- // one the PDB lists no document for gets the no-method-body kind.
+ // one no sequence point of the PDB refers to gets the no-method-body kind.
Debug.Assert(
snapshotMissReason == HotReloadSnapshotMissReason.NoSnapshotFile,
"Only NoSnapshotFile, HashMismatch, or NoCompiledAssembly may reach the no-verified-snapshot wording.");
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs
index 15a23f999..12e403cb8 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs
@@ -12,6 +12,12 @@ internal interface IHotReloadWarmUpItem
/// Name as it appears in the hot_reload_warm_up_complete entry (snake_case).
string Name { get; }
+ ///
+ /// True for an item that looks assemblies up in the compilation assembly list. Such an item
+ /// is skipped when no list was kept, since the lookup would ask Unity on the main thread.
+ ///
+ bool NeedsCompilationList { get; }
+
///
/// Entered on the main thread. Throws OperationCanceledException before the next unit of work
/// (one dll, one process start) once ct is cancelled, so an item that stopped early is never
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