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 readSourceContentHashOrNull, - Action ensureSnapshotCaptured) + Action ensureSnapshotCaptured, + Func isCaptureInFlight) { Debug.Assert(ensureSnapshotCaptured != null, "ensureSnapshotCaptured must not be null."); + Debug.Assert(isCaptureInFlight != null, "isCaptureInFlight must not be null."); _domain = domain; _readSourceContentHashOrNull = readSourceContentHashOrNull; _ensureSnapshotCaptured = ensureSnapshotCaptured; + _isCaptureInFlight = isCaptureInFlight; } public MethodBase GetActiveShimForMethod(MethodBase method) @@ -64,6 +69,21 @@ public string GetVerifiedSnapshotSource(string projectRelativeFile, string dllPa return null; } + // Why the live file is tried while a capture runs: a pause point re-armed on the first + // update tick after a reload would otherwise wait for the whole capture. A live file the + // PDB confirms holds the bytes the compiler read, which is what the snapshot will hold. + // Why only while a capture runs: otherwise the snapshot is ready or about to be captured + // here, and reading it keeps the answer unchanged from before the capture moved off the + // main thread. + if (_isCaptureInFlight()) + { + string liveSource = HotReloadSourceBaseline.LoadVerifiedLiveSourceOrNull(projectRelativeFile, dllPath); + if (liveSource != null) + { + return liveSource; + } + } + // Why: with both tools disabled at domain load nothing was captured, and a pause point // is the first reader once the pause-point tool is enabled. _ensureSnapshotCaptured(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs new file mode 100644 index 000000000..ec31afb47 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One compiled assembly as the snapshot capture sees it: its name, the asset paths of its + /// sources, and whether hot reload treats it as a read-only package's, which the capture skips. + /// + internal sealed class HotReloadCaptureAssembly + { + internal HotReloadCaptureAssembly( + string name, + IReadOnlyList sourceAssetPaths, + bool isImmutablePackageAssembly, + bool hasEverySource, + bool sourcesFromCompilerOutput) + { + Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty."); + Debug.Assert(sourceAssetPaths != null, "sourceAssetPaths must not be null."); + + Name = name; + SourceAssetPaths = sourceAssetPaths; + IsImmutablePackageAssembly = isImmutablePackageAssembly; + HasEverySource = hasEverySource; + SourcesFromCompilerOutput = sourcesFromCompilerOutput; + } + + internal string Name { get; } + + internal IReadOnlyList SourceAssetPaths { get; } + + internal bool IsImmutablePackageAssembly { get; } + + /// + /// True when the sources are every source the compiler was given, one each: the PDB they were + /// read from has as many documents as the source count the compiler recorded. False when the + /// PDB records no count. + /// + internal bool HasEverySource { get; } + + /// + /// True when the sources were read from the compiler's own PDB under Library/Bee instead of + /// the PDB beside the dll. + /// + internal bool SourcesFromCompilerOutput { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs.meta new file mode 100644 index 000000000..13d7c95b6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f8521cce78a084bdab7ef458bca19dd3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs new file mode 100644 index 000000000..8796c2255 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// A compiled assembly found on disk with a PDB beside it, before its sources are read. + /// + internal sealed class HotReloadCaptureAssemblyCandidate + { + internal HotReloadCaptureAssemblyCandidate(string name, string dllPath, string pdbPath) + { + Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + + Name = name; + DllPath = dllPath; + PdbPath = pdbPath; + } + + internal string Name { get; } + + internal string DllPath { get; } + + internal string PdbPath { get; } + } + + /// + /// Lists the project's compiled assemblies from the dlls and PDBs on disk, and reads an + /// assembly's sources from its PDB, instead of asking Unity's compilation pipeline, which only + /// answers on the main thread. Asks Unity nothing, so it can run on any thread. + /// + internal static class HotReloadCaptureAssemblyEnumerator + { + private const string DllSearchPattern = "*.dll"; + + /// + /// Returns every compiled dll that has a PDB beside it, in ordinal order of the file name. + /// A dll without a PDB is left out, because no snapshot can be checked against it. Returns + /// an empty list when the compiled assemblies directory does not exist. + /// + internal static IReadOnlyList ListCompiled(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + string directory = layout.CompiledAssembliesDirectory; + if (!Directory.Exists(directory)) + { + return Array.Empty(); + } + + string[] dllPaths = Directory.GetFiles(directory, DllSearchPattern); + Array.Sort(dllPaths, StringComparer.Ordinal); + List candidates = new List(dllPaths.Length); + foreach (string dllPath in dllPaths) + { + string name = Path.GetFileNameWithoutExtension(dllPath); + string pdbPath = layout.PdbPath(name); + if (!File.Exists(pdbPath)) + { + continue; + } + + candidates.Add(new HotReloadCaptureAssemblyCandidate(name, dllPath, pdbPath)); + } + + return candidates; + } + + /// + /// Reads the sources of one compiled assembly from its PDB's document table, named by their + /// asset paths, and whether it is a read-only package's. When the PDB beside the dll has + /// fewer documents than the sources the compiler recorded, as after a tool rewrote it, the + /// documents are read from the compiler's own PDB under Library/Bee if one of the same build + /// lists every source. The result tells whether it has every source and whether it came from + /// the compiler's PDB. Throws InvalidOperationException when the PDB beside the dll belongs to + /// another build of it, as while a compile replaces them, or its compilation options record + /// cannot be read. + /// + internal static HotReloadCaptureAssembly ReadSources( + HotReloadCaptureAssemblyCandidate candidate, + HotReloadSnapshotCaptureInputs inputs) + { + Debug.Assert(candidate != null, "candidate must not be null."); + Debug.Assert(inputs != null, "inputs must not be null."); + + List documents = HotReloadPdbDocumentTable.Read( + candidate.DllPath, candidate.PdbPath, out int? sourceFileCount); + bool hasEverySource = sourceFileCount == documents.Count; + bool fromCompilerOutput = false; + // Why the compiler's own output: a tool that rewrites the PDB drops the documents no + // sequence point refers to but keeps the count, and the readers that compare a source + // with its snapshot byte for byte take a file without a snapshot as unchanged, so a + // file without a method body needs a snapshot too. A PDB without the count, or with more + // documents than the count (#line), does not come here and keeps what it lists. + if (sourceFileCount.HasValue && documents.Count < sourceFileCount.Value) + { + List compiled = HotReloadCompilerOutputDocuments.TryReadEveryDocument(candidate.DllPath); + if (compiled != null) + { + documents = compiled; + hasEverySource = true; + fromCompilerOutput = true; + } + } + + // Why the main project's root: a Virtual Player reads the main project's assemblies, + // whose PDB names sources from the main project's compile. + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + CompiledAssemblyLayout.Resolve(inputs.ProjectRoot).MainProjectRoot, + inputs.ProjectRoot, + inputs.PackageRoots, + inputs.PathComparison); + return new HotReloadCaptureAssembly( + candidate.Name, + sources.AssetPaths, + sources.IsImmutablePackageAssembly, + hasEverySource, + fromCompilerOutput); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta new file mode 100644 index 000000000..fa815d3e7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 863665f156a0043eaba0b28d6335329f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index 4d0bd6bc7..b429246ca 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -1,29 +1,65 @@ using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using UnityEditor; using UnityEditor.Compilation; +using UnityEngine; + using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// The compilation assembly list for hot reload, asked of Unity once per domain. + /// The compilation assembly list for hot reload, kept for the rest of the domain. /// Why: on a project with several hundred assemblies one CompilationPipeline.GetAssemblies() call /// takes 340-620 ms, while the list only changes through a compile, or through an import that /// changes the script set without compiling (see HotReloadCompilationAssemblyListPostprocessor). - /// The startup snapshot capture is the first caller after a domain reload, so runs find the list - /// already kept. + /// After a domain reload the list is normally rebuilt on a pool thread from the list a previous + /// domain saved (RefillInBackground). Unity is asked only when that rebuild kept nothing or the + /// list was dropped in the domain, by the first caller that then needs it; later callers in the + /// domain find it kept. + /// A list rebuilt at startup from the saved list holds only the fields hot reload reads: name, + /// outputPath, sourceFiles, defines, assemblyReferences and compiledAssemblyReferences. + /// compilerOptions, flags and rootNamespace keep their defaults, so code that needs them reads + /// CompilationPipeline.GetAssemblies instead. /// internal static class HotReloadCompilationAssemblies { + private static readonly HotReloadCompilationAssemblyListStore Store = + new HotReloadCompilationAssemblyListStore( + Path.Combine(ProjectRoot(), HotReloadConstants.CompilationAssemblyListRelativePath), + () => EditorUtility.scriptCompilationFailed, + () => Cache.Generation, + work => Task.Run(work)); + private static readonly HotReloadCompilationAssemblyCache Cache = - new HotReloadCompilationAssemblyCache(CompilationPipeline.GetAssemblies); + new HotReloadCompilationAssemblyCache(FetchAndSaveIfClean); + + private static readonly HotReloadCompilationListRefill Refill = + new HotReloadCompilationListRefill( + Store, + Cache, + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + () => EditorUtility.scriptCompilationFailed, + rebuild => Task.Run(rebuild)); static HotReloadCompilationAssemblies() { // Why: a compile that fails keeps the domain alive, so without this the list would keep // the asmdefs and source files from before that compile until the next reload. CompilationPipeline.compilationStarted += OnCompilationStarted; + AssemblyReloadEvents.beforeAssemblyReload += CancelRefill; + } + + /// + /// Does nothing but make sure the static constructor has run, so the subscription to + /// compilationStarted is made on the main thread when the domain loads rather than by + /// whichever caller first asks for the list. + /// + internal static void EnsureInitialized() + { } /// @@ -42,11 +78,55 @@ internal static UnityCompilationAssembly FindByName(string assemblyName) return Cache.FindByName(assemblyName); } + /// + /// Starts rebuilding the list from the one a previous domain saved and the compiled files, + /// off the main thread, and keeps it unless a caller asked Unity first or the script set + /// changed meanwhile. Completes with whether the list was kept; never faults. Main thread only. + /// + internal static Task RefillInBackground() + { + return Refill.RunAsync(); + } + + /// + /// Whether a list is kept now, whether it was rebuilt at startup or asked of Unity by an + /// earlier caller, so a caller that must not wait for Unity can skip instead. Main thread only. + /// + internal static bool IsKept() + { + return Cache.IsFilled; + } + + // Main thread: the cache asks for the list from Current(). + private static UnityCompilationAssembly[] FetchAndSaveIfClean() + { + UnityCompilationAssembly[] fetched = CompilationPipeline.GetAssemblies(); + if (fetched.Length > 0) + { + Store.SaveInBackgroundIfClean(fetched, Cache.Generation, ProjectRoot()); + } + + return fetched; + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + private static void OnCompilationStarted(object context) { + CancelRefill(); Cache.Invalidate(); } + // Why the rebuild stops here: a compile rewrites the dlls and PDBs it reads, and a reload + // ends the domain whose cache it would fill. + private static void CancelRefill() + { + Refill.Cancel(); + } + /// /// Drops the kept list because an import changed the script set without a domain reload. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs index 5eac8cd97..57d7bd99b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Threading; + +using io.github.hatayama.UnityCliLoop.ToolContracts; using UnityEngine; @@ -9,12 +12,13 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// /// Keeps the last non-empty compilation assembly list so repeated lookups ask Unity only once - /// until the owner invalidates it. Main thread only, like the Unity call it wraps. + /// until the owner invalidates it. Main thread only, like the Unity call it wraps, except Generation. /// internal sealed class HotReloadCompilationAssemblyCache { private readonly Func _fetch; private UnityCompilationAssembly[] _assemblies; + private int _generation; internal HotReloadCompilationAssemblyCache(Func fetch) { @@ -24,6 +28,13 @@ internal HotReloadCompilationAssemblyCache(Func fetc internal bool IsFilled => _assemblies != null; + /// + /// Counts the invalidations of this domain, so a list built off the main thread can tell + /// whether the script set changed while it was being built. The only member readable off the + /// main thread. + /// + internal int Generation => Volatile.Read(ref _generation); + /// /// Returns the memoized list, asking Unity when nothing is kept. The list is shared; callers only read it. /// @@ -34,8 +45,14 @@ internal IReadOnlyList Current() return _assemblies; } + System.Diagnostics.Stopwatch fetchWatch = System.Diagnostics.Stopwatch.StartNew(); UnityCompilationAssembly[] fetched = _fetch(); + fetchWatch.Stop(); Debug.Assert(fetched != null, "fetch must not return null."); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationAssembliesFetched, + "Hot reload asked Unity for the compilation assemblies.", + new { fetchMs = fetchWatch.ElapsedMilliseconds, assemblyCount = fetched.Length }); // Why not memoize an empty result: GetAssemblies() answers nothing while a compile is in // flight, and keeping that would hide every assembly for the rest of the domain's life. @@ -66,9 +83,28 @@ internal UnityCompilationAssembly FindByName(string assemblyName) return null; } + /// + /// Keeps a list built without asking Unity, when nothing is kept yet and no invalidation + /// happened since the builder read the generation. Returns whether the list was kept. + /// + internal bool TryFill(UnityCompilationAssembly[] assemblies, int expectedGeneration) + { + Debug.Assert(assemblies != null && assemblies.Length > 0, "assemblies must not be null or empty."); + + // Why a kept list wins: a list Unity answered is the truth the rebuilt one only approximates. + if (_assemblies != null || Generation != expectedGeneration) + { + return false; + } + + _assemblies = assemblies; + return true; + } + internal void Invalidate() { _assemblies = null; + Interlocked.Increment(ref _generation); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs new file mode 100644 index 000000000..371645512 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs @@ -0,0 +1,184 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json; + +using Debug = UnityEngine.Debug; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Saves the compilation assembly list of a clean domain so the next domain can rebuild it + /// without asking Unity on the main thread, and loads it back. + /// + internal sealed class HotReloadCompilationAssemblyListStore + { + internal const int FormatVersion = 1; + private const string TempFileExtension = ".tmp"; + + private readonly string _filePath; + private readonly Func _isCompilationFailed; + private readonly Func _currentGeneration; + private readonly Func _runInBackground; + + internal HotReloadCompilationAssemblyListStore( + string filePath, + Func isCompilationFailed, + Func currentGeneration, + Func runInBackground) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty."); + Debug.Assert(isCompilationFailed != null, "isCompilationFailed must not be null."); + Debug.Assert(currentGeneration != null, "currentGeneration must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + _filePath = filePath; + _isCompilationFailed = isCompilationFailed; + _currentGeneration = currentGeneration; + _runInBackground = runInBackground; + } + + /// + /// Saves the list in the background when it describes the dlls on disk: no invalidation has + /// happened in this domain (generation 0) and the last compile did not fail. Otherwise the + /// list can name sources that were never compiled, and a later domain whose dlls did not + /// change would trust it. Main thread only, because it reads the compile state. + /// + internal void SaveInBackgroundIfClean(UnityCompilationAssembly[] fetched, int generation, string projectRoot) + { + Debug.Assert(fetched != null && fetched.Length > 0, "fetched must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + if (generation != 0) + { + LogSaveSkipped("generation"); + return; + } + + if (_isCompilationFailed()) + { + LogSaveSkipped("compilation_failed"); + return; + } + + // Why only logged: the saved list is an optimization, and without it the next domain + // asks Unity as it always did. + _runInBackground(() => Write(fetched, projectRoot, generation)) + .ContinueWith( + LogSaveFailure, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + /// + /// Loads the saved list. When it cannot be used, the result has no list and names why: no + /// file, or a file that is torn, of another format version, or missing a required field. + /// Touches only the file, so it can run on a pool thread. + /// + internal HotReloadSavedCompilationListLoad Load() + { + if (!File.Exists(_filePath)) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonNoSavedList); + } + + string json = File.ReadAllText(_filePath, Encoding.UTF8); + HotReloadSavedCompilationList saved; + // Why caught: the file is outside input that a crash can tear or a person can edit, and an + // unreadable file only sends the next domain back to Unity's list. Only JSON errors are + // caught; an IO error goes to the caller's boundary. + try + { + saved = JsonConvert.DeserializeObject(json); + } + catch (JsonException) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonSavedListUnreadable); + } + + // Why the fields are checked here: a missing one would otherwise surface later as a + // NullReferenceException that names no cause. + if (saved == null || saved.formatVersion != FormatVersion || !saved.IsComplete()) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonSavedListUnreadable); + } + + return HotReloadSavedCompilationListLoad.Loaded(saved); + } + + private void Write(UnityCompilationAssembly[] fetched, string projectRoot, int generation) + { + Stopwatch watch = Stopwatch.StartNew(); + HotReloadSavedCompilationList saved = HotReloadSavedCompilationList.Capture(fetched, projectRoot, FormatVersion); + string json = JsonConvert.SerializeObject(saved); + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)); + + // Why a fixed temp name: a domain has one writer, and the previous domain's writer + // stopped when that domain was unloaded. + string tempPath = _filePath + TempFileExtension; + File.WriteAllText(tempPath, json, new UTF8Encoding(false)); + + // Why the generation is read again here: a compile that started after the hand-off may + // have written new dlls whose stamps Capture read, which would pair the old list with + // them. Unity writes dlls only after compilationStarted invalidates the cache, so an + // unchanged generation means every stamp read above belongs to this domain's dlls. + if (_currentGeneration() != generation) + { + File.Delete(tempPath); + LogSaveSkipped("superseded"); + return; + } + + long bytes = new FileInfo(tempPath).Length; + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + + File.Move(tempPath, _filePath); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListSaved, + "Hot reload saved the compilation assembly list for the next domain.", + new + { + bytes, + saveMs = watch.ElapsedMilliseconds, + assemblies = saved.assemblies.Count, + references = saved.references.Count, + }); + } + + private static void LogSaveSkipped(string reason) + { + // Why logged: domains that never save would silently keep the next startup on Unity's list. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListSaveSkipped, + "Hot reload did not save the compilation assembly list of this domain.", + new { reason }); + } + + private static void LogSaveFailure(Task write) + { + if (!write.IsFaulted) + { + return; + } + + // Why read before the call: VibeLogger.LogWarning is compiled out without ULOOP_DEBUG, + // and reading Exception is what marks the fault as observed. + Exception error = write.Exception.GetBaseException(); + VibeLogger.LogWarning( + HotReloadConstants.VibeLogCompilationListSaveFailed, + "Hot reload could not save the compilation assembly list.", + new { error = error.GetType().Name }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta new file mode 100644 index 000000000..e4b4dc5be --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8092f6887a9a4282ba931bbf48543c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs new file mode 100644 index 000000000..6aef02575 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One assembly of a rebuilt compilation assembly list, before the Unity objects are made. + /// Project references are named, precompiled ones are paths as Unity lists them. + /// + internal sealed class HotReloadRebuiltAssemblyEntry + { + internal HotReloadRebuiltAssemblyEntry( + string name, + string outputPath, + string[] sourceFiles, + string[] defines, + List projectReferences, + List compiledReferences) + { + Name = name; + OutputPath = outputPath; + SourceFiles = sourceFiles; + Defines = defines; + ProjectReferences = projectReferences; + CompiledReferences = compiledReferences; + } + + internal string Name { get; } + + internal string OutputPath { get; } + + internal string[] SourceFiles { get; } + + internal string[] Defines { get; } + + internal List ProjectReferences { get; } + + internal List CompiledReferences { get; } + } + + /// + /// Makes the entries of a rebuilt list from one saved list: as saved for an assembly whose dll + /// did not change, or from the PDB of one that was compiled again. A PDB-made entry keeps the + /// saved order of every element it still has and appends new ones in the PDB's order, because + /// the PDB lists them in the compiler's order, which is not Unity's. Touches only files, so it + /// can run on a pool thread. + /// + internal sealed class HotReloadCompilationAssemblyRebuilder + { + private const string ReferenceAssemblySuffix = ".ref.dll"; + private const string AssemblySuffix = ".dll"; + + private readonly HotReloadSavedCompilationList _saved; + private readonly HotReloadSnapshotCaptureInputs _inputs; + private readonly HashSet _savedNames = new HashSet(StringComparer.Ordinal); + private readonly HotReloadSavedReferenceResolver _resolver; + private readonly StringComparer _pathComparer; + + internal HotReloadCompilationAssemblyRebuilder(HotReloadSavedCompilationList saved, HotReloadSnapshotCaptureInputs inputs) + { + Debug.Assert(saved != null && saved.IsComplete(), "saved must be a complete list."); + Debug.Assert(inputs != null, "inputs must not be null."); + + _saved = saved; + _inputs = inputs; + foreach (HotReloadSavedCompilationAssembly assembly in saved.assemblies) + { + _savedNames.Add(assembly.name); + } + + _pathComparer = inputs.PathComparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + _resolver = new HotReloadSavedReferenceResolver(saved.references, inputs.ProjectRoot, _pathComparer); + } + + internal HotReloadRebuiltAssemblyEntry FromSaved(HotReloadSavedCompilationAssembly assembly) + { + List compiledReferences = new List(assembly.compiledAssemblyReferenceIndexes.Length); + foreach (int index in assembly.compiledAssemblyReferenceIndexes) + { + compiledReferences.Add(_saved.references[index].path); + } + + return new HotReloadRebuiltAssemblyEntry( + assembly.name, + assembly.outputPath, + assembly.sourceFiles, + assembly.defines, + new List(assembly.assemblyReferences), + compiledReferences); + } + + /// + /// Makes the entry of a compiled-again assembly from its PDB. Returns null and the entry, or + /// the reason no entry can be trusted: a PDB of another build, sources that cannot be shown + /// to be every source the compiler was given, or a reference that does not resolve to a file + /// of the recorded MVID. A PDB that a rewrite left without some documents still gives an + /// entry when the compiler's own PDB of the same build under Library/Bee lists every source. + /// + internal string TryFromPdb( + HotReloadSavedCompilationAssembly assembly, + HotReloadCaptureAssemblyCandidate candidate, + out HotReloadRebuiltAssemblyEntry entry) + { + Debug.Assert(assembly != null, "assembly must not be null."); + Debug.Assert(candidate != null, "candidate must not be null."); + + entry = null; + HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(candidate.PdbPath); + // Why checked before anything is read from it: a compile that is replacing the files can + // leave a PDB of another build beside the dll. + if (info.PdbGuid != HotReloadPdbDocumentIndex.ReadCodeViewGuid(candidate.DllPath)) + { + return HotReloadConstants.CompilationListReasonPdbMismatch; + } + + // Why the capture's reader decides: it alone judges whether the sources are every + // source the compiler was given, reading the compiler's own PDB when a rewrite after the + // compile (an IL post-processor does this) dropped the documents no sequence point refers + // to. A PDB without the compiler's count cannot show it is whole, and one with extra + // #line documents is refused as well. Read before the references so this reason still + // comes before theirs. + HotReloadCaptureAssembly compiled = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, _inputs); + if (!compiled.HasEverySource) + { + return HotReloadConstants.CompilationListReasonPdbDocumentsIncomplete; + } + + List projectReferences = new List(); + Dictionary compiledPathByFileName = new Dictionary(_pathComparer); + foreach (HotReloadPdbReferencedAssembly reference in info.References) + { + string referencedName = AssemblyNameOf(reference.FileName); + if (_savedNames.Contains(referencedName)) + { + projectReferences.Add(referencedName); + continue; + } + + string failure = _resolver.TryResolve(reference, out string path); + if (failure != null) + { + return failure; + } + + compiledPathByFileName[reference.FileName] = path; + } + + entry = new HotReloadRebuiltAssemblyEntry( + assembly.name, + assembly.outputPath, + InSavedOrder(assembly.sourceFiles, compiled.SourceAssetPaths, _pathComparer).ToArray(), + ToArray(info.Defines), + InSavedOrder(assembly.assemblyReferences, projectReferences, StringComparer.Ordinal), + CompiledReferencesInSavedOrder(assembly, compiledPathByFileName)); + return null; + } + + // Unity lists a precompiled reference once per path, sometimes the same path twice and + // sometimes two paths of one file name, while the PDB records one file name once. So every + // saved path whose file name the PDB still records is kept as saved. + private List CompiledReferencesInSavedOrder( + HotReloadSavedCompilationAssembly assembly, + Dictionary compiledPathByFileName) + { + List ordered = new List(compiledPathByFileName.Count); + HashSet savedFileNames = new HashSet(_pathComparer); + foreach (int index in assembly.compiledAssemblyReferenceIndexes) + { + string path = _saved.references[index].path; + string fileName = Path.GetFileName(path); + savedFileNames.Add(fileName); + if (compiledPathByFileName.ContainsKey(fileName)) + { + ordered.Add(path); + } + } + + foreach (KeyValuePair pair in compiledPathByFileName) + { + if (!savedFileNames.Contains(pair.Key)) + { + ordered.Add(pair.Value); + } + } + + return ordered; + } + + private static List InSavedOrder(string[] saved, IReadOnlyList current, StringComparer comparer) + { + HashSet currentSet = new HashSet(current, comparer); + HashSet savedSet = new HashSet(saved, comparer); + List ordered = new List(current.Count); + foreach (string element in saved) + { + if (currentSet.Contains(element)) + { + ordered.Add(element); + } + } + + foreach (string element in current) + { + if (!savedSet.Contains(element)) + { + ordered.Add(element); + } + } + + return ordered; + } + + private static string[] ToArray(IReadOnlyList values) + { + string[] array = new string[values.Count]; + for (int index = 0; index < array.Length; index++) + { + array[index] = values[index]; + } + + return array; + } + + // A project assembly is referenced through its reference assembly (X.ref.dll), a + // precompiled one through its dll. + private static string AssemblyNameOf(string referenceFileName) + { + if (referenceFileName.EndsWith(ReferenceAssemblySuffix, StringComparison.OrdinalIgnoreCase)) + { + return referenceFileName.Substring(0, referenceFileName.Length - ReferenceAssemblySuffix.Length); + } + + if (referenceFileName.EndsWith(AssemblySuffix, StringComparison.OrdinalIgnoreCase)) + { + return referenceFileName.Substring(0, referenceFileName.Length - AssemblySuffix.Length); + } + + return referenceFileName; + } + } + + /// + /// Finds the saved reference path for a precompiled reference a PDB names by file name and + /// MVID, reading each candidate file's MVID at most once. + /// + internal sealed class HotReloadSavedReferenceResolver + { + private readonly List _references; + private readonly string _projectRoot; + private readonly Dictionary> _indexesByFileName; + private readonly Dictionary _currentMvidByIndex = new Dictionary(); + + internal HotReloadSavedReferenceResolver(List references, string projectRoot, StringComparer pathComparer) + { + _references = references; + _projectRoot = projectRoot; + _indexesByFileName = new Dictionary>(pathComparer); + for (int index = 0; index < references.Count; index++) + { + string fileName = Path.GetFileName(references[index].path); + if (!_indexesByFileName.TryGetValue(fileName, out List indexes)) + { + indexes = new List(); + _indexesByFileName.Add(fileName, indexes); + } + + indexes.Add(index); + } + } + + /// + /// Returns null and the saved path when a saved path with this file name holds a dll of + /// this MVID now; otherwise the reason there is none. + /// + internal string TryResolve(HotReloadPdbReferencedAssembly reference, out string path) + { + path = null; + if (!_indexesByFileName.TryGetValue(reference.FileName, out List indexes)) + { + return HotReloadConstants.CompilationListReasonUnresolvedReference; + } + + bool anyExists = false; + foreach (int index in indexes) + { + Guid? mvid = CurrentMvid(index); + if (mvid == null) + { + continue; + } + + anyExists = true; + if (mvid.Value == reference.Mvid) + { + path = _references[index].path; + return null; + } + } + + return anyExists + ? HotReloadConstants.CompilationListReasonReferenceChanged + : HotReloadConstants.CompilationListReasonReferenceMissing; + } + + // Null when the file is gone. Why the saved MVID is trusted for an unchanged stamp: + // reading every reference's metadata again would cost what the saved list saves. + private Guid? CurrentMvid(int index) + { + if (_currentMvidByIndex.TryGetValue(index, out Guid? known)) + { + return known; + } + + HotReloadSavedReference saved = _references[index]; + string fullPath = Path.GetFullPath(Path.Combine(_projectRoot, saved.path)); + HotReloadSavedFileStamp stamp = HotReloadSavedFileStamp.ReadOrNull(fullPath); + Guid? mvid = null; + if (stamp != null) + { + mvid = stamp.SameAs(saved.stamp) + ? Guid.ParseExact(saved.mvid, "N") + : HotReloadAssemblyMetadataMvid.Read(fullPath); + } + + _currentMvidByIndex.Add(index, mvid); + return mvid; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs.meta new file mode 100644 index 000000000..32dd7cd14 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ff89cc42bd34c4f27b055a72d87910de +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs new file mode 100644 index 000000000..0f18826d9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +using UnityEditor.Compilation; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The outcome of rebuilding the compilation assembly list: the whole list, or why there is none. + /// + internal readonly struct HotReloadCompilationListReconstruction + { + public readonly UnityCompilationAssembly[] Assemblies; + public readonly string FailureReason; + + private HotReloadCompilationListReconstruction(UnityCompilationAssembly[] assemblies, string failureReason) + { + Assemblies = assemblies; + FailureReason = failureReason; + } + + internal static HotReloadCompilationListReconstruction Rebuilt(UnityCompilationAssembly[] assemblies) + { + Debug.Assert(assemblies != null && assemblies.Length > 0, "assemblies must not be null or empty."); + return new HotReloadCompilationListReconstruction(assemblies, null); + } + + internal static HotReloadCompilationListReconstruction Failed(string failureReason) + { + Debug.Assert(!string.IsNullOrEmpty(failureReason), "failureReason must not be null or empty."); + return new HotReloadCompilationListReconstruction(null, failureReason); + } + } + + /// + /// Rebuilds the compilation assembly list of this domain from the list a previous clean domain + /// saved and from the compiled dlls and PDBs, without asking Unity, so it can run on a pool + /// thread. An assembly whose dll did not change keeps its saved entry; one that was compiled + /// again takes its sources, defines and references from its PDB. Returns no list at all when + /// any part cannot be shown to match the files on disk, because a partial list would mislead + /// the default file choice and the patch target lookup. + /// + internal static class HotReloadCompilationAssemblyReconstruction + { + internal static HotReloadCompilationListReconstruction Reconstruct( + HotReloadCompilationAssemblyListStore store, + HotReloadSnapshotCaptureInputs inputs, + CancellationToken ct) + { + Debug.Assert(store != null, "store must not be null."); + Debug.Assert(inputs != null, "inputs must not be null."); + + HotReloadSavedCompilationListLoad load = store.Load(); + if (load.List == null) + { + return HotReloadCompilationListReconstruction.Failed(load.FailureReason); + } + + HotReloadSavedCompilationList saved = load.List; + Dictionary compiled = CompiledByName(inputs.ProjectRoot); + if (!HasTheSavedAssemblySet(saved, compiled)) + { + return HotReloadCompilationListReconstruction.Failed(HotReloadConstants.CompilationListReasonAssemblySetChanged); + } + + HotReloadCompilationAssemblyRebuilder rebuilder = new HotReloadCompilationAssemblyRebuilder(saved, inputs); + List entries = new List(saved.assemblies.Count); + foreach (HotReloadSavedCompilationAssembly assembly in saved.assemblies) + { + ct.ThrowIfCancellationRequested(); + + // Why an unchanged dll keeps its entry: the compile is deterministic, so the same dll + // came from the same sources, defines and references. + if (!compiled.TryGetValue(assembly.name, out HotReloadCaptureAssemblyCandidate candidate) + || assembly.dllStamp.SameAs(HotReloadSavedFileStamp.ReadOrNull(candidate.DllPath))) + { + entries.Add(rebuilder.FromSaved(assembly)); + continue; + } + + string failure = rebuilder.TryFromPdb(assembly, candidate, out HotReloadRebuiltAssemblyEntry rebuilt); + if (failure != null) + { + return HotReloadCompilationListReconstruction.Failed(failure); + } + + entries.Add(rebuilt); + } + + return Build(entries); + } + + private static Dictionary CompiledByName(string projectRoot) + { + Dictionary compiled = + new Dictionary(StringComparer.Ordinal); + foreach (HotReloadCaptureAssemblyCandidate candidate in HotReloadCaptureAssemblyEnumerator.ListCompiled(projectRoot)) + { + compiled[candidate.Name] = candidate; + } + + return compiled; + } + + // Why the set is compared before any entry is used: an added or removed asmdef changes the + // list in ways no single dll shows. + private static bool HasTheSavedAssemblySet( + HotReloadSavedCompilationList saved, + Dictionary compiled) + { + int savedWithDll = 0; + foreach (HotReloadSavedCompilationAssembly assembly in saved.assemblies) + { + if (assembly.dllStamp == null) + { + continue; + } + + if (!compiled.ContainsKey(assembly.name)) + { + return false; + } + + savedWithDll++; + } + + return savedWithDll == compiled.Count; + } + + private static HotReloadCompilationListReconstruction Build(List entries) + { + Dictionary entryByName = + new Dictionary(StringComparer.Ordinal); + foreach (HotReloadRebuiltAssemblyEntry entry in entries) + { + entryByName[entry.Name] = entry; + } + + foreach (HotReloadRebuiltAssemblyEntry entry in entries) + { + foreach (string referencedName in entry.ProjectReferences) + { + if (!entryByName.ContainsKey(referencedName)) + { + return HotReloadCompilationListReconstruction.Failed( + HotReloadConstants.CompilationListReasonUnresolvedReference); + } + } + } + + Dictionary built = + new Dictionary(StringComparer.Ordinal); + UnityCompilationAssembly[] assemblies = new UnityCompilationAssembly[entries.Count]; + for (int index = 0; index < entries.Count; index++) + { + assemblies[index] = BuildAssembly(entries[index], entryByName, built, new HashSet(StringComparer.Ordinal)); + } + + return HotReloadCompilationListReconstruction.Rebuilt(assemblies); + } + + // Why the referenced assemblies are made first: Unity takes assemblyReferences only through + // the constructor. + private static UnityCompilationAssembly BuildAssembly( + HotReloadRebuiltAssemblyEntry entry, + Dictionary entryByName, + Dictionary built, + HashSet inProgress) + { + if (built.TryGetValue(entry.Name, out UnityCompilationAssembly existing)) + { + return existing; + } + + if (!inProgress.Add(entry.Name)) + { + throw new InvalidOperationException("The saved compilation assemblies reference each other in a cycle at " + entry.Name + "."); + } + + UnityCompilationAssembly[] references = new UnityCompilationAssembly[entry.ProjectReferences.Count]; + for (int index = 0; index < references.Length; index++) + { + references[index] = BuildAssembly(entryByName[entry.ProjectReferences[index]], entryByName, built, inProgress); + } + + // Hot reload never reads compiler options, flags or the root namespace, and Unity's + // language version cannot be set from outside, so they keep their defaults. + UnityCompilationAssembly assembly = new UnityCompilationAssembly( + entry.Name, + entry.OutputPath, + entry.SourceFiles, + entry.Defines, + references, + entry.CompiledReferences.ToArray(), + AssemblyFlags.None, + new ScriptCompilerOptions(), + string.Empty); + built.Add(entry.Name, assembly); + return assembly; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs.meta new file mode 100644 index 000000000..7cd5f678f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 03da534490aed4c8c980d45a14a38825 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs new file mode 100644 index 000000000..33e657c47 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs @@ -0,0 +1,138 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using Debug = UnityEngine.Debug; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Fills the compilation assembly cache at startup with a list rebuilt off the main thread from + /// the saved list and the compiled files, so the first caller of the domain does not ask Unity + /// on the main thread. A rebuilt list that was kept is saved as the next domain's baseline. + /// + internal sealed class HotReloadCompilationListRefill + { + private readonly HotReloadCompilationAssemblyListStore _store; + private readonly HotReloadCompilationAssemblyCache _cache; + private readonly Func _readInputs; + private readonly Func _isCompilationFailed; + private readonly Func, Task> _runInBackground; + + // Main thread only. Replaced by each run, so cancelling stops only the latest one. + private CancellationTokenSource _cancellation = new CancellationTokenSource(); + + internal HotReloadCompilationListRefill( + HotReloadCompilationAssemblyListStore store, + HotReloadCompilationAssemblyCache cache, + Func readInputs, + Func isCompilationFailed, + Func, Task> runInBackground) + { + Debug.Assert(store != null, "store must not be null."); + Debug.Assert(cache != null, "cache must not be null."); + Debug.Assert(readInputs != null, "readInputs must not be null."); + Debug.Assert(isCompilationFailed != null, "isCompilationFailed must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + _store = store; + _cache = cache; + _readInputs = readInputs; + _isCompilationFailed = isCompilationFailed; + _runInBackground = runInBackground; + } + + /// + /// Rebuilds the list in the background and keeps it when the cache is still empty and no + /// invalidation happened meanwhile. Returns whether the list was kept. Never throws: the + /// cache asks Unity as before when nothing was kept. Cancels an earlier run that is still + /// going. Call on the main thread. + /// + internal Task RunAsync() + { + _cancellation.Cancel(); + _cancellation = new CancellationTokenSource(); + return RunUntilCancelledAsync(_cancellation.Token); + } + + /// + /// Stops the run in progress, if any: the files it reads are about to change or the domain + /// ends. Main thread only. + /// + internal void Cancel() + { + _cancellation.Cancel(); + } + + private async Task RunUntilCancelledAsync(CancellationToken ct) + { + Stopwatch watch = Stopwatch.StartNew(); + long? backgroundMs = null; + long? mainThreadWaitMs = null; + try + { + int generation = _cache.Generation; + HotReloadSnapshotCaptureInputs inputs = _readInputs(); + HotReloadCompilationListReconstruction reconstruction = await _runInBackground(() => + { + // Why measured inside the work: the time it waited for a pool thread is not rebuilding. + Stopwatch backgroundWatch = Stopwatch.StartNew(); + HotReloadCompilationListReconstruction rebuilt = HotReloadCompilationAssemblyReconstruction.Reconstruct(_store, inputs, ct); + backgroundMs = backgroundWatch.ElapsedMilliseconds; + return rebuilt; + }).ConfigureAwait(false); + + // Why the main thread: the cache has no lock and is kept on the main thread only. + Stopwatch waitWatch = Stopwatch.StartNew(); + await MainThreadSwitcher.SwitchToMainThread(ct); + mainThreadWaitMs = waitWatch.ElapsedMilliseconds; + // Why not kept after a failed compile: the dlls are then older than the sources, so + // every stamp still matches the saved list while Unity's list already names the + // added and removed files. + if (reconstruction.Assemblies != null && _isCompilationFailed()) + { + LogReconstructed(false, HotReloadConstants.CompilationListReasonCompilationFailed, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); + return false; + } + + bool filled = reconstruction.Assemblies != null && _cache.TryFill(reconstruction.Assemblies, generation); + if (filled) + { + // Why saved again: a domain whose cache was filled here never asks Unity, so the + // saved list would otherwise age and send more assemblies through their PDBs. + _store.SaveInBackgroundIfClean(reconstruction.Assemblies, generation, inputs.ProjectRoot); + } + + string reason = filled + ? null + : reconstruction.FailureReason ?? HotReloadConstants.CompilationListReasonSuperseded; + LogReconstructed(filled, reason, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); + return filled; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + LogReconstructed(false, HotReloadConstants.CompilationListReasonCancelled, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); + return false; + } + catch (Exception ex) + { + // Approved deviation: this is the boundary of work that started at Editor startup, + // and a list that cannot be rebuilt only leaves the cache to ask Unity as before. + string reason = ex.GetType().Name; + LogReconstructed(false, reason, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); + return false; + } + } + + // backgroundMs and mainThreadWaitMs stay null when the run did not get through that step. + private static void LogReconstructed(bool filled, string reason, long reconstructMs, long? backgroundMs, long? mainThreadWaitMs) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListReconstructed, + "Hot reload rebuilt the compilation assembly list at startup.", + new { filled, reason, reconstructMs, backgroundMs, mainThreadWaitMs }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs.meta new file mode 100644 index 000000000..2c95e62c6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a21f131436484bee97e2395eeeacc32 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs index 19977800f..44f68a964 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs @@ -97,8 +97,8 @@ internal static HotReloadCompiledSources FromDocuments( pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase, "pathComparison must be Ordinal or OrdinalIgnoreCase."); - string compilingRoot = NormalizeRoot(compilingProjectRoot); - string ownRoot = NormalizeRoot(projectRoot); + string compilingRoot = HotReloadPackageFolderMatch.NormalizeFolder(compilingProjectRoot); + string ownRoot = HotReloadPackageFolderMatch.NormalizeFolder(projectRoot); List packages = NormalizePackages(packageRoots, compilingRoot, ownRoot, pathComparison); List assetPaths = new List(); HashSet seen = new HashSet( @@ -177,8 +177,8 @@ private static bool TryMapProjectRelative( foreach (NormalizedPackageRoot package in packages) { int length = Math.Max( - PrefixLength(relativePath, package.RelativeToCompilingRoot, comparison), - PrefixLength(relativePath, package.RelativeToOwnRoot, comparison)); + HotReloadPackageFolderMatch.PrefixLength(relativePath, package.RelativeToCompilingRoot, comparison), + HotReloadPackageFolderMatch.PrefixLength(relativePath, package.RelativeToOwnRoot, comparison)); if (length > bestLength) { bestLength = length; @@ -196,7 +196,7 @@ private static bool TryMapAbsolute( StringComparison comparison, out MappedSource mapped) { - int rootLength = PrefixLength(absolutePath, compilingRoot, comparison); + int rootLength = HotReloadPackageFolderMatch.PrefixLength(absolutePath, compilingRoot, comparison); if (rootLength >= 0) { return TryMapProjectRelative(absolutePath.Substring(rootLength + 1), packages, comparison, out mapped); @@ -206,7 +206,7 @@ private static bool TryMapAbsolute( NormalizedPackageRoot best = default(NormalizedPackageRoot); foreach (NormalizedPackageRoot package in packages) { - int length = PrefixLength(absolutePath, package.ResolvedPath, comparison); + int length = HotReloadPackageFolderMatch.PrefixLength(absolutePath, package.ResolvedPath, comparison); if (length > bestLength) { bestLength = length; @@ -234,23 +234,6 @@ private static bool TryMapUnderPackage( return true; } - // The length of folder when path is inside it, so that the character after it is "/"; -1 - // otherwise. Why the "/": "/a/LocalPkg2/x.cs" must not count as inside "/a/LocalPkg". - private static int PrefixLength(string path, string folder, StringComparison comparison) - { - if (folder == null || path.Length <= folder.Length + 1) - { - return -1; - } - - if (path[folder.Length] != '/' || !path.StartsWith(folder, comparison)) - { - return -1; - } - - return folder.Length; - } - // Why not Path.IsPathRooted: on macOS and Linux it does not take "C:/proj/..." for rooted, // so a PDB written on Windows would lose its sources there. private static bool IsRooted(string url) @@ -277,12 +260,12 @@ private static List NormalizePackages( List packages = new List(packageRoots.Count); foreach (HotReloadSnapshotPackageRoot root in packageRoots) { - string resolvedPath = NormalizeRoot(root.ResolvedPath); + string resolvedPath = HotReloadPackageFolderMatch.NormalizeFolder(root.ResolvedPath); packages.Add(new NormalizedPackageRoot( resolvedPath, RelativeTo(resolvedPath, compilingRoot, comparison), RelativeTo(resolvedPath, ownRoot, comparison), - NormalizeRoot(root.AssetPath), + HotReloadPackageFolderMatch.NormalizeFolder(root.AssetPath), root.IsImmutable)); } @@ -292,13 +275,8 @@ private static List NormalizePackages( // The rest of path after root and "/", or null when path is not inside root. private static string RelativeTo(string path, string root, StringComparison comparison) { - int length = PrefixLength(path, root, comparison); + int length = HotReloadPackageFolderMatch.PrefixLength(path, root, comparison); return length < 0 ? null : path.Substring(length + 1); } - - private static string NormalizeRoot(string path) - { - return path.Replace('\\', '/').TrimEnd('/'); - } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs new file mode 100644 index 000000000..216a2ca07 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reads the documents of every source an assembly was compiled from out of the compiler's own + /// dll and PDB, which Unity's build leaves under Library/Bee/artifacts/<dag>/, for when a tool + /// that rewrote the PDB beside the dll dropped documents. Does not call Unity, so it runs on a + /// pool thread. + /// + internal static class HotReloadCompilerOutputDocuments + { + private const string BeeDirectoryName = "Bee"; + private const string ArtifactsDirectoryName = "artifacts"; + private const string DagSearchPattern = "*.dag"; + private const string PdbExtension = ".pdb"; + + /// + /// Returns, in table order, the documents of the compiler's PDB for the dll under + /// Library/ScriptAssemblies, when a compiler dll with the same MVID exists, its PDB belongs to + /// it, and that PDB has one document per source the compiler recorded. Returns null otherwise. + /// IO failures are thrown as they are. + /// + internal static List TryReadEveryDocument(string dllPath) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + + // Why beside the dll and not under the project root: a Virtual Player reads the main + // project's Library/ScriptAssemblies, whose build left Bee next to it, and a dll copied + // elsewhere has no compiler output of its own to stand in for its PDB. + string libraryDirectory = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(dllPath))); + string artifactsDirectory = Path.Combine(libraryDirectory, BeeDirectoryName, ArtifactsDirectoryName); + if (!Directory.Exists(artifactsDirectory)) + { + return null; + } + + string fileName = Path.GetFileName(dllPath); + Guid mvid = HotReloadAssemblyMetadataMvid.Read(dllPath); + // Why every dag, matched by MVID: the dag's name changes with the Unity version and the + // code optimization, and a player build leaves dags of its own beside the Editor's. + // Sorted so the order the dags are tried in does not depend on the file system. + string[] dagDirectories = Directory.GetDirectories(artifactsDirectory, DagSearchPattern); + Array.Sort(dagDirectories, StringComparer.Ordinal); + foreach (string dagDirectory in dagDirectories) + { + List documents = TryReadFromDag(dagDirectory, fileName, mvid); + if (documents != null) + { + return documents; + } + } + + return null; + } + + private static List TryReadFromDag(string dagDirectory, string fileName, Guid mvid) + { + string compilerDllPath = Path.Combine(dagDirectory, fileName); + string compilerPdbPath = Path.ChangeExtension(compilerDllPath, PdbExtension); + if (!File.Exists(compilerDllPath) || !File.Exists(compilerPdbPath)) + { + return null; + } + + // Why the MVID: an IL post-processor keeps it while it changes the PDB id, and Unity + // compiles deterministically, so the same MVID means the same compiler inputs. + if (HotReloadAssemblyMetadataMvid.Read(compilerDllPath) != mvid) + { + return null; + } + + List documents = HotReloadPdbDocumentTable.TryRead( + compilerDllPath, compilerPdbPath, out int? sourceFileCount); + if (documents == null) + { + return null; + } + + // Why the count again: a compiler PDB that also lacks documents, or records no count, + // cannot vouch for every source. + return sourceFileCount == documents.Count ? documents : null; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs.meta new file mode 100644 index 000000000..4c3ad8e95 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74d9d6fda54dc4a8b9dbf2e15a082a0d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 7967a25f2..e68dc694c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -49,6 +49,10 @@ internal static class HotReloadConstants // affect the on-disk layout. Adoption is decided at use time by PDB document checksum. public const string SourceSnapshotRelativeDirectory = "Library/UloopHotReload/SourceSnapshot"; + // The compilation assembly list saved by a clean domain, from which the next domain + // rebuilds the list without asking Unity on the main thread. + public const string CompilationAssemblyListRelativePath = "Library/UloopHotReload/CompilationAssemblies.json"; + // The file inside a snapshot directory that records each copied source's length and last // write time (UTC ticks), so a later domain tells an unchanged source by its stat alone. public const string SourceStampManifestFileName = "source-stamps.txt"; @@ -478,10 +482,11 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE "No verified source snapshot for {0} (assembly {1}): the compiled assembly or its PDB is " + "missing; patching all methods. Run 'uloop compile'."; - // Format: file name, assembly name. Used instead of the warnings above when the compiled - // PDB lists no document for the file: a file whose code compiles to no method body (an - // enum, an interface, fields only) is never recorded there, so no compile can produce the - // baseline the other wording asks the reader to establish. + // Format: file name, assembly name. Used instead of the warnings above when no sequence + // point of the compiled PDB refers to the file: a file whose code compiles to no method + // body (an enum, an interface, fields only) has none, so no compile can produce the + // baseline the other wording asks the reader to establish. The compiler still records such + // a file as a document, but an IL post-processor that rewrites the PDB drops it. public const string NoCompiledMethodBodyBaselineWarningFormat = "{0} (assembly {1}) has no compiled method body, so there is no baseline for " + "edited-method detection; patching all methods. This is expected for files that only " @@ -677,6 +682,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogPublicizedCopyWritten = "hot_reload_publicized_copy_written"; public const string VibeLogSourceSnapshotChecked = "hot_reload_source_snapshot_checked"; public const string VibeLogSourceSnapshotBreakdown = "hot_reload_source_snapshot_breakdown"; + public const string VibeLogCompilationAssembliesFetched = "hot_reload_compilation_assemblies_fetched"; public const string VibeLogShimCompilerTiming = "hot_reload_shim_compiler_timing"; public const string VibeLogShimCompileFailed = "hot_reload_shim_compile_failed"; public const string VibeLogIsolationRetry = "hot_reload_isolation_retry"; @@ -690,9 +696,16 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogWarmUpSkipped = "hot_reload_warm_up_skipped"; public const string VibeLogCallerNoteBackfillComplete = "hot_reload_caller_note_backfill_complete"; public const string VibeLogSourceSnapshotCaptured = "hot_reload_source_snapshot_captured"; + // The other entries of a source snapshot capture that runs off the main thread: its start, + // the ways it can end without capturing, and the compile completion giving up on it. + public const string VibeLogSourceSnapshotCaptureStarted = "hot_reload_source_snapshot_capture_started"; + public const string VibeLogSourceSnapshotCaptureCancelled = "hot_reload_source_snapshot_capture_cancelled"; + public const string VibeLogSourceSnapshotCaptureFailed = "hot_reload_source_snapshot_capture_failed"; + public const string VibeLogSourceSnapshotCaptureEmpty = "hot_reload_source_snapshot_capture_empty"; + public const string VibeLogSourceSnapshotCaptureHoldCapExceeded = "hot_reload_source_snapshot_capture_hold_cap_exceeded"; // What captured the source snapshot of a domain, as named in the trigger field of - // hot_reload_source_snapshot_captured. + // hot_reload_source_snapshot_captured and the other capture entries. public const string SourceSnapshotCaptureTriggerDomainLoad = "domain_load"; public const string SourceSnapshotCaptureTriggerFirstUpdateTick = "first_update_tick"; public const string SourceSnapshotCaptureTriggerApply = "apply"; @@ -704,6 +717,32 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogSourceSnapshotSkipped = "hot_reload_source_snapshot_skipped"; public const string SourceSnapshotSkipReasonToolsDisabled = "tools_disabled"; public const string WarmUpSkipReasonToolDisabled = "tool_disabled"; + public const string WarmUpItemSkipReasonCompilationListUnavailable = "compilation_list_unavailable"; + + // Saving the compilation assembly list for the next domain: why a domain did not save it, + // a save that threw, and a save that finished (its size and time). + public const string VibeLogCompilationListSaveSkipped = "hot_reload_compilation_list_save_skipped"; + public const string VibeLogCompilationListSaveFailed = "hot_reload_compilation_list_save_failed"; + public const string VibeLogCompilationListSaved = "hot_reload_compilation_list_saved"; + + // Why a domain found no saved compilation assembly list to rebuild from: none was saved, or + // the saved file is torn, of another format version or missing a required field. + public const string CompilationListReasonNoSavedList = "no_saved_list"; + public const string CompilationListReasonSavedListUnreadable = "saved_list_unreadable"; + + // Why a domain did not rebuild the list from the saved one and the compiled files, or did not + // keep the rebuilt list, as logged in hot_reload_compilation_list_reconstructed. + public const string VibeLogCompilationListReconstructed = "hot_reload_compilation_list_reconstructed"; + public const string CompilationListReasonAssemblySetChanged = "assembly_set_changed"; + public const string CompilationListReasonPdbMismatch = "pdb_mismatch"; + public const string CompilationListReasonPdbDocumentsIncomplete = "pdb_documents_incomplete"; + public const string CompilationListReasonCompilationFailed = "compilation_failed"; + public const string CompilationListReasonUnresolvedReference = "unresolved_reference"; + public const string CompilationListReasonReferenceMissing = "reference_missing"; + public const string CompilationListReasonReferenceChanged = "reference_changed"; + public const string CompilationListReasonSuperseded = "superseded"; + public const string CompilationListReasonCancelled = "cancelled"; + public const string CompilationListReasonToolDisabled = "tool_disabled"; // What stopped a running warm-up or caller-note backfill, as named in the cancelledBy field // of hot_reload_warm_up_complete and hot_reload_caller_note_backfill_complete. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs new file mode 100644 index 000000000..5cd37293e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs @@ -0,0 +1,44 @@ +using System; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Tells whether a slash-separated path lies in a folder, respecting folder boundaries. Does no + /// IO, so it can run on any thread. + /// + internal static class HotReloadPackageFolderMatch + { + /// + /// Returns the length of when is inside it, + /// so that the character after it is "/"; -1 otherwise, and -1 for a null folder. Why the + /// "/": "/a/LocalPkg2/x.cs" must not count as inside "/a/LocalPkg". + /// + internal static int PrefixLength(string path, string folder, StringComparison comparison) + { + Debug.Assert(path != null, "path must not be null."); + + if (folder == null || path.Length <= folder.Length + 1) + { + return -1; + } + + if (path[folder.Length] != '/' || !path.StartsWith(folder, comparison)) + { + return -1; + } + + return folder.Length; + } + + /// + /// Returns the folder with forward slashes and without a trailing slash, the form + /// expects. + /// + internal static string NormalizeFolder(string path) + { + return path.Replace('\\', '/').TrimEnd('/'); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs.meta new file mode 100644 index 000000000..2273eb0cf --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2eeca0b7f89a447aca825430aec932e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs new file mode 100644 index 000000000..361e0d8f1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Reflection.Metadata; +using System.Text; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One metadata reference the compiler was given, as its PDB records it. For an assembly of the + /// project the file is its reference assembly (X.ref.dll), so the MVID is that of the reference + /// assembly, not of the dll under Library/ScriptAssemblies. + /// + internal readonly struct HotReloadPdbReferencedAssembly + { + public readonly string FileName; + public readonly Guid Mvid; + + public HotReloadPdbReferencedAssembly(string fileName, Guid mvid) + { + FileName = fileName; + Mvid = mvid; + } + } + + /// + /// The compiler inputs a portable PDB records besides its sources: the preprocessor defines and + /// the metadata references. Safe to call from a pool thread; the only Unity API it uses is + /// Debug.Assert. + /// + internal sealed class HotReloadPdbCompilationInfo + { + // Roslyn's custom debug information kinds (Portable PDB spec, "Compilation Options" and + // "Compilation Metadata References"). + private static readonly Guid CompilationOptionsKind = new Guid("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8"); + private static readonly Guid MetadataReferencesKind = new Guid("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D"); + private const string DefineKey = "define"; + private const string SourceFileCountKey = "source-file-count"; + private const int MvidLength = 16; + // Kind/flags byte, timestamp and image size that sit between the alias and the MVID. + private const int BytesBeforeMvid = 1 + 4 + 4; + + public readonly IReadOnlyList Defines; + public readonly IReadOnlyList References; + + // The GUID of the PDB id, which the dll's CodeView entry names when both come from one build. + public readonly Guid PdbGuid; + + // How many syntax trees the compiler emitted, generator output included, or null when the + // PDB does not record it. The compiler writes one document per tree, and a tool that + // rewrites the PDB keeps this record but can drop documents. + public readonly int? SourceFileCount; + + // The rows of the PDB's document table. + public readonly int DocumentCount; + + private HotReloadPdbCompilationInfo( + IReadOnlyList defines, + IReadOnlyList references, + Guid pdbGuid, + int? sourceFileCount, + int documentCount) + { + Defines = defines; + References = references; + PdbGuid = pdbGuid; + SourceFileCount = sourceFileCount; + DocumentCount = documentCount; + } + + /// + /// Reads the defines, in the order the compiler was given them, the compiler's source count, + /// the document count, the metadata references and the id GUID of the PDB. Throws InvalidOperationException when the PDB lacks either record or a record + /// is malformed. + /// + internal static HotReloadPdbCompilationInfo Read(string pdbPath) + { + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + + using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + MetadataReader reader = provider.GetMetadataReader(); + + byte[] options = ReadModuleRecord(reader, CompilationOptionsKind, pdbPath); + byte[] references = ReadModuleRecord(reader, MetadataReferencesKind, pdbPath); + Dictionary optionValues = ParseOptions(options, pdbPath); + return new HotReloadPdbCompilationInfo( + ToDefines(optionValues), + ParseReferences(references, pdbPath), + new BlobContentId(reader.DebugMetadataHeader.Id).Guid, + ToSourceFileCount(optionValues), + reader.Documents.Count); + } + + /// + /// Reads the compiler's source count from an open PDB, or null when the PDB has no + /// compilation options record or the record does not hold the count. Throws + /// InvalidOperationException when the record is malformed. + /// + internal static int? ReadSourceFileCount(MetadataReader reader, string pdbPath) + { + Debug.Assert(reader != null, "reader must not be null."); + + byte[] options = TryReadModuleRecord(reader, CompilationOptionsKind); + return options == null ? null : ToSourceFileCount(ParseOptions(options, pdbPath)); + } + + private static byte[] ReadModuleRecord(MetadataReader reader, Guid kind, string pdbPath) + { + byte[] record = TryReadModuleRecord(reader, kind); + if (record == null) + { + throw new InvalidOperationException( + "The PDB " + Path.GetFileName(pdbPath) + " has no compilation record of kind " + kind + "."); + } + + return record; + } + + private static byte[] TryReadModuleRecord(MetadataReader reader, Guid kind) + { + // Why only the module's rows: both records describe the whole compilation, so Roslyn + // attaches them to the module. + foreach (CustomDebugInformationHandle handle in reader.GetCustomDebugInformation(EntityHandle.ModuleDefinition)) + { + CustomDebugInformation information = reader.GetCustomDebugInformation(handle); + if (reader.GetGuid(information.Kind) != kind) + { + continue; + } + + return reader.GetBlobBytes(information.Value); + } + + return null; + } + + // The options blob is a sequence of null-terminated UTF-8 key and value pairs. + private static Dictionary ParseOptions(byte[] blob, string pdbPath) + { + Dictionary values = new Dictionary(StringComparer.Ordinal); + int position = 0; + while (position < blob.Length) + { + string key = ReadNullTerminated(blob, ref position, pdbPath); + string value = ReadNullTerminated(blob, ref position, pdbPath); + if (!values.ContainsKey(key)) + { + values.Add(key, value); + } + } + + return values; + } + + private static IReadOnlyList ToDefines(Dictionary optionValues) + { + if (!optionValues.TryGetValue(DefineKey, out string value) || value.Length == 0) + { + return Array.Empty(); + } + + return value.Split(','); + } + + private static int? ToSourceFileCount(Dictionary optionValues) + { + if (!optionValues.TryGetValue(SourceFileCountKey, out string value) + || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int count)) + { + return null; + } + + return count; + } + + // Each reference is: file name and extern aliases (both null-terminated), a kind/flags + // byte, a uint32 timestamp, a uint32 image size and the 16-byte MVID. + private static IReadOnlyList ParseReferences(byte[] blob, string pdbPath) + { + List references = new List(); + int position = 0; + while (position < blob.Length) + { + string fileName = ReadNullTerminated(blob, ref position, pdbPath); + ReadNullTerminated(blob, ref position, pdbPath); + position += BytesBeforeMvid; + if (position + MvidLength > blob.Length) + { + throw MalformedRecord(pdbPath); + } + + references.Add(new HotReloadPdbReferencedAssembly(fileName, new Guid(Slice(blob, position, MvidLength)))); + position += MvidLength; + } + + return references; + } + + private static string ReadNullTerminated(byte[] blob, ref int position, string pdbPath) + { + int terminator = Array.IndexOf(blob, (byte)0, position); + if (terminator < 0) + { + throw MalformedRecord(pdbPath); + } + + string text = Encoding.UTF8.GetString(blob, position, terminator - position); + position = terminator + 1; + return text; + } + + private static byte[] Slice(byte[] blob, int start, int length) + { + byte[] slice = new byte[length]; + Buffer.BlockCopy(blob, start, slice, 0, length); + return slice; + } + + private static InvalidOperationException MalformedRecord(string pdbPath) + { + return new InvalidOperationException( + "A compilation record in the PDB " + Path.GetFileName(pdbPath) + " is malformed."); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta new file mode 100644 index 000000000..0bc3942fd --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 097901e3ad2b94819a43bd25397d6038 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index e1499f0d8..b8d3866c9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -38,6 +38,7 @@ public HotReloadPdbDocument(string url, DocumentHashAlgorithm hashAlgorithm, byt /// list of urls and checksums, and there is at most one entry per assembly of the project. /// Each list is also written to a file under the persistence directory with the same five /// values, so a new index in the next domain reads it from there instead of walking the PDB. + /// With the list it keeps whether a tool that rewrote the PDB dropped documents from it. /// internal sealed class HotReloadPdbDocumentIndex { @@ -86,8 +87,8 @@ public override int GetHashCode() } } - private const string PersistedFormatHeader = "uloop-pdb-documents 1"; - private const int PersistedStampFieldCount = 6; + private const string PersistedFormatHeader = "uloop-pdb-documents 2"; + private const int PersistedStampFieldCount = 7; private const int PersistedDocumentFieldCount = 3; // Hash algorithm GUIDs a portable PDB records for its documents. @@ -99,11 +100,13 @@ private sealed class Entry { public readonly FileStamp Stamp; public readonly List Documents; + public readonly bool DropsDocuments; - public Entry(FileStamp stamp, List documents) + public Entry(FileStamp stamp, List documents, bool dropsDocuments) { Stamp = stamp; Documents = documents; + DropsDocuments = dropsDocuments; } } @@ -182,6 +185,28 @@ internal bool TryFindDocument( } } + /// + /// Whether the PDB's document table has fewer rows than the sources the compiler recorded + /// it emitted. The compiler writes one row per source, so fewer rows means a tool that + /// rewrote the PDB after the compile, as an IL post-processor does, dropped the documents + /// no sequence point refers to. More rows is not a drop: #line directives add documents. + /// False when the PDB does not record the count. Takes the same paths and MVID as + /// TryFindDocument, and reads the files only when TryFindDocument would. + /// + internal bool DropsDocuments(string dllPath, string pdbPath, string moduleVersionId) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(moduleVersionId), "moduleVersionId must not be null or empty."); + + string fullDllPath = Path.GetFullPath(dllPath); + FileStamp stamp = ReadStamp(fullDllPath, pdbPath, moduleVersionId); + lock (_gate) + { + return EnsureEntryLocked(fullDllPath, pdbPath, stamp).DropsDocuments; + } + } + /// /// Reads the dll's document list now so that a later TryFindDocument in this domain reads /// nothing. Takes the same paths and MVID as TryFindDocument. @@ -208,7 +233,7 @@ private Entry EnsureEntryLocked(string fullDllPath, string pdbPath, FileStamp st } string persistedPath = PersistedListPath(fullDllPath); - List documents = TryReadPersistedList(persistedPath, stamp); + List documents = TryReadPersistedList(persistedPath, stamp, out bool dropsDocuments); if (documents != null) { _persistedLoadCount++; @@ -218,15 +243,15 @@ private Entry EnsureEntryLocked(string fullDllPath, string pdbPath, FileStamp st // Why the walk before any write: a walk that throws leaves no entry and no // file, so the next lookup reads the files again instead of answering from // a list of other files. - documents = ReadDocuments(fullDllPath, pdbPath); + documents = ReadDocumentsNotingDrops(fullDllPath, pdbPath, out dropsDocuments); _loadCount++; - WritePersistedList(persistedPath, stamp, documents); + WritePersistedList(persistedPath, stamp, documents, dropsDocuments); } // Why stored under the stamp read before the walk: if a file is replaced while // it is being read, the next lookup sees another stamp and reads again, so a // list is never served for files it was not read from. - entry = new Entry(stamp, documents); + entry = new Entry(stamp, documents, dropsDocuments); _entries[fullDllPath] = entry; return entry; } @@ -239,13 +264,15 @@ private string PersistedListPath(string fullDllPath) // File format: UTF-8 without a BOM, every line ends with "\n", fields are TAB-separated and // numbers are invariant. // line 1: the format header - // line 2: dll length, dll write time ticks, MVID, PDB length, PDB write time ticks, count + // line 2: dll length, dll write time ticks, MVID, PDB length, PDB write time ticks, + // 1 when the PDB dropped documents and 0 when not, count // then count lines: (int)HashAlgorithm, the hash in hex ("" for none), url // Null for a missing file, another stamp, or any malformed part: a file cut short or edited // by hand must not answer with part of a list. Why the url last: it is the only field that // can hold anything, so splitting into three keeps a TAB in a url intact. - private static List TryReadPersistedList(string path, FileStamp stamp) + private static List TryReadPersistedList(string path, FileStamp stamp, out bool dropsDocuments) { + dropsDocuments = false; if (!File.Exists(path)) { return null; @@ -262,7 +289,7 @@ private static List TryReadPersistedList(string path, File return null; } - if (!TryParseStampLine(lines[1], out FileStamp persistedStamp, out int count) + if (!TryParseStampLine(lines[1], out FileStamp persistedStamp, out bool persistedDrops, out int count) || !persistedStamp.Equals(stamp)) { return null; @@ -285,12 +312,14 @@ private static List TryReadPersistedList(string path, File documents.Add(document); } + dropsDocuments = persistedDrops; return documents; } - private static bool TryParseStampLine(string line, out FileStamp stamp, out int count) + private static bool TryParseStampLine(string line, out FileStamp stamp, out bool dropsDocuments, out int count) { stamp = default(FileStamp); + dropsDocuments = false; count = 0; string[] fields = line.Split('\t'); if (fields.Length != PersistedStampFieldCount @@ -299,7 +328,8 @@ private static bool TryParseStampLine(string line, out FileStamp stamp, out int || fields[2].Length == 0 || !TryParseLong(fields[3], out long pdbLength) || !TryParseLong(fields[4], out long pdbTicks) - || !int.TryParse(fields[5], NumberStyles.None, CultureInfo.InvariantCulture, out count)) + || !TryParseFlag(fields[5], out dropsDocuments) + || !int.TryParse(fields[6], NumberStyles.None, CultureInfo.InvariantCulture, out count)) { return false; } @@ -308,6 +338,12 @@ private static bool TryParseStampLine(string line, out FileStamp stamp, out int return true; } + private static bool TryParseFlag(string text, out bool value) + { + value = string.Equals(text, "1", StringComparison.Ordinal); + return value || string.Equals(text, "0", StringComparison.Ordinal); + } + private static bool TryParseDocumentLine(string line, out HotReloadPdbDocument document) { document = default(HotReloadPdbDocument); @@ -369,7 +405,7 @@ private static bool TryParseHex(string text, out byte[] bytes) return true; } - private void WritePersistedList(string path, FileStamp stamp, List documents) + private void WritePersistedList(string path, FileStamp stamp, List documents, bool dropsDocuments) { Directory.CreateDirectory(_persistenceDirectory); StringBuilder text = new StringBuilder(); @@ -379,6 +415,7 @@ private void WritePersistedList(string path, FileStamp stamp, List ReadDocuments(string dllPath, string pdbPath) + { + return ReadDocumentsNotingDrops(dllPath, pdbPath, out bool _); + } + + // ReadDocuments, also answering whether the PDB dropped documents; see DropsDocuments. + // Why from the reader the walk already opened: the source count is one more record of + // the same PDB, so the answer costs no second read of the file. + private static List ReadDocumentsNotingDrops(string dllPath, string pdbPath, out bool dropsDocuments) { Guid codeViewGuid = ReadCodeViewGuid(dllPath); using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); @@ -475,6 +520,8 @@ internal static List ReadDocuments(string dllPath, string reader.GetBlobBytes(document.Hash))); } + int? sourceFileCount = HotReloadPdbCompilationInfo.ReadSourceFileCount(reader, pdbPath); + dropsDocuments = sourceFileCount.HasValue && reader.Documents.Count < sourceFileCount.Value; return documents; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs index c0eed27fa..65be35832 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs @@ -15,14 +15,32 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class HotReloadPdbDocumentTable { /// - /// Returns the documents of the PDB in table order, after checking that the PDB belongs to - /// the build of the dll. Throws InvalidOperationException for a PDB of another build. + /// Returns the documents of the PDB in table order and, in sourceFileCount, the number of + /// sources the compiler recorded (null when it did not), after checking that the PDB belongs + /// to the build of the dll. Throws InvalidOperationException for a PDB of another build. /// - internal static List Read(string dllPath, string pdbPath) + internal static List Read(string dllPath, string pdbPath, out int? sourceFileCount) + { + List documents = TryRead(dllPath, pdbPath, out sourceFileCount); + if (documents == null) + { + throw new InvalidOperationException( + "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + } + + return documents; + } + + /// + /// Same as Read, but returns null instead of throwing when the PDB belongs to another build + /// of the dll. + /// + internal static List TryRead(string dllPath, string pdbPath, out int? sourceFileCount) { Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + sourceFileCount = null; Guid codeViewGuid = HotReloadPdbDocumentIndex.ReadCodeViewGuid(dllPath); using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); @@ -32,8 +50,7 @@ internal static List Read(string dllPath, string pdbPath) BlobContentId pdbId = new BlobContentId(reader.DebugMetadataHeader.Id); if (pdbId.Guid != codeViewGuid) { - throw new InvalidOperationException( - "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + return null; } List documents = new List(reader.Documents.Count); @@ -46,6 +63,8 @@ internal static List Read(string dllPath, string pdbPath) reader.GetBlobBytes(document.Hash))); } + // Why from the same reader: the PDB is then opened once for both. + sourceFileCount = HotReloadPdbCompilationInfo.ReadSourceFileCount(reader, pdbPath); return documents; } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs new file mode 100644 index 000000000..e3ebea157 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The compilation assembly list as saved for the next domain: only the fields hot reload reads, + /// plus the file stamps and MVIDs that tell whether each entry still describes the dlls on disk. + /// + internal sealed class HotReloadSavedCompilationList + { + public int formatVersion; + + // In the order CompilationPipeline.GetAssemblies returned them. + public List assemblies; + + // One per distinct compiled assembly reference path, in first-seen order. Assemblies name + // their references by index, because most of them share the same few hundred paths and + // repeating the paths made the file several times larger. + public List references; + + /// + /// True when every field a rebuild reads is present and every reference index is in range. + /// + internal bool IsComplete() + { + if (assemblies == null || references == null) + { + return false; + } + + foreach (HotReloadSavedReference reference in references) + { + if (reference == null || !reference.IsComplete()) + { + return false; + } + } + + foreach (HotReloadSavedCompilationAssembly assembly in assemblies) + { + if (assembly == null || !assembly.IsComplete(references.Count)) + { + return false; + } + } + + return true; + } + + /// + /// Reads the saved fields of every assembly, the stamp of each assembly's dll and the stamp + /// and MVID of every referenced dll. Touches only files and managed properties, so it can + /// run on a pool thread. + /// + internal static HotReloadSavedCompilationList Capture( + IReadOnlyList fetched, + string projectRoot, + int formatVersion) + { + Debug.Assert(fetched != null, "fetched must not be null."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + List assemblies = new List(fetched.Count); + List references = new List(); + Dictionary indexByPath = new Dictionary(StringComparer.Ordinal); + foreach (UnityCompilationAssembly assembly in fetched) + { + string[] referencePaths = assembly.compiledAssemblyReferences ?? Array.Empty(); + int[] referenceIndexes = new int[referencePaths.Length]; + for (int position = 0; position < referencePaths.Length; position++) + { + referenceIndexes[position] = IndexOf(referencePaths[position], projectRoot, indexByPath, references); + } + + assemblies.Add(HotReloadSavedCompilationAssembly.From(assembly, referenceIndexes, layout)); + } + + return new HotReloadSavedCompilationList + { + formatVersion = formatVersion, + assemblies = assemblies, + references = references, + }; + } + + private static int IndexOf( + string referencePath, + string projectRoot, + Dictionary indexByPath, + List references) + { + if (indexByPath.TryGetValue(referencePath, out int index)) + { + return index; + } + + index = references.Count; + indexByPath.Add(referencePath, index); + references.Add(HotReloadSavedReference.Read(referencePath, projectRoot)); + return index; + } + } + + /// + /// The outcome of loading the saved list: the list, or why there is none to use. + /// + internal readonly struct HotReloadSavedCompilationListLoad + { + public readonly HotReloadSavedCompilationList List; + public readonly string FailureReason; + + private HotReloadSavedCompilationListLoad(HotReloadSavedCompilationList list, string failureReason) + { + List = list; + FailureReason = failureReason; + } + + internal static HotReloadSavedCompilationListLoad Loaded(HotReloadSavedCompilationList list) + { + Debug.Assert(list != null, "list must not be null."); + return new HotReloadSavedCompilationListLoad(list, null); + } + + internal static HotReloadSavedCompilationListLoad Failed(string failureReason) + { + Debug.Assert(!string.IsNullOrEmpty(failureReason), "failureReason must not be null or empty."); + return new HotReloadSavedCompilationListLoad(null, failureReason); + } + } + + /// + /// One saved compilation assembly. Fields hot reload never reads (compiler options, flags, root + /// namespace) are not saved. + /// + internal sealed class HotReloadSavedCompilationAssembly + { + public string name; + public string outputPath; + public string[] sourceFiles; + public string[] defines; + + // Names of the project assemblies this one references, in Unity's order. + public string[] assemblyReferences; + + // The precompiled assemblies this one references, in Unity's order, as indexes into the + // list's references. + public int[] compiledAssemblyReferenceIndexes; + + // Null when the assembly had no dll when the list was saved. + public HotReloadSavedFileStamp dllStamp; + + internal bool IsComplete(int referenceCount) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(outputPath) || sourceFiles == null + || defines == null || assemblyReferences == null || compiledAssemblyReferenceIndexes == null) + { + return false; + } + + foreach (int index in compiledAssemblyReferenceIndexes) + { + if (index < 0 || index >= referenceCount) + { + return false; + } + } + + return true; + } + + internal static HotReloadSavedCompilationAssembly From( + UnityCompilationAssembly assembly, + int[] compiledAssemblyReferenceIndexes, + CompiledAssemblyLayout layout) + { + UnityCompilationAssembly[] referenced = assembly.assemblyReferences ?? Array.Empty(); + string[] referencedNames = new string[referenced.Length]; + for (int index = 0; index < referenced.Length; index++) + { + referencedNames[index] = referenced[index].name; + } + + return new HotReloadSavedCompilationAssembly + { + name = assembly.name, + outputPath = assembly.outputPath, + sourceFiles = assembly.sourceFiles ?? Array.Empty(), + defines = assembly.defines ?? Array.Empty(), + assemblyReferences = referencedNames, + compiledAssemblyReferenceIndexes = compiledAssemblyReferenceIndexes, + dllStamp = HotReloadSavedFileStamp.ReadOrNull(layout.DllPath(assembly.name)), + }; + } + } + + /// + /// A precompiled assembly reference as it was when the list was saved. + /// + internal sealed class HotReloadSavedReference + { + // The path exactly as Unity listed it in compiledAssemblyReferences. + public string path; + + // Both null when the file did not exist when the list was saved. + public HotReloadSavedFileStamp stamp; + public string mvid; + + internal bool IsComplete() + { + if (string.IsNullOrEmpty(path) || (stamp == null) != (mvid == null)) + { + return false; + } + + return mvid == null || Guid.TryParseExact(mvid, "N", out Guid _); + } + + internal static HotReloadSavedReference Read(string referencePath, string projectRoot) + { + string fullPath = Path.GetFullPath(Path.Combine(projectRoot, referencePath)); + HotReloadSavedFileStamp stamp = HotReloadSavedFileStamp.ReadOrNull(fullPath); + return new HotReloadSavedReference + { + path = referencePath, + stamp = stamp, + mvid = stamp == null ? null : HotReloadAssemblyMetadataMvid.Read(fullPath).ToString("N"), + }; + } + } + + /// + /// The length and last write time of a file, which together tell that a dll was not rebuilt. + /// + internal sealed class HotReloadSavedFileStamp + { + public long length; + public long lastWriteTicks; + + /// + /// Returns the stamp of the file, or null when the file does not exist. + /// + internal static HotReloadSavedFileStamp ReadOrNull(string fullPath) + { + FileInfo file = new FileInfo(fullPath); + if (!file.Exists) + { + return null; + } + + return new HotReloadSavedFileStamp { length = file.Length, lastWriteTicks = file.LastWriteTimeUtc.Ticks }; + } + + /// + /// True when the other stamp has the same length and last write time. False for null, which + /// stands for a file that does not exist. + /// + internal bool SameAs(HotReloadSavedFileStamp other) + { + return other != null && other.length == length && other.lastWriteTicks == lastWriteTicks; + } + } + + /// + /// Reads a dll's MVID from its metadata header without loading the assembly. Safe on a pool thread. + /// + internal static class HotReloadAssemblyMetadataMvid + { + internal static Guid Read(string dllPath) + { + using FileStream stream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using PEReader peReader = new PEReader(stream); + MetadataReader reader = peReader.GetMetadataReader(); + return reader.GetGuid(reader.GetModuleDefinition().Mvid); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta new file mode 100644 index 000000000..9af5d3b30 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9450b5e2fb2394510b9039e08c5628f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs new file mode 100644 index 000000000..3dec67974 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Everything the snapshot capture needs from Unity, read once on the main thread and held as + /// values, so that the capture itself asks Unity nothing. + /// + internal sealed class HotReloadSnapshotCaptureInputs + { + internal HotReloadSnapshotCaptureInputs( + string projectRoot, + HotReloadCompileStart compileStart, + IReadOnlyList packageRoots, + StringComparison pathComparison) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + Debug.Assert( + pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase, + "pathComparison must be Ordinal or OrdinalIgnoreCase."); + + ProjectRoot = projectRoot; + CompileStart = compileStart; + PackageRoots = packageRoots; + PathComparison = pathComparison; + } + + /// The root of this Editor's project, which owns the snapshots. + internal string ProjectRoot { get; } + + /// When the compile behind the current assemblies started, as far as this session knows. + internal HotReloadCompileStart CompileStart { get; } + + /// The registered packages, with whether hot reload treats each as read-only. + internal IReadOnlyList PackageRoots { get; } + + /// How paths compare on this platform: case-insensitive on Windows only. + internal StringComparison PathComparison { get; } + + /// + /// Reads the inputs from Unity. Main thread only: the compile start lives in SessionState and + /// the package roots come from the Package Manager, both of which Unity allows on the main + /// thread alone. + /// + internal static HotReloadSnapshotCaptureInputs ReadOnMainThread() + { + return new HotReloadSnapshotCaptureInputs( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadCompileStartRecord.Read(), + HotReloadSnapshotPackageRoots.ReadCurrent(), + CurrentPathComparison()); + } + + /// + /// Returns the comparison the package paths use on this platform, the same one + /// ScriptPackageRoots.ToPhysicalPath uses: case-insensitive on Windows only. + /// + internal static StringComparison CurrentPathComparison() + { + return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs.meta new file mode 100644 index 000000000..8416ac3dd --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6e7c8fd81a9e480698a38feff62c9fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs index 3f939b7bf..60b05df80 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs @@ -9,6 +9,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal sealed class HotReloadSnapshotCaptureStats { // Times are TimeSpan ticks, summed over every assembly and source of the capture. + internal long ListingTicks; + internal long PdbSourcesTicks; internal long PackageLookupTicks; internal long StampTicks; internal long MvidTicks; @@ -22,16 +24,19 @@ internal sealed class HotReloadSnapshotCaptureStats internal int AssembliesImmutable; internal int AssembliesUnchanged; internal int AssembliesCaptured; + internal int AssembliesSkippedPdbMismatch; + internal int AssembliesFromCompilerPdb; internal int FilesReused; internal int FilesCopied; internal long BytesCopied; internal int FilesChecked; - internal object ToVibeLogContext(long getAssembliesMs, long totalMs) + internal object ToVibeLogContext(long totalMs) { return new { - getAssembliesMs, + listingMs = ToMilliseconds(ListingTicks), + pdbSourcesMs = ToMilliseconds(PdbSourcesTicks), packageLookupMs = ToMilliseconds(PackageLookupTicks), stampMs = ToMilliseconds(StampTicks), mvidMs = ToMilliseconds(MvidTicks), @@ -45,6 +50,8 @@ internal object ToVibeLogContext(long getAssembliesMs, long totalMs) assembliesImmutable = AssembliesImmutable, assembliesUnchanged = AssembliesUnchanged, assembliesCaptured = AssembliesCaptured, + assembliesSkippedPdbMismatch = AssembliesSkippedPdbMismatch, + assembliesFromCompilerPdb = AssembliesFromCompilerPdb, filesReused = FilesReused, filesCopied = FilesCopied, bytesCopied = BytesCopied, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs new file mode 100644 index 000000000..239890c61 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Names a source the way its assembly's PDB records it, from package roots read earlier on the + /// main thread, so that the snapshot capture can check a copy against the PDB without asking the + /// Package Manager. + /// + internal static class HotReloadSnapshotLookupPath + { + /// + /// Returns the same path as ScriptPackageRoots.ToPhysicalPath: project-relative when the file + /// is inside , absolute when its package lives outside + /// it, and the input itself when no package owns the path, as for Assets. Does no IO. + /// + internal static string ToPdbLookupPath( + string compiledProjectRoot, + string slashNormalizedAssetPath, + IReadOnlyList packageRoots, + StringComparison comparison) + { + Debug.Assert(!string.IsNullOrEmpty(compiledProjectRoot), "compiledProjectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(slashNormalizedAssetPath), "slashNormalizedAssetPath must not be null or empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + + List roots = new List(packageRoots.Count); + foreach (HotReloadSnapshotPackageRoot packageRoot in packageRoots) + { + roots.Add(new ScriptPackageRoot(packageRoot.ResolvedPath, packageRoot.AssetPath)); + } + + return ScriptPathNormalizer.ToPhysicalProjectRelative( + slashNormalizedAssetPath, + compiledProjectRoot, + roots, + comparison); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs.meta new file mode 100644 index 000000000..a667a3151 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 35bdf8ef1cb2a4adf9cf78bac5b907b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs index 8237b8167..d79c0bde6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs @@ -16,8 +16,10 @@ internal enum HotReloadSnapshotMissReason NoSnapshotFile, /// - /// The PDB has no document for the file because none of its code compiled to a method - /// body, so no compile can produce a baseline for it. + /// No sequence point of the PDB refers to the file because none of its code compiled to a + /// method body, so no compile can produce a baseline for it. Reported when a snapshot of the + /// file was written, and, without one, for a file the compilation assemblies list in an + /// assembly whose PDB a tool rewrote and dropped such documents from. /// NoDocumentInPdb, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs index 63b40820c..b37bbadc0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -12,24 +15,31 @@ internal sealed class HotReloadSnapshotSourceCheck private readonly string _pdbPath; private readonly string _moduleVersionId; private readonly HotReloadPdbDocumentIndex _documentIndex; + private readonly IReadOnlyList _packageRoots; + private readonly StringComparison _pathComparison; internal HotReloadSnapshotSourceCheck( long suspectWritesFromUtcTicks, string dllPath, string pdbPath, string moduleVersionId, - HotReloadPdbDocumentIndex documentIndex) + HotReloadPdbDocumentIndex documentIndex, + IReadOnlyList packageRoots, + StringComparison pathComparison) { Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(moduleVersionId), "moduleVersionId must not be null or empty."); Debug.Assert(documentIndex != null, "documentIndex must not be null."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); SuspectWritesFromUtcTicks = suspectWritesFromUtcTicks; _dllPath = dllPath; _pdbPath = pdbPath; _moduleVersionId = moduleVersionId; _documentIndex = documentIndex; + _packageRoots = packageRoots; + _pathComparison = pathComparison; } /// @@ -40,15 +50,24 @@ internal HotReloadSnapshotSourceCheck( /// /// Returns whether the bytes match the checksum the compiled assembly's PDB recorded for the - /// source. A source the PDB has no usable checksum for is not confirmed. Main thread only. + /// source. A source the PDB has no usable checksum for is not confirmed. Asks nothing of the + /// Package Manager: the package roots are the ones the caller read on the main thread. /// internal bool IsCompiledSource(string projectRoot, string slashNormalizedRelativePath, byte[] sourceBytes) { - // Why NoDocumentInPdb counts as not compiled: a file without method bodies has no document, - // and a copy that cannot be confirmed is safer treated as edited than trusted. - return HotReloadSourceBaseline.CompareWithCompiledDocument( - projectRoot, - slashNormalizedRelativePath, + // Why the root that compiled the assembly: a Virtual Player reads the main project's + // assemblies, whose PDB names sources relative to the main project (see + // HotReloadSourceBaseline.CompareWithCompiledDocument). + string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; + string pdbLookupPath = HotReloadSnapshotLookupPath.ToPdbLookupPath( + compiledProjectRoot, + slashNormalizedRelativePath, + _packageRoots, + _pathComparison); + // Why NoDocumentInPdb counts as not compiled: no sequence point refers to a file without + // method bodies, and a copy that cannot be confirmed is safer treated as edited than trusted. + return HotReloadSourceBaseline.CompareWithCompiledDocumentAtLookupPath( + pdbLookupPath, _dllPath, _pdbPath, _moduleVersionId, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs index 7fe4f34c8..54d87d885 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -58,30 +59,66 @@ internal static string LoadVerifiedSnapshotSourceAt( /// second lookup reuses the document list the first one kept, so it does not read the PDB /// again. /// + /// + /// The evidence that placed a file Unity's compilation assemblies do not list in the + /// assembly, or null for a file they list. Null does not mean the last compile built the + /// file: the list also changes on an import that compiles nothing yet, so a file moved, + /// renamed or split off into a partial since the compile is listed too. + /// internal static HotReloadSnapshotMissReason DescribeSnapshotMiss( string projectRelativeSourcePath, - string targetDllPath) + string targetDllPath, + HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); return DescribeSnapshotMissAt( projectRoot, projectRelativeSourcePath, targetDllPath, - HotReloadPdbDocumentIndex.Shared); + HotReloadPdbDocumentIndex.Shared, + newSourceMembershipEvidence); } internal static HotReloadSnapshotMissReason DescribeSnapshotMissAt( string projectRoot, string projectRelativeSourcePath, string targetDllPath, - HotReloadPdbDocumentIndex documentIndex) + HotReloadPdbDocumentIndex documentIndex, + HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence) { - return TryLoadVerifiedSnapshotSource( + HotReloadSnapshotMissReason reason = TryLoadVerifiedSnapshotSource( projectRoot, projectRelativeSourcePath, targetDllPath, documentIndex, out string _); + // Why a listed file without a snapshot still asks the PDB: an IL post-processor that + // rewrites the PDB drops the documents no sequence point refers to, so the capture, which + // names the sources from the PDB, writes no snapshot for a file without a method body. + // Such a file must read as one no compile gives a baseline, as it does where the snapshot + // was written. Why only when the PDB dropped documents: elsewhere a listed file without a + // snapshot is one the last compile did not build yet, and the next compile does give it + // a baseline. A file with evidence is not in the list at all, so it is such a file too. + if (reason != HotReloadSnapshotMissReason.NoSnapshotFile || newSourceMembershipEvidence != null) + { + return reason; + } + + string pdbPath = Path.ChangeExtension(targetDllPath, ".pdb"); + string mvid = HotReloadAssemblyMvid.Read(targetDllPath); + if (!documentIndex.DropsDocuments(targetDllPath, pdbPath, mvid)) + { + return reason; + } + + return documentIndex.TryFindDocument( + targetDllPath, + pdbPath, + mvid, + ToCompiledLookupPath(projectRoot, projectRelativeSourcePath.Replace('\\', '/')), + out HotReloadPdbDocument _) + ? HotReloadSnapshotMissReason.NoSnapshotFile + : HotReloadSnapshotMissReason.NoDocumentInPdb; } private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( @@ -130,12 +167,104 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( return comparison; } - using MemoryStream memoryStream = new MemoryStream(snapshotBytes, writable: false); - using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - source = reader.ReadToEnd(); + source = DecodeVerifiedSource(snapshotBytes); return HotReloadSnapshotMissReason.None; } + /// + /// Returns the text of the file on disk when its bytes match the checksum the compiled + /// assembly's PDB recorded for it, or null when the file is missing or unreadable, the PDB has + /// no document for it, or the checksums differ. Matching bytes are the bytes the compiler read, + /// so the text equals what the snapshot holds for the same assembly. Main thread only, because + /// the checksum lookup asks the Package Manager for the source's physical path. + /// + public static string LoadVerifiedLiveSourceOrNull(string projectRelativeSourcePath, string targetDllPath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativeSourcePath), "projectRelativeSourcePath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); + + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return LoadVerifiedLiveSourceAt( + projectRoot, + projectRelativeSourcePath, + targetDllPath, + HotReloadPdbDocumentIndex.Shared); + } + + // projectRoot is injectable so EditMode tests can point at a copy of a source under a + // temporary root, the same way they do for the snapshot. + internal static string LoadVerifiedLiveSourceAt( + string projectRoot, + string projectRelativeSourcePath, + string targetDllPath, + HotReloadPdbDocumentIndex documentIndex) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRelativeSourcePath), "projectRelativeSourcePath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); + + string pdbPath = Path.ChangeExtension(targetDllPath, ".pdb"); + if (!File.Exists(targetDllPath) || !File.Exists(pdbPath)) + { + return null; + } + + // Why the same path the snapshot capture reads: the live file must be the file the + // capture would have copied, so a match here means the snapshot would hold these bytes. + string slashNormalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); + string absoluteSourcePath = Path.Combine( + projectRoot, + slashNormalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); + byte[] liveBytes = ReadAllBytesOrNull(HotReloadFileSystemPath.GetFileSystemPath(absoluteSourcePath)); + if (liveBytes == null) + { + return null; + } + + HotReloadSnapshotMissReason comparison = CompareWithCompiledDocument( + projectRoot, + slashNormalizedRelativePath, + targetDllPath, + pdbPath, + HotReloadAssemblyMvid.Read(targetDllPath), + liveBytes, + documentIndex); + return comparison == HotReloadSnapshotMissReason.None ? DecodeVerifiedSource(liveBytes) : null; + } + + // Why an unreadable file is a miss rather than an error: the caller falls back to waiting for + // the snapshot, which answers for that file the same way it does today. + private static byte[] ReadAllBytesOrNull(string fileSystemPath) + { + if (!File.Exists(fileSystemPath)) + { + return null; + } + + try + { + return File.ReadAllBytes(fileSystemPath); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + // Why one decoder for the snapshot and the live file: a pause point maps lines in whichever + // text it gets, so both must treat a byte order mark the same way. + private static string DecodeVerifiedSource(byte[] verifiedBytes) + { + using MemoryStream memoryStream = new MemoryStream(verifiedBytes, writable: false); + using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + /// /// Answers whether the bytes match the checksum the compiled assembly's PDB recorded for the /// source: , or why they could not be confirmed @@ -163,8 +292,9 @@ internal static HotReloadSnapshotMissReason CompareWithCompiledDocument( // Packages/ path. Why the Package Manager is asked rather than the package roots a // run captures: those live in the main hot-reload assembly, which this one cannot see, and // every caller already runs on the Unity main thread the Package Manager requires: a run's - // group step, the pause-point port, which the pause-point tools and a run's patch step call, - // and the snapshot capture after a domain reload. + // group step, and the pause-point port, which the pause-point tools and a run's patch step + // call. The snapshot capture names the source from package roots it was handed instead and + // calls the second stage directly, so that it asks the Package Manager nothing. // Why the root that compiled the assembly rather than the snapshot owner's: the PDB spells a // source inside the compiling project as a path relative to that project's root (behind a // leading "./"), which the lookup matches as the end of the url. A Multiplayer Play Mode @@ -172,8 +302,39 @@ internal static HotReloadSnapshotMissReason CompareWithCompiledDocument( // Library, so a package folder of the main project lies outside the player's root, and // relativizing against the player's root would hand the lookup an absolute path that no // document ends with. For an ordinary project the two roots are the same. + return CompareWithCompiledDocumentAtLookupPath( + ToCompiledLookupPath(projectRoot, slashNormalizedRelativePath), + targetDllPath, + pdbPath, + moduleVersionId, + sourceBytes, + documentIndex); + } + + // The path the compiled assembly's PDB records for the source; see CompareWithCompiledDocument. + private static string ToCompiledLookupPath(string projectRoot, string slashNormalizedRelativePath) + { string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; - string pdbLookupPath = ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, slashNormalizedRelativePath); + return ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, slashNormalizedRelativePath); + } + + /// + /// Answers the same as for a source already named + /// the way the PDB records it. Asks nothing of the Package Manager, so it can run off the + /// main thread. + /// + internal static HotReloadSnapshotMissReason CompareWithCompiledDocumentAtLookupPath( + string pdbLookupPath, + string targetDllPath, + string pdbPath, + string moduleVersionId, + byte[] sourceBytes, + HotReloadPdbDocumentIndex documentIndex) + { + Debug.Assert(!string.IsNullOrEmpty(pdbLookupPath), "pdbLookupPath must not be null or empty."); + Debug.Assert(sourceBytes != null, "sourceBytes must not be null."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); + if (!documentIndex.TryFindDocument( targetDllPath, pdbPath, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs index bbeadc3ee..d740988f0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs @@ -1,5 +1,7 @@ using System; using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -8,54 +10,270 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Captures the source snapshot of the current compile once per domain, at the end of the - /// domain load. When that capture throws or Unity listed no compilation assembly yet, the - /// first update tick or the hot reload apply entry captures again. + /// Captures the source snapshot of the current compile once per domain, off the main thread. + /// The domain load starts the capture without waiting for it; a hot reload apply awaits it and + /// a pause point blocks on it. At most one capture runs at a time. A capture that throws, saw + /// no compiled assembly, or was cancelled leaves the domain uncaptured, so the next caller + /// starts a new one. /// internal sealed class HotReloadSourceSnapshotCapture { - private readonly Func _capture; + // Why a cap on the hold: if a capture never finishes, compile must still return. Readers + // keep waiting for the capture; only the compile completion stops waiting for it. + private static readonly long HoldCapTicks = TimeSpan.FromSeconds(30).Ticks; + + private readonly Func _readInputsOnMainThread; + private readonly Func _capture; + private readonly Func, Task> _runInBackground; + private readonly Func _utcNowTicks; + // Why a lock: the capture finishes on a pool thread while the main thread asks whether it + // runs, and a stale answer could start a second capture that collides with the first. + private readonly object _lock = new object(); private bool _captured; + private Task _inFlight; + private CancellationTokenSource _inFlightCancellation; + private long _inFlightStartedUtcTicks; + private bool _holdCapLogged; + /// Reads what the capture needs from Unity on the main thread. /// - /// Captures the snapshot and returns whether Unity listed at least one compilation assembly. + /// Captures the snapshot without touching Unity APIs and returns whether it found at least + /// one compiled assembly. /// - internal HotReloadSourceSnapshotCapture(Func capture) + /// Runs the capture off the main thread. + /// Reads the current UTC time, for the cap on the compile hold. + internal HotReloadSourceSnapshotCapture( + Func readInputsOnMainThread, + Func capture, + Func, Task> runInBackground, + Func utcNowTicks) { + Debug.Assert(readInputsOnMainThread != null, "readInputsOnMainThread must not be null."); Debug.Assert(capture != null, "capture must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + Debug.Assert(utcNowTicks != null, "utcNowTicks must not be null."); + + _readInputsOnMainThread = readInputsOnMainThread; _capture = capture; + _runInBackground = runInBackground; + _utcNowTicks = utcNowTicks; + } + + /// + /// Whether a capture is running now. + /// + internal bool IsInFlight + { + get + { + lock (_lock) + { + return _inFlight != null; + } + } } /// - /// Runs the capture unless it already ran to completion in this domain, and records - /// when this call completes it. Main thread only. A capture that - /// throws or saw no compilation assembly is not marked done, so the next caller runs it again. + /// Whether the compile completion should wait for the running capture. False once it has run + /// for 30 seconds, which is logged once per capture. Main thread only. /// - internal void EnsureCaptured(string trigger) + internal bool IsHoldingCompileCompletion + { + get + { + bool logCapExceeded; + lock (_lock) + { + if (_inFlight == null) + { + return false; + } + + if (_utcNowTicks() - _inFlightStartedUtcTicks < HoldCapTicks) + { + return true; + } + + logCapExceeded = !_holdCapLogged; + _holdCapLogged = true; + } + + if (logCapExceeded) + { + VibeLogger.LogWarning( + HotReloadConstants.VibeLogSourceSnapshotCaptureHoldCapExceeded, + "Hot reload stopped holding the compile completion for a source snapshot capture that has not finished.", + new { holdCapSeconds = TimeSpan.FromTicks(HoldCapTicks).TotalSeconds }); + } + + return false; + } + } + + /// + /// Returns a completed task when this domain is captured, the running capture's task when one + /// runs, and otherwise the task of a capture started now. Main thread only, because the + /// inputs are read from Unity here. An exception while reading them reaches the caller and + /// starts nothing. + /// + internal Task StartIfNeeded(string trigger) { Debug.Assert(!string.IsNullOrEmpty(trigger), "trigger must not be null or empty."); - if (_captured) + + lock (_lock) + { + if (_captured) + { + return Task.FromResult(true); + } + + if (_inFlight != null) + { + return _inFlight; + } + } + + Stopwatch mainThreadWatch = Stopwatch.StartNew(); + HotReloadSnapshotCaptureInputs inputs = _readInputsOnMainThread(); + CancellationTokenSource cancellation = new CancellationTokenSource(); + Stopwatch poolWatch = new Stopwatch(); + Task task = _runInBackground(() => + { + poolWatch.Start(); + try + { + return _capture(inputs, cancellation.Token); + } + finally + { + poolWatch.Stop(); + } + }); + Debug.Assert(task != null, "runInBackground must return a task."); + lock (_lock) + { + _inFlight = task; + _inFlightCancellation = cancellation; + _inFlightStartedUtcTicks = _utcNowTicks(); + _holdCapLogged = false; + } + + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptureStarted, + "Hot reload started the source snapshot capture of this domain.", + new { trigger, mainThreadMs = mainThreadWatch.ElapsedMilliseconds }); + // Why the continuation is attached after the task is published: a task that already + // finished then runs it right here, and it finds the published task to clear. + // Why ExecuteSynchronously on the default scheduler: the bookkeeping must not wait for the + // main thread, which a pause point may be blocking on this very task. + task.ContinueWith( + finished => OnFinished(finished, cancellation, trigger, poolWatch), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return task; + } + + /// + /// Completes when this domain's capture has run, passing on the exception of a capture that + /// failed. Main thread only. It may complete on a pool thread, so a caller that continues with + /// main-thread work switches back itself. + /// + internal async Task EnsureCapturedAsync(string trigger) + { + await StartIfNeeded(trigger).ConfigureAwait(false); + } + + /// + /// Blocks the main thread until this domain's capture has run, passing on the exception of a + /// capture that failed. For synchronous readers only. It cannot dead-lock, because the + /// capture and its bookkeeping never wait for the main thread. + /// + internal void EnsureCapturedBlocking(string trigger) + { + StartIfNeeded(trigger).GetAwaiter().GetResult(); + } + + /// + /// Asks the running capture to stop, without waiting for it. Its assemblies that were not + /// finished get no stamp, so the next capture redoes them. + /// + internal void CancelInFlight() + { + lock (_lock) + { + _inFlightCancellation?.Cancel(); + } + } + + private void OnFinished( + Task task, + CancellationTokenSource cancellation, + string trigger, + Stopwatch poolWatch) + { + // Why the exception is read on every path that has one: the domain load and the first + // update tick never await the task, and an exception nobody reads is reported later as + // an unobserved task exception. Why here and not in a log argument: the VibeLogger + // calls are conditional on ULOOP_DEBUG, so an ordinary build drops them with their + // arguments, and a read inside one would never run. + Exception failure = task.IsFaulted ? task.Exception.GetBaseException() : null; + bool cancelled = task.IsCanceled + || (failure is OperationCanceledException && cancellation.IsCancellationRequested); + bool captured = task.Status == TaskStatus.RanToCompletion && task.Result; + lock (_lock) + { + if (!ReferenceEquals(_inFlightCancellation, cancellation)) + { + return; + } + + _inFlight = null; + _inFlightCancellation = null; + // Marked only after the capture returns true: a capture that threw may have left + // assemblies without a snapshot, and one that found no compiled assembly ran while + // Unity was compiling. Marking either done would keep every later reader from + // capturing them. + _captured = captured; + } + + cancellation.Dispose(); + LogOutcome(trigger, captured, cancelled, failure, poolWatch.ElapsedMilliseconds); + } + + private static void LogOutcome(string trigger, bool captured, bool cancelled, Exception failure, long captureMs) + { + if (captured) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptured, + "Hot reload captured the source snapshot of this domain.", + new { trigger, captureMs }); + return; + } + + if (cancelled) { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled, + "Hot reload cancelled the source snapshot capture of this domain.", + new { trigger }); return; } - Stopwatch watch = Stopwatch.StartNew(); - // Marked only after the capture returns: a capture that threw may have left assemblies - // without a snapshot, and marking it done first would keep every later reader from - // capturing them. - bool sawAssemblies = _capture(); - // Why an empty list is not done: Unity lists none while it compiles, and a domain marked - // captured from an empty list would never be captured by any of its readers. - if (!sawAssemblies) + if (failure != null) { + VibeLogger.LogWarning( + HotReloadConstants.VibeLogSourceSnapshotCaptureFailed, + "Hot reload failed to capture the source snapshot of this domain.", + new { trigger, error = failure.GetType().Name, message = failure.Message }); return; } - _captured = true; VibeLogger.LogInfo( - HotReloadConstants.VibeLogSourceSnapshotCaptured, - "Hot reload captured the source snapshot of this domain.", - new { trigger, captureMs = watch.ElapsedMilliseconds }); + HotReloadConstants.VibeLogSourceSnapshotCaptureEmpty, + "Hot reload found no compiled assembly to capture the source snapshot from.", + new { trigger }); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs index 62db46410..5a20342e7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs @@ -15,7 +15,7 @@ internal static void CaptureIncrementally( string projectRoot, string assemblySnapshotDirectory, string previousSnapshotDirectory, - string[] sourceFiles, + IReadOnlyList sourceFiles, string assemblyName, HotReloadSnapshotSourceCheck check, HotReloadSnapshotCaptureStats stats) @@ -46,7 +46,7 @@ internal static void CaptureIncrementally( HashSet listedNames = new HashSet(StringComparer.Ordinal); SourceCopyTally tally = new SourceCopyTally(); - List manifestLines = new List(sourceFiles.Length); + List manifestLines = new List(sourceFiles.Count); foreach (string projectRelativeSourcePath in sourceFiles) { string normalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs index 40886d6be..407b3a22b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs @@ -18,7 +18,7 @@ internal static partial class HotReloadSourceSnapshotCopier internal static void CaptureAtomically( string projectRoot, string assemblySnapshotDirectory, - string[] sourceFiles, + IReadOnlyList sourceFiles, string assemblyName, HotReloadSnapshotSourceCheck check, HotReloadSnapshotCaptureStats stats) @@ -39,7 +39,7 @@ internal static void CaptureAtomically( Directory.CreateDirectory(temporaryDirectory); stats.PublishTicks += prepareWatch.Elapsed.Ticks; SourceCopyTally tally = new SourceCopyTally(); - List manifestLines = new List(sourceFiles.Length); + List manifestLines = new List(sourceFiles.Count); foreach (string projectRelativeSourcePath in sourceFiles) { SourceCopyOutcome outcome = CopySourceFileByteExact( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs index 41bb66dfb..80a5ae76c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs @@ -16,11 +16,23 @@ internal static class HotReloadSourceSnapshotLayout // The suffix of an assembly directory that is still being written; only a Move makes it complete. internal const string IncompleteDirectorySuffix = ".tmp"; + private const string ImmutableMarkerExtension = ".immutable"; + internal static string Root(string projectRoot) { return Path.Combine(projectRoot, HotReloadConstants.SourceSnapshotRelativeDirectory); } + /// + /// The file that records the compiled dll of a read-only package's assembly, in the stamp's + /// form, so a later capture skips that dll without reading its PDB. A file rather than a + /// directory, so the cleanup of earlier builds' directories leaves it alone. + /// + internal static string ImmutableMarkerPath(string projectRoot, string assemblyName) + { + return Path.Combine(Root(projectRoot), assemblyName + ImmutableMarkerExtension); + } + internal static string AssemblyDirectoryName(string assemblyName, string mvid) { return assemblyName + "-" + mvid; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs index 41f96e320..2360eda6d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs @@ -9,6 +9,10 @@ internal static partial class HotReloadSourceSnapshotter { private const string StampFileExtension = ".stamp"; + // Why a tag where a stamp has the MVID: the marker of a read-only package's assembly names + // no snapshot directory, and reading the MVID would open a dll the capture otherwise skips. + private const string ImmutableMarkerTag = "immutable"; + private static string StampPath(string snapshotRoot, string assemblyName) { return Path.Combine(snapshotRoot, assemblyName + StampFileExtension); @@ -95,4 +99,20 @@ private static void WriteStamp(string stampPath, string mvid, long dllMtimeTicks File.WriteAllText(stampPath, mvid + "," + dllMtimeTicks + "," + dllByteLength); } } + + /// + /// The write time and length of a compiled dll, which the stamp and the marker beside its + /// snapshots record. + /// + internal readonly struct HotReloadCompiledDllStamp + { + internal readonly long MtimeTicks; + internal readonly long ByteLength; + + internal HotReloadCompiledDllStamp(long mtimeTicks, long byteLength) + { + MtimeTicks = mtimeTicks; + ByteLength = byteLength; + } + } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 54d3b0e8d..d231c9397 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -8,7 +9,6 @@ using UnityEngine; -using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; using PackageManagerPackageInfo = UnityEditor.PackageManager.PackageInfo; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -21,57 +21,69 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static partial class HotReloadSourceSnapshotter { /// - /// Captures snapshots for project assemblies that have adjacent portable PDBs. + /// Captures snapshots for the project's compiled assemblies that have adjacent portable PDBs, + /// listed from the dlls on disk and each read from its PDB. /// A copy of a source written since the compile started is checked against the PDB checksum /// when it is captured, and marked in the manifest when it does not match, so the default file /// selection, the sibling scan and the skip check treat that file as changed. The method-diff /// baseline checks every copy against the PDB when it uses one. - /// Logs how long each phase took, even when the list is empty. - /// Returns false when Unity listed no compilation assembly (as while it compiles). + /// Logs how long each phase took, even when nothing is listed. + /// Returns false when no compiled dll with a PDB was listed. + /// Asks Unity nothing beyond what carries, so it may run on a pool + /// thread. Throws once + /// is cancelled. /// - internal static bool CaptureAfterDomainReload() + internal static bool CaptureAfterDomainReload( + HotReloadSnapshotCaptureInputs inputs, + CancellationToken cancellationToken) { + Debug.Assert(inputs != null, "inputs must not be null."); + + cancellationToken.ThrowIfCancellationRequested(); + System.Diagnostics.Stopwatch totalWatch = System.Diagnostics.Stopwatch.StartNew(); - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - System.Diagnostics.Stopwatch getAssembliesWatch = System.Diagnostics.Stopwatch.StartNew(); - IReadOnlyList assemblies = HotReloadCompilationAssemblies.Current(); - getAssembliesWatch.Stop(); HotReloadSnapshotCaptureStats stats = CaptureAssemblies( - projectRoot, - assemblies, - HotReloadCompileStartRecord.Read(), - HotReloadPdbDocumentIndex.Shared); + inputs, + HotReloadPdbDocumentIndex.Shared, + HotReloadCaptureAssemblyEnumerator.ReadSources, + cancellationToken); totalWatch.Stop(); VibeLogger.LogInfo( HotReloadConstants.VibeLogSourceSnapshotBreakdown, "Hot reload source snapshot capture breakdown.", - stats.ToVibeLogContext(getAssembliesWatch.ElapsedMilliseconds, totalWatch.ElapsedMilliseconds)); - return assemblies.Count > 0; + stats.ToVibeLogContext(totalWatch.ElapsedMilliseconds)); + return stats.Assemblies > 0; } - // Why separate from CaptureAfterDomainReload: the project root and the compilation assemblies come - // from Unity, so taking them as arguments lets tests drive the per-assembly loop in a temporary root. + // Why separate from CaptureAfterDomainReload: taking the document index and the PDB source + // reader as arguments lets tests drive the per-assembly loop in a temporary root, without + // writing the shared index, and see whether a PDB was read. // Returns how long each phase took and how much the capture did. internal static HotReloadSnapshotCaptureStats CaptureAssemblies( - string projectRoot, - IEnumerable assemblies, - HotReloadCompileStart compileStart, - HotReloadPdbDocumentIndex documentIndex) + HotReloadSnapshotCaptureInputs inputs, + HotReloadPdbDocumentIndex documentIndex, + Func readSources, + CancellationToken cancellationToken) { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); - Debug.Assert(assemblies != null, "assemblies must not be null."); + Debug.Assert(inputs != null, "inputs must not be null."); Debug.Assert(documentIndex != null, "documentIndex must not be null."); + Debug.Assert(readSources != null, "readSources must not be null."); - string snapshotRoot = HotReloadSourceSnapshotLayout.Root(projectRoot); + string snapshotRoot = HotReloadSourceSnapshotLayout.Root(inputs.ProjectRoot); Directory.CreateDirectory(snapshotRoot); HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats(); - foreach (UnityCompilationAssembly assembly in assemblies) + System.Diagnostics.Stopwatch listingWatch = System.Diagnostics.Stopwatch.StartNew(); + IReadOnlyList candidates = + HotReloadCaptureAssemblyEnumerator.ListCompiled(inputs.ProjectRoot); + stats.ListingTicks += listingWatch.Elapsed.Ticks; + foreach (HotReloadCaptureAssemblyCandidate candidate in candidates) { + cancellationToken.ThrowIfCancellationRequested(); stats.Assemblies++; try { - CaptureAssemblyIfNeeded(projectRoot, snapshotRoot, assembly, compileStart, documentIndex, stats); + CaptureCandidateIfNeeded(inputs, snapshotRoot, candidate, documentIndex, readSources, stats); } catch (Exception ex) when (IsSkippableAssemblyCaptureException(ex)) { @@ -80,7 +92,7 @@ internal static HotReloadSnapshotCaptureStats CaptureAssemblies( // Continuing preserves the fail-closed invariant because the method-diff baseline // rejects missing snapshots by PDB checksum rather than using them for an incorrect diff. UnityEngine.Debug.LogWarning( - $"[UnityCliLoop] Snapshot capture failed for assembly {assembly.name}: {ex.Message}"); + $"[UnityCliLoop] Snapshot capture failed for assembly {candidate.Name}: {ex.Message}"); } } @@ -96,73 +108,129 @@ ex is UnauthorizedAccessException || ex is BadImageFormatException; } - private static void CaptureAssemblyIfNeeded( - string projectRoot, + // Why the stamps are checked before the PDB is read: after an ordinary edit only one dll has + // changed, and after files are added or removed a few dozen, so only those PDBs are read. + private static void CaptureCandidateIfNeeded( + HotReloadSnapshotCaptureInputs inputs, string snapshotRoot, - UnityCompilationAssembly assembly, - HotReloadCompileStart compileStart, + HotReloadCaptureAssemblyCandidate candidate, HotReloadPdbDocumentIndex documentIndex, + Func readSources, HotReloadSnapshotCaptureStats stats) { - string[] sourceFiles = assembly.sourceFiles; - if (sourceFiles == null || sourceFiles.Length == 0) + FileInfo dllInfo = new FileInfo(candidate.DllPath); + HotReloadCompiledDllStamp dll = new HotReloadCompiledDllStamp(dllInfo.LastWriteTimeUtc.Ticks, dllInfo.Length); + string markerPath = HotReloadSourceSnapshotLayout.ImmutableMarkerPath(inputs.ProjectRoot, candidate.Name); + string stampPath = StampPath(snapshotRoot, candidate.Name); + + // Why stamp short-circuits Cecil: a false stamp (identical mtime+length with different + // bytes) is vanishingly rare, and even then LoadVerifiedSnapshotSource rejects via PDB + // checksum — so stamp lies degrade to fallback, never to a wrong method diff. The marker of + // a read-only package's assembly works the same way: a dll rebuilt after the package was + // made editable no longer matches it. + System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew(); + bool markerMatches = HasMatchingStamp(markerPath, dll.MtimeTicks, dll.ByteLength); + bool stampMatches = !markerMatches && HasMatchingStamp(stampPath, dll.MtimeTicks, dll.ByteLength); + stats.StampTicks += stampWatch.Elapsed.Ticks; + if (markerMatches) + { + stats.AssembliesImmutable++; + return; + } + + if (stampMatches) + { + stats.AssembliesUnchanged++; + return; + } + + HotReloadCaptureAssembly assembly = ReadSourcesOrNull(candidate, inputs, readSources, stats); + if (assembly == null || assembly.SourceAssetPaths.Count == 0) { + // No source maps to an asset path: nothing is written, as for an assembly Unity listed + // without sources, so the next capture reads the PDB again. return; } System.Diagnostics.Stopwatch packageLookupWatch = System.Diagnostics.Stopwatch.StartNew(); - bool immutable = ShouldSkipImmutablePackageSources(sourceFiles); + bool immutable = assembly.IsImmutablePackageAssembly; stats.PackageLookupTicks += packageLookupWatch.Elapsed.Ticks; if (immutable) { + System.Diagnostics.Stopwatch writeMarkerWatch = System.Diagnostics.Stopwatch.StartNew(); + WriteStamp(markerPath, ImmutableMarkerTag, dll.MtimeTicks, dll.ByteLength); + stats.StampTicks += writeMarkerWatch.Elapsed.Ticks; stats.AssembliesImmutable++; return; } - // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main - // project for a Virtual Player; its snapshots stay under its own root. - CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); - string dllPath = layout.DllPath(assembly.name); - string pdbPath = layout.PdbPath(assembly.name); - if (!File.Exists(dllPath) || !File.Exists(pdbPath)) - { - return; - } + CaptureChangedAssembly(inputs, snapshotRoot, candidate, assembly, dll, stampPath, documentIndex, stats); + } - FileInfo dllInfo = new FileInfo(dllPath); - long dllMtimeTicks = dllInfo.LastWriteTimeUtc.Ticks; - long dllByteLength = dllInfo.Length; - string stampPath = StampPath(snapshotRoot, assembly.name); + // The assembly's sources from its PDB, or null when the PDB belongs to another build of the + // dll. Why that build is skipped rather than failing the capture: it happens when the capture + // reads while a compile replaces the dll and the PDB; no stamp is written, so the next domain + // captures that assembly. + private static HotReloadCaptureAssembly ReadSourcesOrNull( + HotReloadCaptureAssemblyCandidate candidate, + HotReloadSnapshotCaptureInputs inputs, + Func readSources, + HotReloadSnapshotCaptureStats stats) + { + System.Diagnostics.Stopwatch readWatch = System.Diagnostics.Stopwatch.StartNew(); + try + { + HotReloadCaptureAssembly assembly = readSources(candidate, inputs); + if (assembly.SourcesFromCompilerOutput) + { + stats.AssembliesFromCompilerPdb++; + } - // Why stamp short-circuits Cecil: a false stamp (identical mtime+length with different - // bytes) is vanishingly rare, and even then LoadVerifiedSnapshotSource rejects via PDB - // checksum — so stamp lies degrade to fallback, never to a wrong method diff. - System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew(); - bool stampMatches = HasMatchingStamp(stampPath, dllMtimeTicks, dllByteLength); - stats.StampTicks += stampWatch.Elapsed.Ticks; - if (stampMatches) + return assembly; + } + catch (InvalidOperationException) { - stats.AssembliesUnchanged++; - return; + stats.AssembliesSkippedPdbMismatch++; + return null; + } + finally + { + stats.PdbSourcesTicks += readWatch.Elapsed.Ticks; } + } + private static void CaptureChangedAssembly( + HotReloadSnapshotCaptureInputs inputs, + string snapshotRoot, + HotReloadCaptureAssemblyCandidate candidate, + HotReloadCaptureAssembly assembly, + HotReloadCompiledDllStamp dll, + string stampPath, + HotReloadPdbDocumentIndex documentIndex, + HotReloadSnapshotCaptureStats stats) + { + // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main + // project for a Virtual Player; its snapshots stay under its own root. + string projectRoot = inputs.ProjectRoot; System.Diagnostics.Stopwatch mvidWatch = System.Diagnostics.Stopwatch.StartNew(); - string mvid = HotReloadAssemblyMvid.Read(dllPath); + string mvid = HotReloadAssemblyMvid.Read(candidate.DllPath); stats.MvidTicks += mvidWatch.Elapsed.Ticks; - string assemblySnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assembly.name, mvid); + string assemblySnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assembly.Name, mvid); if (!Directory.Exists(assemblySnapshotDirectory)) { HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck( - compileStart.SuspectWritesFrom(dllMtimeTicks), - dllPath, - pdbPath, + inputs.CompileStart.SuspectWritesFrom(dll.MtimeTicks), + candidate.DllPath, + candidate.PdbPath, mvid, - documentIndex); + documentIndex, + inputs.PackageRoots, + inputs.PathComparison); System.Diagnostics.Stopwatch previousWatch = System.Diagnostics.Stopwatch.StartNew(); string previousSnapshotDirectory = FindReusablePreviousSnapshotDirectory( projectRoot, stampPath, - assembly.name, + assembly.Name, assemblySnapshotDirectory); stats.StampTicks += previousWatch.Elapsed.Ticks; if (previousSnapshotDirectory == null) @@ -170,8 +238,8 @@ private static void CaptureAssemblyIfNeeded( HotReloadSourceSnapshotCopier.CaptureAtomically( projectRoot, assemblySnapshotDirectory, - sourceFiles, - assembly.name, + assembly.SourceAssetPaths, + assembly.Name, check, stats); } @@ -181,15 +249,15 @@ private static void CaptureAssemblyIfNeeded( projectRoot, assemblySnapshotDirectory, previousSnapshotDirectory, - sourceFiles, - assembly.name, + assembly.SourceAssetPaths, + assembly.Name, check, stats); } stats.AssembliesCaptured++; System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew(); - DeleteStaleSnapshotDirectories(snapshotRoot, assembly.name, assemblySnapshotDirectory); + DeleteStaleSnapshotDirectories(snapshotRoot, assembly.Name, assemblySnapshotDirectory); stats.CleanupTicks += cleanupWatch.Elapsed.Ticks; } @@ -198,7 +266,7 @@ private static void CaptureAssemblyIfNeeded( // because the method-diff baseline requires a PDB checksum match, and a changed DLL // invalidates this stamp through its mtime or length before the next capture. System.Diagnostics.Stopwatch writeStampWatch = System.Diagnostics.Stopwatch.StartNew(); - WriteStamp(stampPath, mvid, dllMtimeTicks, dllByteLength); + WriteStamp(stampPath, mvid, dll.MtimeTicks, dll.ByteLength); stats.StampTicks += writeStampWatch.Elapsed.Ticks; } diff --git a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs index 68e9a04fd..049b75d01 100644 --- a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs @@ -36,15 +36,18 @@ public static GetCompileStatusResponse Execute(JToken paramsToken) bool isDomainReloadInProgress = sessionFlagsRepository.GetIsDomainReloadInProgress() || DomainReloadStateRegistry.IsDomainReloadInProgress(); + bool isSnapshotCaptureHolding = + HotReloadSnapshotCaptureCoordination.IsHoldingCompileCompletion?.Invoke() ?? false; GetCompileStatusResponse response = BuildResponse( requestId, isCompiling, isUpdating, isDomainReloadInProgress, + isSnapshotCaptureHolding, compileSessionLifecycleService, compileResultSessionRepository, pendingCompileSessionRepository); - LogCompileStatusQueryReceived(requestId, response); + LogCompileStatusQueryReceived(requestId, response, isSnapshotCaptureHolding); return response; } @@ -53,6 +56,7 @@ internal static GetCompileStatusResponse BuildResponse( bool isCompiling, bool isUpdating, bool isDomainReloadInProgress, + bool isSnapshotCaptureHolding, UnityCliLoopCompileSessionLifecycleService compileSessionLifecycleService, ICompileResultSessionRepository compileResultSessionRepository, IPendingCompileSessionRepository pendingCompileSessionRepository) @@ -61,7 +65,9 @@ internal static GetCompileStatusResponse BuildResponse( Debug.Assert(compileResultSessionRepository != null, "compileResultSessionRepository must not be null"); Debug.Assert(pendingCompileSessionRepository != null, "pendingCompileSessionRepository must not be null"); - bool ready = !isCompiling && !isUpdating && !isDomainReloadInProgress; + // The hot reload snapshot capture must finish before compile returns, or an edit made + // right after the CLI returns could be captured as the source the compiler read. + bool ready = !isCompiling && !isUpdating && !isDomainReloadInProgress && !isSnapshotCaptureHolding; compileSessionLifecycleService.ClearExpiredPendingCompileRequest(DateTime.UtcNow); UnityCliLoopStoredCompileResult storedResult = string.IsNullOrWhiteSpace(requestId) ? UnityCliLoopStoredCompileResult.None() @@ -100,7 +106,8 @@ private static string ReadRequestId(JToken paramsToken) private static void LogCompileStatusQueryReceived( string requestId, - GetCompileStatusResponse response) + GetCompileStatusResponse response, + bool isSnapshotCaptureHolding) { Debug.Assert(response != null, "response must not be null"); @@ -115,6 +122,7 @@ private static void LogCompileStatusQueryReceived( is_compiling = response.IsCompiling, is_updating = response.IsUpdating, is_domain_reload_in_progress = response.IsDomainReloadInProgress, + is_snapshot_capture_holding = isSnapshotCaptureHolding, message = response.Message }, requestId); diff --git a/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs new file mode 100644 index 000000000..62f16cddd --- /dev/null +++ b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs @@ -0,0 +1,20 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Editor-domain coordination point that lets the compile status report wait for the hot + /// reload source snapshot capture. The hot-reload tool lives in a sibling assembly the + /// server must not reference, so it publishes the hold through a delegate wired at its own + /// startup. + /// + public static class HotReloadSnapshotCaptureCoordination + { + // Set by the hot-reload startup. Null means the hot-reload tool has not initialized in + // this domain, so nothing holds the compile completion - callers treat null as false. + // While it returns true, the compile completion is not reported: if the CLI returned + // from compile before the capture finished, an edit made right after could be captured + // as the source the compiler read. + public static Func IsHoldingCompileCompletion { get; set; } + } +} diff --git a/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta new file mode 100644 index 000000000..5b7e6eb86 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d901119d9fa444fe90222d442d4bd21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md new file mode 100644 index 000000000..299626f03 --- /dev/null +++ b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md @@ -0,0 +1,70 @@ +# Capture the Hot Reload Source Snapshot Off the Main Thread + +Date: 2026-10-11 + +## Decision + +After every domain reload, hot reload copies the sources of the compile into a snapshot so it can +later tell which methods were edited. That capture now runs on a pool thread: + +- The domain load reads on the main thread only the values the capture needs from Unity (the + project root, the compile start record, and the package roots). It hands them to the pool and + returns without waiting. +- At most one capture runs per domain. A hot reload run awaits it. A pause point blocks on it, + except while a capture runs and the file on disk matches the checksum the compiled assembly's + PDB recorded: those are the bytes the compiler read, so the pause point reads that file + instead of waiting. +- `get-compile-status` reports not ready while a capture runs, so `uloop compile` returns only + after the capture, up to 30 seconds. The hold lives in the internal bridge command, because + the CLI waits for a compile by polling it, outside the tool execution path. +- A domain reload or a compile start cancels the running capture without waiting. A capture that + failed, found no compiled dll with a PDB, or was cancelled leaves the domain uncaptured, and the + next reader starts a new one. + +## Context + +Issue #3222: on a large project the capture ran synchronously on the main thread after every +script compile and took about 1.9–2.3 s, writing 781 files (3.3 MB) for each compile, even when +hot reload was not used. Copying only the changed sources and skipping the capture when both +readers are disabled shortened it, but the main thread still waited on it, most of all on +listing the compilation assemblies from Unity. + +The capture used to list the assemblies with `CompilationPipeline.GetAssemblies`, which Unity +allows on the main thread only. #3283 made it list them from the compiled dlls and their PDBs +instead, so what stays on the main thread is the compile start record (`SessionState`) and the +package list (the Package Manager). Everything else the capture does is file IO and PDB reads, +which the pool can do. + +The guarantee that an edit made right after `uloop compile` returns is never captured as +compiled source used to hold because the capture finished inside the domain load, before the +command listener opened. Holding `get-compile-status` keeps that guarantee. A source written +after the compile started is still checked against the PDB, so a capture that ends after the +30-second cap stays correct; only the timing guarantee of the compile response is lost. + +## Considered Options + +- **1. Keep `GetAssemblies` on the main thread and stop at the incremental copy.** Rejected: the + main thread still stalls. +- **2. Build only the capture's assembly list from the dlls and PDBs.** Not enough alone: the + cost of `GetAssemblies` only moves to the warm-up or the first run. Adopted together with 4. +- **3. Move the capture, the warm-up's reference graph, the default file selection, and the + patch target resolution all to lists built from dlls and PDBs.** Too large. Only the part that + keeps the warm-up from calling `GetAssemblies` at startup (rebuilding the list from a saved + one and the PDBs) was adopted. +- **4. Run the capture on a pool thread.** Not possible alone: `GetAssemblies` is main-thread + only, and while the capture moves the previous snapshot directory nothing is published, so + every reader has to wait on the capture. Adopted together with 2, with the gate below. +- **Waiting for the capture in the tool execution path.** Rejected: `get-compile-status` is an + internal bridge command that never enters it, so the compile response would not wait. The + hold is in its `Ready` instead. +- **Making the pause point wait for the capture in every case.** Rejected: a pause point + re-armed on the first update tick after a reload would block the main thread for the whole + capture, which is the stall this change removes. +- **Re-arming pause points after the capture instead.** Rejected: the re-arm might not finish + before Play Mode starts. + +## Revisit When + +- Unity makes `CompilationPipeline.GetAssemblies` safe to call off the main thread, or +- the way the CLI waits for a compile changes, so the hold in `get-compile-status` no longer + stops the compile response. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 86b0fc72e..78c470180 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -81,10 +81,18 @@ and the default selection, the sibling scan and the skip check treat a marked so changed; a capture moves the previous build's directory to the new MVID's and rewrites only the sources whose length or write time changed, that carry no unmarked manifest line, or that were written since the compile started; it deletes the copies of sources no longer in the assembly. +The capture runs on a pool thread after the domain load reads its inputs on the main thread, one +capture at a time; `uloop compile` returns only after it finishes (or after 30 seconds), a hot +reload run waits for it, and a pause point waits for it unless the file on disk matches the PDB +checksum, in which case it reads that file. A domain reload or a compile start cancels it, and +the next reader starts it again (`docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md`). When both the hot-reload and pause-point tools are disabled in the tool settings, the capture after a domain reload is skipped; the first hot reload run or pause point after either tool is enabled captures it, and any source written since the compile started is then checked against -the PDB), +the PDB. The capture lists the assemblies from the compiled dlls that have a PDB beside them and +reads an assembly's sources from its PDB only when its dll changed; a read-only package's +assembly gets a `.immutable` marker beside the snapshots instead, which skips its +PDB until the dll changes), `Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the first run after a domain reload does not walk the PDB again while they still match), and diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index b750dc519..25e7caf46 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -90,23 +90,43 @@ command ran against: sent), `start_failed` or `request_failed` (with `failureReason`; the worker is stopped and the next compile starts it again), or `failed` (an exception, with `failureReason` set to its type and `exceptionMessage`, logged as a warning). - `hot_reload_source_snapshot_captured` appears once per domain when the source snapshot - capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or - `apply` only when the capture at domain load threw or Unity listed no compilation assembly - yet; `apply` or `pause_point` when the captures at startup were skipped and a hot reload run - or a pause point is the first to read the snapshot) and `captureMs`. When both the hot-reload - and pause-point tools are disabled in the tool settings, the captures at `domain_load` and + The source snapshot capture reads its inputs on the main thread and then runs on a pool + thread, one capture at a time. Each start writes `hot_reload_source_snapshot_capture_started` + with its `trigger` and `mainThreadMs` (the time the main thread spent reading the inputs and + handing the capture over). How it ended follows as one of these, with the same `trigger`: + - `hot_reload_source_snapshot_captured`, once per domain, with `captureMs` (the time on the + pool thread). `trigger` is `domain_load` normally; `first_update_tick`, `apply` or + `pause_point` when the capture at domain load failed, found no compiled dll with a PDB, + or was cancelled, or when the captures at startup were skipped and a hot reload run or a + pause point is the first to read the snapshot. + - `hot_reload_source_snapshot_capture_failed` (a warning) with `error` (the exception type) and + `message`. + - `hot_reload_source_snapshot_capture_empty` when no compiled dll with a PDB beside it was + found. + - `hot_reload_source_snapshot_capture_cancelled` when a domain reload or a compile start + stopped it. + After any of the last three, the next reader or the first update tick starts a new capture. + While a capture runs, `get-compile-status` reports not ready, so `uloop compile` returns only + after it. That hold gives up after 30 seconds and writes one + `hot_reload_source_snapshot_capture_hold_cap_exceeded` (a warning). Readers keep waiting for + the capture. `compile_status_query_received` carries `is_snapshot_capture_holding`. When both + the hot-reload and pause-point tools are disabled in the tool settings, the captures at `domain_load` and `first_update_tick` each write `hot_reload_source_snapshot_skipped` with that `trigger` and `reason` `tools_disabled` instead, and the first hot reload run or pause point after either tool is enabled captures it. `hot_reload_source_snapshot_breakdown` appears once each time the capture runs to the end, - just before `hot_reload_source_snapshot_captured`. It also appears when Unity listed no - compilation assembly, in which case no captured entry follows, and it does not appear when the - capture threw. It says where the capture spent its time: - - `getAssembliesMs`: listing the compilation assemblies from Unity. - - `packageLookupMs`: asking Package Manager whether each assembly's sources are immutable. - - `stampMs`: reading and writing the stamps beside the snapshots, and reading the previous - build's manifest. + just before `hot_reload_source_snapshot_captured`. It also appears when no compiled dll with a + PDB beside it was found, in which case `hot_reload_source_snapshot_capture_empty` follows + instead, and it does not appear when the capture threw or was cancelled. The capture lists the + compiled assemblies from the dlls and PDBs in the compiled assemblies folder and reads an + assembly's sources from its PDB, not from Unity's compilation assembly list. It says where the capture spent its time: + - `listingMs`: listing the compiled dlls that have a PDB beside them. + - `pdbSourcesMs`: reading the sources of each assembly whose dll changed from its PDB. + - `packageLookupMs`: reading whether each assembly's sources are immutable, which the PDB's + sources and the package roots read once from Package Manager decide, so this is close to 0; + it no longer asks Package Manager for each assembly. + - `stampMs`: reading and writing the stamps and the read-only markers beside the snapshots, and + reading the previous build's manifest. - `mvidMs`: reading the MVID of each compiled dll whose stamp did not match. - `statMs`: reading each source's length and write time before and after it is copied, and checking whether a source and its previous copy are unchanged. @@ -117,16 +137,69 @@ command ran against: previous build's directory there, writing the manifest, and moving the directory into place. - `cleanupMs`: deleting the snapshot directories of earlier builds, and the copies of sources the assembly no longer lists. - - `totalMs`: the whole capture. - - `assemblies`: the compilation assemblies listed. - - `assembliesImmutable`: those skipped because their sources belong to an immutable package. + - `totalMs`: the whole capture. Reading the compile start and the package roots before it is + not counted here. + - `assemblies`: the compiled assemblies listed. + - `assembliesImmutable`: those skipped because their sources belong to an immutable package: + either their read-only marker matched the compiled dll, or their PDB showed it and the marker + was written. - `assembliesUnchanged`: those skipped because their stamp matched the compiled dll. - `assembliesCaptured`: those whose sources were copied into a new snapshot directory. + - `assembliesSkippedPdbMismatch`: those skipped because their PDB belonged to another build of + the dll, as while a compile replaces them; no stamp is written, so the next domain captures them. + - `assembliesFromCompilerPdb`: assemblies whose sources were read from the compiler's own PDB + under `Library/Bee` because the PDB beside the dll had lost documents. - `filesReused`: the sources whose previous copy was kept without reading them. - `filesCopied` and `bytesCopied`: the sources copied and their total size. - `filesChecked`: the sources checked against the PDB. The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry - also includes writing this breakdown entry. + also includes reading the compile start and the package roots, and writing this breakdown entry. + `hot_reload_compilation_assemblies_fetched` appears each time hot reload asks Unity for the + compilation assembly list, with `fetchMs` and `assemblyCount`. After a domain reload the list is + normally rebuilt off the main thread from the list a previous domain saved + (`hot_reload_compilation_list_reconstructed` below), so this appears only when that rebuild kept + no list or the cache was invalidated in the domain, from the first hot reload run that needs the + list. The list is then kept for the rest of the domain once it is not empty. + `hot_reload_compilation_list_reconstructed` appears once per domain reload. `filled` says whether + the rebuilt list filled the cache, and `reason` says why it did not: + - `no_saved_list`: no list has been saved yet. + - `saved_list_unreadable`: the saved list is torn, of another format version, or missing a + required field. + - `assembly_set_changed`: an assembly definition was added or removed after the list was saved. + - `pdb_mismatch`: the PDB beside a compiled-again dll belongs to another build, as while a + compile replaces the files. + - `pdb_documents_incomplete`: neither the PDB of a compiled-again assembly nor the compiler's + own PDB of the same build shows that it names every source the compile was given. + - `unresolved_reference`: a reference has a file name the saved list does not know, or a project + reference is not in the list. + - `reference_missing`: every path saved for a reference's file name is gone, as when a package + update renames its folder. + - `reference_changed`: a saved path exists, but not with the dll of the recorded MVID. + - `compilation_failed`: the last compile failed, so the dlls are older than the sources. + - `superseded`: the cache was filled or invalidated meanwhile. + - `cancelled`: a compile started or the domain is ending. + - `tool_disabled`: the hot-reload tool is disabled in the tool settings. + - Any other value is the type name of the exception that stopped the rebuild. + `reconstructMs` is the time from the start of the rebuild to the entry, including the wait for + the main thread. `backgroundMs` is the time spent rebuilding on a pool thread, without the wait + for that thread, and `mainThreadWaitMs` the time it then waited for the main thread to run again. + Either is null when the rebuild did not get through that step. A `tool_disabled` entry has only + `filled` and `reason`, because no rebuild started. + When the cache was not filled, the first hot reload run that needs the list asks Unity for it + (`hot_reload_compilation_assemblies_fetched`) and saves it when the cache was not invalidated + in the domain and the last compile did not fail, so a reason that comes from an old saved list + (`no_saved_list`, `saved_list_unreadable`, `assembly_set_changed`, `unresolved_reference`, + `reference_missing`, `reference_changed`) does not repeat after that. Without a list, the + warm-up skips `publicized_targets`, `referenced_method_sets` and `transform_worker` with + `compilation_list_unavailable`. + `hot_reload_compilation_list_saved` appears when a list was written for the next domain, with + its size in `bytes`, `saveMs`, and the counts of `assemblies` and `references`. It is written on + a pool thread after a list is asked of Unity and after a rebuilt list fills the cache, only when + the cache was not invalidated in the domain and the last compile did not fail. + `hot_reload_compilation_list_save_skipped` names why a list was not saved in `reason`: + `generation` (the cache was invalidated in the domain), `compilation_failed`, or `superseded` + (the cache was invalidated while it was written, as when a compile started). + `hot_reload_compilation_list_save_failed` is a warning with the exception type name in `error`. After a domain reload, the warm-up writes one `hot_reload_warm_up_complete` with the target assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy` (`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one