diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs new file mode 100644 index 000000000..1b0cca35e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs @@ -0,0 +1,163 @@ +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 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 each editable one the sources read from the PDB are Unity's source files. + /// + [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 editableSources = 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; + } + + HotReloadCaptureAssembly assembly = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, inputs); + 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; + } + + // Why the sources of a read-only package's assembly are not compared: the capture + // copies none of them, and some such packages list files their PDB does not. + if (expectedImmutable) + { + continue; + } + + editableSources += 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, " + editableSources + " sources of editable ones; " + mismatches.Count + " mismatches"; + TestContext.WriteLine(summary + "."); + 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))); + } + + 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/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs index 13135c996..aac139d38 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,32 +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); HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( _root, HotReloadCompileStart.Unknown, Array.Empty(), - StringComparison.Ordinal, - new[] - { - HotReloadSnapshotCaptureInputs.ToCaptureAssembly( - assembly, - Array.Empty(), - StringComparison.Ordinal) - }, - 0); + StringComparison.Ordinal); return HotReloadSourceSnapshotter.CaptureAssemblies( inputs, - new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); + new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")), + (candidate, capturedInputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false), + CancellationToken.None); } // A check whose suspect window starts at the planted compile start, against the given build. diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index 116440f3a..0c3b551d8 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,9 +66,7 @@ public void CaptureAssemblies_WithoutStamp_CapturesSourcesRemovesStaleSnapshotAn string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staleDirectory); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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); @@ -85,9 +84,7 @@ public void CaptureAssemblies_WhenStampMatches_SkipsCapture() string stamp = ExpectedStamp(dllPath, mvid); File.WriteAllText(StampPath("Fixture"), stamp); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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)); @@ -107,9 +104,7 @@ public void CaptureAssemblies_WhenMvidDirectoryExists_KeepsSnapshotsAndWritesSta string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staleDirectory); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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); @@ -147,9 +142,7 @@ public void CaptureAssemblies_WhenStampIsMalformedOrDiffers_CapturesAgain(string .Replace("{length}", length.ToString(CultureInfo.InvariantCulture)); File.WriteAllText(StampPath("Fixture"), stamp); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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))); @@ -175,9 +168,7 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories() Directory.CreateDirectory(sibling); Directory.CreateDirectory(nonMvid); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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); @@ -187,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() @@ -195,35 +188,132 @@ public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing() string dllPath = PlantCompiledAssembly("NoSources"); string mvid = HotReloadAssemblyMvid.Read(dllPath); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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 an assembly the inputs classified as an immutable package's is counted and skipped - /// before anything is written, though its DLL and PDB are there: no snapshot directory and no stamp. + /// 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_ImmutablePackageAssembly_CountsItAndWritesNothing() + public void CaptureAssemblies_ImmutableMarkerMatchingTheDll_SkipsWithoutReadingThePdb() { - PlantCompiledAssembly("Fixture"); - HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( + string dllPath = PlantCompiledAssembly("Fixture"); + File.WriteAllText(MarkerPath("Fixture"), DllStampText("immutable", dllPath, 0)); + int reads = 0; + + HotReloadSnapshotCaptureStats stats = Capture( _projectRoot, HotReloadCompileStart.Unknown, - Array.Empty(), - StringComparison.Ordinal, - new[] { new HotReloadCaptureAssembly("Fixture", new[] { SourceRelativePath }, true) }, - 0); - - HotReloadSnapshotCaptureStats stats = HotReloadSourceSnapshotter.CaptureAssemblies(inputs, CreateDocumentIndex()); + 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); + return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, 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); } /// @@ -236,9 +326,7 @@ public void CaptureAssemblies_WhenCompiledOutputIsMissing_WritesNothingAndDoesNo string dllPath = PlantCompiledAssembly("Fixture"); File.Delete(Path.ChangeExtension(dllPath, missingExtension)); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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); @@ -260,9 +348,7 @@ public void CaptureAssemblies_WhenStampMatches_DoesNotOpenTheCompiledAssembly() string stamp = ExpectedStamp(dllPath, mvid); File.WriteAllText(StampPath("Fixture"), stamp); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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)); @@ -286,9 +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( - CreateInputs(_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); @@ -347,9 +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( - CreateInputs(playerRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(playerRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); string playerSnapshotSource = HotReloadSourceSnapshotLayout.SourcePath( HotReloadSourceSnapshotLayout.AssemblyDirectory(playerRoot, "Fixture", mvid), @@ -414,17 +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( - CreateInputs(_projectRoot, assemblies, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); - HotReloadSnapshotCaptureStats second = HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_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"); @@ -450,9 +526,7 @@ private string CaptureWithSourceSavedBetweenStartAndDll( new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); string mvid = HotReloadAssemblyMvid.Read(dllPath); - stats = HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, compileStart), - CreateDocumentIndex()); + stats = Capture(_projectRoot, compileStart, ReadsSources(SourceRelativePath)); return Path.Combine(_snapshotRoot, "Fixture-" + mvid); } @@ -508,29 +582,53 @@ private static UnityCompilationAssembly CreateAssemblyWithSources(string assembl AssemblyFlags.EditorAssembly); } - // The capture's inputs for a temporary root: no package is registered there, so every - // assembly reads as editable. - private static HotReloadSnapshotCaptureInputs CreateInputs( + // 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, - IEnumerable assemblies, - HotReloadCompileStart compileStart) + HotReloadCompileStart compileStart, + Func readSources) { - List captureAssemblies = new List(); - foreach (UnityCompilationAssembly assembly in assemblies) - { - captureAssemblies.Add(HotReloadSnapshotCaptureInputs.ToCaptureAssembly( - assembly, - Array.Empty(), - StringComparison.Ordinal)); - } - - return new HotReloadSnapshotCaptureInputs( + HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( projectRoot, compileStart, Array.Empty(), - StringComparison.Ordinal, - captureAssemblies, - 0); + 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); + } + + // 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); + }; + } + + 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) diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs index 41f7ac0e8..804fe5b8c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs @@ -1,158 +1,29 @@ -using System; -using System.Collections.Generic; using System.IO; 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 { /// - /// Covers the values the snapshot capture reads from Unity before it starts: how one of Unity's - /// compilation assemblies is classified from the package roots, and, on this project's real - /// assemblies, that the classification agrees with the Package Manager's answer. + /// Covers the values the snapshot capture reads from Unity on the main thread before it starts. /// public sealed class HotReloadSnapshotCaptureInputsTests { - 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), - new HotReloadSnapshotPackageRoot("/proj/Library/PackageCache/com.example.reg@1.0.0", "Packages/com.example.reg", true), - }; - - /// - /// Verifies an assembly whose first source lies in an immutable package is classified as - /// immutable, and keeps its name and sources. - /// - [Test] - public void ToCaptureAssembly_FirstSourceInAnImmutablePackage_IsImmutable() - { - HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs"); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.True); - Assert.That(assembly.Name, Is.EqualTo("Fixture")); - Assert.That(assembly.SourceAssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs" })); - } - - /// - /// Verifies sources of an embedded package, a local package and Assets are editable. - /// - [TestCase("Packages/com.example.embedded/Editor/A.cs")] - [TestCase("Packages/com.example.local/A.cs")] - [TestCase("Assets/A.cs")] - public void ToCaptureAssembly_FirstSourceEditable_IsNotImmutable(string firstSource) - { - HotReloadCaptureAssembly assembly = Classify(firstSource); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.False); - } - - /// - /// Verifies a package whose name only starts with an immutable package's name is not taken for it. - /// - [Test] - public void ToCaptureAssembly_PackageNameExtendingAnImmutableOne_IsNotImmutable() - { - HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg2/A.cs"); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.False); - } - - /// - /// Verifies an assembly without sources is classified as editable with an empty source list. - /// - [Test] - public void ToCaptureAssembly_WithoutSources_IsNotImmutableAndHasNoSources() - { - HotReloadCaptureAssembly assembly = Classify(); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.False); - Assert.That(assembly.SourceAssetPaths, Is.Empty); - } - - /// - /// Verifies, for every compilation assembly of this project that has sources, that the - /// classification from the package roots equals the Package Manager's answer the capture - /// asked before. - /// - [Test] - public void ToCaptureAssembly_FromPackageRoots_MatchesThePackageManagerAnswerForEveryAssembly() - { - IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); - StringComparison comparison = HotReloadSnapshotCaptureInputs.CurrentPathComparison(); - List mismatches = new List(); - int checkedAssemblies = 0; - int immutableAssemblies = 0; - - foreach (UnityCompilationAssembly compilationAssembly in HotReloadCompilationAssemblies.Current()) - { - string[] sourceFiles = compilationAssembly.sourceFiles; - if (sourceFiles == null || sourceFiles.Length == 0) - { - continue; - } - - checkedAssemblies++; - bool expected = HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); - immutableAssemblies += expected ? 1 : 0; - bool actual = HotReloadSnapshotCaptureInputs - .ToCaptureAssembly(compilationAssembly, packageRoots, comparison) - .IsImmutablePackageAssembly; - if (actual != expected) - { - mismatches.Add(compilationAssembly.name + " (" + sourceFiles[0] + "): package roots say " - + actual + ", the Package Manager says " + expected + "."); - } - } - - TestContext.WriteLine( - "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; " - + mismatches.Count + " mismatches."); - Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compilation assembly with sources was found."); - Assert.That(immutableAssemblies, Is.GreaterThan(0), "No immutable package assembly was found to compare."); - Assert.That( - mismatches, - Is.Empty, - "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; " - + mismatches.Count + " mismatches:\n" - + string.Join("\n", mismatches.GetRange(0, Math.Min(MaxListedMismatches, mismatches.Count)))); - } - /// - /// Verifies the inputs read on the main thread name this project's root and carry one entry - /// for each compilation assembly Unity lists. + /// Verifies the inputs read on the main thread name this project's root and carry the + /// registered packages. /// [Test] - public void ReadOnMainThread_CarriesTheProjectRootAndEveryCompilationAssembly() + public void ReadOnMainThread_CarriesTheProjectRootAndThePackageRoots() { HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread(); Assert.That(inputs.ProjectRoot, Is.EqualTo(Path.GetFullPath(Path.Combine(Application.dataPath, "..")))); - Assert.That(inputs.Assemblies.Count, Is.EqualTo(HotReloadCompilationAssemblies.Current().Count)); Assert.That(inputs.PackageRoots, Is.Not.Empty); } - - private static HotReloadCaptureAssembly Classify(params string[] sourceFiles) - { - UnityCompilationAssembly compilationAssembly = new UnityCompilationAssembly( - "Fixture", - "Library/ScriptAssemblies/Fixture.dll", - sourceFiles, - Array.Empty(), - Array.Empty(), - Array.Empty(), - AssemblyFlags.EditorAssembly); - return HotReloadSnapshotCaptureInputs.ToCaptureAssembly(compilationAssembly, Packages, StringComparison.Ordinal); - } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 52fd7ec15..8c4067726 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -208,9 +208,9 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase() JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown); string[] fields = { - "getAssembliesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs", + "getAssembliesMs", "pdbSourcesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs", "pdbCheckMs", "publishMs", "cleanupMs", "totalMs", "assemblies", "assembliesImmutable", - "assembliesUnchanged", "assembliesCaptured", "filesCopied", "bytesCopied", "filesChecked" + "assembliesUnchanged", "assembliesCaptured", "assembliesSkippedPdbMismatch", "filesCopied", "bytesCopied", "filesChecked" }; foreach (string field in fields) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index b3e05786d..3382ae4d5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -16,6 +16,11 @@ 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 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 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..b6f371a5b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs @@ -0,0 +1,99 @@ +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. Throws InvalidOperationException when + /// the PDB belongs to another build of the dll, as while a compile replaces them. + /// + 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); + // 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); + } + } +} 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 8f38993d1..d502dd2bb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -16,8 +16,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// 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. + /// Whichever caller first needs the list after a domain reload asks Unity for 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 @@ -42,6 +42,15 @@ static HotReloadCompilationAssemblies() CompilationPipeline.compilationStarted += OnCompilationStarted; } + /// + /// 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() + { + } + /// /// Returns the compilation assemblies. The list is shared; callers only read it. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs index b025a41a3..57d7bd99b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Threading; +using io.github.hatayama.UnityCliLoop.ToolContracts; + using UnityEngine; using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; @@ -43,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. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 8d0f5547f..f38b407e5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -681,6 +681,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"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs index 1ed87b328..5cd37293e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs @@ -1,14 +1,12 @@ using System; -using System.Collections.Generic; using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Tells which package folder a slash-separated path lies in, and whether hot reload treats that - /// package's sources as read-only. Does no IO and asks nothing of the Package Manager, so it can - /// run on any thread. + /// 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 { @@ -34,35 +32,6 @@ internal static int PrefixLength(string path, string folder, StringComparison co return folder.Length; } - /// - /// Returns whether the asset path lies in a package hot reload treats as read-only: the - /// of the package whose asset path - /// contains it, or false when no package does, as for Assets. The same rule the PDB source - /// map applies to the first source of an assembly. - /// - internal static bool IsInImmutablePackage( - string slashNormalizedAssetPath, - IReadOnlyList packageRoots, - StringComparison comparison) - { - Debug.Assert(!string.IsNullOrEmpty(slashNormalizedAssetPath), "slashNormalizedAssetPath must not be null or empty."); - Debug.Assert(packageRoots != null, "packageRoots must not be null."); - - int bestLength = -1; - bool bestIsImmutable = false; - foreach (HotReloadSnapshotPackageRoot root in packageRoots) - { - int length = PrefixLength(slashNormalizedAssetPath, NormalizeFolder(root.AssetPath), comparison); - if (length > bestLength) - { - bestLength = length; - bestIsImmutable = root.IsImmutable; - } - } - - return bestIsImmutable; - } - /// /// Returns the folder with forward slashes and without a trailing slash, the form /// expects. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs index 9fdc288ca..3dec67974 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs @@ -4,8 +4,6 @@ using UnityEngine; -using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; - namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -18,23 +16,18 @@ internal HotReloadSnapshotCaptureInputs( string projectRoot, HotReloadCompileStart compileStart, IReadOnlyList packageRoots, - StringComparison pathComparison, - IReadOnlyList assemblies, - long assemblyListingMs) + 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."); - Debug.Assert(assemblies != null, "assemblies must not be null."); ProjectRoot = projectRoot; CompileStart = compileStart; PackageRoots = packageRoots; PathComparison = pathComparison; - Assemblies = assemblies; - AssemblyListingMs = assemblyListingMs; } /// The root of this Editor's project, which owns the snapshots. @@ -49,39 +42,18 @@ internal HotReloadSnapshotCaptureInputs( /// How paths compare on this platform: case-insensitive on Windows only. internal StringComparison PathComparison { get; } - /// The compiled assemblies to capture. - internal IReadOnlyList Assemblies { get; } - - /// How long listing the assemblies took, in milliseconds. - internal long AssemblyListingMs { get; } - /// - /// Reads the inputs from Unity. Main thread only: the compile start lives in SessionState, the - /// package roots come from the Package Manager, and the assemblies from - /// CompilationPipeline.GetAssemblies, all of which Unity allows on the main thread alone. + /// 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() { - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadCompileStart compileStart = HotReloadCompileStartRecord.Read(); - IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); - StringComparison pathComparison = CurrentPathComparison(); - System.Diagnostics.Stopwatch listingWatch = System.Diagnostics.Stopwatch.StartNew(); - IReadOnlyList compilationAssemblies = HotReloadCompilationAssemblies.Current(); - listingWatch.Stop(); - List assemblies = new List(compilationAssemblies.Count); - foreach (UnityCompilationAssembly compilationAssembly in compilationAssemblies) - { - assemblies.Add(ToCaptureAssembly(compilationAssembly, packageRoots, pathComparison)); - } - return new HotReloadSnapshotCaptureInputs( - projectRoot, - compileStart, - packageRoots, - pathComparison, - assemblies, - listingWatch.ElapsedMilliseconds); + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadCompileStartRecord.Read(), + HotReloadSnapshotPackageRoots.ReadCurrent(), + CurrentPathComparison()); } /// @@ -92,24 +64,5 @@ internal static StringComparison CurrentPathComparison() { return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } - - /// - /// Turns one of Unity's compilation assemblies into what the capture reads, classifying it by - /// its first source because an asmdef keeps one assembly inside one package. - /// - internal static HotReloadCaptureAssembly ToCaptureAssembly( - UnityCompilationAssembly compilationAssembly, - IReadOnlyList packageRoots, - StringComparison pathComparison) - { - Debug.Assert(compilationAssembly != null, "compilationAssembly must not be null."); - - string[] sourceFiles = compilationAssembly.sourceFiles ?? Array.Empty(); - bool isImmutable = sourceFiles.Length > 0 && HotReloadPackageFolderMatch.IsInImmutablePackage( - sourceFiles[0].Replace('\\', '/'), - packageRoots, - pathComparison); - return new HotReloadCaptureAssembly(compilationAssembly.name, sourceFiles, isImmutable); - } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs index 3f939b7bf..997e07e28 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,18 @@ internal sealed class HotReloadSnapshotCaptureStats internal int AssembliesImmutable; internal int AssembliesUnchanged; internal int AssembliesCaptured; + internal int AssembliesSkippedPdbMismatch; 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, + getAssembliesMs = ToMilliseconds(ListingTicks), + pdbSourcesMs = ToMilliseconds(PdbSourcesTicks), packageLookupMs = ToMilliseconds(PackageLookupTicks), stampMs = ToMilliseconds(StampTicks), mvidMs = ToMilliseconds(MvidTicks), @@ -45,6 +49,7 @@ internal object ToVibeLogContext(long getAssembliesMs, long totalMs) assembliesImmutable = AssembliesImmutable, assembliesUnchanged = AssembliesUnchanged, assembliesCaptured = AssembliesCaptured, + assembliesSkippedPdbMismatch = AssembliesSkippedPdbMismatch, filesReused = FilesReused, filesCopied = FilesCopied, bytesCopied = BytesCopied, 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 bdae7bfdc..413df68c9 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; @@ -20,53 +21,63 @@ 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 the inputs list no compilation assembly (as while Unity 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. /// internal static bool CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs inputs) { Debug.Assert(inputs != null, "inputs must not be null."); - System.Diagnostics.Stopwatch captureWatch = System.Diagnostics.Stopwatch.StartNew(); - HotReloadSnapshotCaptureStats stats = CaptureAssemblies(inputs, HotReloadPdbDocumentIndex.Shared); - captureWatch.Stop(); - // Why the listing time is added: the assemblies are now listed while the inputs are read, - // before this method runs, and totalMs has always included that listing. + System.Diagnostics.Stopwatch totalWatch = System.Diagnostics.Stopwatch.StartNew(); + HotReloadSnapshotCaptureStats stats = CaptureAssemblies( + inputs, + HotReloadPdbDocumentIndex.Shared, + HotReloadCaptureAssemblyEnumerator.ReadSources, + CancellationToken.None); + totalWatch.Stop(); VibeLogger.LogInfo( HotReloadConstants.VibeLogSourceSnapshotBreakdown, "Hot reload source snapshot capture breakdown.", - stats.ToVibeLogContext( - inputs.AssemblyListingMs, - inputs.AssemblyListingMs + captureWatch.ElapsedMilliseconds)); - return inputs.Assemblies.Count > 0; + stats.ToVibeLogContext(totalWatch.ElapsedMilliseconds)); + return stats.Assemblies > 0; } - // Why separate from CaptureAfterDomainReload: taking the document index as an argument lets - // tests drive the per-assembly loop in a temporary root without writing the shared index. + // 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( HotReloadSnapshotCaptureInputs inputs, - HotReloadPdbDocumentIndex documentIndex) + HotReloadPdbDocumentIndex documentIndex, + Func readSources, + CancellationToken cancellationToken) { 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(inputs.ProjectRoot); Directory.CreateDirectory(snapshotRoot); HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats(); - foreach (HotReloadCaptureAssembly assembly in inputs.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(inputs, snapshotRoot, assembly, documentIndex, stats); + CaptureCandidateIfNeeded(inputs, snapshotRoot, candidate, documentIndex, readSources, stats); } catch (Exception ex) when (IsSkippableAssemblyCaptureException(ex)) { @@ -75,7 +86,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}"); } } @@ -91,68 +102,114 @@ ex is UnauthorizedAccessException || ex is BadImageFormatException; } - private static void CaptureAssemblyIfNeeded( + // 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, - HotReloadCaptureAssembly assembly, + HotReloadCaptureAssemblyCandidate candidate, HotReloadPdbDocumentIndex documentIndex, + Func readSources, HotReloadSnapshotCaptureStats stats) { - IReadOnlyList sourceFiles = assembly.SourceAssetPaths; - if (sourceFiles.Count == 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; } - string projectRoot = inputs.ProjectRoot; - // Why only a read: the inputs classified the assembly from the package roots, so the - // capture no longer asks the Package Manager here. System.Diagnostics.Stopwatch packageLookupWatch = System.Diagnostics.Stopwatch.StartNew(); 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)) + CaptureChangedAssembly(inputs, snapshotRoot, candidate, assembly, dll, stampPath, documentIndex, stats); + } + + // 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 { - return; + return readSources(candidate, inputs); } - - FileInfo dllInfo = new FileInfo(dllPath); - long dllMtimeTicks = dllInfo.LastWriteTimeUtc.Ticks; - long dllByteLength = dllInfo.Length; - string stampPath = StampPath(snapshotRoot, assembly.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. - System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew(); - bool stampMatches = HasMatchingStamp(stampPath, dllMtimeTicks, dllByteLength); - stats.StampTicks += stampWatch.Elapsed.Ticks; - if (stampMatches) + 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); if (!Directory.Exists(assemblySnapshotDirectory)) { HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck( - inputs.CompileStart.SuspectWritesFrom(dllMtimeTicks), - dllPath, - pdbPath, + inputs.CompileStart.SuspectWritesFrom(dll.MtimeTicks), + candidate.DllPath, + candidate.PdbPath, mvid, documentIndex, inputs.PackageRoots, @@ -169,7 +226,7 @@ private static void CaptureAssemblyIfNeeded( HotReloadSourceSnapshotCopier.CaptureAtomically( projectRoot, assemblySnapshotDirectory, - sourceFiles, + assembly.SourceAssetPaths, assembly.Name, check, stats); @@ -180,7 +237,7 @@ private static void CaptureAssemblyIfNeeded( projectRoot, assemblySnapshotDirectory, previousSnapshotDirectory, - sourceFiles, + assembly.SourceAssetPaths, assembly.Name, check, stats); @@ -197,7 +254,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/docs/hot-reload.md b/docs/hot-reload.md index 86b0fc72e..0c4093728 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -84,7 +84,10 @@ written since the compile started; it deletes the copies of sources no longer in 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 3ca5cb287..d94951c8c 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -92,7 +92,7 @@ command ran against: 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 + `apply` only when the capture at domain load threw or found no compiled dll with a PDB 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 @@ -100,16 +100,18 @@ command ran against: `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, which happens while the - capture's inputs are read on the main thread, before any copying starts. - - `packageLookupMs`: reading whether each assembly's sources are immutable. The inputs decide - that from the package roots they read from Package Manager once, so this is close to 0; it - no longer asks Package Manager for each assembly. - - `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 no captured entry follows, and it does not appear when + the capture threw. 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: + - `getAssembliesMs`: 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. @@ -120,17 +122,25 @@ 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`: listing the assemblies plus the whole per-assembly loop. Reading the compile start - and the package roots for the inputs is not counted here. - - `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. - `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 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`. The list is kept for the rest of + the domain once it is not empty, so this normally appears once per domain, from whichever of the + warm-up or the first hot reload run needs the list first. 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