From b356da1f4e2da48c6d940f4a12780bd991f5a683 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 18:17:19 +0900 Subject: [PATCH 1/3] List the snapshot capture's assemblies from the compiled dlls and their PDBs The capture no longer asks Unity's compilation pipeline for the assembly list, which only answers on the main thread. It lists the dlls in the compiled assemblies folder that have a PDB beside them and reads an assembly's sources from its PDB's document table, which runs on any thread. - The stamp is checked before the PDB is read, so only the assemblies whose dll changed pay for reading their PDB. - A read-only package's assembly gets a marker in the stamp's form, so later captures skip its PDB until the dll changes. - An assembly whose PDB belongs to another build, as while a compile replaces them, is skipped without a stamp instead of failing the capture. - The startup touches the compilation assembly list once so its compilationStarted subscription is made on the main thread, and every fetch of that list is logged so the move of GetAssemblies out of the domain load can be measured. --- ...HotReloadCaptureAssemblyEnumeratorTests.cs | 161 ++++++++++++ ...loadCaptureAssemblyEnumeratorTests.cs.meta | 11 + ...otReloadIncrementalSnapshotCaptureTests.cs | 29 +-- ...tReloadSnapshotAssemblyEnumerationTests.cs | 244 ++++++++++++------ .../HotReloadSnapshotCaptureInputsTests.cs | 137 +--------- .../HotReload/HotReloadSourceSnapshotTests.cs | 4 +- .../HotReload/HotReloadEditorStartup.cs | 5 + .../HotReloadCaptureAssemblyEnumerator.cs | 99 +++++++ ...HotReloadCaptureAssemblyEnumerator.cs.meta | 11 + .../Shared/HotReloadCompilationAssemblies.cs | 13 +- .../HotReloadCompilationAssemblyCache.cs | 8 + .../HotReload/Shared/HotReloadConstants.cs | 1 + .../Shared/HotReloadPackageFolderMatch.cs | 35 +-- .../Shared/HotReloadSnapshotCaptureInputs.cs | 63 +---- .../Shared/HotReloadSnapshotCaptureStats.cs | 9 +- .../Shared/HotReloadSourceSnapshotLayout.cs | 12 + .../HotReloadSourceSnapshotter.Stamp.cs | 20 ++ .../Shared/HotReloadSourceSnapshotter.cs | 165 ++++++++---- 18 files changed, 651 insertions(+), 376 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs new file mode 100644 index 000000000..b9df1895d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs @@ -0,0 +1,161 @@ +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() + { + string directory = Path.Combine(_projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory); + 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; } From 1e933a46ecc431b2f3259f669ff1d0715de66224 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 18:17:24 +0900 Subject: [PATCH 2/3] Document that the snapshot capture lists assemblies from the compiled dlls and their PDBs getAssembliesMs now times the dll listing, pdbSourcesMs and assembliesSkippedPdbMismatch are new, the read-only marker is described, and the new compilation assemblies fetched entry shows where GetAssemblies lands in a domain. --- docs/hot-reload.md | 5 ++++- docs/vibe-logs.md | 40 +++++++++++++++++++++++++--------------- 2 files changed, 29 insertions(+), 16 deletions(-) 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 From 7422963b36c4d757160d9921130e5e4e344d55c6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 12 Oct 2026 00:41:37 +0900 Subject: [PATCH 3/3] Build the listing test's expected paths the way the listing does Combining the project root with the "Library/ScriptAssemblies" constant keeps its "/" on Windows, while the listing returns paths with "\" throughout, so the path assertions would fail there whatever the listing did. --- .../HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs index b9df1895d..1b0cca35e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs @@ -47,7 +47,9 @@ public void TearDown() [Test] public void ListCompiled_DllWithoutPdb_IsNotListed() { - string directory = Path.Combine(_projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory); + // 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");