From 95d0e622937415eec0327eb0d6f2fe49ba96ff16 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 13:32:57 +0900 Subject: [PATCH 1/4] chore: Read the sources a compiled assembly was built from out of its PDB document table (#3275) --- Assets/Tests/Editor/HotReload/Fixtures.meta | 8 + .../HotReload/Fixtures/PdbDocumentTable.meta | 8 + .../CommentOnlySourceFixture.cs | 1 + .../CommentOnlySourceFixture.cs.meta | 11 + .../PdbDocumentTable/EmptySourceFixture.cs | 0 .../EmptySourceFixture.cs.meta | 11 + .../InterfaceOnlySourceFixture.cs | 10 + .../InterfaceOnlySourceFixture.cs.meta | 11 + .../HotReloadCompiledSourceCrossCheckTests.cs | 145 ++++++++ ...eloadCompiledSourceCrossCheckTests.cs.meta | 11 + .../HotReloadCompiledSourceMapTests.cs | 310 ++++++++++++++++++ .../HotReloadCompiledSourceMapTests.cs.meta | 11 + .../HotReloadPdbDocumentTableTests.cs | 96 ++++++ .../HotReloadPdbDocumentTableTests.cs.meta | 11 + .../Shared/HotReloadCompiledSourceMap.cs | 304 +++++++++++++++++ .../Shared/HotReloadCompiledSourceMap.cs.meta | 11 + .../Shared/HotReloadPdbDocumentIndex.cs | 4 +- .../Shared/HotReloadPdbDocumentTable.cs | 52 +++ .../Shared/HotReloadPdbDocumentTable.cs.meta | 11 + .../Shared/HotReloadSnapshotPackageRoots.cs | 58 ++++ .../HotReloadSnapshotPackageRoots.cs.meta | 11 + 21 files changed, 1093 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/Fixtures.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta diff --git a/Assets/Tests/Editor/HotReload/Fixtures.meta b/Assets/Tests/Editor/HotReload/Fixtures.meta new file mode 100644 index 0000000000..5d26d7d21e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3298d49965eca4acbba263b69f9df9d7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta new file mode 100644 index 0000000000..d2be3a9894 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 747fd421be97e470792bafc10aa93f02 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs new file mode 100644 index 0000000000..88249b1ebb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs @@ -0,0 +1 @@ +// A source with only this comment, so the compiled PDB lists it in its document table but no method maps to it. diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta new file mode 100644 index 0000000000..b3daab23a0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e11477b08cdb4a8282b9052296b3694 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta new file mode 100644 index 0000000000..ef2b11b256 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b28fb164e01984cb699d60514414cc90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs new file mode 100644 index 0000000000..a76345970f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs @@ -0,0 +1,10 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A source with no method body, so the compiled PDB lists it in its document table but no sequence point refers to it. + /// + internal interface IPdbDocumentTableFixture + { + void Run(); + } +} diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta new file mode 100644 index 0000000000..01cacb640d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8328444c980024a6b82be1a025db1c44 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs new file mode 100644 index 0000000000..46fd9aa9c1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Cross-checks, on this project's compiled assemblies, the source list built from each PDB's + /// document table against the source list Unity reports for the assembly. Whether the two agree + /// decides if the snapshot capture can stop asking Unity for its assemblies. + /// + public sealed class HotReloadCompiledSourceCrossCheckTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const int MaxListedDifferences = 10; + + /// + /// What: for every compiled assembly with sources, a dll and a PDB, the PDB list agrees with + /// Unity's on whether the assembly is an immutable package's, and for the others names the same sources. + /// + [Test] + public void FromDocuments_MatchesTheSourceFilesUnityListsForEveryCompiledAssembly() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); + StringComparer comparer = HotReloadSourcePathNormalizer.ProjectRelativePathComparer(); + List mismatches = new List(); + Stopwatch readTime = new Stopwatch(); + int checkedAssemblies = 0; + + foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current()) + { + string[] sourceFiles = assembly.sourceFiles; + string dllPath = layout.DllPath(assembly.name); + string pdbPath = layout.PdbPath(assembly.name); + if (sourceFiles == null || sourceFiles.Length == 0 || !File.Exists(dllPath) || !File.Exists(pdbPath)) + { + continue; + } + + readTime.Start(); + List documents = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + readTime.Stop(); + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + layout.MainProjectRoot, + layout.ProjectRoot, + packageRoots, + PathComparison()); + checkedAssemblies++; + + bool expectedImmutable = HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); + if (sources.IsImmutablePackageAssembly != expectedImmutable) + { + mismatches.Add( + assembly.name + ": IsImmutablePackageAssembly is " + sources.IsImmutablePackageAssembly + + " but Unity's first source says " + expectedImmutable + "."); + continue; + } + + if (expectedImmutable) + { + continue; + } + + HashSet fromPdb = new HashSet(sources.AssetPaths, comparer); + HashSet fromUnity = new HashSet( + sourceFiles.Select(HotReloadSourcePathNormalizer.ToForwardSlashes), + comparer); + if (!fromPdb.SetEquals(fromUnity)) + { + mismatches.Add(DescribeDifference(assembly.name, fromPdb, fromUnity)); + } + } + + TestContext.WriteLine( + "Read " + checkedAssemblies + " PDB document tables in " + readTime.ElapsedMilliseconds + " ms."); + Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compiled assembly with a dll and a PDB was found."); + Assert.That(mismatches, Is.Empty, string.Join("\n", mismatches)); + } + + /// + /// What: the test assembly's list holds the fixtures without a method body, so the list is the + /// document table and not only the documents a sequence point refers to. + /// + [Test] + public void FromDocuments_IncludesTheBodylessFixtures() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + List documents = HotReloadPdbDocumentTable.Read( + layout.DllPath(TestAssemblyName), + layout.PdbPath(TestAssemblyName)); + + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + layout.MainProjectRoot, + layout.ProjectRoot, + HotReloadSnapshotPackageRoots.ReadCurrent(), + PathComparison()); + + Assert.That(sources.AssetPaths, Is.SupersetOf(HotReloadPdbDocumentTableTests.BodylessFixtureAssetPaths)); + } + + private static string DescribeDifference(string assemblyName, HashSet fromPdb, HashSet fromUnity) + { + StringBuilder text = new StringBuilder(); + text.Append(assemblyName).Append(": the source lists differ."); + AppendOnlyIn(text, "only in the PDB", fromPdb, fromUnity); + AppendOnlyIn(text, "only in Unity's sourceFiles", fromUnity, fromPdb); + return text.ToString(); + } + + private static void AppendOnlyIn(StringBuilder text, string label, HashSet side, HashSet other) + { + List only = side.Where(path => !other.Contains(path)).OrderBy(path => path, StringComparer.Ordinal).ToList(); + text.Append("\n ").Append(label).Append(" (").Append(only.Count).Append("):"); + foreach (string path in only.Take(MaxListedDifferences)) + { + text.Append("\n ").Append(path); + } + } + + // The comparison the snapshot paths use on this platform: case-insensitive on Windows only. + private static StringComparison PathComparison() + { + return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta new file mode 100644 index 0000000000..b36ce15804 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa9fbc393cd4c42fb8b852c9c82dfcba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs new file mode 100644 index 0000000000..c74701520f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; + +using Mono.Cecil.Cil; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers how the document urls of a PDB map to the asset paths Unity uses for the sources of an + /// assembly, and whether the assembly belongs to an immutable package. Only synthetic urls and + /// package roots are used; nothing is read from disk. + /// + public sealed class HotReloadCompiledSourceMapTests + { + private const string ProjectRoot = "/proj"; + private const string EmbeddedResolvedPath = "/proj/Packages/src"; + private const string EmbeddedAssetPath = "Packages/com.example.embedded"; + private const string LocalResolvedPath = "/elsewhere/LocalPkg"; + private const string LocalAssetPath = "Packages/com.example.local"; + private const string RegistryResolvedPath = "/proj/Library/PackageCache/com.example.reg@1.0.0"; + private const string RegistryAssetPath = "Packages/com.example.reg"; + + private static readonly HotReloadSnapshotPackageRoot[] Packages = + { + new HotReloadSnapshotPackageRoot(EmbeddedResolvedPath, EmbeddedAssetPath, false), + new HotReloadSnapshotPackageRoot(LocalResolvedPath, LocalAssetPath, false), + new HotReloadSnapshotPackageRoot(RegistryResolvedPath, RegistryAssetPath, true), + }; + + /// + /// What: a project-relative url of an Assets source maps to its asset path, and the assembly is not immutable. + /// + [Test] + public void FromDocuments_ProjectRelativeAssetsSource_MapsToTheAssetPath() + { + HotReloadCompiledSources sources = Map("./Assets/A.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a url written with backslashes maps to the asset path written with slashes. + /// + [Test] + public void FromDocuments_BackslashUrl_MapsToASlashAssetPath() + { + HotReloadCompiledSources sources = Map(".\\Assets\\B.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/B.cs" })); + } + + /// + /// What: an absolute url inside the compiling project, as an assembly without a pathmap records it, maps to the asset path. + /// + [Test] + public void FromDocuments_AbsoluteUrlInsideTheCompilingProject_MapsToTheAssetPath() + { + HotReloadCompiledSources sources = Map("/proj/Assets/C.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/C.cs" })); + } + + /// + /// What: a source of an embedded package whose folder differs from its name maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_EmbeddedPackageWhoseFolderDiffersFromItsName_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("./Packages/src/Editor/X.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/Editor/X.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a source of a local package outside the project, recorded as an absolute url, maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_LocalPackageOutsideTheProject_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("/elsewhere/LocalPkg/Runtime/Y.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.local/Runtime/Y.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a source of a registry package makes the assembly an immutable package assembly. + /// + [Test] + public void FromDocuments_RegistryPackageSource_IsImmutable() + { + HotReloadCompiledSources sources = Map("./Library/PackageCache/com.example.reg@1.0.0/Z.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Z.cs" })); + } + + /// + /// What: a relative url without a leading "./", which is how a source generator names its output, is dropped. + /// + [Test] + public void FromDocuments_GeneratedDocumentName_IsDropped() + { + HotReloadCompiledSources sources = Map("Example.SourceGen/Gen.g.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a file inside the project but under neither Assets nor a package root is dropped. + /// + [Test] + public void FromDocuments_ProjectFileOutsideAssetsAndPackages_IsDropped() + { + HotReloadCompiledSources sources = Map("./Library/Bee/artifacts/Gen.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a document that is not a C# source is dropped. + /// + [Test] + public void FromDocuments_NonCSharpDocument_IsDropped() + { + HotReloadCompiledSources sources = Map("./Assets/Shader.hlsl"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a url listed twice yields its asset path once. + /// + [Test] + public void FromDocuments_DuplicateDocuments_YieldsTheAssetPathOnce() + { + HotReloadCompiledSources sources = Map("./Assets/A.cs", "./Assets/A.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: for a Virtual Player, whose urls are relative to the main project, an embedded package + /// source maps whether Package Manager resolves the package under the player's root or the main project's. + /// + [TestCase("/proj/Library/VP/p1/Packages/src")] + [TestCase("/proj/Packages/src")] + public void FromDocuments_VirtualPlayerEmbeddedPackage_MapsUnderThePackageName(string resolvedPath) + { + HotReloadSnapshotPackageRoot[] packages = + { + new HotReloadSnapshotPackageRoot(resolvedPath, EmbeddedAssetPath, false), + }; + + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents("./Packages/src/X.cs"), + ProjectRoot, + "/proj/Library/VP/p1", + packages, + StringComparison.Ordinal); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/X.cs" })); + } + + /// + /// What: a folder beside a package root whose name starts with the root's name is not taken for the package. + /// + [Test] + public void FromDocuments_SiblingFolderOfAPackageRoot_IsDropped() + { + HotReloadCompiledSources sources = Map("/elsewhere/LocalPkg2/A.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: the first mapped source decides immutability, so an immutable source before a mutable one makes the assembly immutable. + /// + [Test] + public void FromDocuments_FirstMappedSourceDecidesImmutability_IsImmutable() + { + HotReloadCompiledSources sources = Map( + "./Library/PackageCache/com.example.reg@1.0.0/Z.cs", + "./Assets/A.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + } + + /// + /// What: a dropped document before a mutable source does not decide immutability. + /// + [Test] + public void FromDocuments_FirstDroppedDocumentDoesNotDecide_IsMutable() + { + HotReloadCompiledSources sources = Map("Example.SourceGen/Gen.g.cs", "./Assets/A.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: under a case-insensitive comparison a url spelled in another case still maps, keeping the url's spelling. + /// + [Test] + public void FromDocuments_IgnoreCaseComparison_MapsKeepingTheUrlSpelling() + { + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents("./ASSETS/a.CS"), + ProjectRoot, + ProjectRoot, + Packages, + StringComparison.OrdinalIgnoreCase); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "ASSETS/a.CS" })); + } + + /// + /// What: no documents yield no asset paths and a mutable assembly. + /// + [Test] + public void FromDocuments_NoDocuments_IsEmptyAndMutable() + { + HotReloadCompiledSources sources = Map(); + + Assert.That(sources.AssetPaths, Is.Empty); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a Windows url with a drive letter maps to the asset path on every platform, whatever its + /// separators and the case of its drive letter. + /// + [TestCase("C:/proj/Assets/A.cs")] + [TestCase("C:\\proj\\Assets\\A.cs")] + [TestCase("c:/proj/Assets/A.cs")] + public void FromDocuments_WindowsDriveLetterUrl_MapsToTheAssetPath(string url) + { + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents(url), + "C:/proj", + "C:/proj", + Array.Empty(), + StringComparison.OrdinalIgnoreCase); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: an absolute url of an embedded package inside the project maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_AbsoluteUrlOfAnEmbeddedPackage_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("/proj/Packages/src/Editor/X.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/Editor/X.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: an absolute url of a registry package inside the project makes the assembly immutable. + /// + [Test] + public void FromDocuments_AbsoluteUrlOfARegistryPackage_IsImmutable() + { + HotReloadCompiledSources sources = Map("/proj/Library/PackageCache/com.example.reg@1.0.0/Z.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Z.cs" })); + } + + /// + /// What: a folder inside the project beside a package root whose name starts with the root's name is not taken for the package. + /// + [Test] + public void FromDocuments_SiblingFolderOfAnInProjectPackageRoot_IsDropped() + { + HotReloadCompiledSources sources = Map("./Packages/src2/A.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + private static HotReloadCompiledSources Map(params string[] urls) + { + return HotReloadCompiledSourceMap.FromDocuments( + Documents(urls), + ProjectRoot, + ProjectRoot, + Packages, + StringComparison.Ordinal); + } + + private static List Documents(params string[] urls) + { + List documents = new List(urls.Length); + foreach (string url in urls) + { + documents.Add(new HotReloadPdbDocument(url, DocumentHashAlgorithm.None, Array.Empty())); + } + + return documents; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta new file mode 100644 index 0000000000..8cca48e740 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: def368e6a13934412aef3302f6b104e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs new file mode 100644 index 0000000000..a1442d3e9a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of reading every row of a PDB's document table: it lists sources without a method + /// body, which the sequence-point walk of the document index leaves out, and it refuses a PDB + /// of another build than the dll. + /// + public sealed class HotReloadPdbDocumentTableTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string OtherAssemblyName = "UnityCLILoop.PausePoints.Runtime"; + + internal static readonly string[] BodylessFixtureAssetPaths = + { + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs", + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs", + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs", + }; + + /// + /// What: the table lists the empty, comment-only and interface-only fixtures, none of which the + /// sequence-point walk returns. The urls are compared by their end because an assembly under + /// Assets has no pathmap, so its urls are absolute. + /// + [Test] + public void Read_ListsSourcesWithoutAMethodBody() + { + string dllPath = DllPath(TestAssemblyName); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + + List table = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + List withSequencePoints = HotReloadPdbDocumentIndex.ReadDocuments(dllPath, pdbPath); + + foreach (string assetPath in BodylessFixtureAssetPaths) + { + Assert.That( + table.Any(document => EndsWithAssetPath(document, assetPath)), + Is.True, + "The document table must list " + assetPath + "."); + Assert.That( + withSequencePoints.Any(document => EndsWithAssetPath(document, assetPath)), + Is.False, + "No sequence point may refer to " + assetPath + "."); + } + } + + /// + /// What: a PDB of another assembly than the dll makes the read throw instead of returning its documents. + /// + [Test] + public void Read_WhenThePdbBelongsToAnotherBuild_Throws() + { + string directory = Path.Combine( + Path.GetTempPath(), + "uloop-pdb-document-table-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string dllPath = Path.Combine(directory, "X.dll"); + string pdbPath = Path.Combine(directory, "X.pdb"); + File.Copy(DllPath(TestAssemblyName), dllPath); + File.Copy(Path.ChangeExtension(DllPath(OtherAssemblyName), ".pdb"), pdbPath); + + Assert.Throws(() => HotReloadPdbDocumentTable.Read(dllPath, pdbPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static bool EndsWithAssetPath(HotReloadPdbDocument document, string assetPath) + { + return document.Url.Replace('\\', '/').EndsWith("/" + assetPath, StringComparison.Ordinal); + } + + private static string DllPath(string assemblyName) + { + return Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + assemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta new file mode 100644 index 0000000000..e4632109c8 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea345d95bc3234bb99c3a9eb447240ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs new file mode 100644 index 0000000000..19977800f8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The sources a compiled assembly was built from, named by the asset paths Unity uses for them, + /// and whether the assembly belongs to a package hot reload treats as read-only. + /// + internal sealed class HotReloadCompiledSources + { + internal HotReloadCompiledSources(IReadOnlyList assetPaths, bool isImmutablePackageAssembly) + { + Debug.Assert(assetPaths != null, "assetPaths must not be null."); + AssetPaths = assetPaths; + IsImmutablePackageAssembly = isImmutablePackageAssembly; + } + + /// Each asset path once, in the order the PDB lists its document. + internal IReadOnlyList AssetPaths { get; } + + /// True when the first source that maps to an asset path is in an immutable package. + internal bool IsImmutablePackageAssembly { get; } + } + + /// + /// Turns the document urls of a PDB into the asset paths Unity lists as the assembly's sources. + /// A url takes one of three forms: "./" and a path relative to the compiling project, which an + /// assembly compiled with a pathmap records; an absolute path, which an assembly without a + /// pathmap records for every source and any assembly records for a package outside the project; + /// or a relative name without "./", which is a source generator's output. Urls of files that are + /// not C# sources, not under Assets and not under a package root are left out. + /// + internal static class HotReloadCompiledSourceMap + { + private const string CSharpExtension = ".cs"; + private const string ProjectRelativePrefix = "./"; + private const string AssetsPrefix = "Assets/"; + + // A package root in every form a url can name its folder by. A relative form is null when + // the folder is not under that root. + private readonly struct NormalizedPackageRoot + { + public readonly string ResolvedPath; + public readonly string RelativeToCompilingRoot; + public readonly string RelativeToOwnRoot; + public readonly string AssetPath; + public readonly bool IsImmutable; + + public NormalizedPackageRoot( + string resolvedPath, + string relativeToCompilingRoot, + string relativeToOwnRoot, + string assetPath, + bool isImmutable) + { + ResolvedPath = resolvedPath; + RelativeToCompilingRoot = relativeToCompilingRoot; + RelativeToOwnRoot = relativeToOwnRoot; + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + private readonly struct MappedSource + { + public readonly string AssetPath; + public readonly bool IsImmutable; + + public MappedSource(string assetPath, bool isImmutable) + { + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + /// + /// Maps the documents of one assembly's PDB. is the + /// project whose compile wrote the PDB, which is the main project for a Virtual Player, and + /// is this Editor's own project. + /// is Ordinal or OrdinalIgnoreCase. Does no IO. + /// + internal static HotReloadCompiledSources FromDocuments( + IReadOnlyList documents, + string compilingProjectRoot, + string projectRoot, + IReadOnlyList packageRoots, + StringComparison pathComparison) + { + Debug.Assert(documents != null, "documents must not be null."); + Debug.Assert(!string.IsNullOrEmpty(compilingProjectRoot), "compilingProjectRoot must not be null or empty."); + 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."); + + string compilingRoot = NormalizeRoot(compilingProjectRoot); + string ownRoot = NormalizeRoot(projectRoot); + List packages = NormalizePackages(packageRoots, compilingRoot, ownRoot, pathComparison); + List assetPaths = new List(); + HashSet seen = new HashSet( + pathComparison == StringComparison.OrdinalIgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + bool? firstIsImmutable = null; + foreach (HotReloadPdbDocument document in documents) + { + if (!TryMap(document.Url, compilingRoot, packages, pathComparison, out MappedSource mapped)) + { + continue; + } + + if (!seen.Add(mapped.AssetPath)) + { + continue; + } + + // Why the first mapped source: it keeps the rule of ShouldSkipImmutablePackageSources, + // which classifies an assembly by its first source because an asmdef keeps one + // assembly inside one package. + if (firstIsImmutable == null) + { + firstIsImmutable = mapped.IsImmutable; + } + + assetPaths.Add(mapped.AssetPath); + } + + return new HotReloadCompiledSources(assetPaths, firstIsImmutable ?? false); + } + + private static bool TryMap( + string documentUrl, + string compilingRoot, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + mapped = default(MappedSource); + string url = documentUrl.Replace('\\', '/'); + if (!url.EndsWith(CSharpExtension, comparison)) + { + return false; + } + + if (url.StartsWith(ProjectRelativePrefix, StringComparison.Ordinal)) + { + return TryMapProjectRelative(url.Substring(ProjectRelativePrefix.Length), packages, comparison, out mapped); + } + + if (IsRooted(url)) + { + return TryMapAbsolute(url, compilingRoot, packages, comparison, out mapped); + } + + // A relative name without "./" is a generated source, which has no asset path. + return false; + } + + private static bool TryMapProjectRelative( + string relativePath, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + if (relativePath.StartsWith(AssetsPrefix, comparison)) + { + mapped = new MappedSource(relativePath, false); + return true; + } + + // Why both relative forms: a Virtual Player's urls are relative to the main project, + // while the Package Manager may resolve an embedded package under the player's root. + int bestLength = -1; + NormalizedPackageRoot best = default(NormalizedPackageRoot); + foreach (NormalizedPackageRoot package in packages) + { + int length = Math.Max( + PrefixLength(relativePath, package.RelativeToCompilingRoot, comparison), + PrefixLength(relativePath, package.RelativeToOwnRoot, comparison)); + if (length > bestLength) + { + bestLength = length; + best = package; + } + } + + return TryMapUnderPackage(relativePath, best, bestLength, out mapped); + } + + private static bool TryMapAbsolute( + string absolutePath, + string compilingRoot, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + int rootLength = PrefixLength(absolutePath, compilingRoot, comparison); + if (rootLength >= 0) + { + return TryMapProjectRelative(absolutePath.Substring(rootLength + 1), packages, comparison, out mapped); + } + + int bestLength = -1; + NormalizedPackageRoot best = default(NormalizedPackageRoot); + foreach (NormalizedPackageRoot package in packages) + { + int length = PrefixLength(absolutePath, package.ResolvedPath, comparison); + if (length > bestLength) + { + bestLength = length; + best = package; + } + } + + return TryMapUnderPackage(absolutePath, best, bestLength, out mapped); + } + + // path minus its first prefixLength characters and the "/" after them, under the package's asset path. + private static bool TryMapUnderPackage( + string path, + NormalizedPackageRoot package, + int prefixLength, + out MappedSource mapped) + { + if (prefixLength < 0) + { + mapped = default(MappedSource); + return false; + } + + mapped = new MappedSource(package.AssetPath + "/" + path.Substring(prefixLength + 1), package.IsImmutable); + return true; + } + + // The length of folder when path is inside it, so that the character after it is "/"; -1 + // otherwise. Why the "/": "/a/LocalPkg2/x.cs" must not count as inside "/a/LocalPkg". + private static int PrefixLength(string path, string folder, StringComparison comparison) + { + if (folder == null || path.Length <= folder.Length + 1) + { + return -1; + } + + if (path[folder.Length] != '/' || !path.StartsWith(folder, comparison)) + { + return -1; + } + + return folder.Length; + } + + // Why not Path.IsPathRooted: on macOS and Linux it does not take "C:/proj/..." for rooted, + // so a PDB written on Windows would lose its sources there. + private static bool IsRooted(string url) + { + if (url.Length > 0 && url[0] == '/') + { + return true; + } + + return url.Length >= 3 && IsAsciiLetter(url[0]) && url[1] == ':' && url[2] == '/'; + } + + private static bool IsAsciiLetter(char character) + { + return (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z'); + } + + private static List NormalizePackages( + IReadOnlyList packageRoots, + string compilingRoot, + string ownRoot, + StringComparison comparison) + { + List packages = new List(packageRoots.Count); + foreach (HotReloadSnapshotPackageRoot root in packageRoots) + { + string resolvedPath = NormalizeRoot(root.ResolvedPath); + packages.Add(new NormalizedPackageRoot( + resolvedPath, + RelativeTo(resolvedPath, compilingRoot, comparison), + RelativeTo(resolvedPath, ownRoot, comparison), + NormalizeRoot(root.AssetPath), + root.IsImmutable)); + } + + return packages; + } + + // The rest of path after root and "/", or null when path is not inside root. + private static string RelativeTo(string path, string root, StringComparison comparison) + { + int length = PrefixLength(path, root, comparison); + return length < 0 ? null : path.Substring(length + 1); + } + + private static string NormalizeRoot(string path) + { + return path.Replace('\\', '/').TrimEnd('/'); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta new file mode 100644 index 0000000000..7401d0860e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7cd0f305ace4344be91cb7ea5588de22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index f17ab12805..e1499f0d8e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -509,7 +509,7 @@ private static List CollectDocumentsWithVisibleSequencePoints(Me } // Reads only the PE headers and the debug directory; the type and method tables stay unread. - private static Guid ReadCodeViewGuid(string dllPath) + internal static Guid ReadCodeViewGuid(string dllPath) { using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using PEReader peReader = new PEReader(dllStream); @@ -528,7 +528,7 @@ private static Guid ReadCodeViewGuid(string dllPath) } // The same mapping Cecil's portable PDB reader applies to a document's hash algorithm GUID. - private static DocumentHashAlgorithm ToHashAlgorithm(Guid algorithm) + internal static DocumentHashAlgorithm ToHashAlgorithm(Guid algorithm) { if (algorithm == Sha1HashAlgorithmGuid) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs new file mode 100644 index 0000000000..c0eed27fab --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reads every row of a portable PDB's document table: each source the compiler was given, + /// including files without a method body, which HotReloadPdbDocumentIndex leaves out because + /// no sequence point refers to them. + /// + internal static class HotReloadPdbDocumentTable + { + /// + /// Returns the documents of the PDB in table order, after checking that the PDB belongs to + /// the build of the dll. Throws InvalidOperationException for a PDB of another build. + /// + internal static List Read(string dllPath, string pdbPath) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + + Guid codeViewGuid = HotReloadPdbDocumentIndex.ReadCodeViewGuid(dllPath); + using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + MetadataReader reader = provider.GetMetadataReader(); + + // Why checked here: a PDB of another build would list sources the dll was not compiled from. + BlobContentId pdbId = new BlobContentId(reader.DebugMetadataHeader.Id); + if (pdbId.Guid != codeViewGuid) + { + throw new InvalidOperationException( + "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + } + + List documents = new List(reader.Documents.Count); + foreach (DocumentHandle handle in reader.Documents) + { + Document document = reader.GetDocument(handle); + documents.Add(new HotReloadPdbDocument( + reader.GetString(document.Name), + HotReloadPdbDocumentIndex.ToHashAlgorithm(reader.GetGuid(document.HashAlgorithm)), + reader.GetBlobBytes(document.Hash))); + } + + return documents; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta new file mode 100644 index 0000000000..233654e520 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b406ae595de0a4bd99e539062bf7eff8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs new file mode 100644 index 0000000000..5bb8eee856 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +using UnityEditor.PackageManager; + +using PackageManagerPackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One registered package as the snapshot capture sees it: the folder its files live in, the + /// virtual path Unity names them by, and whether hot reload treats its sources as read-only. + /// + internal readonly struct HotReloadSnapshotPackageRoot + { + public readonly string ResolvedPath; + public readonly string AssetPath; + public readonly bool IsImmutable; + + public HotReloadSnapshotPackageRoot(string resolvedPath, string assetPath, bool isImmutable) + { + ResolvedPath = resolvedPath; + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + /// + /// Asks the Package Manager for the registered packages and whether each is immutable for hot + /// reload. Why not an extension of ScriptPackageRoots.ReadCurrent: that type is shared with + /// pause points, and adding the immutability to it would change their code too. + /// + internal static class HotReloadSnapshotPackageRoots + { + /// + /// Lists every registered package that has both a folder on disk and a virtual path. Reads + /// Package Manager state, so it must be called on the Unity main thread. + /// + internal static IReadOnlyList ReadCurrent() + { + PackageManagerPackageInfo[] packages = PackageManagerPackageInfo.GetAllRegisteredPackages(); + List roots = new List(packages.Length); + foreach (PackageManagerPackageInfo package in packages) + { + if (string.IsNullOrEmpty(package.resolvedPath) || string.IsNullOrEmpty(package.assetPath)) + { + continue; + } + + // The same rule as HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources: + // only project-owned packages stay editable through hot reload. + bool isImmutable = !(package.source == PackageSource.Embedded || package.source == PackageSource.Local); + roots.Add(new HotReloadSnapshotPackageRoot(package.resolvedPath, package.assetPath, isImmutable)); + } + + return roots; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta new file mode 100644 index 0000000000..cbd3384bea --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b006190806cec4bfe96c4947fb9a8831 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From c8137355ef76b10c1d44eba5d85c853c89f94cae Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 13:48:26 +0900 Subject: [PATCH 2/4] chore: Log where the hot reload source snapshot capture spends its time (#3274) --- ...tReloadSnapshotAssemblyEnumerationTests.cs | 67 +++++++++++++++++-- ...tReloadSnapshotEditedDuringCompileTests.cs | 3 +- .../HotReload/HotReloadSourceSnapshotTests.cs | 37 +++++++++- .../HotReloadSourceSnapshotterTests.cs | 9 ++- .../HotReload/Shared/HotReloadConstants.cs | 1 + .../Shared/HotReloadSnapshotCaptureStats.cs | 58 ++++++++++++++++ .../HotReloadSnapshotCaptureStats.cs.meta | 11 +++ .../Shared/HotReloadSourceSnapshotCopier.cs | 36 ++++++++-- .../Shared/HotReloadSourceSnapshotter.cs | 47 +++++++++++-- docs/vibe-logs.md | 23 +++++++ 10 files changed, 268 insertions(+), 24 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index c6f1621618..035bb8f097 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs @@ -366,35 +366,83 @@ public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSna /// /// Verifies that the capture takes the recorded compile start as the start of the PDB check, so /// a source saved after the compile started but before the DLL was written is checked and, with - /// no PDB document to confirm it, marked as edited after the compile. + /// no PDB document to confirm it, marked as edited after the compile. The capture counts that + /// one file as checked. /// [Test] public void CaptureAssemblies_ASourceSavedAfterTheCompileStartedButBeforeTheDll_IsMarked() { string snapshotDirectory = CaptureWithSourceSavedBetweenStartAndDll( - HotReloadCompileStart.At(CompileStartUtc.Ticks)); + HotReloadCompileStart.At(CompileStartUtc.Ticks), + out HotReloadSnapshotCaptureStats stats); HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(snapshotDirectory); Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName()), Is.True); + Assert.That(stats.FilesChecked, Is.EqualTo(1), "files checked"); } /// /// Verifies that without a recorded compile start the same source is not checked, because the /// check then starts at the DLL write; the mark in the case above comes from the compile start. + /// The capture counts no file as checked, though it copied one. /// [Test] public void CaptureAssemblies_WithoutARecordedStart_LeavesASourceSavedBeforeTheDllUnmarked() { - string snapshotDirectory = CaptureWithSourceSavedBetweenStartAndDll(HotReloadCompileStart.Unknown); + string snapshotDirectory = CaptureWithSourceSavedBetweenStartAndDll( + HotReloadCompileStart.Unknown, + out HotReloadSnapshotCaptureStats stats); HotReloadSourceStampManifest manifest = HotReloadSourceStampManifest.Load(snapshotDirectory); Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName()), Is.False); Assert.That(manifest.TryGetStamp(SnapshotFileName(), out long _, out long _), Is.True); + Assert.That(stats.FilesCopied, Is.EqualTo(1), "files copied"); + Assert.That(stats.FilesChecked, Is.EqualTo(0), "files checked"); + } + + /// + /// Verifies a capture reports how many assemblies it captured or found unchanged and how many + /// files and bytes it copied: the first capture copies the two sources that exist and skips the + /// missing one, and a second capture of the same build copies nothing. + /// + [Test] + 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" }) + }; + + HotReloadSnapshotCaptureStats first = HotReloadSourceSnapshotter.CaptureAssemblies( + _projectRoot, + assemblies, + HotReloadCompileStart.Unknown, + CreateDocumentIndex()); + HotReloadSnapshotCaptureStats second = HotReloadSourceSnapshotter.CaptureAssemblies( + _projectRoot, + assemblies, + HotReloadCompileStart.Unknown, + CreateDocumentIndex()); + + Assert.That(first.Assemblies, Is.EqualTo(1), "first: assemblies"); + Assert.That(first.AssembliesCaptured, Is.EqualTo(1), "first: captured"); + Assert.That(first.AssembliesUnchanged, Is.EqualTo(0), "first: unchanged"); + Assert.That(first.FilesCopied, Is.EqualTo(2), "first: files copied"); + Assert.That(first.BytesCopied, Is.EqualTo(8), "first: bytes copied"); + Assert.That(second.AssembliesCaptured, Is.EqualTo(0), "second: captured"); + Assert.That(second.AssembliesUnchanged, Is.EqualTo(1), "second: unchanged"); + Assert.That(second.FilesCopied, Is.EqualTo(0), "second: files copied"); } // Plants the compiled assembly and orders the write times as compile start < source < DLL. // The fixture source has no document in the planted PDB, so a check cannot confirm it. - private string CaptureWithSourceSavedBetweenStartAndDll(HotReloadCompileStart compileStart) + // Returns the snapshot directory and hands out what the capture reported. + private string CaptureWithSourceSavedBetweenStartAndDll( + HotReloadCompileStart compileStart, + out HotReloadSnapshotCaptureStats stats) { string dllPath = PlantCompiledAssembly("Fixture"); File.SetLastWriteTimeUtc(dllPath, new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc)); @@ -403,7 +451,7 @@ private string CaptureWithSourceSavedBetweenStartAndDll(HotReloadCompileStart co new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); string mvid = HotReloadAssemblyMvid.Read(dllPath); - HotReloadSourceSnapshotter.CaptureAssemblies( + stats = HotReloadSourceSnapshotter.CaptureAssemblies( _projectRoot, new[] { CreateAssembly("Fixture") }, compileStart, @@ -412,6 +460,15 @@ private string CaptureWithSourceSavedBetweenStartAndDll(HotReloadCompileStart co return Path.Combine(_snapshotRoot, "Fixture-" + mvid); } + // Writes the source as ASCII with a write time before the planted compile, so the capture + // copies it without a PDB check. + private void WriteSourceWrittenBeforeTheCompile(string projectRelativePath, string text) + { + string path = Path.Combine(_projectRoot, projectRelativePath.Replace('/', Path.DirectorySeparatorChar)); + File.WriteAllBytes(path, System.Text.Encoding.ASCII.GetBytes(text)); + File.SetLastWriteTimeUtc(path, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + } + private static string SnapshotFileName() { return HotReloadSourceSnapshotLayout.SourceFileName(SourceRelativePath); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs index 290c33f9ce..e0699649e6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs @@ -134,7 +134,8 @@ private HotReloadSourceStampManifest Capture(string projectRelativePath) _snapshotDirectory, new[] { projectRelativePath }, TestAssemblyName, - check); + check, + new HotReloadSnapshotCaptureStats()); return HotReloadSourceStampManifest.Load(_snapshotDirectory); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index e33fbb0895..860c5ccd70 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -9,6 +9,8 @@ using Mono.Cecil.Cil; using Mono.Cecil.Pdb; +using Newtonsoft.Json.Linq; + using NUnit.Framework; using UnityEditor.Compilation; @@ -17,6 +19,7 @@ using UnityEngine.TestTools; using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; @@ -191,6 +194,34 @@ public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsT Assert.That(loaded, Is.EqualTo(File.ReadAllText(Path.Combine(projectRoot, PackageSourcePhysicalPath)))); } + /// + /// What: a capture after a domain reload writes one breakdown entry over a non-empty assembly + /// list, carrying every phase time and count, none of them negative. + /// + [Test] + public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase() + { + VibeLogger.ClearMemoryLogs(); + + HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown); + string[] fields = + { + "getAssembliesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs", + "pdbCheckMs", "publishMs", "cleanupMs", "totalMs", "assemblies", "assembliesImmutable", + "assembliesUnchanged", "assembliesCaptured", "filesCopied", "bytesCopied", "filesChecked" + }; + foreach (string field in fields) + { + Assert.That(context[field], Is.Not.Null, field); + Assert.That(context[field].Type, Is.EqualTo(JTokenType.Integer), field + " type"); + Assert.That((long)context[field], Is.GreaterThanOrEqualTo(0), field); + } + + Assert.That((long)context["assemblies"], Is.GreaterThan(0)); + } + /// /// What: in a Multiplayer Play Mode Virtual Player, whose root lies under the main project's /// Library, a package source's PDB document is found by the path the main project's compile @@ -598,7 +629,8 @@ public void CaptureAtomically_LongWindowsSource_CapturesByteExactSnapshot() snapshotDirectory, new[] { sourceRelativePath }, "LongPathAssembly", - SuspectsNothing(root)); + SuspectsNothing(root), + new HotReloadSnapshotCaptureStats()); string normalizedRelativePath = sourceRelativePath.Replace('\\', '/'); string snapshotPath = Path.Combine( @@ -672,7 +704,8 @@ public void CaptureAtomically_OneLockedSource_CompletesWithReadableSnapshots() snapshotDirectory, sourceRelativePaths, "LockedSourceAssembly", - SuspectsNothing(root)); + SuspectsNothing(root), + new HotReloadSnapshotCaptureStats()); } Assert.That(Directory.Exists(snapshotDirectory), Is.True); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs index 365c5fd246..49203054ff 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs @@ -53,7 +53,8 @@ public void CaptureAtomically_ExistingAndMissingSources_CopiesExistingByteExactU snapshotDirectory, sourceFiles, "Fixture", - SuspectsNothing(_tempRoot)); + SuspectsNothing(_tempRoot), + new HotReloadSnapshotCaptureStats()); string firstSnapshot = Path.Combine( snapshotDirectory, @@ -89,7 +90,8 @@ public void CaptureAtomically_RecordsTheLengthAndWriteTimeOfEachCopiedSource() snapshotDirectory, sourceFiles, "Fixture", - SuspectsNothing(_tempRoot)); + SuspectsNothing(_tempRoot), + new HotReloadSnapshotCaptureStats()); string expected = HotReloadConstants.SourceStampManifestHeader + "\n" + HotReloadSourceSnapshotLayout.SourceFileName("Sources/First.cs") + "\t7\t" @@ -121,7 +123,8 @@ public void CaptureAtomically_WhenTemporaryDirectoryIsLeftOver_PublishesOnlyFres snapshotDirectory, new[] { "Sources/Fresh.cs" }, "Fixture", - SuspectsNothing(_tempRoot)); + SuspectsNothing(_tempRoot), + new HotReloadSnapshotCaptureStats()); string[] publishedNames = Directory.GetFiles(snapshotDirectory).Select(Path.GetFileName).ToArray(); // Why EquivalentTo: the order GetFiles lists files in depends on the file system. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 1d552fa219..da47c35f54 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -676,6 +676,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogShimReferencesTiming = "hot_reload_shim_references_timing"; 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 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/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs new file mode 100644 index 0000000000..1097c0cd47 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs @@ -0,0 +1,58 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The time one source snapshot capture spent in each of its phases and how much it did, so the + /// breakdown log shows where a slow capture went. + /// + internal sealed class HotReloadSnapshotCaptureStats + { + // Times are TimeSpan ticks, summed over every assembly and source of the capture. + internal long PackageLookupTicks; + internal long StampTicks; + internal long MvidTicks; + internal long StatTicks; + internal long ReadWriteTicks; + internal long PdbCheckTicks; + internal long PublishTicks; + internal long CleanupTicks; + + internal int Assemblies; + internal int AssembliesImmutable; + internal int AssembliesUnchanged; + internal int AssembliesCaptured; + internal int FilesCopied; + internal long BytesCopied; + internal int FilesChecked; + + internal object ToVibeLogContext(long getAssembliesMs, long totalMs) + { + return new + { + getAssembliesMs, + packageLookupMs = ToMilliseconds(PackageLookupTicks), + stampMs = ToMilliseconds(StampTicks), + mvidMs = ToMilliseconds(MvidTicks), + statMs = ToMilliseconds(StatTicks), + readWriteMs = ToMilliseconds(ReadWriteTicks), + pdbCheckMs = ToMilliseconds(PdbCheckTicks), + publishMs = ToMilliseconds(PublishTicks), + cleanupMs = ToMilliseconds(CleanupTicks), + totalMs, + assemblies = Assemblies, + assembliesImmutable = AssembliesImmutable, + assembliesUnchanged = AssembliesUnchanged, + assembliesCaptured = AssembliesCaptured, + filesCopied = FilesCopied, + bytesCopied = BytesCopied, + filesChecked = FilesChecked + }; + } + + private static long ToMilliseconds(long ticks) + { + return (long)TimeSpan.FromTicks(ticks).TotalMilliseconds; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs.meta new file mode 100644 index 0000000000..f0b624a612 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d7b4040037434c64a08d61389d35e3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs index 8188dd5261..7b5c140d27 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs @@ -20,19 +20,24 @@ internal static void CaptureAtomically( string assemblySnapshotDirectory, string[] sourceFiles, string assemblyName, - HotReloadSnapshotSourceCheck check) + HotReloadSnapshotSourceCheck check, + HotReloadSnapshotCaptureStats stats) { + Debug.Assert(stats != null, "stats must not be null."); + // Why temp + Move: Directory.Exists is the "complete" signal. Copying into the final // directory first would leave a partial tree on interrupt that later reloads treat as // done and stamp-short-circuit forever. A sibling .tmp only becomes visible as complete // after Move succeeds. string temporaryDirectory = assemblySnapshotDirectory + HotReloadSourceSnapshotLayout.IncompleteDirectorySuffix; + System.Diagnostics.Stopwatch prepareWatch = System.Diagnostics.Stopwatch.StartNew(); if (Directory.Exists(temporaryDirectory)) { Directory.Delete(temporaryDirectory, recursive: true); } Directory.CreateDirectory(temporaryDirectory); + stats.PublishTicks += prepareWatch.Elapsed.Ticks; int skippedSourceCount = 0; string firstSkippedSourcePath = null; int suspectSourceCount = 0; @@ -46,7 +51,8 @@ internal static void CaptureAtomically( temporaryDirectory, projectRelativeSourcePath, check, - manifestLines); + manifestLines, + stats); if (outcome.Kind == SourceCopyKind.Unreadable) { skippedSourceCount++; @@ -66,8 +72,10 @@ internal static void CaptureAtomically( } } + System.Diagnostics.Stopwatch publishWatch = System.Diagnostics.Stopwatch.StartNew(); HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines); Directory.Move(temporaryDirectory, assemblySnapshotDirectory); + stats.PublishTicks += publishWatch.Elapsed.Ticks; if (suspectSourceCount > 0) { VibeLogger.LogInfo( @@ -95,25 +103,30 @@ private static SourceCopyOutcome CopySourceFileByteExact( string assemblySnapshotDirectory, string projectRelativeSourcePath, HotReloadSnapshotSourceCheck check, - List manifestLines) + List manifestLines, + HotReloadSnapshotCaptureStats stats) { string normalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); string absoluteSourcePath = Path.Combine(projectRoot, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); string fileSystemSourcePath = HotReloadFileSystemPath.GetFileSystemPath(absoluteSourcePath); + System.Diagnostics.Stopwatch statWatch = System.Diagnostics.Stopwatch.StartNew(); FileInfo sourceBefore = new FileInfo(fileSystemSourcePath); - if (!sourceBefore.Exists) + bool sourceExists = sourceBefore.Exists; + long length = sourceExists ? sourceBefore.Length : 0; + long lastWriteTimeUtcTicks = sourceExists ? sourceBefore.LastWriteTimeUtc.Ticks : 0; + stats.StatTicks += statWatch.Elapsed.Ticks; + if (!sourceExists) { return new SourceCopyOutcome(SourceCopyKind.NotFound, 0); } - long length = sourceBefore.Length; - long lastWriteTimeUtcTicks = sourceBefore.LastWriteTimeUtc.Ticks; string snapshotFileName = HotReloadSourceSnapshotLayout.SourceFileName(normalizedRelativePath); if (!TryCopySource( fileSystemSourcePath, Path.Combine(assemblySnapshotDirectory, snapshotFileName), length, lastWriteTimeUtcTicks, + stats, out byte[] bytes, out bool stampHeldDuringRead)) { @@ -149,6 +162,9 @@ private static SourceCopyOutcome CopySourceFileByteExact( return new SourceCopyOutcome(SourceCopyKind.Trusted, 0); } + stats.PdbCheckTicks += checkWatch.Elapsed.Ticks; + stats.FilesChecked++; + SourceCopyKind checkedKind = verdict == HotReloadSnapshotCopyVerdict.EditedAfterCompile ? SourceCopyKind.CheckedEditedAfterCompile : SourceCopyKind.Checked; @@ -162,21 +178,28 @@ private static bool TryCopySource( string destinationPath, long length, long lastWriteTimeUtcTicks, + HotReloadSnapshotCaptureStats stats, out byte[] bytes, out bool stampHeldDuringRead) { + System.Diagnostics.Stopwatch readWriteWatch = System.Diagnostics.Stopwatch.StartNew(); try { bytes = File.ReadAllBytes(fileSystemSourcePath); File.WriteAllBytes(destinationPath, bytes); + stats.ReadWriteTicks += readWriteWatch.Elapsed.Ticks; + stats.FilesCopied++; + stats.BytesCopied += bytes.Length; // Why the stamp is taken before the read and checked again after it: a write during the // read moves the stamp on, and a stamp recorded then could vouch for bytes the copy does // not hold. Such a source is checked against the PDB: it gets no line when it matches, // so readers compare it by bytes until the next capture, and a marked line when it does not. + System.Diagnostics.Stopwatch statWatch = System.Diagnostics.Stopwatch.StartNew(); FileInfo sourceAfter = new FileInfo(fileSystemSourcePath); stampHeldDuringRead = sourceAfter.Exists && sourceAfter.Length == length && sourceAfter.LastWriteTimeUtc.Ticks == lastWriteTimeUtcTicks; + stats.StatTicks += statWatch.Elapsed.Ticks; return true; } catch (Exception ex) when (IsSkippableSourceReadException(ex)) @@ -185,6 +208,7 @@ private static bool TryCopySource( // while an assembly is being snapshotted. Skipping preserves the fail-closed // invariant because the method-diff baseline rejects any missing snapshot by PDB // checksum at use time. + stats.ReadWriteTicks += readWriteWatch.Elapsed.Ticks; bytes = null; stampHeldDuringRead = false; return false; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index e06eb48575..a1f874e7ec 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; +using io.github.hatayama.UnityCliLoop.ToolContracts; + using UnityEditor.PackageManager; using UnityEngine; @@ -24,23 +26,33 @@ internal static partial class HotReloadSourceSnapshotter /// when it is captured, and marked in the manifest when it does not match, so the default file /// selection, the sibling scan and the skip check treat that file as changed. The method-diff /// baseline checks every copy against the PDB when it uses one. + /// Logs how long each phase took, even when the list is empty. /// Returns false when Unity listed no compilation assembly (as while it compiles). /// internal static bool CaptureAfterDomainReload() { + System.Diagnostics.Stopwatch totalWatch = System.Diagnostics.Stopwatch.StartNew(); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + System.Diagnostics.Stopwatch getAssembliesWatch = System.Diagnostics.Stopwatch.StartNew(); IReadOnlyList assemblies = HotReloadCompilationAssemblies.Current(); - CaptureAssemblies( + getAssembliesWatch.Stop(); + HotReloadSnapshotCaptureStats stats = CaptureAssemblies( projectRoot, assemblies, HotReloadCompileStartRecord.Read(), HotReloadPdbDocumentIndex.Shared); + totalWatch.Stop(); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotBreakdown, + "Hot reload source snapshot capture breakdown.", + stats.ToVibeLogContext(getAssembliesWatch.ElapsedMilliseconds, totalWatch.ElapsedMilliseconds)); return assemblies.Count > 0; } // Why separate from CaptureAfterDomainReload: the project root and the compilation assemblies come // from Unity, so taking them as arguments lets tests drive the per-assembly loop in a temporary root. - internal static void CaptureAssemblies( + // Returns how long each phase took and how much the capture did. + internal static HotReloadSnapshotCaptureStats CaptureAssemblies( string projectRoot, IEnumerable assemblies, HotReloadCompileStart compileStart, @@ -53,11 +65,13 @@ internal static void CaptureAssemblies( string snapshotRoot = HotReloadSourceSnapshotLayout.Root(projectRoot); Directory.CreateDirectory(snapshotRoot); + HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats(); foreach (UnityCompilationAssembly assembly in assemblies) { + stats.Assemblies++; try { - CaptureAssemblyIfNeeded(projectRoot, snapshotRoot, assembly, compileStart, documentIndex); + CaptureAssemblyIfNeeded(projectRoot, snapshotRoot, assembly, compileStart, documentIndex, stats); } catch (Exception ex) when (IsSkippableAssemblyCaptureException(ex)) { @@ -69,6 +83,8 @@ internal static void CaptureAssemblies( $"[UnityCliLoop] Snapshot capture failed for assembly {assembly.name}: {ex.Message}"); } } + + return stats; } private static bool IsSkippableAssemblyCaptureException(Exception ex) @@ -85,7 +101,8 @@ private static void CaptureAssemblyIfNeeded( string snapshotRoot, UnityCompilationAssembly assembly, HotReloadCompileStart compileStart, - HotReloadPdbDocumentIndex documentIndex) + HotReloadPdbDocumentIndex documentIndex, + HotReloadSnapshotCaptureStats stats) { string[] sourceFiles = assembly.sourceFiles; if (sourceFiles == null || sourceFiles.Length == 0) @@ -93,8 +110,12 @@ private static void CaptureAssemblyIfNeeded( return; } - if (ShouldSkipImmutablePackageSources(sourceFiles)) + System.Diagnostics.Stopwatch packageLookupWatch = System.Diagnostics.Stopwatch.StartNew(); + bool immutable = ShouldSkipImmutablePackageSources(sourceFiles); + stats.PackageLookupTicks += packageLookupWatch.Elapsed.Ticks; + if (immutable) { + stats.AssembliesImmutable++; return; } @@ -116,12 +137,18 @@ private static void CaptureAssemblyIfNeeded( // 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. - if (HasMatchingStamp(stampPath, dllMtimeTicks, dllByteLength)) + System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew(); + bool stampMatches = HasMatchingStamp(stampPath, dllMtimeTicks, dllByteLength); + stats.StampTicks += stampWatch.Elapsed.Ticks; + if (stampMatches) { + stats.AssembliesUnchanged++; return; } + System.Diagnostics.Stopwatch mvidWatch = System.Diagnostics.Stopwatch.StartNew(); string mvid = HotReloadAssemblyMvid.Read(dllPath); + stats.MvidTicks += mvidWatch.Elapsed.Ticks; string assemblySnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assembly.name, mvid); if (!Directory.Exists(assemblySnapshotDirectory)) { @@ -136,15 +163,21 @@ private static void CaptureAssemblyIfNeeded( assemblySnapshotDirectory, sourceFiles, assembly.name, - check); + check, + stats); + stats.AssembliesCaptured++; + System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew(); DeleteStaleSnapshotDirectories(snapshotRoot, assembly.name, assemblySnapshotDirectory); + stats.CleanupTicks += cleanupWatch.Elapsed.Ticks; } // Write the stamp even when individual sources were skipped: retrying every domain // reload would only repeat IO and warning spam. The partial snapshot remains fail-closed // 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); + stats.StampTicks += writeStampWatch.Elapsed.Ticks; } /// diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 60d022fedd..9a799638e1 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -94,6 +94,29 @@ command ran against: capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or `apply` only when the capture at domain load threw or Unity listed no compilation assembly yet) and `captureMs`. + `hot_reload_source_snapshot_breakdown` appears once each time the capture runs to the end, + just before `hot_reload_source_snapshot_captured`. It also appears when Unity listed no + compilation assembly, in which case no captured entry follows, and it does not appear when the + capture threw. It says where the capture spent its time: + - `getAssembliesMs`: listing the compilation assemblies from Unity. + - `packageLookupMs`: asking Package Manager whether each assembly's sources are immutable. + - `stampMs`: reading and writing the stamps beside the snapshots. + - `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. + - `readWriteMs`: reading each source and writing its copy. + - `pdbCheckMs`: checking the sources written since the compile started against the PDB. + - `publishMs`: deleting a leftover temporary directory and creating a new one, writing the + manifest, and moving the directory into place. + - `cleanupMs`: deleting the snapshot directories of earlier builds. + - `totalMs`: the whole capture. + - `assemblies`: the compilation assemblies listed. + - `assembliesImmutable`: those skipped because their sources belong to an immutable package. + - `assembliesUnchanged`: those skipped because their stamp matched the compiled dll. + - `assembliesCaptured`: those whose sources were copied into a new snapshot directory. + - `filesCopied` and `bytesCopied`: the sources copied and their total size. + - `filesChecked`: the sources checked against the PDB. + The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry + also includes writing this breakdown entry. 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 5fd6da3a79f931b822d7693c591b9e29b7272809 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 14:28:39 +0900 Subject: [PATCH 3/4] perf: Hot reload's source snapshot capture rewrites only the sources that changed since the last compile (#3277) --- ...otReloadIncrementalSnapshotCaptureTests.cs | 726 ++++++++++++++++++ ...oadIncrementalSnapshotCaptureTests.cs.meta | 11 + .../Shared/HotReloadSnapshotCaptureStats.cs | 2 + ...tReloadSourceSnapshotCopier.Incremental.cs | 190 +++++ ...adSourceSnapshotCopier.Incremental.cs.meta | 11 + .../Shared/HotReloadSourceSnapshotCopier.cs | 103 +-- .../HotReloadSourceSnapshotter.Stamp.cs | 49 ++ .../Shared/HotReloadSourceSnapshotter.cs | 32 +- docs/hot-reload.md | 4 +- docs/vibe-logs.md | 17 +- 10 files changed, 1090 insertions(+), 55 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs new file mode 100644 index 0000000000..e054e18067 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs @@ -0,0 +1,726 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; + +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 +{ + /// + /// EditMode coverage for the capture that reuses the previous build's snapshot directory and + /// rewrites only the sources that changed, run in a per-test temporary project root. A previous + /// copy holds a sentinel that no source holds, so a copy that still holds it was reused and a + /// copy that holds the source's bytes was written again. + /// + public sealed class HotReloadIncrementalSnapshotCaptureTests + { + private const string AssemblyName = "Fixture"; + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime"; + private const string FixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/HotReloadE2EFixtures.cs"; + private const string Sentinel = "PREVIOUS"; + private const uint RestoredDirectoryMode = 0x1ED; // 0755 + private const uint RestoredFileMode = 0x1A4; // 0644 + + private static readonly DateTime BeforeTheCompileUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime CompileStartUtc = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime SinceTheCompileUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private string _root; + private string _previousDirectory; + private string _newDirectory; + private readonly List _restoreDirectoryModes = new List(); + private readonly List _restoreFileModes = new List(); + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(_root, "Assets")); + Directory.CreateDirectory(Path.Combine(_root, HotReloadConstants.ScriptAssembliesRelativeDirectory)); + Directory.CreateDirectory(HotReloadSourceSnapshotLayout.Root(_root)); + _previousDirectory = SnapshotDirectory(Guid.NewGuid().ToString("N")); + _newDirectory = SnapshotDirectory(Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_previousDirectory); + } + + [TearDown] + public void TearDown() + { + // Why the modes come back first: a directory without write permission cannot be emptied. + foreach (string path in _restoreDirectoryModes) + { + Chmod(path, RestoredDirectoryMode); + } + + foreach (string path in _restoreFileModes) + { + Chmod(path, RestoredFileMode); + } + + _restoreDirectoryModes.Clear(); + _restoreFileModes.Clear(); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, true); + } + } + + /// + /// Verifies a source with an unmarked line whose length and write time still match, written + /// before the compile started, keeps the previous copy and its line. + /// + [Test] + public void CaptureIncrementally_UnchangedTrustedSource_ReusesThePreviousCopy() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel)); + AssertNewLine("Assets/A.cs", false); + } + + /// + /// Verifies a source whose length or write time differs from its line is copied again. + /// + [TestCase(1, 0)] + [TestCase(0, 1)] + public void CaptureIncrementally_SourceWhoseLengthOrWriteTimeChanged_CopiesTheCurrentBytes( + int lineLengthOffset, + int lineSecondsOffset) + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + FileInfo source = SourceInfo("Assets/A.cs"); + WritePreviousManifest(HotReloadSourceStampManifest.FormatLine( + SnapshotFileName("Assets/A.cs"), + source.Length + lineLengthOffset, + source.LastWriteTimeUtc.AddSeconds(-lineSecondsOffset).Ticks, + false)); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n")); + AssertNewLine("Assets/A.cs", false); + } + + /// + /// Verifies a source whose line marks it as edited after the compile is copied again, even + /// though its length and write time match. + /// + [Test] + public void CaptureIncrementally_SourceMarkedEditedAfterCompile_CopiesTheCurrentBytes() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", true)); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n")); + AssertNewLine("Assets/A.cs", false); + } + + /// + /// Verifies a source the previous manifest has no line for is copied again, while a source + /// beside it that has one is reused. + /// + [Test] + public void CaptureIncrementally_SourceWithoutAManifestLine_CopiesTheCurrentBytes() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + WriteSource("Assets/B.cs", "class B {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + PlantPreviousCopy("Assets/B.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs", "Assets/B.cs"); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel), "A is reused"); + Assert.That(NewCopyText("Assets/B.cs"), Is.EqualTo("class B {}\n"), "B is copied"); + AssertNewLine("Assets/B.cs", false); + } + + /// + /// Verifies a source the previous build did not have is copied and gets a line. + /// + [Test] + public void CaptureIncrementally_SourceNewToTheAssembly_CopiesIt() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + WriteSource("Assets/New.cs", "class New {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs", "Assets/New.cs"); + + Assert.That(NewCopyText("Assets/New.cs"), Is.EqualTo("class New {}\n")); + AssertNewLine("Assets/New.cs", false); + } + + /// + /// Verifies a source whose unmarked line still matches is copied again when the previous + /// directory has no copy of it. + /// + [Test] + public void CaptureIncrementally_PreviousCopyFileMissing_CopiesTheCurrentBytes() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n")); + AssertNewLine("Assets/A.cs", false); + } + + /// + /// Verifies a source whose unmarked line still matches, but whose write time falls inside the + /// new compile's suspect window, is copied again and checked against the new PDB. + /// + [Test] + public void CaptureIncrementally_SourceWrittenInsideTheNewSuspectWindow_IsCopiedAndCheckedAgain() + { + byte[] realBytes = File.ReadAllBytes(Path.Combine(RealProjectRoot(), FixtureProjectRelativePath)); + WriteSourceBytes(FixtureProjectRelativePath, realBytes, SinceTheCompileUtc); + PlantPreviousCopy(FixtureProjectRelativePath); + WritePreviousManifest(CurrentLine(FixtureProjectRelativePath, false)); + string dllPath = RealDllPath(TestAssemblyName); + + HotReloadSnapshotCaptureStats stats = CaptureIncrementally( + CheckAgainst(dllPath, Path.ChangeExtension(dllPath, ".pdb")), + FixtureProjectRelativePath); + + Assert.That(File.ReadAllBytes(NewCopyPath(FixtureProjectRelativePath)), Is.EqualTo(realBytes)); + Assert.That(stats.FilesChecked, Is.EqualTo(1)); + AssertNewLine(FixtureProjectRelativePath, false); + } + + /// + /// Verifies the copy of a source that is no longer in the assembly does not reach the new + /// directory. + /// + [Test] + public void CaptureIncrementally_SourceRemovedFromTheAssembly_DeletesItsCopy() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + WriteSource("Assets/Removed.cs", "class Removed {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + PlantPreviousCopy("Assets/Removed.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false), CurrentLine("Assets/Removed.cs", false)); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(File.Exists(NewCopyPath("Assets/Removed.cs")), Is.False); + Assert.That(NewManifest().TryGetStamp(SnapshotFileName("Assets/Removed.cs"), out long _, out long _), Is.False); + } + + /// + /// Verifies a listed source that no longer exists leaves neither its previous copy nor a line. + /// + [Test] + public void CaptureIncrementally_SourceMissingNow_LeavesNoCopyAndNoLine() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + WriteSource("Assets/Gone.cs", "class Gone {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + PlantPreviousCopy("Assets/Gone.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false), CurrentLine("Assets/Gone.cs", false)); + File.Delete(SourcePath("Assets/Gone.cs")); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs", "Assets/Gone.cs"); + + Assert.That(File.Exists(NewCopyPath("Assets/Gone.cs")), Is.False); + Assert.That(NewManifest().TryGetStamp(SnapshotFileName("Assets/Gone.cs"), out long _, out long _), Is.False); + } + + /// + /// Verifies a capture whose stamp names the previous build's complete directory publishes the + /// snapshot under the new MVID by moving that directory, so the reused copy is there, the + /// previous directory is gone, and the stamp names the new MVID. + /// + [Test] + public void CaptureIncrementally_PublishesUnderTheNewMvidAndThePreviousDirectoryIsGone() + { + string dllPath = PlantCompiledAssembly(); + string mvid = HotReloadAssemblyMvid.Read(dllPath); + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + WriteStampNamingPrevious(); + + CaptureAssemblies("Assets/A.cs"); + + string publishedDirectory = SnapshotDirectory(mvid); + Assert.That( + File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(publishedDirectory, "Assets/A.cs")), + Is.EqualTo(Sentinel)); + Assert.That(Directory.Exists(_previousDirectory), Is.False); + Assert.That(File.ReadAllText(StampPath()).Split(',')[0], Is.EqualTo(mvid)); + } + + /// + /// Verifies a temporary directory left over under the new name is deleted before the previous + /// directory is moved there, so the capture still publishes and the leftover file is gone. + /// + [Test] + public void CaptureIncrementally_WhenAnIncompleteDirectoryIsLeftOver_DeletesItFirst() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + string leftover = _newDirectory + HotReloadSourceSnapshotLayout.IncompleteDirectorySuffix; + Directory.CreateDirectory(leftover); + File.WriteAllText(Path.Combine(leftover, "stale.cs"), "stale\n"); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel)); + Assert.That(File.Exists(Path.Combine(_newDirectory, "stale.cs")), Is.False); + Assert.That(Directory.Exists(leftover), Is.False); + } + + /// + /// Verifies that when the previous directory cannot be moved, the capture copies every source + /// into the new directory instead and leaves the previous directory where it was. + /// + [Test] + [UnityPlatform(exclude = new[] { RuntimePlatform.WindowsEditor })] + public void CaptureIncrementally_WhenThePreviousDirectoryCannotBeMoved_FallsBackToAFullCopy() + { + IgnoreWhenRoot(); + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + string lockedParent = Path.Combine(_root, "Locked"); + string previousDirectory = Path.Combine(lockedParent, AssemblyName + "-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(previousDirectory); + File.WriteAllText(HotReloadSourceSnapshotLayout.SourcePath(previousDirectory, "Assets/A.cs"), Sentinel); + HotReloadSourceStampManifest.Write(previousDirectory, new[] { CurrentLine("Assets/A.cs", false) }); + ChangeDirectoryMode(lockedParent, 0x16D); // 0555 + + HotReloadSourceSnapshotCopier.CaptureIncrementally( + _root, + _newDirectory, + previousDirectory, + new[] { "Assets/A.cs" }, + AssemblyName, + SuspectsNothing(), + new HotReloadSnapshotCaptureStats()); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo("class A {}\n")); + Assert.That( + File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(previousDirectory, "Assets/A.cs")), + Is.EqualTo(Sentinel)); + } + + /// + /// Verifies a stamp that names no usable previous snapshot makes the capture copy every source: + /// a first field that is not an MVID, an MVID with no directory, a directory with no manifest, + /// and a manifest with an older header. + /// + [TestCase("not_an_mvid")] + [TestCase("no_directory")] + [TestCase("no_manifest")] + [TestCase("old_header")] + public void CaptureAssemblies_WhenTheStampNamesAnUnusablePreviousDirectory_CopiesEverything(string unusable) + { + string dllPath = PlantCompiledAssembly(); + string mvid = HotReloadAssemblyMvid.Read(dllPath); + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + string line = CurrentLine("Assets/A.cs", false); + string manifestPath = Path.Combine(_previousDirectory, HotReloadConstants.SourceStampManifestFileName); + switch (unusable) + { + case "not_an_mvid": + WritePreviousManifest(line); + File.WriteAllText(StampPath(), "immutable,0,0"); + break; + case "no_directory": + WritePreviousManifest(line); + File.WriteAllText(StampPath(), Guid.NewGuid().ToString("N") + ",0,0"); + break; + case "no_manifest": + WriteStampNamingPrevious(); + break; + case "old_header": + File.WriteAllText(manifestPath, "uloop-source-stamps 1\n" + line + "\n"); + WriteStampNamingPrevious(); + break; + default: + Assert.Fail("Unknown case " + unusable); + break; + } + + CaptureAssemblies("Assets/A.cs"); + + Assert.That( + File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(SnapshotDirectory(mvid), "Assets/A.cs")), + Is.EqualTo("class A {}\n")); + } + + /// + /// Verifies that when the incremental snapshot cannot be published, the capture warns, the + /// previous directory is gone and the stamp still names it, and the next capture copies every + /// source. + /// + [Test] + public void CaptureAssemblies_WhenPublishingAnIncrementalSnapshotFails_TheNextCaptureCopiesEverything() + { + string dllPath = PlantCompiledAssembly(); + string mvid = HotReloadAssemblyMvid.Read(dllPath); + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + WriteStampNamingPrevious(); + string stampBefore = File.ReadAllText(StampPath()); + string publishedDirectory = SnapshotDirectory(mvid); + // A file where the published directory goes makes the final move fail. + File.WriteAllText(publishedDirectory, "blocker"); + LogAssert.Expect(LogType.Warning, new Regex("Snapshot capture failed for assembly " + AssemblyName)); + + CaptureAssemblies("Assets/A.cs"); + + Assert.That(Directory.Exists(_previousDirectory), Is.False, "previous directory"); + Assert.That(File.ReadAllText(StampPath()), Is.EqualTo(stampBefore), "stamp"); + + File.Delete(publishedDirectory); + CaptureAssemblies("Assets/A.cs"); + + Assert.That( + File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(publishedDirectory, "Assets/A.cs")), + Is.EqualTo("class A {}\n")); + } + + /// + /// Verifies the capture reports the reused and the copied files: of three sources, the one that + /// changed is copied and the other two are reused. + /// + [Test] + public void CaptureAssemblies_ReportsReusedAndCopiedFiles() + { + PlantCompiledAssembly(); + string[] sources = { "Assets/A.cs", "Assets/B.cs", "Assets/C.cs" }; + List lines = new List(); + foreach (string source in sources) + { + WriteSource(source, "class X {}\n", BeforeTheCompileUtc); + PlantPreviousCopy(source); + lines.Add(CurrentLine(source, false)); + } + + WritePreviousManifest(lines.ToArray()); + WriteStampNamingPrevious(); + WriteSource("Assets/B.cs", "class Changed {}\n", BeforeTheCompileUtc.AddSeconds(1)); + + HotReloadSnapshotCaptureStats stats = CaptureAssemblies(sources); + + Assert.That(stats.FilesReused, Is.EqualTo(2), "reused"); + Assert.That(stats.FilesCopied, Is.EqualTo(1), "copied"); + } + + /// + /// Verifies a source that cannot be read but whose unmarked line still matches is reused + /// without being read, keeping its line and raising no warning. + /// + [Test] + [UnityPlatform(exclude = new[] { RuntimePlatform.WindowsEditor })] + public void CaptureIncrementally_UnreadableSourceWhoseStampIsUnchanged_ReusesThePreviousCopyWithoutReadingIt() + { + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + ChangeFileMode(SourcePath("Assets/A.cs"), 0); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(NewCopyText("Assets/A.cs"), Is.EqualTo(Sentinel)); + AssertNewLine("Assets/A.cs", false); + LogAssert.NoUnexpectedReceived(); + } + + /// + /// Verifies a source that changed since its line and cannot be read leaves neither the previous + /// copy nor a line, and the capture warns about it. + /// + [Test] + [UnityPlatform(exclude = new[] { RuntimePlatform.WindowsEditor })] + public void CaptureIncrementally_UnreadableSourceThatChanged_LeavesNoCopyAndNoLineAndWarns() + { + IgnoreWhenRoot(); + WriteSource("Assets/A.cs", "class A {}\n", BeforeTheCompileUtc); + PlantPreviousCopy("Assets/A.cs"); + WritePreviousManifest(CurrentLine("Assets/A.cs", false)); + File.SetLastWriteTimeUtc(SourcePath("Assets/A.cs"), BeforeTheCompileUtc.AddSeconds(1)); + ChangeFileMode(SourcePath("Assets/A.cs"), 0); + LogAssert.Expect(LogType.Warning, new Regex("Skipped 1 unreadable source")); + + CaptureIncrementally(SuspectsNothing(), "Assets/A.cs"); + + Assert.That(File.Exists(NewCopyPath("Assets/A.cs")), Is.False); + Assert.That(NewManifest().TryGetStamp(SnapshotFileName("Assets/A.cs"), out long _, out long _), Is.False); + } + + /// + /// Verifies that when the PDB check throws after the previous directory was moved to the new + /// temporary directory, the exception reaches the caller and nothing is published: the + /// previous directory now sits under the temporary name with the copy reused before the throw. + /// + [Test] + public void CaptureIncrementally_WhenTheCheckThrowsAfterAdoptingThePreviousDirectory_PublishesNothing() + { + string mismatchedDirectory = Path.Combine(_root, "Mismatched"); + Directory.CreateDirectory(mismatchedDirectory); + string dllPath = Path.Combine(mismatchedDirectory, "X.dll"); + string pdbPath = Path.Combine(mismatchedDirectory, "X.pdb"); + File.Copy(RealDllPath(TestAssemblyName), dllPath); + File.Copy(Path.ChangeExtension(RealDllPath(PausePointsRuntimeAssemblyName), ".pdb"), pdbPath); + WriteSource("Assets/Reused.cs", "class Reused {}\n", BeforeTheCompileUtc); + WriteSource("Assets/Suspect.cs", "class Suspect {}\n", SinceTheCompileUtc); + PlantPreviousCopy("Assets/Reused.cs"); + WritePreviousManifest(CurrentLine("Assets/Reused.cs", false)); + + Assert.Throws(() => HotReloadSourceSnapshotCopier.CaptureIncrementally( + _root, + _newDirectory, + _previousDirectory, + new[] { "Assets/Reused.cs", "Assets/Suspect.cs" }, + AssemblyName, + CheckAgainst(dllPath, pdbPath), + new HotReloadSnapshotCaptureStats())); + + string temporaryDirectory = _newDirectory + HotReloadSourceSnapshotLayout.IncompleteDirectorySuffix; + Assert.That(Directory.Exists(_newDirectory), Is.False, "published directory"); + Assert.That(Directory.Exists(_previousDirectory), Is.False, "previous directory"); + Assert.That( + File.ReadAllText(HotReloadSourceSnapshotLayout.SourcePath(temporaryDirectory, "Assets/Reused.cs")), + Is.EqualTo(Sentinel)); + } + + private HotReloadSnapshotCaptureStats CaptureIncrementally(HotReloadSnapshotSourceCheck check, params string[] sources) + { + HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats(); + HotReloadSourceSnapshotCopier.CaptureIncrementally( + _root, + _newDirectory, + _previousDirectory, + sources, + AssemblyName, + check, + stats); + return stats; + } + + private HotReloadSnapshotCaptureStats CaptureAssemblies(params string[] sources) + { + UnityCompilationAssembly assembly = new UnityCompilationAssembly( + AssemblyName, + HotReloadConstants.ScriptAssembliesRelativeDirectory + "/" + AssemblyName + HotReloadConstants.CompiledAssemblyExtension, + sources, + Array.Empty(), + Array.Empty(), + Array.Empty(), + AssemblyFlags.EditorAssembly); + return HotReloadSourceSnapshotter.CaptureAssemblies( + _root, + new[] { assembly }, + HotReloadCompileStart.Unknown, + new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); + } + + // A check whose suspect window starts at the planted compile start, against the given build. + private HotReloadSnapshotSourceCheck CheckAgainst(string dllPath, string pdbPath) + { + return new HotReloadSnapshotSourceCheck( + CompileStartUtc.Ticks, + dllPath, + pdbPath, + HotReloadAssemblyMvid.Read(dllPath), + new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); + } + + // A check whose suspect window never opens, so no copy is checked against a PDB. + private HotReloadSnapshotSourceCheck SuspectsNothing() + { + return new HotReloadSnapshotSourceCheck( + DateTime.MaxValue.Ticks, + "unused.dll", + "unused.pdb", + "unused", + new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); + } + + // Copies this test assembly's own DLL and PDB, so the fixture has a real image for Cecil to read. + private string PlantCompiledAssembly() + { + string dllPath = Path.Combine( + _root, + HotReloadConstants.ScriptAssembliesRelativeDirectory, + AssemblyName + HotReloadConstants.CompiledAssemblyExtension); + File.Copy(RealDllPath(TestAssemblyName), dllPath); + File.Copy(Path.ChangeExtension(RealDllPath(TestAssemblyName), ".pdb"), Path.ChangeExtension(dllPath, ".pdb")); + return dllPath; + } + + // Why an mtime and length no DLL has: the stamp must not match, or the capture stops there. + private void WriteStampNamingPrevious() + { + string previousMvid = Path.GetFileName(_previousDirectory).Substring(AssemblyName.Length + 1); + File.WriteAllText(StampPath(), previousMvid + ",0,0"); + } + + private void WriteSource(string projectRelativePath, string text, DateTime lastWriteTimeUtc) + { + WriteSourceBytes(projectRelativePath, Encoding.ASCII.GetBytes(text), lastWriteTimeUtc); + } + + // Why a write time in whole seconds: SetLastWriteTimeUtc stores microseconds while + // LastWriteTimeUtc reports 100 ns ticks, so only such a value is read back exactly. + private void WriteSourceBytes(string projectRelativePath, byte[] bytes, DateTime lastWriteTimeUtc) + { + string path = SourcePath(projectRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, bytes); + File.SetLastWriteTimeUtc(path, lastWriteTimeUtc); + Assert.That(new FileInfo(path).LastWriteTimeUtc, Is.EqualTo(lastWriteTimeUtc), "write time of " + projectRelativePath); + } + + private void PlantPreviousCopy(string projectRelativePath) + { + File.WriteAllText(HotReloadSourceSnapshotLayout.SourcePath(_previousDirectory, projectRelativePath), Sentinel); + } + + private void WritePreviousManifest(params string[] lines) + { + HotReloadSourceStampManifest.Write(_previousDirectory, lines); + } + + // The line the previous capture would have written for the source as it is now. + private string CurrentLine(string projectRelativePath, bool editedAfterCompile) + { + FileInfo source = SourceInfo(projectRelativePath); + return HotReloadSourceStampManifest.FormatLine( + SnapshotFileName(projectRelativePath), + source.Length, + source.LastWriteTimeUtc.Ticks, + editedAfterCompile); + } + + private void AssertNewLine(string projectRelativePath, bool editedAfterCompile) + { + HotReloadSourceStampManifest manifest = NewManifest(); + FileInfo source = SourceInfo(projectRelativePath); + Assert.That( + manifest.TryGetStamp(SnapshotFileName(projectRelativePath), out long length, out long ticks), + Is.True, + "line of " + projectRelativePath); + Assert.That(length, Is.EqualTo(source.Length), "length of " + projectRelativePath); + Assert.That(ticks, Is.EqualTo(source.LastWriteTimeUtc.Ticks), "write time of " + projectRelativePath); + Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(projectRelativePath)), Is.EqualTo(editedAfterCompile)); + } + + private HotReloadSourceStampManifest NewManifest() + { + return HotReloadSourceStampManifest.Load(_newDirectory); + } + + private string NewCopyText(string projectRelativePath) + { + return File.ReadAllText(NewCopyPath(projectRelativePath)); + } + + private string NewCopyPath(string projectRelativePath) + { + return HotReloadSourceSnapshotLayout.SourcePath(_newDirectory, projectRelativePath); + } + + private FileInfo SourceInfo(string projectRelativePath) + { + return new FileInfo(SourcePath(projectRelativePath)); + } + + private string SourcePath(string projectRelativePath) + { + return Path.Combine(_root, projectRelativePath.Replace('/', Path.DirectorySeparatorChar)); + } + + private string SnapshotDirectory(string mvid) + { + return HotReloadSourceSnapshotLayout.AssemblyDirectory(_root, AssemblyName, mvid); + } + + private string StampPath() + { + return Path.Combine(HotReloadSourceSnapshotLayout.Root(_root), AssemblyName + ".stamp"); + } + + private static string SnapshotFileName(string projectRelativePath) + { + return HotReloadSourceSnapshotLayout.SourceFileName(projectRelativePath); + } + + private static string RealProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string RealDllPath(string assemblyName) + { + return Path.Combine( + RealProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + assemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + + private void ChangeDirectoryMode(string path, uint mode) + { + _restoreDirectoryModes.Add(path); + ChangeMode(path, mode); + } + + private void ChangeFileMode(string path, uint mode) + { + _restoreFileModes.Add(path); + ChangeMode(path, mode); + } + + private static void ChangeMode(string path, uint mode) + { + if (Chmod(path, mode) != 0) + { + Assert.Fail("chmod failed for " + path + ": errno " + Marshal.GetLastWin32Error()); + } + } + + // Why: the nightly EditMode run is root inside its container, and root ignores permission + // bits, so a mode that stops this user stops nothing there. Ignore, not Assume: batchmode + // -runTests exits with 2 on an inconclusive test even when nothing failed. + private static void IgnoreWhenRoot() + { + if (GetEffectiveUserId() == 0) + { + Assert.Ignore("Permission bits do not stop root."); + } + } + + [DllImport("libc", EntryPoint = "chmod", SetLastError = true)] + private static extern int Chmod(string path, uint mode); + + [DllImport("libc", EntryPoint = "geteuid", SetLastError = true)] + private static extern uint GetEffectiveUserId(); + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta new file mode 100644 index 0000000000..cca725a36a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bc36733be484d452587aa5ea99513875 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs index 1097c0cd47..3f939b7bf4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs @@ -22,6 +22,7 @@ internal sealed class HotReloadSnapshotCaptureStats internal int AssembliesImmutable; internal int AssembliesUnchanged; internal int AssembliesCaptured; + internal int FilesReused; internal int FilesCopied; internal long BytesCopied; internal int FilesChecked; @@ -44,6 +45,7 @@ internal object ToVibeLogContext(long getAssembliesMs, long totalMs) assembliesImmutable = AssembliesImmutable, assembliesUnchanged = AssembliesUnchanged, assembliesCaptured = AssembliesCaptured, + filesReused = FilesReused, filesCopied = FilesCopied, bytesCopied = BytesCopied, filesChecked = FilesChecked diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs new file mode 100644 index 0000000000..62db464106 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + // Builds an assembly's new snapshot from the previous build's: it moves the previous directory to + // the new temporary name, keeps the copies whose sources have not changed since, and writes only + // the rest again. + internal static partial class HotReloadSourceSnapshotCopier + { + internal static void CaptureIncrementally( + string projectRoot, + string assemblySnapshotDirectory, + string previousSnapshotDirectory, + string[] sourceFiles, + string assemblyName, + HotReloadSnapshotSourceCheck check, + HotReloadSnapshotCaptureStats stats) + { + Debug.Assert(stats != null, "stats must not be null."); + + string temporaryDirectory = assemblySnapshotDirectory + HotReloadSourceSnapshotLayout.IncompleteDirectorySuffix; + System.Diagnostics.Stopwatch adoptWatch = System.Diagnostics.Stopwatch.StartNew(); + // Why the leftover goes first: moving the previous directory onto an existing one throws. + if (Directory.Exists(temporaryDirectory)) + { + Directory.Delete(temporaryDirectory, recursive: true); + } + + stats.PublishTicks += adoptWatch.Elapsed.Ticks; + System.Diagnostics.Stopwatch manifestWatch = System.Diagnostics.Stopwatch.StartNew(); + // Read before the move: the manifest moves with the directory and is rewritten there. + HotReloadSourceStampManifest previousManifest = HotReloadSourceStampManifest.Load(previousSnapshotDirectory); + stats.StampTicks += manifestWatch.Elapsed.Ticks; + adoptWatch.Restart(); + bool adopted = TryAdoptPreviousSnapshot(previousSnapshotDirectory, temporaryDirectory); + stats.PublishTicks += adoptWatch.Elapsed.Ticks; + if (!adopted) + { + CaptureAtomically(projectRoot, assemblySnapshotDirectory, sourceFiles, assemblyName, check, stats); + return; + } + + HashSet listedNames = new HashSet(StringComparer.Ordinal); + SourceCopyTally tally = new SourceCopyTally(); + List manifestLines = new List(sourceFiles.Length); + foreach (string projectRelativeSourcePath in sourceFiles) + { + string normalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); + string snapshotFileName = HotReloadSourceSnapshotLayout.SourceFileName(normalizedRelativePath); + listedNames.Add(snapshotFileName); + string copyPath = Path.Combine(temporaryDirectory, snapshotFileName); + if (TryReusePreviousCopy( + projectRoot, + normalizedRelativePath, + snapshotFileName, + copyPath, + previousManifest, + check.SuspectWritesFromUtcTicks, + manifestLines, + stats)) + { + stats.FilesReused++; + continue; + } + + // Why the previous copy goes before the copy: a source that is missing or unreadable now + // gets no copy, and a stale copy left in its place would make readers that compare bytes + // see a change that is not there. + System.Diagnostics.Stopwatch deleteWatch = System.Diagnostics.Stopwatch.StartNew(); + File.Delete(copyPath); + stats.ReadWriteTicks += deleteWatch.Elapsed.Ticks; + SourceCopyOutcome outcome = CopySourceFileByteExact( + projectRoot, + temporaryDirectory, + projectRelativeSourcePath, + check, + manifestLines, + stats); + tally.Count(outcome, projectRelativeSourcePath); + } + + System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew(); + DeleteUnlistedCopies(temporaryDirectory, listedNames); + stats.CleanupTicks += cleanupWatch.Elapsed.Ticks; + System.Diagnostics.Stopwatch publishWatch = System.Diagnostics.Stopwatch.StartNew(); + HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines); + Directory.Move(temporaryDirectory, assemblySnapshotDirectory); + stats.PublishTicks += publishWatch.Elapsed.Ticks; + tally.LogAndWarn(assemblyName); + } + + // Keeps the previous copy and its line when the source has not been written since the previous + // capture recorded it. Returns false when the source must be copied again. + private static bool TryReusePreviousCopy( + string projectRoot, + string normalizedRelativePath, + string snapshotFileName, + string copyPath, + HotReloadSourceStampManifest previousManifest, + long suspectWritesFromUtcTicks, + List manifestLines, + HotReloadSnapshotCaptureStats stats) + { + // Why an unmarked line with the same stat lets the copy stand: the previous capture wrote + // that line only when the source's stat held while it read it and the bytes were the ones + // the previous compile read, or matched the previous PDB. A source with the same length and + // write time has not been written since, so this compile, which started after that capture, + // read the same bytes. + if (!previousManifest.TryGetStamp(snapshotFileName, out long length, out long lastWriteTimeUtcTicks)) + { + return false; + } + + if (previousManifest.IsEditedAfterCompile(snapshotFileName)) + { + return false; + } + + System.Diagnostics.Stopwatch statWatch = System.Diagnostics.Stopwatch.StartNew(); + string absoluteSourcePath = Path.Combine(projectRoot, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); + FileInfo source = new FileInfo(HotReloadFileSystemPath.GetFileSystemPath(absoluteSourcePath)); + bool unchanged = source.Exists + && source.Length == length + && source.LastWriteTimeUtc.Ticks == lastWriteTimeUtcTicks; + bool copyExists = unchanged && File.Exists(copyPath); + stats.StatTicks += statWatch.Elapsed.Ticks; + if (!unchanged) + { + return false; + } + + // A defense in depth: a source written inside this compile's suspect window is checked + // against this build's PDB instead of being trusted. + if (lastWriteTimeUtcTicks >= suspectWritesFromUtcTicks) + { + return false; + } + + if (!copyExists) + { + return false; + } + + manifestLines.Add(HotReloadSourceStampManifest.FormatLine(snapshotFileName, length, lastWriteTimeUtcTicks, false)); + return true; + } + + private static bool TryAdoptPreviousSnapshot(string previousSnapshotDirectory, string temporaryDirectory) + { + try + { + Directory.Move(previousSnapshotDirectory, temporaryDirectory); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + // Approved deviation from the no-try-catch rule: on Windows another process holding a + // previous copy open stops the move. The previous directory then still sits whole under + // its own name, so the capture copies every source instead. + return false; + } + } + + // Deletes the copies of sources the assembly no longer lists, so no reader finds a copy of a + // source this build did not compile. + private static void DeleteUnlistedCopies(string temporaryDirectory, HashSet listedNames) + { + foreach (string path in Directory.GetFiles(temporaryDirectory)) + { + string fileName = Path.GetFileName(path); + // The manifest is written again right after this. + if (string.Equals(fileName, HotReloadConstants.SourceStampManifestFileName, StringComparison.Ordinal)) + { + continue; + } + + if (listedNames.Contains(fileName)) + { + continue; + } + + File.Delete(path); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta new file mode 100644 index 0000000000..25245131e1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d6af6c2e0a4fd4776b2159ff33b769b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs index 7b5c140d27..40886d6be1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs @@ -13,7 +13,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// into a temporary directory first and publishing it with one Move, and decides what the /// manifest records for each copy. /// - internal static class HotReloadSourceSnapshotCopier + internal static partial class HotReloadSourceSnapshotCopier { internal static void CaptureAtomically( string projectRoot, @@ -38,11 +38,7 @@ internal static void CaptureAtomically( Directory.CreateDirectory(temporaryDirectory); stats.PublishTicks += prepareWatch.Elapsed.Ticks; - int skippedSourceCount = 0; - string firstSkippedSourcePath = null; - int suspectSourceCount = 0; - int editedAfterCompileCount = 0; - long checkTicks = 0; + SourceCopyTally tally = new SourceCopyTally(); List manifestLines = new List(sourceFiles.Length); foreach (string projectRelativeSourcePath in sourceFiles) { @@ -53,49 +49,14 @@ internal static void CaptureAtomically( check, manifestLines, stats); - if (outcome.Kind == SourceCopyKind.Unreadable) - { - skippedSourceCount++; - firstSkippedSourcePath ??= projectRelativeSourcePath; - continue; - } - - if (outcome.Kind == SourceCopyKind.Checked || outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) - { - suspectSourceCount++; - checkTicks += outcome.CheckTicks; - } - - if (outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) - { - editedAfterCompileCount++; - } + tally.Count(outcome, projectRelativeSourcePath); } System.Diagnostics.Stopwatch publishWatch = System.Diagnostics.Stopwatch.StartNew(); HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines); Directory.Move(temporaryDirectory, assemblySnapshotDirectory); stats.PublishTicks += publishWatch.Elapsed.Ticks; - if (suspectSourceCount > 0) - { - VibeLogger.LogInfo( - HotReloadConstants.VibeLogSourceSnapshotChecked, - "Hot reload checked the sources written since the compile started against the PDB.", - new - { - assemblyName, - suspect = suspectSourceCount, - editedAfterCompile = editedAfterCompileCount, - checkMs = (long)TimeSpan.FromTicks(checkTicks).TotalMilliseconds - }); - } - - if (skippedSourceCount > 0) - { - UnityEngine.Debug.LogWarning( - $"[UnityCliLoop] Skipped {skippedSourceCount} unreadable source(s) while snapshotting " + - $"{assemblyName}: {firstSkippedSourcePath}"); - } + tally.LogAndWarn(assemblyName); } private static SourceCopyOutcome CopySourceFileByteExact( @@ -278,5 +239,61 @@ internal SourceCopyOutcome(SourceCopyKind kind, long checkTicks) CheckTicks = checkTicks; } } + + // Counts the outcomes of one assembly's copies, then logs the PDB checks and warns about the + // sources that could not be read. + private sealed class SourceCopyTally + { + private int _skippedSourceCount; + private string _firstSkippedSourcePath; + private int _suspectSourceCount; + private int _editedAfterCompileCount; + private long _checkTicks; + + internal void Count(SourceCopyOutcome outcome, string projectRelativeSourcePath) + { + if (outcome.Kind == SourceCopyKind.Unreadable) + { + _skippedSourceCount++; + _firstSkippedSourcePath ??= projectRelativeSourcePath; + return; + } + + if (outcome.Kind == SourceCopyKind.Checked || outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) + { + _suspectSourceCount++; + _checkTicks += outcome.CheckTicks; + } + + if (outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) + { + _editedAfterCompileCount++; + } + } + + internal void LogAndWarn(string assemblyName) + { + if (_suspectSourceCount > 0) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotChecked, + "Hot reload checked the sources written since the compile started against the PDB.", + new + { + assemblyName, + suspect = _suspectSourceCount, + editedAfterCompile = _editedAfterCompileCount, + checkMs = (long)TimeSpan.FromTicks(_checkTicks).TotalMilliseconds + }); + } + + if (_skippedSourceCount > 0) + { + UnityEngine.Debug.LogWarning( + $"[UnityCliLoop] Skipped {_skippedSourceCount} unreadable source(s) while snapshotting " + + $"{assemblyName}: {_firstSkippedSourcePath}"); + } + } + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs index 9d14551323..41f96e320c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs @@ -1,3 +1,4 @@ +using System; using System.IO; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -41,6 +42,54 @@ private static bool HasMatchingStamp(string stampPath, long dllMtimeTicks, long return stampedMtimeTicks == dllMtimeTicks && stampedByteLength == dllByteLength; } + // The complete snapshot directory of the build the stamp names, when it has a manifest to reuse; + // null when there is none, so the capture copies every source. + private static string FindReusablePreviousSnapshotDirectory( + string projectRoot, + string stampPath, + string assemblyName, + string currentSnapshotDirectory) + { + if (!File.Exists(stampPath)) + { + return null; + } + + string[] parts = File.ReadAllText(stampPath).Trim().Split(','); + if (parts.Length != 3) + { + return null; + } + + // Why the same check as the stale-directory cleanup: only an MVID names a snapshot directory. + if (!Guid.TryParseExact(parts[0], "N", out Guid _)) + { + return null; + } + + string previousSnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assemblyName, parts[0]); + if (string.Equals( + Path.GetFullPath(previousSnapshotDirectory), + Path.GetFullPath(currentSnapshotDirectory), + StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!Directory.Exists(previousSnapshotDirectory)) + { + return null; + } + + // A missing, malformed or older-format manifest answers no line, so nothing could be reused. + if (HotReloadSourceStampManifest.Load(previousSnapshotDirectory).Count == 0) + { + return null; + } + + return previousSnapshotDirectory; + } + private static void WriteStamp(string stampPath, string mvid, long dllMtimeTicks, long dllByteLength) { File.WriteAllText(stampPath, mvid + "," + dllMtimeTicks + "," + dllByteLength); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index a1f874e7ec..54d3b0e8d3 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -158,13 +158,35 @@ private static void CaptureAssemblyIfNeeded( pdbPath, mvid, documentIndex); - HotReloadSourceSnapshotCopier.CaptureAtomically( + System.Diagnostics.Stopwatch previousWatch = System.Diagnostics.Stopwatch.StartNew(); + string previousSnapshotDirectory = FindReusablePreviousSnapshotDirectory( projectRoot, - assemblySnapshotDirectory, - sourceFiles, + stampPath, assembly.name, - check, - stats); + assemblySnapshotDirectory); + stats.StampTicks += previousWatch.Elapsed.Ticks; + if (previousSnapshotDirectory == null) + { + HotReloadSourceSnapshotCopier.CaptureAtomically( + projectRoot, + assemblySnapshotDirectory, + sourceFiles, + assembly.name, + check, + stats); + } + else + { + HotReloadSourceSnapshotCopier.CaptureIncrementally( + projectRoot, + assemblySnapshotDirectory, + previousSnapshotDirectory, + sourceFiles, + assembly.name, + check, + stats); + } + stats.AssembliesCaptured++; System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew(); DeleteStaleSnapshotDirectories(snapshotRoot, assembly.name, assemblySnapshotDirectory); diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 26352e61cc..36606e3e36 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -78,7 +78,9 @@ records each copied source's length and write time, so a run after a domain relo unchanged sibling by a stat instead of reading it; a source written since the compile started whose bytes do not match the PDB checksum carries a mark that it was edited after the compile, and the default selection, the sibling scan and the skip check treat a marked source as -changed), +changed; a capture moves the previous build's directory to the new MVID's and rewrites only the +sources whose length or write time changed, that carry no unmarked manifest line, or that were +written since the compile started; it deletes the copies of sources no longer in the assembly), `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 9a799638e1..7aa1744589 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -100,19 +100,24 @@ command ran against: capture threw. It says where the capture spent its time: - `getAssembliesMs`: listing the compilation assemblies from Unity. - `packageLookupMs`: asking Package Manager whether each assembly's sources are immutable. - - `stampMs`: reading and writing the stamps beside the snapshots. + - `stampMs`: reading and writing the stamps 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. - - `readWriteMs`: reading each source and writing its copy. + - `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. + - `readWriteMs`: reading each source and writing its copy, including deleting the previous copy + it replaces. - `pdbCheckMs`: checking the sources written since the compile started against the PDB. - - `publishMs`: deleting a leftover temporary directory and creating a new one, writing the - manifest, and moving the directory into place. - - `cleanupMs`: deleting the snapshot directories of earlier builds. + - `publishMs`: deleting a leftover temporary directory and creating a new one or moving the + previous build's directory there, writing the manifest, and moving the directory into place. + - `cleanupMs`: deleting the snapshot directories of earlier builds, and the copies of sources + the assembly no longer lists. - `totalMs`: the whole capture. - `assemblies`: the compilation assemblies listed. - `assembliesImmutable`: those skipped because their sources belong to an immutable package. - `assembliesUnchanged`: those skipped because their stamp matched the compiled dll. - `assembliesCaptured`: those whose sources were copied into a new snapshot directory. + - `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 From 974a2b562d181a1834560dd93a7418b35c55853a Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 14:44:10 +0900 Subject: [PATCH 4/4] perf: Skip the hot reload source snapshot capture when the hot-reload and pause-point tools are both disabled (#3276) --- .../HotReloadCompositionRootTests.cs | 31 ++++ .../Editor/HotReload/HotReloadDomainTests.cs | 53 +++++- .../HotReload/HotReloadEditorStartupTests.cs | 160 +++++++++++++++++- .../UnityCliLoopEditorBootstrapper.cs | 10 +- .../FirstPartyToolsEditorStartup.cs | 17 +- .../HotReload/HotReloadCompositionRoot.cs | 4 +- .../HotReload/HotReloadEditorStartup.cs | 66 +++++++- .../Patching/HotReloadPausePointPort.cs | 15 +- .../HotReload/Shared/HotReloadConstants.cs | 8 + docs/hot-reload.md | 6 +- docs/vibe-logs.md | 10 +- 11 files changed, 358 insertions(+), 22 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs index 73c2a6fe6e..2707d8012b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs @@ -4,6 +4,8 @@ using HarmonyLib; +using Newtonsoft.Json.Linq; + using NUnit.Framework; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -101,6 +103,35 @@ public void UninstallInstalledServices_LeavesTheSiblingToolsReadingNoDomainInsta Assert.That(HotReloadPausePointCoordination.HotReloadSide, Is.Not.SameAs(installedPort)); } + /// + /// The pause point side the composition root installs captures the source snapshot + /// through the installed gate, under the pause_point trigger, before it reads the snapshot. + /// + [Test] + public void Install_PausePointSide_CapturesThroughTheInstalledGateWithThePausePointTrigger() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + { + captureCount++; + return true; + }); + string source; + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + source = HotReloadPausePointCoordination.HotReloadSide.GetVerifiedSnapshotSource( + "Assets/NoSuchSource.cs", + "Library/ScriptAssemblies/NoSuchAssembly.dll"); + } + + Assert.That(captureCount, Is.EqualTo(1), "captures"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); + Assert.That(source, Is.Null, "no snapshot exists for a dll that does not exist"); + } + /// /// The copies that swap one stage keep the same wired-value persistence and restore /// refresh, so a wiring recorded before the swap can still be restored after it and an diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index 8a85bc8644..d2a11ac072 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -1004,6 +1004,52 @@ public void FindUnappliedRowForMethod_MethodWithAConstructedGenericParameter_Fin Assert.That(row.Label, Is.EqualTo(workerLabel)); } + /// + /// What: reading a verified snapshot source asks the gate to capture the snapshot first, so + /// a pause point set after both tools were disabled at domain load still has one to read. + /// + [Test] + public void GetVerifiedSnapshotSource_EnsuresTheSnapshotIsCaptured() + { + int ensureCount = 0; + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++); + + port.GetVerifiedSnapshotSource(FileOne, typeof(HotReloadDomainTests).Assembly.Location); + + Assert.That(ensureCount, Is.EqualTo(1)); + } + + /// + /// What: an empty path is answered with no source before the gate is asked to capture. + /// + [Test] + public void GetVerifiedSnapshotSource_WithAnEmptyPath_DoesNotEnsureTheCapture() + { + int ensureCount = 0; + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++); + + string source = port.GetVerifiedSnapshotSource(string.Empty, typeof(HotReloadDomainTests).Assembly.Location); + + Assert.That(source, Is.Null); + Assert.That(ensureCount, Is.EqualTo(0)); + } + + /// + /// What: a capture that throws reaches the pause point command instead of being turned into + /// a missing snapshot. + /// + [Test] + public void GetVerifiedSnapshotSource_WhenTheCaptureThrows_PassesTheExceptionOn() + { + InvalidOperationException captureFailure = new InvalidOperationException("The capture failed."); + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => throw captureFailure); + + InvalidOperationException thrown = Assert.Throws( + () => port.GetVerifiedSnapshotSource(FileOne, typeof(HotReloadDomainTests).Assembly.Location)); + + Assert.That(thrown, Is.SameAs(captureFailure)); + } + // Why a generated name: an artifact assembly is compiled under a name of its own, so a // fixture that reused a project assembly's name would not resolve the way production does. private static Assembly CreateIntroducedTypeAssembly() @@ -1025,7 +1071,12 @@ private static void AssertRow( private HotReloadPausePointPort CreatePort(Func readSourceContentHashOrNull) { - return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull); + return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull, () => { }); + } + + private HotReloadPausePointPort CreatePortWithSnapshotCapture(Action ensureSnapshotCaptured) + { + return new HotReloadPausePointPort(_access.Domain, _ => RecordedSourceHash, ensureSnapshotCaptured); } // Records a partially applied reload of FileOne that read RecordedWorkerSourcePath. diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs index 7cb294de6e..4a0288dae7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Newtonsoft.Json.Linq; @@ -31,7 +32,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(); + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled); } Assert.That(captureCount, Is.EqualTo(1), "captures"); @@ -62,7 +63,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { Assert.Throws( - () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands()); + () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled)); HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); @@ -70,5 +71,160 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro Assert.That(captureCount, Is.EqualTo(2), "captures"); } + + /// + /// What: with both tools that read the snapshot disabled, the capture before serving + /// commands is skipped and logged, and the skip leaves the capture undone, so the first + /// reader that runs later captures through the gate. + /// + [Test] + public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(NoToolEnabled); + + Assert.That(captureCount, Is.EqualTo(0), "captures while both readers are disabled"); + JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped); + Assert.That((string)skipped["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled)); + Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0), "captured entries"); + + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( + HotReloadConstants.SourceSnapshotCaptureTriggerApply); + } + + Assert.That(captureCount, Is.EqualTo(1), "captures once a reader asks the gate"); + } + + /// + /// What: one enabled tool that reads the snapshot is enough for the capture before serving + /// commands to run. + /// + [TestCase(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)] + [TestCase(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT)] + public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool) + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(name => name == enabledTool); + } + + Assert.That(captureCount, Is.EqualTo(1), "captures"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + } + + /// + /// What: the startup capture asks about both reader tools by the names the tool settings + /// store them under, hot-reload first. + /// + [Test] + public void CaptureAtStartupUnlessReadersAreDisabled_WhenNeitherIsEnabled_AsksBothReaderToolsBySettingsName() + { + VibeLogger.ClearMemoryLogs(); + List askedNames = new List(); + HotReloadSourceSnapshotCapture capture = CountingCapture(() => { }); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad, + name => + { + askedNames.Add(name); + return false; + }); + } + + Assert.That( + askedNames, + Is.EqualTo(new[] { UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT })); + } + + /// + /// What: the first update tick's capture is skipped the same way when both reader tools are + /// disabled, and the skip names that trigger. + /// + [Test] + public void CaptureAtStartupUnlessReadersAreDisabled_AtTheFirstUpdateTick_WhenNeitherIsEnabled_SkipsWithThatTrigger() + { + VibeLogger.ClearMemoryLogs(); + int captureCount = 0; + HotReloadSourceSnapshotCapture capture = CountingCapture(() => captureCount++); + + using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) + { + HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick, + NoToolEnabled); + } + + Assert.That(captureCount, Is.EqualTo(0), "captures"); + JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped); + Assert.That((string)skipped["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick)); + } + + /// + /// What: with the hot-reload tool disabled the warm-up is not started, and the skip is logged + /// with its reason. + /// + [Test] + public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsDisabled_DoesNotStartAndLogsTheReason() + { + VibeLogger.ClearMemoryLogs(); + int startCount = 0; + + HotReloadEditorStartup.StartWarmUpUnlessHotReloadIsDisabled( + name => name != UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, + () => startCount++); + + Assert.That(startCount, Is.EqualTo(0), "warm-up starts"); + JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpSkipped); + Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.WarmUpSkipReasonToolDisabled)); + } + + /// + /// What: with the hot-reload tool enabled the warm-up is started once and no skip is logged. + /// + [Test] + public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsEnabled_StartsOnce() + { + VibeLogger.ClearMemoryLogs(); + int startCount = 0; + + HotReloadEditorStartup.StartWarmUpUnlessHotReloadIsDisabled( + name => name == UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, + () => startCount++); + + Assert.That(startCount, Is.EqualTo(1), "warm-up starts"); + Assert.That(CountVibeEntries(HotReloadConstants.VibeLogWarmUpSkipped), Is.EqualTo(0), "skipped entries"); + } + + private static bool AllToolsEnabled(string toolName) => true; + + private static bool NoToolEnabled(string toolName) => false; + + private static HotReloadSourceSnapshotCapture CountingCapture(Action onCapture) + { + return new HotReloadSourceSnapshotCapture(() => + { + onCapture(); + return true; + }); + } + + private static int CountVibeEntries(string operation) + { + return JArray.Parse(VibeLogger.GetLogsForAi(operation)).Count; + } } } diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs index e811db4752..619394bea4 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs @@ -1,3 +1,5 @@ +using System; + using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Infrastructure; @@ -27,7 +29,8 @@ internal void Initialize() UnityCliLoopApplicationServices applicationServices = _applicationRegistration.Register(); ApplicationEditorStartup.Initialize(applicationServices.DomainReloadDetectionService); EditorRuntimeStateSnapshotSubscriber.InitializeForEditorStartup(); - FirstPartyToolsEditorStartup.Initialize(); + Func isToolEnabled = applicationServices.ToolSettingsUseCase.IsToolEnabled; + FirstPartyToolsEditorStartup.Initialize(isToolEnabled); InfrastructureEditorStartup.Initialize(applicationServices.EditorSettingsPort); PresentationEditorStartup.Initialize( applicationServices.EditorSettingsPort, @@ -45,8 +48,9 @@ internal void Initialize() // afterAssemblyReload, and every command except get-editor-status runs on the main // thread's update or tick, so no command of this domain is answered before this // returns — an edit made after `uloop compile` returns can no longer be captured as - // compiled source. - FirstPartyToolsEditorStartup.PrepareBeforeServingCommands(); + // compiled source. When both the hot-reload and pause-point tools are disabled, the + // capture is skipped here and runs on the first use of either tool instead. + FirstPartyToolsEditorStartup.PrepareBeforeServingCommands(isToolEnabled); } } } diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index 7a279d88b1..2bc77b7572 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -1,3 +1,5 @@ +using System; + using UnityEditor; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -8,14 +10,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public static class FirstPartyToolsEditorStartup { - public static void Initialize() + /// + /// Initializes every first-party tool's Editor startup work. + /// answers whether the user enabled a tool in the tool settings, by its settings name. + /// + public static void Initialize(Func isToolEnabled) { DomainReloadDisableScopeRecovery.RestoreForEditorStartup(); ExternalSceneChangeTracker.Initialize(); ControlPlayModeEditorStartup.Initialize(); PausePointEditorStartup.Initialize(); CompileEditorStartup.Initialize(); - HotReloadEditorStartup.Initialize(); + HotReloadEditorStartup.Initialize(isToolEnabled); WatchEditorStartup.Initialize(); ExecuteDynamicCodeEditorStartup.Initialize(); GetLogsEditorStartup.Initialize(); @@ -35,9 +41,10 @@ public static void Initialize() /// /// Runs the first-party tool work that has to finish before this domain answers its first - /// uloop command. Called once, at the end of the Editor startup. + /// uloop command. Called once, at the end of the Editor startup. + /// answers whether the user enabled a tool in the tool settings, by its settings name. /// - public static void PrepareBeforeServingCommands() + public static void PrepareBeforeServingCommands(Func isToolEnabled) { // Why not in an import worker: it never serves commands (the server does not start // there) and it shares Library with the main Editor, so a capture there would only @@ -47,7 +54,7 @@ public static void PrepareBeforeServingCommands() return; } - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(); + HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(isToolEnabled); } public static void ResetServerScopedServices() diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 6828ece287..11663b5e3d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -271,7 +271,9 @@ private static void Install(HotReloadServices services) HotReloadPausePointCoordination.HotReloadSide = new HotReloadPausePointPort( domain, - path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path)); + path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path), + () => services.SourceSnapshotCapture.EnsureCaptured( + HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); // Attaching last keeps the invariant across the gap: the resolver only starts // answering binds once every gateway already points at the domain behind it. domain.IntroducedTypeResolver.Resume(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 881fd704b6..b3e05786d1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using UnityEditor; @@ -11,8 +12,10 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools // root only depends on the bundled-tool facade. internal static class HotReloadEditorStartup { - public static void Initialize() + public static void Initialize(Func isToolEnabled) { + Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + // 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 @@ -23,7 +26,8 @@ public static void Initialize() void CaptureOnFirstUpdateTick() { EditorApplication.update -= CaptureOnFirstUpdateTick; - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick, isToolEnabled); } // Why a callback of its own rather than a line in the capture above: an exception in @@ -42,7 +46,8 @@ void SweepArtifactsOnFirstUpdateTick() void StartWarmUpOnFirstUpdateTick() { EditorApplication.update -= StartWarmUpOnFirstUpdateTick; - HotReloadCompositionRoot.Services.WarmUp.Start(); + StartWarmUpUnlessHotReloadIsDisabled( + isToolEnabled, () => HotReloadCompositionRoot.Services.WarmUp.Start()); } // The services are rebuilt here rather than on first use because the introduced type @@ -91,10 +96,59 @@ void StartWarmUpOnFirstUpdateTick() /// Captures the source snapshot of the current compile before this domain answers any uloop /// command, so an edit made after the compile returned is never captured as compiled source. /// - internal static void CaptureSourceSnapshotBeforeServingCommands() + internal static void CaptureSourceSnapshotBeforeServingCommands(Func isToolEnabled) + { + CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad, isToolEnabled); + } + + /// + /// Captures the source snapshot at a startup trigger unless both tools that read it are + /// disabled in the tool settings. + /// + internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Func isToolEnabled) { - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( - HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + Debug.Assert(!string.IsNullOrEmpty(trigger), "trigger must not be null or empty."); + Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + + // Why only the startup triggers ask: a reader that runs (a hot reload run, a pause point) + // belongs to an enabled tool and captures through the gate itself. A capture that comes + // late stays correct, because a source written after the compile started is checked + // against the PDB. + bool anyReaderEnabled = isToolEnabled(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD) + || isToolEnabled(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT); + if (!anyReaderEnabled) + { + // Why the gate is not marked: a tool enabled later in this domain must still + // capture through it before its first read. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotSkipped, + "Hot reload source snapshot capture skipped.", + new { trigger, reason = HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled }); + return; + } + + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(trigger); + } + + /// + /// Starts the warm-up unless the hot-reload tool is disabled in the tool settings. + /// + internal static void StartWarmUpUnlessHotReloadIsDisabled(Func isToolEnabled, Action startWarmUp) + { + Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + Debug.Assert(startWarmUp != null, "startWarmUp must not be null."); + + if (!isToolEnabled(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogWarmUpSkipped, + "Hot reload warm-up skipped.", + new { reason = HotReloadConstants.WarmUpSkipReasonToolDisabled }); + return; + } + + startWarmUp(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs index 75c0009293..c6ad534821 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Reflection; using System.Reflection.Emit; @@ -16,13 +17,22 @@ internal sealed class HotReloadPausePointPort : IHotReloadPausePointPort { private readonly HotReloadDomain _domain; private readonly Func _readSourceContentHashOrNull; + private readonly Action _ensureSnapshotCaptured; // Why the hash reader is passed in: the hasher lives in the apply pipeline's assembly, // which this one may not reference, and the check must hash the way the worker did. - public HotReloadPausePointPort(HotReloadDomain domain, Func readSourceContentHashOrNull) + // Why the capture is passed in: the gate that captures the source snapshot belongs to the + // services the composition root installs, which this assembly does not reference. + public HotReloadPausePointPort( + HotReloadDomain domain, + Func readSourceContentHashOrNull, + Action ensureSnapshotCaptured) { + Debug.Assert(ensureSnapshotCaptured != null, "ensureSnapshotCaptured must not be null."); + _domain = domain; _readSourceContentHashOrNull = readSourceContentHashOrNull; + _ensureSnapshotCaptured = ensureSnapshotCaptured; } public MethodBase GetActiveShimForMethod(MethodBase method) @@ -54,6 +64,9 @@ public string GetVerifiedSnapshotSource(string projectRelativeFile, string dllPa return null; } + // Why: with both tools disabled at domain load nothing was captured, and a pause point + // is the first reader once the pause-point tool is enabled. + _ensureSnapshotCaptured(); return HotReloadSourceBaseline.LoadVerifiedSnapshotSource(projectRelativeFile, dllPath); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index da47c35f54..7967a25f21 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -696,6 +696,14 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string SourceSnapshotCaptureTriggerDomainLoad = "domain_load"; public const string SourceSnapshotCaptureTriggerFirstUpdateTick = "first_update_tick"; public const string SourceSnapshotCaptureTriggerApply = "apply"; + public const string SourceSnapshotCaptureTriggerPausePoint = "pause_point"; + + // Why the capture at a startup trigger did not run, as logged in + // hot_reload_source_snapshot_skipped; and the warm-up's matching reason in + // hot_reload_warm_up_skipped. + public const string VibeLogSourceSnapshotSkipped = "hot_reload_source_snapshot_skipped"; + public const string SourceSnapshotSkipReasonToolsDisabled = "tools_disabled"; + public const string WarmUpSkipReasonToolDisabled = "tool_disabled"; // What stopped a running warm-up or caller-note backfill, as named in the cancelledBy field // of hot_reload_warm_up_complete and hot_reload_caller_note_backfill_complete. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 36606e3e36..86b0fc72ee 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -80,7 +80,11 @@ whose bytes do not match the PDB checksum carries a mark that it was edited afte and the default selection, the sibling scan and the skip check treat a marked source as changed; a capture moves the previous build's directory to the new MVID's and rewrites only the sources whose length or write time changed, that carry no unmarked manifest line, or that were -written since the compile started; it deletes the copies of sources no longer in the assembly), +written since the compile started; it deletes the copies of sources no longer in the assembly. +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), `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 7aa1744589..b750dc519d 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -93,7 +93,12 @@ command ran against: `hot_reload_source_snapshot_captured` appears once per domain when the source snapshot capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or `apply` only when the capture at domain load threw or Unity listed no compilation assembly - yet) and `captureMs`. + 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 + `first_update_tick` each write `hot_reload_source_snapshot_skipped` with that `trigger` and + `reason` `tools_disabled` instead, and the first hot reload run or pause point after either + tool is enabled captures it. `hot_reload_source_snapshot_breakdown` appears once each time the capture runs to the end, just before `hot_reload_source_snapshot_captured`. It also appears when Unity listed no compilation assembly, in which case no captured entry follows, and it does not appear when the @@ -126,7 +131,8 @@ command ran against: assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy` (`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one `hot_reload_warm_up_skipped` with its `reason` (`no_targets`, `compiling`, `updating`, - `no_compiled_assembly`, `run_started_first`). `cancelled` covers both an item that never started + `no_compiled_assembly`, `run_started_first`, `tool_disabled` when the hot-reload tool is + disabled in the tool settings). `cancelled` covers both an item that never started (`ms` 0) and one stopped between its units (one dll or one transform worker request each) with units left, with the time it ran. The items run in the order `publicized_targets`, `call_sites`, `referenced_method_sets`, `transform_worker`, `pdb_documents`. The warm-up's transform worker