From 95d0e622937415eec0327eb0d6f2fe49ba96ff16 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 13:32:57 +0900 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 From 58f102634532362da040657b4b0e6399d6441618 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 18:12:22 +0900 Subject: [PATCH 05/15] chore: Prepare compile status and pause points for a background source snapshot capture (#3280) --- .../Editor/CompileStatusBridgeCommandTests.cs | 37 +++++ .../Editor/HotReload/HotReloadDomainTests.cs | 75 ++++++++- .../HotReloadLiveSourceBomFixture.cs | 14 ++ .../HotReloadLiveSourceBomFixture.cs.meta | 11 ++ .../HotReloadLiveSourceVerificationTests.cs | 148 ++++++++++++++++++ ...tReloadLiveSourceVerificationTests.cs.meta | 11 ++ .../HotReload/HotReloadCompositionRoot.cs | 5 +- .../Patching/HotReloadPausePointPort.cs | 24 ++- .../Shared/HotReloadSourceBaseline.cs | 99 +++++++++++- .../Api/CompileStatusBridgeCommand.cs | 14 +- .../HotReloadSnapshotCaptureCoordination.cs | 20 +++ ...tReloadSnapshotCaptureCoordination.cs.meta | 11 ++ 12 files changed, 458 insertions(+), 11 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta create mode 100644 Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs create mode 100644 Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta diff --git a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs index 5a4b1ac007..5ac4bb8f58 100644 --- a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs +++ b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs @@ -52,6 +52,7 @@ public void BuildResponse_WhenUnityIsIdleAndResultMatches_ReturnsReadyResult() isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -77,6 +78,7 @@ public void BuildResponse_WhenUnityIsStillCompiling_ReturnsNotReadyWithStoredRes isCompiling: true, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -101,6 +103,7 @@ public void BuildResponse_WhenRequestIdDiffers_DoesNotReturnStaleResult() isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -124,6 +127,7 @@ public void BuildResponse_WhenUnityIsIdleAndPendingRequestHasNoReloadSignal_Wait isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -152,6 +156,7 @@ public void BuildResponse_WhenUnityIsIdleAndPendingRequestObservedReload_Returns isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -182,6 +187,37 @@ public void BuildResponse_WhenUnityIsBusyAndPendingRequestHasNoResult_WaitsForRe isCompiling: true, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, + _compileSessionLifecycleService, + _compileResultSessionRepository, + _pendingCompileSessionRepository); + + Assert.That(response.Ready, Is.False); + Assert.That(response.HasResult, Is.False); + Assert.That(response.Result, Is.Null); + Assert.That( + UnityCliLoopEditorSessionStateTestFactory.GetSinglePendingCompileRequest( + _pendingCompileSessionRepository).HasRequest, + Is.True); + } + + [Test] + public void BuildResponse_WhileTheSnapshotCaptureHolds_IsNotReadyAndDoesNotRecoverAPendingResult() + { + // Verifies an idle Unity still withholds the compile completion, and leaves the pending + // request for a later poll, while the hot reload source snapshot capture is running. + _compileSessionLifecycleService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: false, + markedAtUtc: System.DateTime.UtcNow); + _pendingCompileSessionRepository.MarkPendingCompileRequestReloadObserved(); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: false, + isUpdating: false, + isDomainReloadInProgress: false, + isSnapshotCaptureHolding: true, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); @@ -210,6 +246,7 @@ public void BuildResponse_WhenForceCompilePendingRequestHasNoResult_ReturnsExpla isCompiling: false, isUpdating: false, isDomainReloadInProgress: false, + isSnapshotCaptureHolding: false, _compileSessionLifecycleService, _compileResultSessionRepository, _pendingCompileSessionRepository); diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index d2a11ac072..06638df512 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -22,6 +22,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload public class HotReloadDomainTests { private const string FileOne = "Assets/Tests/Editor/HotReload/DomainFileOne.cs"; + // Real sources of this test assembly: one its PDB has a document for, one it has none for. + private const string CompiledFixtureFile = "Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs"; + private const string BodylessFixtureFile = "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; private const string FileTwo = "Assets/Tests/Editor/HotReload/DomainFileTwo.cs"; private const string AddedMethodKey = "DomainHost.AddedPing(System.Int32)"; private const string OtherAddedMethodKey = "DomainHost.AddedPong()"; @@ -1050,6 +1053,74 @@ public void GetVerifiedSnapshotSource_WhenTheCaptureThrows_PassesTheExceptionOn( Assert.That(thrown, Is.SameAs(captureFailure)); } + /// + /// What: while a capture runs, a live file the PDB confirms is returned without waiting for + /// the capture, so a pause point re-armed on the first update tick does not stall on it. + /// + [Test] + public void GetVerifiedSnapshotSource_WhileCaptureRuns_ReturnsTheLiveFileWhenItMatchesThePdbWithoutWaiting() + { + int ensureCount = 0; + HotReloadPausePointPort port = new HotReloadPausePointPort( + _access.Domain, + _ => RecordedSourceHash, + () => ensureCount++, + () => true); + + string source = port.GetVerifiedSnapshotSource(CompiledFixtureFile, TestAssemblyDllPath()); + + Assert.That(source, Is.EqualTo(File.ReadAllText(Path.Combine(ProjectRoot(), CompiledFixtureFile)))); + Assert.That(ensureCount, Is.EqualTo(0)); + } + + /// + /// What: while a capture runs, a live file the PDB cannot confirm (here it has no document) + /// makes the port wait for the capture once and read the snapshot instead. + /// + [Test] + public void GetVerifiedSnapshotSource_WhileCaptureRuns_WaitsWhenTheLiveFileDiffersFromThePdb() + { + int ensureCount = 0; + HotReloadPausePointPort port = new HotReloadPausePointPort( + _access.Domain, + _ => RecordedSourceHash, + () => ensureCount++, + () => true); + + port.GetVerifiedSnapshotSource(BodylessFixtureFile, TestAssemblyDllPath()); + + Assert.That(ensureCount, Is.EqualTo(1)); + } + + /// + /// What: with no capture running, the port waits for the capture and reads the snapshot even + /// when the live file matches the PDB, so the answer is the one it gave before captures ran + /// in the background. + /// + [Test] + public void GetVerifiedSnapshotSource_WhenNoCaptureRuns_EnsuresTheCaptureEvenIfTheLiveFileMatches() + { + int ensureCount = 0; + HotReloadPausePointPort port = CreatePortWithSnapshotCapture(() => ensureCount++); + + port.GetVerifiedSnapshotSource(CompiledFixtureFile, TestAssemblyDllPath()); + + Assert.That(ensureCount, Is.EqualTo(1)); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string TestAssemblyDllPath() + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + typeof(HotReloadDomainTests).Assembly.GetName().Name + HotReloadConstants.CompiledAssemblyExtension); + } + // Why a generated name: an artifact assembly is compiled under a name of its own, so a // fixture that reused a project assembly's name would not resolve the way production does. private static Assembly CreateIntroducedTypeAssembly() @@ -1071,12 +1142,12 @@ private static void AssertRow( private HotReloadPausePointPort CreatePort(Func readSourceContentHashOrNull) { - return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull, () => { }); + return new HotReloadPausePointPort(_access.Domain, readSourceContentHashOrNull, () => { }, () => false); } private HotReloadPausePointPort CreatePortWithSnapshotCapture(Action ensureSnapshotCaptured) { - return new HotReloadPausePointPort(_access.Domain, _ => RecordedSourceHash, ensureSnapshotCaptured); + return new HotReloadPausePointPort(_access.Domain, _ => RecordedSourceHash, ensureSnapshotCaptured, () => false); } // Records a partially applied reload of FileOne that read RecordedWorkerSourcePath. diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs new file mode 100644 index 0000000000..a90bc65594 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Fixture saved with a UTF-8 byte order mark, so the live-source tests can check that the live + /// file and the snapshot decode the mark the same way. Its method body gives it a PDB document. + /// + internal static class HotReloadLiveSourceBomFixture + { + internal static int Answer() + { + return 42; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta new file mode 100644 index 0000000000..9851829600 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 10bdf23520ce04bd28b441d24256fe64 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs new file mode 100644 index 0000000000..fdeebfa4bf --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs @@ -0,0 +1,148 @@ +using System; +using System.IO; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for reading a source file on disk only when the compiled assembly's PDB + /// confirms it holds the bytes the compiler read. + /// + public class HotReloadLiveSourceVerificationTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string BomFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadLiveSourceBomFixture.cs"; + private const string BodylessFixtureProjectRelativePath = + "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; + + private string _temporaryRoot; + + [SetUp] + public void SetUp() + { + _temporaryRoot = Path.Combine(Path.GetTempPath(), "uloop-live-source-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_temporaryRoot)) + { + Directory.Delete(_temporaryRoot, recursive: true); + } + } + + /// + /// What: a live file and a snapshot holding the same bytes, a byte order mark included, decode + /// to the same text, and the mark is not part of it. + /// + [Test] + public void LoadVerifiedLiveSourceOrNull_ReturnsTheSameTextAsTheSnapshotForTheSameBytes() + { + string dllPath = TestAssemblyDllPath(); + byte[] compiledBytes = File.ReadAllBytes(Path.Combine(ProjectRoot(), BomFixtureProjectRelativePath)); + Assert.That(compiledBytes[0], Is.EqualTo(0xEF), "Precondition: the fixture must start with a byte order mark."); + WriteLiveFile(BomFixtureProjectRelativePath, compiledBytes); + string snapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory( + _temporaryRoot, + TestAssemblyName, + HotReloadAssemblyMvid.Read(dllPath)); + Directory.CreateDirectory(snapshotDirectory); + File.WriteAllBytes( + HotReloadSourceSnapshotLayout.SourcePath(snapshotDirectory, BomFixtureProjectRelativePath), + compiledBytes); + + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + dllPath, + HotReloadPdbDocumentIndex.Shared); + string snapshot = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + dllPath, + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Not.Null); + Assert.That(live, Is.EqualTo(snapshot)); + Assert.That(live[0], Is.Not.EqualTo('')); + } + + /// + /// What: a live file whose bytes differ from the checksum the PDB recorded is not returned. + /// + [Test] + public void LoadVerifiedLiveSourceAt_WhenTheLiveBytesDifferFromThePdb_ReturnsNull() + { + byte[] editedBytes = File.ReadAllBytes(Path.Combine(ProjectRoot(), BomFixtureProjectRelativePath)); + editedBytes[editedBytes.Length - 1] = (byte)(editedBytes[editedBytes.Length - 1] ^ 0xFF); + WriteLiveFile(BomFixtureProjectRelativePath, editedBytes); + + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + TestAssemblyDllPath(), + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Null); + } + + /// + /// What: a source that is not on disk is not returned. + /// + [Test] + public void LoadVerifiedLiveSourceAt_WhenTheFileIsMissing_ReturnsNull() + { + Directory.CreateDirectory(_temporaryRoot); + + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + _temporaryRoot, + BomFixtureProjectRelativePath, + TestAssemblyDllPath(), + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Null); + } + + /// + /// What: a source the PDB has no document for is not returned, because nothing confirms it. + /// + [Test] + public void LoadVerifiedLiveSourceAt_WhenThePdbHasNoDocument_ReturnsNull() + { + string live = HotReloadSourceBaseline.LoadVerifiedLiveSourceAt( + ProjectRoot(), + BodylessFixtureProjectRelativePath, + TestAssemblyDllPath(), + HotReloadPdbDocumentIndex.Shared); + + Assert.That(live, Is.Null); + } + + private void WriteLiveFile(string projectRelativePath, byte[] bytes) + { + string livePath = Path.Combine(_temporaryRoot, projectRelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(livePath)); + File.WriteAllBytes(livePath, bytes); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static string TestAssemblyDllPath() + { + return Path.Combine( + ProjectRoot(), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta new file mode 100644 index 0000000000..a63fbe0b78 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadLiveSourceVerificationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3307c460a59a4e558157a13346e02d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 11663b5e3d..5c376c8d83 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -273,7 +273,10 @@ private static void Install(HotReloadServices services) domain, path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path), () => services.SourceSnapshotCapture.EnsureCaptured( - HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); + HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint), + // The capture still runs synchronously inside EnsureCaptured, so none is ever in + // flight when a pause point asks. + () => false); // 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/Patching/HotReloadPausePointPort.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs index c6ad534821..f1dfa4963e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadPausePointPort.cs @@ -18,21 +18,26 @@ internal sealed class HotReloadPausePointPort : IHotReloadPausePointPort private readonly HotReloadDomain _domain; private readonly Func _readSourceContentHashOrNull; private readonly Action _ensureSnapshotCaptured; + private readonly Func _isCaptureInFlight; // Why the hash reader is passed in: the hasher lives in the apply pipeline's assembly, // which this one may not reference, and the check must hash the way the worker did. // Why the capture is passed in: the gate that captures the source snapshot belongs to the - // services the composition root installs, which this assembly does not reference. + // services the composition root installs, which this assembly does not reference, and the + // in-flight check is passed in for the same reason. public HotReloadPausePointPort( HotReloadDomain domain, Func readSourceContentHashOrNull, - Action ensureSnapshotCaptured) + Action ensureSnapshotCaptured, + Func isCaptureInFlight) { Debug.Assert(ensureSnapshotCaptured != null, "ensureSnapshotCaptured must not be null."); + Debug.Assert(isCaptureInFlight != null, "isCaptureInFlight must not be null."); _domain = domain; _readSourceContentHashOrNull = readSourceContentHashOrNull; _ensureSnapshotCaptured = ensureSnapshotCaptured; + _isCaptureInFlight = isCaptureInFlight; } public MethodBase GetActiveShimForMethod(MethodBase method) @@ -64,6 +69,21 @@ public string GetVerifiedSnapshotSource(string projectRelativeFile, string dllPa return null; } + // Why the live file is tried while a capture runs: a pause point re-armed on the first + // update tick after a reload would otherwise wait for the whole capture. A live file the + // PDB confirms holds the bytes the compiler read, which is what the snapshot will hold. + // Why only while a capture runs: otherwise the snapshot is ready or about to be captured + // here, and reading it keeps the answer unchanged from before the capture moved off the + // main thread. + if (_isCaptureInFlight()) + { + string liveSource = HotReloadSourceBaseline.LoadVerifiedLiveSourceOrNull(projectRelativeFile, dllPath); + if (liveSource != null) + { + return liveSource; + } + } + // Why: with both tools disabled at domain load nothing was captured, and a pause point // is the first reader once the pause-point tool is enabled. _ensureSnapshotCaptured(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs index 7fe4f34c86..5f052ad6e1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -130,12 +131,104 @@ private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( return comparison; } - using MemoryStream memoryStream = new MemoryStream(snapshotBytes, writable: false); - using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - source = reader.ReadToEnd(); + source = DecodeVerifiedSource(snapshotBytes); return HotReloadSnapshotMissReason.None; } + /// + /// Returns the text of the file on disk when its bytes match the checksum the compiled + /// assembly's PDB recorded for it, or null when the file is missing or unreadable, the PDB has + /// no document for it, or the checksums differ. Matching bytes are the bytes the compiler read, + /// so the text equals what the snapshot holds for the same assembly. Main thread only, because + /// the checksum lookup asks the Package Manager for the source's physical path. + /// + public static string LoadVerifiedLiveSourceOrNull(string projectRelativeSourcePath, string targetDllPath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativeSourcePath), "projectRelativeSourcePath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); + + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return LoadVerifiedLiveSourceAt( + projectRoot, + projectRelativeSourcePath, + targetDllPath, + HotReloadPdbDocumentIndex.Shared); + } + + // projectRoot is injectable so EditMode tests can point at a copy of a source under a + // temporary root, the same way they do for the snapshot. + internal static string LoadVerifiedLiveSourceAt( + string projectRoot, + string projectRelativeSourcePath, + string targetDllPath, + HotReloadPdbDocumentIndex documentIndex) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRelativeSourcePath), "projectRelativeSourcePath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(targetDllPath), "targetDllPath must not be null or empty."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); + + string pdbPath = Path.ChangeExtension(targetDllPath, ".pdb"); + if (!File.Exists(targetDllPath) || !File.Exists(pdbPath)) + { + return null; + } + + // Why the same path the snapshot capture reads: the live file must be the file the + // capture would have copied, so a match here means the snapshot would hold these bytes. + string slashNormalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); + string absoluteSourcePath = Path.Combine( + projectRoot, + slashNormalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)); + byte[] liveBytes = ReadAllBytesOrNull(HotReloadFileSystemPath.GetFileSystemPath(absoluteSourcePath)); + if (liveBytes == null) + { + return null; + } + + HotReloadSnapshotMissReason comparison = CompareWithCompiledDocument( + projectRoot, + slashNormalizedRelativePath, + targetDllPath, + pdbPath, + HotReloadAssemblyMvid.Read(targetDllPath), + liveBytes, + documentIndex); + return comparison == HotReloadSnapshotMissReason.None ? DecodeVerifiedSource(liveBytes) : null; + } + + // Why an unreadable file is a miss rather than an error: the caller falls back to waiting for + // the snapshot, which answers for that file the same way it does today. + private static byte[] ReadAllBytesOrNull(string fileSystemPath) + { + if (!File.Exists(fileSystemPath)) + { + return null; + } + + try + { + return File.ReadAllBytes(fileSystemPath); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + // Why one decoder for the snapshot and the live file: a pause point maps lines in whichever + // text it gets, so both must treat a byte order mark the same way. + private static string DecodeVerifiedSource(byte[] verifiedBytes) + { + using MemoryStream memoryStream = new MemoryStream(verifiedBytes, writable: false); + using StreamReader reader = new StreamReader(memoryStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + /// /// Answers whether the bytes match the checksum the compiled assembly's PDB recorded for the /// source: , or why they could not be confirmed diff --git a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs index 68e9a04fd6..049b75d011 100644 --- a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs @@ -36,15 +36,18 @@ public static GetCompileStatusResponse Execute(JToken paramsToken) bool isDomainReloadInProgress = sessionFlagsRepository.GetIsDomainReloadInProgress() || DomainReloadStateRegistry.IsDomainReloadInProgress(); + bool isSnapshotCaptureHolding = + HotReloadSnapshotCaptureCoordination.IsHoldingCompileCompletion?.Invoke() ?? false; GetCompileStatusResponse response = BuildResponse( requestId, isCompiling, isUpdating, isDomainReloadInProgress, + isSnapshotCaptureHolding, compileSessionLifecycleService, compileResultSessionRepository, pendingCompileSessionRepository); - LogCompileStatusQueryReceived(requestId, response); + LogCompileStatusQueryReceived(requestId, response, isSnapshotCaptureHolding); return response; } @@ -53,6 +56,7 @@ internal static GetCompileStatusResponse BuildResponse( bool isCompiling, bool isUpdating, bool isDomainReloadInProgress, + bool isSnapshotCaptureHolding, UnityCliLoopCompileSessionLifecycleService compileSessionLifecycleService, ICompileResultSessionRepository compileResultSessionRepository, IPendingCompileSessionRepository pendingCompileSessionRepository) @@ -61,7 +65,9 @@ internal static GetCompileStatusResponse BuildResponse( Debug.Assert(compileResultSessionRepository != null, "compileResultSessionRepository must not be null"); Debug.Assert(pendingCompileSessionRepository != null, "pendingCompileSessionRepository must not be null"); - bool ready = !isCompiling && !isUpdating && !isDomainReloadInProgress; + // The hot reload snapshot capture must finish before compile returns, or an edit made + // right after the CLI returns could be captured as the source the compiler read. + bool ready = !isCompiling && !isUpdating && !isDomainReloadInProgress && !isSnapshotCaptureHolding; compileSessionLifecycleService.ClearExpiredPendingCompileRequest(DateTime.UtcNow); UnityCliLoopStoredCompileResult storedResult = string.IsNullOrWhiteSpace(requestId) ? UnityCliLoopStoredCompileResult.None() @@ -100,7 +106,8 @@ private static string ReadRequestId(JToken paramsToken) private static void LogCompileStatusQueryReceived( string requestId, - GetCompileStatusResponse response) + GetCompileStatusResponse response, + bool isSnapshotCaptureHolding) { Debug.Assert(response != null, "response must not be null"); @@ -115,6 +122,7 @@ private static void LogCompileStatusQueryReceived( is_compiling = response.IsCompiling, is_updating = response.IsUpdating, is_domain_reload_in_progress = response.IsDomainReloadInProgress, + is_snapshot_capture_holding = isSnapshotCaptureHolding, message = response.Message }, requestId); diff --git a/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs new file mode 100644 index 0000000000..62f16cdddb --- /dev/null +++ b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs @@ -0,0 +1,20 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Editor-domain coordination point that lets the compile status report wait for the hot + /// reload source snapshot capture. The hot-reload tool lives in a sibling assembly the + /// server must not reference, so it publishes the hold through a delegate wired at its own + /// startup. + /// + public static class HotReloadSnapshotCaptureCoordination + { + // Set by the hot-reload startup. Null means the hot-reload tool has not initialized in + // this domain, so nothing holds the compile completion - callers treat null as false. + // While it returns true, the compile completion is not reported: if the CLI returned + // from compile before the capture finished, an edit made right after could be captured + // as the source the compiler read. + public static Func IsHoldingCompileCompletion { get; set; } + } +} diff --git a/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta new file mode 100644 index 0000000000..5b7e6eb861 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/HotReloadSnapshotCaptureCoordination.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d901119d9fa444fe90222d442d4bd21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 574b1b775e9180370911ef06c24becd1bf89cbc3 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 18:18:38 +0900 Subject: [PATCH 06/15] chore: Read the defines and metadata references a compiled assembly's PDB records (#3279) --- .../HotReloadPdbCompilationInfoTests.cs | 225 ++++++++++++++++++ .../HotReloadPdbCompilationInfoTests.cs.meta | 11 + .../Shared/HotReloadPdbCompilationInfo.cs | 158 ++++++++++++ .../HotReloadPdbCompilationInfo.cs.meta | 11 + 4 files changed, 405 insertions(+) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs new file mode 100644 index 0000000000..a70a0deefb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +using Mono.Cecil; +using Mono.Cecil.Cil; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of reading the defines and metadata references a portable PDB records, and a + /// cross-check of both against what Unity reports for every compiled assembly of this project. + /// Whether they agree decides if the compilation list can be rebuilt from PDBs at startup. + /// + public sealed class HotReloadPdbCompilationInfoTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string ReferenceAssemblySuffix = ".ref.dll"; + private const string AssemblySuffix = ".dll"; + private const int MaxListedDifferences = 10; + + /// + /// What: the test assembly's own PDB yields the editor and test defines it was compiled with, + /// and references whose file names and MVIDs are filled in. + /// + [Test] + public void Read_ReturnsTheDefinesAndReferencesOfTheTestAssembly() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + + HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(layout.PdbPath(TestAssemblyName)); + + CollectionAssert.Contains(info.Defines, "UNITY_EDITOR"); + CollectionAssert.Contains(info.Defines, "UNITY_INCLUDE_TESTS"); + Assert.That(info.References, Is.Not.Empty); + Assert.That(info.References.Select(reference => reference.FileName), Has.Some.EqualTo("nunit.framework.dll")); + Assert.That(info.References.Select(reference => reference.FileName), Has.All.EndsWith(AssemblySuffix)); + Assert.That(info.References.Select(reference => reference.Mvid), Has.None.EqualTo(Guid.Empty)); + } + + /// + /// What: for every compiled assembly with a PDB, the PDB's defines equal Unity's defines in + /// order, and its reference file names equal the file names of Unity's references, counting a + /// project assembly's X.ref.dll as X.dll. Each precompiled reference also carries the MVID of + /// the dll Unity lists for it, which is what the PDB's MVID is later compared with. + /// + [Test] + public void PdbCompilationInfo_MatchesGetAssembliesForEveryCompiledAssembly() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + List mismatches = new List(); + Dictionary mvidByReferencePath = new Dictionary(StringComparer.Ordinal); + int checkedAssemblies = 0; + + foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current()) + { + string pdbPath = layout.PdbPath(assembly.name); + if (!File.Exists(layout.DllPath(assembly.name)) || !File.Exists(pdbPath)) + { + continue; + } + + HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(pdbPath); + checkedAssemblies++; + AddDefineMismatch(assembly, info, mismatches); + AddReferenceMismatch(assembly, info, mismatches); + AddMvidMismatches(assembly, info, mvidByReferencePath, mismatches); + } + + Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compiled assembly with a PDB was found."); + Assert.That(mismatches, Is.Empty, string.Join("\n", mismatches)); + } + + /// + /// What: a portable PDB written without Roslyn's compilation records (here by Cecil) is + /// refused, instead of reading as an assembly with no defines and no references. + /// + [Test] + public void Read_WhenThePdbHasNoCompilationRecords_Throws() + { + string directory = Path.Combine( + Path.GetTempPath(), + "uloop-pdb-compilation-info-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string dllPath = Path.Combine(directory, "NoRecords.dll"); + using (ModuleDefinition module = ModuleDefinition.CreateModule("NoRecords", ModuleKind.Dll)) + { + module.Write(dllPath, new WriterParameters + { + WriteSymbols = true, + SymbolWriterProvider = new PortablePdbWriterProvider(), + }); + } + + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + Assert.That(File.Exists(pdbPath), Is.True, "Cecil did not write the portable PDB."); + + Assert.Throws(() => HotReloadPdbCompilationInfo.Read(pdbPath)); + } + finally + { + Directory.Delete(directory, true); + } + } + + private static void AddDefineMismatch( + UnityCompilationAssembly assembly, + HotReloadPdbCompilationInfo info, + List mismatches) + { + string[] fromUnity = assembly.defines ?? Array.Empty(); + if (info.Defines.SequenceEqual(fromUnity, StringComparer.Ordinal)) + { + return; + } + + // The order question is reported separately from a content difference. + bool sameSet = new HashSet(info.Defines, StringComparer.Ordinal).SetEquals(fromUnity); + mismatches.Add( + assembly.name + ": defines differ " + (sameSet ? "in order only" : "in content") + + DescribeDifference(info.Defines, fromUnity)); + } + + private static void AddReferenceMismatch( + UnityCompilationAssembly assembly, + HotReloadPdbCompilationInfo info, + List mismatches) + { + HashSet fromPdb = new HashSet( + info.References.Select(reference => ToImplementationFileName(reference.FileName)), + StringComparer.Ordinal); + HashSet fromUnity = new HashSet( + (assembly.allReferences ?? Array.Empty()).Select(Path.GetFileName), + StringComparer.Ordinal); + if (fromPdb.SetEquals(fromUnity)) + { + return; + } + + mismatches.Add(assembly.name + ": reference file names differ" + DescribeDifference(fromPdb, fromUnity)); + } + + private static void AddMvidMismatches( + UnityCompilationAssembly assembly, + HotReloadPdbCompilationInfo info, + Dictionary mvidByReferencePath, + List mismatches) + { + Dictionary pathByFileName = new Dictionary(StringComparer.Ordinal); + foreach (string referencePath in assembly.compiledAssemblyReferences ?? Array.Empty()) + { + pathByFileName[Path.GetFileName(referencePath)] = referencePath; + } + + List wrong = new List(); + foreach (HotReloadPdbReferencedAssembly reference in info.References) + { + if (!pathByFileName.TryGetValue(reference.FileName, out string referencePath)) + { + continue; + } + + if (!mvidByReferencePath.TryGetValue(referencePath, out string expected)) + { + expected = HotReloadAssemblyMvid.Read(Path.GetFullPath(Path.Combine(ProjectRoot(), referencePath))); + mvidByReferencePath[referencePath] = expected; + } + + if (reference.Mvid.ToString("N") != expected) + { + wrong.Add(reference.FileName); + } + } + + if (wrong.Count == 0) + { + return; + } + + mismatches.Add( + assembly.name + ": " + wrong.Count + " reference MVIDs differ from the dll (" + + string.Join(", ", wrong.Take(MaxListedDifferences)) + ")"); + } + + private static string DescribeDifference(IEnumerable fromPdb, IEnumerable fromUnity) + { + List onlyInPdb = fromPdb.Except(fromUnity, StringComparer.Ordinal).ToList(); + List onlyInUnity = fromUnity.Except(fromPdb, StringComparer.Ordinal).ToList(); + StringBuilder message = new StringBuilder(); + message.Append(" (only in PDB: ").Append(onlyInPdb.Count).Append(' '); + message.Append(string.Join(", ", onlyInPdb.Take(MaxListedDifferences))); + message.Append("; only in Unity: ").Append(onlyInUnity.Count).Append(' '); + message.Append(string.Join(", ", onlyInUnity.Take(MaxListedDifferences))); + message.Append(')'); + return message.ToString(); + } + + private static string ToImplementationFileName(string fileName) + { + if (!fileName.EndsWith(ReferenceAssemblySuffix, StringComparison.Ordinal)) + { + return fileName; + } + + return fileName.Substring(0, fileName.Length - ReferenceAssemblySuffix.Length) + AssemblySuffix; + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta new file mode 100644 index 0000000000..2910bab906 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eaa911ed77928402a93d83a195b80e97 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs new file mode 100644 index 0000000000..2fd9e4111a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; +using System.Text; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One metadata reference the compiler was given, as its PDB records it. For an assembly of the + /// project the file is its reference assembly (X.ref.dll), so the MVID is that of the reference + /// assembly, not of the dll under Library/ScriptAssemblies. + /// + internal readonly struct HotReloadPdbReferencedAssembly + { + public readonly string FileName; + public readonly Guid Mvid; + + public HotReloadPdbReferencedAssembly(string fileName, Guid mvid) + { + FileName = fileName; + Mvid = mvid; + } + } + + /// + /// The compiler inputs a portable PDB records besides its sources: the preprocessor defines and + /// the metadata references. Safe to call from a pool thread; the only Unity API it uses is + /// Debug.Assert. + /// + internal sealed class HotReloadPdbCompilationInfo + { + // Roslyn's custom debug information kinds (Portable PDB spec, "Compilation Options" and + // "Compilation Metadata References"). + private static readonly Guid CompilationOptionsKind = new Guid("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8"); + private static readonly Guid MetadataReferencesKind = new Guid("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D"); + private const string DefineKey = "define"; + private const int MvidLength = 16; + // Kind/flags byte, timestamp and image size that sit between the alias and the MVID. + private const int BytesBeforeMvid = 1 + 4 + 4; + + public readonly IReadOnlyList Defines; + public readonly IReadOnlyList References; + + private HotReloadPdbCompilationInfo( + IReadOnlyList defines, + IReadOnlyList references) + { + Defines = defines; + References = references; + } + + /// + /// Reads the defines, in the order the compiler was given them, and the metadata references + /// of the PDB. Throws InvalidOperationException when the PDB lacks either record or a record + /// is malformed. + /// + internal static HotReloadPdbCompilationInfo Read(string pdbPath) + { + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + + using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + MetadataReader reader = provider.GetMetadataReader(); + + byte[] options = ReadModuleRecord(reader, CompilationOptionsKind, pdbPath); + byte[] references = ReadModuleRecord(reader, MetadataReferencesKind, pdbPath); + return new HotReloadPdbCompilationInfo(ParseDefines(options, pdbPath), ParseReferences(references, pdbPath)); + } + + private static byte[] ReadModuleRecord(MetadataReader reader, Guid kind, string pdbPath) + { + // Why only the module's rows: both records describe the whole compilation, so Roslyn + // attaches them to the module. + foreach (CustomDebugInformationHandle handle in reader.GetCustomDebugInformation(EntityHandle.ModuleDefinition)) + { + CustomDebugInformation information = reader.GetCustomDebugInformation(handle); + if (reader.GetGuid(information.Kind) != kind) + { + continue; + } + + return reader.GetBlobBytes(information.Value); + } + + throw new InvalidOperationException( + "The PDB " + Path.GetFileName(pdbPath) + " has no compilation record of kind " + kind + "."); + } + + // The options blob is a sequence of null-terminated UTF-8 key and value pairs. + private static IReadOnlyList ParseDefines(byte[] blob, string pdbPath) + { + int position = 0; + while (position < blob.Length) + { + string key = ReadNullTerminated(blob, ref position, pdbPath); + string value = ReadNullTerminated(blob, ref position, pdbPath); + if (key == DefineKey) + { + return value.Length == 0 ? Array.Empty() : value.Split(','); + } + } + + return Array.Empty(); + } + + // Each reference is: file name and extern aliases (both null-terminated), a kind/flags + // byte, a uint32 timestamp, a uint32 image size and the 16-byte MVID. + private static IReadOnlyList ParseReferences(byte[] blob, string pdbPath) + { + List references = new List(); + int position = 0; + while (position < blob.Length) + { + string fileName = ReadNullTerminated(blob, ref position, pdbPath); + ReadNullTerminated(blob, ref position, pdbPath); + position += BytesBeforeMvid; + if (position + MvidLength > blob.Length) + { + throw MalformedRecord(pdbPath); + } + + references.Add(new HotReloadPdbReferencedAssembly(fileName, new Guid(Slice(blob, position, MvidLength)))); + position += MvidLength; + } + + return references; + } + + private static string ReadNullTerminated(byte[] blob, ref int position, string pdbPath) + { + int terminator = Array.IndexOf(blob, (byte)0, position); + if (terminator < 0) + { + throw MalformedRecord(pdbPath); + } + + string text = Encoding.UTF8.GetString(blob, position, terminator - position); + position = terminator + 1; + return text; + } + + private static byte[] Slice(byte[] blob, int start, int length) + { + byte[] slice = new byte[length]; + Buffer.BlockCopy(blob, start, slice, 0, length); + return slice; + } + + private static InvalidOperationException MalformedRecord(string pdbPath) + { + return new InvalidOperationException( + "A compilation record in the PDB " + Path.GetFileName(pdbPath) + " is malformed."); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta new file mode 100644 index 0000000000..0bc3942fdd --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 097901e3ad2b94819a43bd25397d6038 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From ef1e125e1579fba759668d101e020deeb7d13448 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 18:34:35 +0900 Subject: [PATCH 07/15] chore: Save hot reload's compilation assembly list for the next domain when it matches the compiled dlls (#3281) --- .../HotReloadCompilationAssemblyCacheTests.cs | 51 ++++ ...ReloadCompilationAssemblyListStoreTests.cs | 275 +++++++++++++++++ ...dCompilationAssemblyListStoreTests.cs.meta | 11 + .../Shared/HotReloadCompilationAssemblies.cs | 35 ++- .../HotReloadCompilationAssemblyCache.cs | 30 +- .../HotReloadCompilationAssemblyListStore.cs | 184 ++++++++++++ ...ReloadCompilationAssemblyListStore.cs.meta | 11 + .../HotReload/Shared/HotReloadConstants.cs | 15 + .../Shared/HotReloadSavedCompilationList.cs | 276 ++++++++++++++++++ .../HotReloadSavedCompilationList.cs.meta | 11 + 10 files changed, 897 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs index b26b95ea23..6c5f6840e9 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs @@ -154,6 +154,57 @@ public void FindByName_UnknownName_ReturnsNull() Assert.That(found, Is.Null); } + /// + /// Verifies that a list built off the main thread is kept, without asking Unity, when nothing + /// is kept and no invalidation happened since its generation was read. + /// + [Test] + public void TryFill_WhenEmptyAtTheSameGeneration_KeepsTheRebuiltList() + { + UnityCompilationAssembly[] rebuilt = { CreateAssembly("Rebuilt") }; + HotReloadCompilationAssemblyCache cache = CreateCache(); + + bool filled = cache.TryFill(rebuilt, cache.Generation); + + Assert.That(filled, Is.True); + Assert.That(cache.Current(), Is.SameAs(rebuilt)); + Assert.That(_fetchCount, Is.EqualTo(0)); + } + + /// + /// Verifies that a list Unity already answered wins over a rebuilt one. + /// + [Test] + public void TryFill_WhenAlreadyFilled_KeepsTheFetchedList() + { + UnityCompilationAssembly[] fetched = { CreateAssembly("Fetched") }; + _answers.Enqueue(fetched); + HotReloadCompilationAssemblyCache cache = CreateCache(); + cache.Current(); + + bool filled = cache.TryFill(new[] { CreateAssembly("Rebuilt") }, cache.Generation); + + Assert.That(filled, Is.False); + Assert.That(cache.Current(), Is.SameAs(fetched)); + } + + /// + /// Verifies that a list built before an invalidation is refused even though nothing is kept, + /// because the script set may have changed while it was built. + /// + [Test] + public void TryFill_AfterAnInvalidation_Refuses() + { + HotReloadCompilationAssemblyCache cache = CreateCache(); + int generationBeforeBuild = cache.Generation; + cache.Invalidate(); + + bool filled = cache.TryFill(new[] { CreateAssembly("Rebuilt") }, generationBeforeBuild); + + Assert.That(filled, Is.False); + Assert.That(cache.IsFilled, Is.False); + } + private HotReloadCompilationAssemblyCache CreateCache() { return new HotReloadCompilationAssemblyCache(Fetch); diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs new file mode 100644 index 0000000000..08e354a40f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of when the compilation assembly list is saved for the next domain (only the first + /// fetch of a domain without an invalidation and without a failed compile, and only when no + /// invalidation happened before the file is published, because only then does the list + /// describe the dlls on disk) and of telling a missing saved list from an unusable one when it + /// is loaded. + /// + public sealed class HotReloadCompilationAssemblyListStoreTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string MissingReferencePath = "Packages/com.example.missing/Plugins/Missing.dll"; + private const string PreviousFileContent = "previous"; + + // A complete saved file: one assembly whose only reference is the one reference entry. + // Each unreadable case below changes exactly one part of it. + private const string CompleteFile = + "{\"formatVersion\":1," + + "\"assemblies\":[{\"name\":\"A\",\"outputPath\":\"Library/ScriptAssemblies/A.dll\",\"sourceFiles\":[]," + + "\"defines\":[],\"assemblyReferences\":[],\"compiledAssemblyReferenceIndexes\":[0],\"dllStamp\":null}]," + + "\"references\":[{\"path\":\"Plugins/R.dll\",\"stamp\":{\"length\":1,\"lastWriteTicks\":2}," + + "\"mvid\":\"0123456789abcdef0123456789abcdef\"}]}"; + + private string _projectRoot; + private string _filePath; + private string _referencePath; + private bool _isCompilationFailed; + private int _currentGeneration; + private int _backgroundRuns; + private Action _deferredWork; + + [SetUp] + public void SetUp() + { + _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-compilation-list-store-" + Guid.NewGuid().ToString("N")); + string scriptAssemblies = Path.Combine(_projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory); + Directory.CreateDirectory(scriptAssemblies); + File.WriteAllText(Path.Combine(scriptAssemblies, "Saved" + HotReloadConstants.CompiledAssemblyExtension), "dll"); + _filePath = Path.Combine(_projectRoot, HotReloadConstants.CompilationAssemblyListRelativePath); + _referencePath = CompiledAssemblyLayout.Resolve(RealProjectRoot()).DllPath(TestAssemblyName); + _isCompilationFailed = false; + _currentGeneration = 0; + _backgroundRuns = 0; + _deferredWork = null; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectRoot)) + { + Directory.Delete(_projectRoot, true); + } + } + + /// + /// What: a clean first fetch is saved with the read fields and the dll stamp. Precompiled + /// references are saved once in the references table, with stamp and MVID, or with neither + /// when the file is missing, and assemblies point at them by index in Unity's order. + /// + [Test] + public void Store_SavesTheFirstFetchOfACleanDomain() + { + HotReloadCompilationAssemblyListStore store = CreateStore(); + + store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot); + + HotReloadSavedCompilationListLoad load = store.Load(); + Assert.That(load.FailureReason, Is.Null); + HotReloadSavedCompilationList saved = load.List; + Assert.That(saved.assemblies.Count, Is.EqualTo(2)); + HotReloadSavedCompilationAssembly entry = saved.assemblies[1]; + Assert.That(entry.name, Is.EqualTo("Saved")); + Assert.That(entry.sourceFiles, Is.EqualTo(new[] { "Assets/Saved/A.cs", "Assets/Saved/B.cs" })); + Assert.That(entry.defines, Is.EqualTo(new[] { "SECOND", "FIRST" })); + Assert.That(entry.assemblyReferences, Is.EqualTo(new[] { "Referenced" })); + Assert.That(entry.compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 1, 0 })); + Assert.That(saved.assemblies[0].compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 0 })); + Assert.That(entry.dllStamp, Is.Not.Null); + Assert.That(saved.assemblies[0].dllStamp, Is.Null, "Referenced has no dll in the temporary project."); + Assert.That(saved.references.Count, Is.EqualTo(2)); + Assert.That(saved.references[0].path, Is.EqualTo(_referencePath)); + Assert.That(saved.references[0].mvid, Is.EqualTo(HotReloadAssemblyMvid.Read(_referencePath))); + Assert.That(saved.references[1].path, Is.EqualTo(MissingReferencePath)); + Assert.That(saved.references[1].stamp, Is.Null); + Assert.That(saved.references[1].mvid, Is.Null); + } + + /// + /// What: a fetch after an invalidation of the domain (a compile start, or a script set change + /// without a reload) is not saved. + /// + [Test] + public void Store_DoesNotSaveAFetchAfterAnInvalidation() + { + HotReloadCompilationAssemblyListStore store = CreateStore(); + + store.SaveInBackgroundIfClean(CreateFetchedList(), 1, _projectRoot); + + Assert.That(File.Exists(_filePath), Is.False); + Assert.That(_backgroundRuns, Is.EqualTo(0)); + } + + /// + /// What: a fetch while the last compile failed is not saved, since the list can name sources + /// the dlls were not built from. + /// + [Test] + public void Store_DoesNotSaveWhileCompilationFailed() + { + _isCompilationFailed = true; + HotReloadCompilationAssemblyListStore store = CreateStore(); + + store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot); + + Assert.That(File.Exists(_filePath), Is.False); + Assert.That(_backgroundRuns, Is.EqualTo(0)); + } + + /// + /// What: when an invalidation happens after the hand-off but before the background write + /// publishes, the earlier saved file is left as it was and no temporary file remains. + /// + [Test] + public void Store_WhenInvalidatedBeforeTheWriteFinishes_KeepsThePreviousFile() + { + WriteSavedFile(PreviousFileContent); + HotReloadCompilationAssemblyListStore store = CreateDeferringStore(); + store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot); + + _currentGeneration = 1; + _deferredWork(); + + Assert.That(File.ReadAllText(_filePath), Is.EqualTo(PreviousFileContent)); + Assert.That(File.Exists(_filePath + ".tmp"), Is.False); + } + + /// + /// What: without a saved file, the load names no_saved_list. + /// + [Test] + public void Load_WithoutASavedFile_ReportsNoSavedList() + { + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.List, Is.Null); + Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonNoSavedList)); + } + + /// + /// What: a saved file with every required field, in-range indexes and the current format + /// version loads. The unreadable cases below each differ from it in one way. + /// + [Test] + public void Load_CompleteFileOfTheCurrentVersion_ReturnsTheList() + { + WriteSavedFile(CompleteFile); + + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.FailureReason, Is.Null); + Assert.That(load.List.assemblies[0].compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 0 })); + } + + /// + /// What: a torn file loads as saved_list_unreadable instead of throwing. + /// + [Test] + public void Load_TornFile_ReportsUnreadable() + { + WriteSavedFile(CompleteFile.Substring(0, CompleteFile.Length / 2)); + + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.List, Is.Null); + Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonSavedListUnreadable)); + } + + /// + /// What: a file of another format version, an assembly without a name, a reference index + /// outside the table and a reference with a stamp but no MVID all load as + /// saved_list_unreadable instead of failing later in the rebuild. + /// + [TestCase("\"formatVersion\":1", "\"formatVersion\":2", TestName = "Load_OtherFormatVersion_ReportsUnreadable")] + [TestCase("\"name\":\"A\",", "", TestName = "Load_AssemblyWithoutAName_ReportsUnreadable")] + [TestCase("\"compiledAssemblyReferenceIndexes\":[0]", "\"compiledAssemblyReferenceIndexes\":[1]", TestName = "Load_ReferenceIndexOutOfRange_ReportsUnreadable")] + [TestCase(",\"mvid\":\"0123456789abcdef0123456789abcdef\"", "", TestName = "Load_ReferenceWithAStampButNoMvid_ReportsUnreadable")] + public void Load_IncompleteFile_ReportsUnreadable(string completePart, string replacement) + { + Assert.That(CompleteFile, Does.Contain(completePart), "The case must change the complete file."); + WriteSavedFile(CompleteFile.Replace(completePart, replacement)); + + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.List, Is.Null); + Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonSavedListUnreadable)); + } + + private void WriteSavedFile(string json) + { + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)); + File.WriteAllText(_filePath, json); + } + + private HotReloadCompilationAssemblyListStore CreateStore() + { + // Why inline: EditMode tests must not start pool work, so the "background" runs here. + return new HotReloadCompilationAssemblyListStore( + _filePath, + () => _isCompilationFailed, + () => _currentGeneration, + work => + { + _backgroundRuns++; + work(); + return Task.CompletedTask; + }); + } + + private HotReloadCompilationAssemblyListStore CreateDeferringStore() + { + // Keeps the write so the test can change the generation between the hand-off and the write. + return new HotReloadCompilationAssemblyListStore( + _filePath, + () => _isCompilationFailed, + () => _currentGeneration, + work => + { + _deferredWork = work; + return Task.CompletedTask; + }); + } + + private UnityCompilationAssembly[] CreateFetchedList() + { + UnityCompilationAssembly referenced = new UnityCompilationAssembly( + "Referenced", + HotReloadConstants.ScriptAssembliesRelativeDirectory + "/Referenced" + HotReloadConstants.CompiledAssemblyExtension, + new[] { "Assets/Referenced/R.cs" }, + Array.Empty(), + Array.Empty(), + new[] { _referencePath }, + AssemblyFlags.EditorAssembly); + UnityCompilationAssembly saved = new UnityCompilationAssembly( + "Saved", + HotReloadConstants.ScriptAssembliesRelativeDirectory + "/Saved" + HotReloadConstants.CompiledAssemblyExtension, + new[] { "Assets/Saved/A.cs", "Assets/Saved/B.cs" }, + new[] { "SECOND", "FIRST" }, + new[] { referenced }, + new[] { MissingReferencePath, _referencePath }, + AssemblyFlags.EditorAssembly); + return new[] { referenced, saved }; + } + + private static string RealProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta new file mode 100644 index 0000000000..2e4bec536a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a13938635d42e496b82fb97fdf46f182 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index 4d0bd6bc71..8f38993d17 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -1,7 +1,12 @@ using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using UnityEditor; using UnityEditor.Compilation; +using UnityEngine; + using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -13,11 +18,22 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// changes the script set without compiling (see HotReloadCompilationAssemblyListPostprocessor). /// The startup snapshot capture is the first caller after a domain reload, so runs find the list /// already kept. + /// A list rebuilt at startup from the saved list holds only the fields hot reload reads: name, + /// outputPath, sourceFiles, defines, assemblyReferences and compiledAssemblyReferences. + /// compilerOptions, flags and rootNamespace keep their defaults, so code that needs them reads + /// CompilationPipeline.GetAssemblies instead. /// internal static class HotReloadCompilationAssemblies { + private static readonly HotReloadCompilationAssemblyListStore Store = + new HotReloadCompilationAssemblyListStore( + Path.Combine(ProjectRoot(), HotReloadConstants.CompilationAssemblyListRelativePath), + () => EditorUtility.scriptCompilationFailed, + () => Cache.Generation, + work => Task.Run(work)); + private static readonly HotReloadCompilationAssemblyCache Cache = - new HotReloadCompilationAssemblyCache(CompilationPipeline.GetAssemblies); + new HotReloadCompilationAssemblyCache(FetchAndSaveIfClean); static HotReloadCompilationAssemblies() { @@ -42,6 +58,23 @@ internal static UnityCompilationAssembly FindByName(string assemblyName) return Cache.FindByName(assemblyName); } + // Main thread: the cache asks for the list from Current(). + private static UnityCompilationAssembly[] FetchAndSaveIfClean() + { + UnityCompilationAssembly[] fetched = CompilationPipeline.GetAssemblies(); + if (fetched.Length > 0) + { + Store.SaveInBackgroundIfClean(fetched, Cache.Generation, ProjectRoot()); + } + + return fetched; + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + private static void OnCompilationStarted(object context) { Cache.Invalidate(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs index 5eac8cd972..b025a41a34 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using UnityEngine; @@ -9,12 +10,13 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// /// Keeps the last non-empty compilation assembly list so repeated lookups ask Unity only once - /// until the owner invalidates it. Main thread only, like the Unity call it wraps. + /// until the owner invalidates it. Main thread only, like the Unity call it wraps, except Generation. /// internal sealed class HotReloadCompilationAssemblyCache { private readonly Func _fetch; private UnityCompilationAssembly[] _assemblies; + private int _generation; internal HotReloadCompilationAssemblyCache(Func fetch) { @@ -24,6 +26,13 @@ internal HotReloadCompilationAssemblyCache(Func fetc internal bool IsFilled => _assemblies != null; + /// + /// Counts the invalidations of this domain, so a list built off the main thread can tell + /// whether the script set changed while it was being built. The only member readable off the + /// main thread. + /// + internal int Generation => Volatile.Read(ref _generation); + /// /// Returns the memoized list, asking Unity when nothing is kept. The list is shared; callers only read it. /// @@ -66,9 +75,28 @@ internal UnityCompilationAssembly FindByName(string assemblyName) return null; } + /// + /// Keeps a list built without asking Unity, when nothing is kept yet and no invalidation + /// happened since the builder read the generation. Returns whether the list was kept. + /// + internal bool TryFill(UnityCompilationAssembly[] assemblies, int expectedGeneration) + { + Debug.Assert(assemblies != null && assemblies.Length > 0, "assemblies must not be null or empty."); + + // Why a kept list wins: a list Unity answered is the truth the rebuilt one only approximates. + if (_assemblies != null || Generation != expectedGeneration) + { + return false; + } + + _assemblies = assemblies; + return true; + } + internal void Invalidate() { _assemblies = null; + Interlocked.Increment(ref _generation); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs new file mode 100644 index 0000000000..3716455129 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs @@ -0,0 +1,184 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json; + +using Debug = UnityEngine.Debug; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Saves the compilation assembly list of a clean domain so the next domain can rebuild it + /// without asking Unity on the main thread, and loads it back. + /// + internal sealed class HotReloadCompilationAssemblyListStore + { + internal const int FormatVersion = 1; + private const string TempFileExtension = ".tmp"; + + private readonly string _filePath; + private readonly Func _isCompilationFailed; + private readonly Func _currentGeneration; + private readonly Func _runInBackground; + + internal HotReloadCompilationAssemblyListStore( + string filePath, + Func isCompilationFailed, + Func currentGeneration, + Func runInBackground) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty."); + Debug.Assert(isCompilationFailed != null, "isCompilationFailed must not be null."); + Debug.Assert(currentGeneration != null, "currentGeneration must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + _filePath = filePath; + _isCompilationFailed = isCompilationFailed; + _currentGeneration = currentGeneration; + _runInBackground = runInBackground; + } + + /// + /// Saves the list in the background when it describes the dlls on disk: no invalidation has + /// happened in this domain (generation 0) and the last compile did not fail. Otherwise the + /// list can name sources that were never compiled, and a later domain whose dlls did not + /// change would trust it. Main thread only, because it reads the compile state. + /// + internal void SaveInBackgroundIfClean(UnityCompilationAssembly[] fetched, int generation, string projectRoot) + { + Debug.Assert(fetched != null && fetched.Length > 0, "fetched must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + if (generation != 0) + { + LogSaveSkipped("generation"); + return; + } + + if (_isCompilationFailed()) + { + LogSaveSkipped("compilation_failed"); + return; + } + + // Why only logged: the saved list is an optimization, and without it the next domain + // asks Unity as it always did. + _runInBackground(() => Write(fetched, projectRoot, generation)) + .ContinueWith( + LogSaveFailure, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + /// + /// Loads the saved list. When it cannot be used, the result has no list and names why: no + /// file, or a file that is torn, of another format version, or missing a required field. + /// Touches only the file, so it can run on a pool thread. + /// + internal HotReloadSavedCompilationListLoad Load() + { + if (!File.Exists(_filePath)) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonNoSavedList); + } + + string json = File.ReadAllText(_filePath, Encoding.UTF8); + HotReloadSavedCompilationList saved; + // Why caught: the file is outside input that a crash can tear or a person can edit, and an + // unreadable file only sends the next domain back to Unity's list. Only JSON errors are + // caught; an IO error goes to the caller's boundary. + try + { + saved = JsonConvert.DeserializeObject(json); + } + catch (JsonException) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonSavedListUnreadable); + } + + // Why the fields are checked here: a missing one would otherwise surface later as a + // NullReferenceException that names no cause. + if (saved == null || saved.formatVersion != FormatVersion || !saved.IsComplete()) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonSavedListUnreadable); + } + + return HotReloadSavedCompilationListLoad.Loaded(saved); + } + + private void Write(UnityCompilationAssembly[] fetched, string projectRoot, int generation) + { + Stopwatch watch = Stopwatch.StartNew(); + HotReloadSavedCompilationList saved = HotReloadSavedCompilationList.Capture(fetched, projectRoot, FormatVersion); + string json = JsonConvert.SerializeObject(saved); + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)); + + // Why a fixed temp name: a domain has one writer, and the previous domain's writer + // stopped when that domain was unloaded. + string tempPath = _filePath + TempFileExtension; + File.WriteAllText(tempPath, json, new UTF8Encoding(false)); + + // Why the generation is read again here: a compile that started after the hand-off may + // have written new dlls whose stamps Capture read, which would pair the old list with + // them. Unity writes dlls only after compilationStarted invalidates the cache, so an + // unchanged generation means every stamp read above belongs to this domain's dlls. + if (_currentGeneration() != generation) + { + File.Delete(tempPath); + LogSaveSkipped("superseded"); + return; + } + + long bytes = new FileInfo(tempPath).Length; + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + + File.Move(tempPath, _filePath); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListSaved, + "Hot reload saved the compilation assembly list for the next domain.", + new + { + bytes, + saveMs = watch.ElapsedMilliseconds, + assemblies = saved.assemblies.Count, + references = saved.references.Count, + }); + } + + private static void LogSaveSkipped(string reason) + { + // Why logged: domains that never save would silently keep the next startup on Unity's list. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListSaveSkipped, + "Hot reload did not save the compilation assembly list of this domain.", + new { reason }); + } + + private static void LogSaveFailure(Task write) + { + if (!write.IsFaulted) + { + return; + } + + // Why read before the call: VibeLogger.LogWarning is compiled out without ULOOP_DEBUG, + // and reading Exception is what marks the fault as observed. + Exception error = write.Exception.GetBaseException(); + VibeLogger.LogWarning( + HotReloadConstants.VibeLogCompilationListSaveFailed, + "Hot reload could not save the compilation assembly list.", + new { error = error.GetType().Name }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta new file mode 100644 index 0000000000..e4b4dc5be4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8092f6887a9a4282ba931bbf48543c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 7967a25f21..8d0f5547f7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -49,6 +49,10 @@ internal static class HotReloadConstants // affect the on-disk layout. Adoption is decided at use time by PDB document checksum. public const string SourceSnapshotRelativeDirectory = "Library/UloopHotReload/SourceSnapshot"; + // The compilation assembly list saved by a clean domain, from which the next domain + // rebuilds the list without asking Unity on the main thread. + public const string CompilationAssemblyListRelativePath = "Library/UloopHotReload/CompilationAssemblies.json"; + // The file inside a snapshot directory that records each copied source's length and last // write time (UTC ticks), so a later domain tells an unchanged source by its stat alone. public const string SourceStampManifestFileName = "source-stamps.txt"; @@ -705,6 +709,17 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string SourceSnapshotSkipReasonToolsDisabled = "tools_disabled"; public const string WarmUpSkipReasonToolDisabled = "tool_disabled"; + // Saving the compilation assembly list for the next domain: why a domain did not save it, + // a save that threw, and a save that finished (its size and time). + public const string VibeLogCompilationListSaveSkipped = "hot_reload_compilation_list_save_skipped"; + public const string VibeLogCompilationListSaveFailed = "hot_reload_compilation_list_save_failed"; + public const string VibeLogCompilationListSaved = "hot_reload_compilation_list_saved"; + + // Why a domain found no saved compilation assembly list to rebuild from: none was saved, or + // the saved file is torn, of another format version or missing a required field. + public const string CompilationListReasonNoSavedList = "no_saved_list"; + public const string CompilationListReasonSavedListUnreadable = "saved_list_unreadable"; + // 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. public const string WarmUpCancelledByRun = "run"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs new file mode 100644 index 0000000000..86e2256d5a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The compilation assembly list as saved for the next domain: only the fields hot reload reads, + /// plus the file stamps and MVIDs that tell whether each entry still describes the dlls on disk. + /// + internal sealed class HotReloadSavedCompilationList + { + public int formatVersion; + + // In the order CompilationPipeline.GetAssemblies returned them. + public List assemblies; + + // One per distinct compiled assembly reference path, in first-seen order. Assemblies name + // their references by index, because most of them share the same few hundred paths and + // repeating the paths made the file several times larger. + public List references; + + /// + /// True when every field a rebuild reads is present and every reference index is in range. + /// + internal bool IsComplete() + { + if (assemblies == null || references == null) + { + return false; + } + + foreach (HotReloadSavedReference reference in references) + { + if (reference == null || !reference.IsComplete()) + { + return false; + } + } + + foreach (HotReloadSavedCompilationAssembly assembly in assemblies) + { + if (assembly == null || !assembly.IsComplete(references.Count)) + { + return false; + } + } + + return true; + } + + /// + /// Reads the saved fields of every assembly, the stamp of each assembly's dll and the stamp + /// and MVID of every referenced dll. Touches only files and managed properties, so it can + /// run on a pool thread. + /// + internal static HotReloadSavedCompilationList Capture( + IReadOnlyList fetched, + string projectRoot, + int formatVersion) + { + Debug.Assert(fetched != null, "fetched must not be null."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + List assemblies = new List(fetched.Count); + List references = new List(); + Dictionary indexByPath = new Dictionary(StringComparer.Ordinal); + foreach (UnityCompilationAssembly assembly in fetched) + { + string[] referencePaths = assembly.compiledAssemblyReferences ?? Array.Empty(); + int[] referenceIndexes = new int[referencePaths.Length]; + for (int position = 0; position < referencePaths.Length; position++) + { + referenceIndexes[position] = IndexOf(referencePaths[position], projectRoot, indexByPath, references); + } + + assemblies.Add(HotReloadSavedCompilationAssembly.From(assembly, referenceIndexes, layout)); + } + + return new HotReloadSavedCompilationList + { + formatVersion = formatVersion, + assemblies = assemblies, + references = references, + }; + } + + private static int IndexOf( + string referencePath, + string projectRoot, + Dictionary indexByPath, + List references) + { + if (indexByPath.TryGetValue(referencePath, out int index)) + { + return index; + } + + index = references.Count; + indexByPath.Add(referencePath, index); + references.Add(HotReloadSavedReference.Read(referencePath, projectRoot)); + return index; + } + } + + /// + /// The outcome of loading the saved list: the list, or why there is none to use. + /// + internal readonly struct HotReloadSavedCompilationListLoad + { + public readonly HotReloadSavedCompilationList List; + public readonly string FailureReason; + + private HotReloadSavedCompilationListLoad(HotReloadSavedCompilationList list, string failureReason) + { + List = list; + FailureReason = failureReason; + } + + internal static HotReloadSavedCompilationListLoad Loaded(HotReloadSavedCompilationList list) + { + Debug.Assert(list != null, "list must not be null."); + return new HotReloadSavedCompilationListLoad(list, null); + } + + internal static HotReloadSavedCompilationListLoad Failed(string failureReason) + { + Debug.Assert(!string.IsNullOrEmpty(failureReason), "failureReason must not be null or empty."); + return new HotReloadSavedCompilationListLoad(null, failureReason); + } + } + + /// + /// One saved compilation assembly. Fields hot reload never reads (compiler options, flags, root + /// namespace) are not saved. + /// + internal sealed class HotReloadSavedCompilationAssembly + { + public string name; + public string outputPath; + public string[] sourceFiles; + public string[] defines; + + // Names of the project assemblies this one references, in Unity's order. + public string[] assemblyReferences; + + // The precompiled assemblies this one references, in Unity's order, as indexes into the + // list's references. + public int[] compiledAssemblyReferenceIndexes; + + // Null when the assembly had no dll when the list was saved. + public HotReloadSavedFileStamp dllStamp; + + internal bool IsComplete(int referenceCount) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(outputPath) || sourceFiles == null + || defines == null || assemblyReferences == null || compiledAssemblyReferenceIndexes == null) + { + return false; + } + + foreach (int index in compiledAssemblyReferenceIndexes) + { + if (index < 0 || index >= referenceCount) + { + return false; + } + } + + return true; + } + + internal static HotReloadSavedCompilationAssembly From( + UnityCompilationAssembly assembly, + int[] compiledAssemblyReferenceIndexes, + CompiledAssemblyLayout layout) + { + UnityCompilationAssembly[] referenced = assembly.assemblyReferences ?? Array.Empty(); + string[] referencedNames = new string[referenced.Length]; + for (int index = 0; index < referenced.Length; index++) + { + referencedNames[index] = referenced[index].name; + } + + return new HotReloadSavedCompilationAssembly + { + name = assembly.name, + outputPath = assembly.outputPath, + sourceFiles = assembly.sourceFiles ?? Array.Empty(), + defines = assembly.defines ?? Array.Empty(), + assemblyReferences = referencedNames, + compiledAssemblyReferenceIndexes = compiledAssemblyReferenceIndexes, + dllStamp = HotReloadSavedFileStamp.ReadOrNull(layout.DllPath(assembly.name)), + }; + } + } + + /// + /// A precompiled assembly reference as it was when the list was saved. + /// + internal sealed class HotReloadSavedReference + { + // The path exactly as Unity listed it in compiledAssemblyReferences. + public string path; + + // Both null when the file did not exist when the list was saved. + public HotReloadSavedFileStamp stamp; + public string mvid; + + internal bool IsComplete() + { + if (string.IsNullOrEmpty(path) || (stamp == null) != (mvid == null)) + { + return false; + } + + return mvid == null || Guid.TryParseExact(mvid, "N", out Guid _); + } + + internal static HotReloadSavedReference Read(string referencePath, string projectRoot) + { + string fullPath = Path.GetFullPath(Path.Combine(projectRoot, referencePath)); + HotReloadSavedFileStamp stamp = HotReloadSavedFileStamp.ReadOrNull(fullPath); + return new HotReloadSavedReference + { + path = referencePath, + stamp = stamp, + mvid = stamp == null ? null : HotReloadAssemblyMetadataMvid.Read(fullPath).ToString("N"), + }; + } + } + + /// + /// The length and last write time of a file, which together tell that a dll was not rebuilt. + /// + internal sealed class HotReloadSavedFileStamp + { + public long length; + public long lastWriteTicks; + + /// + /// Returns the stamp of the file, or null when the file does not exist. + /// + internal static HotReloadSavedFileStamp ReadOrNull(string fullPath) + { + FileInfo file = new FileInfo(fullPath); + if (!file.Exists) + { + return null; + } + + return new HotReloadSavedFileStamp { length = file.Length, lastWriteTicks = file.LastWriteTimeUtc.Ticks }; + } + } + + /// + /// Reads a dll's MVID from its metadata header without loading the assembly. Safe on a pool thread. + /// + internal static class HotReloadAssemblyMetadataMvid + { + internal static Guid Read(string dllPath) + { + using FileStream stream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using PEReader peReader = new PEReader(stream); + MetadataReader reader = peReader.GetMetadataReader(); + return reader.GetGuid(reader.GetModuleDefinition().Mvid); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta new file mode 100644 index 0000000000..9af5d3b308 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9450b5e2fb2394510b9039e08c5628f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From f79a2a006104a1eeb989e590e50f9ed3a0180fb5 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 11 Oct 2026 18:54:19 +0900 Subject: [PATCH 08/15] perf: Hot reload's source snapshot capture no longer asks the Package Manager for each assembly and source (#3282) --- ...otReloadIncrementalSnapshotCaptureTests.cs | 23 ++- ...tReloadSnapshotAssemblyEnumerationTests.cs | 100 ++++++----- .../HotReloadSnapshotCaptureInputsTests.cs | 158 ++++++++++++++++++ ...otReloadSnapshotCaptureInputsTests.cs.meta | 11 ++ ...tReloadSnapshotEditedDuringCompileTests.cs | 71 +++++++- .../HotReloadSnapshotLookupPathTests.cs | 92 ++++++++++ .../HotReloadSnapshotLookupPathTests.cs.meta | 11 ++ .../HotReload/HotReloadSourceSnapshotTests.cs | 8 +- .../HotReloadSourceSnapshotterTests.cs | 4 +- .../PausePointScriptPathFormTests.cs | 8 +- .../HotReload/HotReloadCompositionRoot.cs | 3 +- .../Shared/HotReloadCaptureAssembly.cs | 32 ++++ .../Shared/HotReloadCaptureAssembly.cs.meta | 11 ++ .../Shared/HotReloadCompiledSourceMap.cs | 40 +---- .../Shared/HotReloadPackageFolderMatch.cs | 75 +++++++++ .../HotReloadPackageFolderMatch.cs.meta | 11 ++ .../Shared/HotReloadSnapshotCaptureInputs.cs | 115 +++++++++++++ .../HotReloadSnapshotCaptureInputs.cs.meta | 11 ++ .../Shared/HotReloadSnapshotLookupPath.cs | 43 +++++ .../HotReloadSnapshotLookupPath.cs.meta | 11 ++ .../Shared/HotReloadSnapshotSourceCheck.cs | 29 +++- .../Shared/HotReloadSourceBaseline.cs | 31 +++- ...tReloadSourceSnapshotCopier.Incremental.cs | 4 +- .../Shared/HotReloadSourceSnapshotCopier.cs | 4 +- .../Shared/HotReloadSourceSnapshotter.cs | 85 +++++----- docs/vibe-logs.md | 12 +- 26 files changed, 854 insertions(+), 149 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs index e054e18067..13135c996b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs @@ -535,10 +535,21 @@ private HotReloadSnapshotCaptureStats CaptureAssemblies(params string[] sources) Array.Empty(), Array.Empty(), AssemblyFlags.EditorAssembly); - return HotReloadSourceSnapshotter.CaptureAssemblies( + HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( _root, - new[] { assembly }, HotReloadCompileStart.Unknown, + Array.Empty(), + StringComparison.Ordinal, + new[] + { + HotReloadSnapshotCaptureInputs.ToCaptureAssembly( + assembly, + Array.Empty(), + StringComparison.Ordinal) + }, + 0); + return HotReloadSourceSnapshotter.CaptureAssemblies( + inputs, new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); } @@ -550,7 +561,9 @@ private HotReloadSnapshotSourceCheck CheckAgainst(string dllPath, string pdbPath dllPath, pdbPath, HotReloadAssemblyMvid.Read(dllPath), - new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); + new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")), + HotReloadSnapshotPackageRoots.ReadCurrent(), + HotReloadSnapshotCaptureInputs.CurrentPathComparison()); } // A check whose suspect window never opens, so no copy is checked against a PDB. @@ -561,7 +574,9 @@ private HotReloadSnapshotSourceCheck SuspectsNothing() "unused.dll", "unused.pdb", "unused", - new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); + new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")), + Array.Empty(), + StringComparison.Ordinal); } // Copies this test assembly's own DLL and PDB, so the fixture has a real image for Cecil to read. diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index 035bb8f097..116440f3a0 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs @@ -66,9 +66,7 @@ public void CaptureAssemblies_WithoutStamp_CapturesSourcesRemovesStaleSnapshotAn Directory.CreateDirectory(staleDirectory); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True); @@ -88,9 +86,7 @@ public void CaptureAssemblies_WhenStampMatches_SkipsCapture() File.WriteAllText(StampPath("Fixture"), stamp); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(Directory.Exists(Path.Combine(_snapshotRoot, "Fixture-" + mvid)), Is.False); @@ -112,9 +108,7 @@ public void CaptureAssemblies_WhenMvidDirectoryExists_KeepsSnapshotsAndWritesSta Directory.CreateDirectory(staleDirectory); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(Directory.GetFiles(currentDirectory), Is.Empty); @@ -154,9 +148,7 @@ public void CaptureAssemblies_WhenStampIsMalformedOrDiffers_CapturesAgain(string File.WriteAllText(StampPath("Fixture"), stamp); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.True); @@ -184,9 +176,7 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories() Directory.CreateDirectory(nonMvid); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(Directory.Exists(orphanTemporary), Is.False); @@ -206,15 +196,36 @@ public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing() string mvid = HotReloadAssemblyMvid.Read(dllPath); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssemblyWithSources("NoSources", Array.Empty()) }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssemblyWithSources("NoSources", Array.Empty()) }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "NoSources", mvid)), Is.False); Assert.That(File.Exists(StampPath("NoSources")), Is.False); } + /// + /// Verifies an assembly the inputs classified as an immutable package's is counted and skipped + /// before anything is written, though its DLL and PDB are there: no snapshot directory and no stamp. + /// + [Test] + public void CaptureAssemblies_ImmutablePackageAssembly_CountsItAndWritesNothing() + { + PlantCompiledAssembly("Fixture"); + HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( + _projectRoot, + HotReloadCompileStart.Unknown, + Array.Empty(), + StringComparison.Ordinal, + new[] { new HotReloadCaptureAssembly("Fixture", new[] { SourceRelativePath }, true) }, + 0); + + HotReloadSnapshotCaptureStats stats = HotReloadSourceSnapshotter.CaptureAssemblies(inputs, CreateDocumentIndex()); + + Assert.That(stats.AssembliesImmutable, Is.EqualTo(1), "immutable"); + Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty); + Assert.That(File.Exists(StampPath("Fixture")), Is.False); + } + /// /// Verifies an assembly whose compiled DLL or PDB is missing is skipped without a warning: no snapshot directory and no stamp. /// @@ -226,9 +237,7 @@ public void CaptureAssemblies_WhenCompiledOutputIsMissing_WritesNothingAndDoesNo File.Delete(Path.ChangeExtension(dllPath, missingExtension)); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty); @@ -252,9 +261,7 @@ public void CaptureAssemblies_WhenStampMatches_DoesNotOpenTheCompiledAssembly() File.WriteAllText(StampPath("Fixture"), stamp); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.False); @@ -280,9 +287,7 @@ public void CaptureAssemblies_WhenOneAssemblyCannotBeRead_WarnsAndCapturesTheRes LogAssert.Expect(LogType.Warning, new Regex("Snapshot capture failed for assembly Broken")); HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Broken"), CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, new[] { CreateAssembly("Broken"), CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(File.Exists(StampPath("Broken")), Is.False); @@ -343,9 +348,7 @@ public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSna string mvid = HotReloadAssemblyMvid.Read(dllPath); HotReloadSourceSnapshotter.CaptureAssemblies( - playerRoot, - new[] { CreateAssembly("Fixture") }, - HotReloadCompileStart.Unknown, + CreateInputs(playerRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), CreateDocumentIndex()); string playerSnapshotSource = HotReloadSourceSnapshotLayout.SourcePath( @@ -417,14 +420,10 @@ public void CaptureAssemblies_ReturnsWhatItCopiedAndWhatItSkipped() }; HotReloadSnapshotCaptureStats first = HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - assemblies, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, assemblies, HotReloadCompileStart.Unknown), CreateDocumentIndex()); HotReloadSnapshotCaptureStats second = HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - assemblies, - HotReloadCompileStart.Unknown, + CreateInputs(_projectRoot, assemblies, HotReloadCompileStart.Unknown), CreateDocumentIndex()); Assert.That(first.Assemblies, Is.EqualTo(1), "first: assemblies"); @@ -452,9 +451,7 @@ private string CaptureWithSourceSavedBetweenStartAndDll( string mvid = HotReloadAssemblyMvid.Read(dllPath); stats = HotReloadSourceSnapshotter.CaptureAssemblies( - _projectRoot, - new[] { CreateAssembly("Fixture") }, - compileStart, + CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, compileStart), CreateDocumentIndex()); return Path.Combine(_snapshotRoot, "Fixture-" + mvid); @@ -511,6 +508,31 @@ private static UnityCompilationAssembly CreateAssemblyWithSources(string assembl AssemblyFlags.EditorAssembly); } + // The capture's inputs for a temporary root: no package is registered there, so every + // assembly reads as editable. + private static HotReloadSnapshotCaptureInputs CreateInputs( + string projectRoot, + IEnumerable assemblies, + HotReloadCompileStart compileStart) + { + List captureAssemblies = new List(); + foreach (UnityCompilationAssembly assembly in assemblies) + { + captureAssemblies.Add(HotReloadSnapshotCaptureInputs.ToCaptureAssembly( + assembly, + Array.Empty(), + StringComparison.Ordinal)); + } + + return new HotReloadSnapshotCaptureInputs( + projectRoot, + compileStart, + Array.Empty(), + StringComparison.Ordinal, + captureAssemblies, + 0); + } + private string SnapshotSourcePath(string assemblyName, string mvid) { return HotReloadSourceSnapshotLayout.SourcePath( diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs new file mode 100644 index 0000000000..41f7ac0e8c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers the values the snapshot capture reads from Unity before it starts: how one of Unity's + /// compilation assemblies is classified from the package roots, and, on this project's real + /// assemblies, that the classification agrees with the Package Manager's answer. + /// + public sealed class HotReloadSnapshotCaptureInputsTests + { + private const int MaxListedMismatches = 5; + + private static readonly HotReloadSnapshotPackageRoot[] Packages = + { + new HotReloadSnapshotPackageRoot("/proj/Packages/src", "Packages/com.example.embedded", false), + new HotReloadSnapshotPackageRoot("/elsewhere/LocalPkg", "Packages/com.example.local", false), + new HotReloadSnapshotPackageRoot("/proj/Library/PackageCache/com.example.reg@1.0.0", "Packages/com.example.reg", true), + }; + + /// + /// Verifies an assembly whose first source lies in an immutable package is classified as + /// immutable, and keeps its name and sources. + /// + [Test] + public void ToCaptureAssembly_FirstSourceInAnImmutablePackage_IsImmutable() + { + HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs"); + + Assert.That(assembly.IsImmutablePackageAssembly, Is.True); + Assert.That(assembly.Name, Is.EqualTo("Fixture")); + Assert.That(assembly.SourceAssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs" })); + } + + /// + /// Verifies sources of an embedded package, a local package and Assets are editable. + /// + [TestCase("Packages/com.example.embedded/Editor/A.cs")] + [TestCase("Packages/com.example.local/A.cs")] + [TestCase("Assets/A.cs")] + public void ToCaptureAssembly_FirstSourceEditable_IsNotImmutable(string firstSource) + { + HotReloadCaptureAssembly assembly = Classify(firstSource); + + Assert.That(assembly.IsImmutablePackageAssembly, Is.False); + } + + /// + /// Verifies a package whose name only starts with an immutable package's name is not taken for it. + /// + [Test] + public void ToCaptureAssembly_PackageNameExtendingAnImmutableOne_IsNotImmutable() + { + HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg2/A.cs"); + + Assert.That(assembly.IsImmutablePackageAssembly, Is.False); + } + + /// + /// Verifies an assembly without sources is classified as editable with an empty source list. + /// + [Test] + public void ToCaptureAssembly_WithoutSources_IsNotImmutableAndHasNoSources() + { + HotReloadCaptureAssembly assembly = Classify(); + + Assert.That(assembly.IsImmutablePackageAssembly, Is.False); + Assert.That(assembly.SourceAssetPaths, Is.Empty); + } + + /// + /// Verifies, for every compilation assembly of this project that has sources, that the + /// classification from the package roots equals the Package Manager's answer the capture + /// asked before. + /// + [Test] + public void ToCaptureAssembly_FromPackageRoots_MatchesThePackageManagerAnswerForEveryAssembly() + { + IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); + StringComparison comparison = HotReloadSnapshotCaptureInputs.CurrentPathComparison(); + List mismatches = new List(); + int checkedAssemblies = 0; + int immutableAssemblies = 0; + + foreach (UnityCompilationAssembly compilationAssembly in HotReloadCompilationAssemblies.Current()) + { + string[] sourceFiles = compilationAssembly.sourceFiles; + if (sourceFiles == null || sourceFiles.Length == 0) + { + continue; + } + + checkedAssemblies++; + bool expected = HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); + immutableAssemblies += expected ? 1 : 0; + bool actual = HotReloadSnapshotCaptureInputs + .ToCaptureAssembly(compilationAssembly, packageRoots, comparison) + .IsImmutablePackageAssembly; + if (actual != expected) + { + mismatches.Add(compilationAssembly.name + " (" + sourceFiles[0] + "): package roots say " + + actual + ", the Package Manager says " + expected + "."); + } + } + + TestContext.WriteLine( + "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; " + + mismatches.Count + " mismatches."); + Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compilation assembly with sources was found."); + Assert.That(immutableAssemblies, Is.GreaterThan(0), "No immutable package assembly was found to compare."); + Assert.That( + mismatches, + Is.Empty, + "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; " + + mismatches.Count + " mismatches:\n" + + string.Join("\n", mismatches.GetRange(0, Math.Min(MaxListedMismatches, mismatches.Count)))); + } + + /// + /// Verifies the inputs read on the main thread name this project's root and carry one entry + /// for each compilation assembly Unity lists. + /// + [Test] + public void ReadOnMainThread_CarriesTheProjectRootAndEveryCompilationAssembly() + { + HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread(); + + Assert.That(inputs.ProjectRoot, Is.EqualTo(Path.GetFullPath(Path.Combine(Application.dataPath, "..")))); + Assert.That(inputs.Assemblies.Count, Is.EqualTo(HotReloadCompilationAssemblies.Current().Count)); + Assert.That(inputs.PackageRoots, Is.Not.Empty); + } + + private static HotReloadCaptureAssembly Classify(params string[] sourceFiles) + { + UnityCompilationAssembly compilationAssembly = new UnityCompilationAssembly( + "Fixture", + "Library/ScriptAssemblies/Fixture.dll", + sourceFiles, + Array.Empty(), + Array.Empty(), + Array.Empty(), + AssemblyFlags.EditorAssembly); + return HotReloadSnapshotCaptureInputs.ToCaptureAssembly(compilationAssembly, Packages, StringComparison.Ordinal); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs.meta new file mode 100644 index 0000000000..df92ac9fdf --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ee49cc5e8e1b42da9117502651883ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs index e0699649e6..09abcf338c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotEditedDuringCompileTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -23,6 +24,12 @@ public sealed class HotReloadSnapshotEditedDuringCompileTests // A file without method bodies, so the PDB has no document for it. private const string BodylessFixtureProjectRelativePath = "Assets/Tests/Editor/HotReload/HotReloadSnapshotBodylessFixture.cs"; + private const string PausePointsRuntimeAssemblyName = "UnityCLILoop.PausePoints.Runtime"; + // A file of this repository's embedded package, whose folder differs from its package name, + // so its asset path and the path its PDB records differ. + private const string PackageSourceAssetPath = + "Packages/io.github.hatayama.uloopmcp/Runtime/PausePoints/UloopPausePoint.cs"; + private const string PackageSourcePhysicalPath = "Packages/src/Runtime/PausePoints/UloopPausePoint.cs"; private static readonly long CompileStartUtcTicks = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; private static readonly DateTime SinceTheStartUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -59,13 +66,36 @@ public void CaptureAtomically_ASourceWrittenSinceTheStartThatMatchesThePdb_Recor { PlantSource(FixtureProjectRelativePath, RealBytes(FixtureProjectRelativePath), SinceTheStartUtc); - HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath); + HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent()); string snapshotFileName = SnapshotFileName(FixtureProjectRelativePath); Assert.That(manifest.IsEditedAfterCompile(snapshotFileName), Is.False); Assert.That(manifest.TryGetStamp(snapshotFileName, out long _, out long _), Is.True); } + /// + /// Verifies that a source of an embedded package written since the compile started whose bytes + /// match the PDB gets an unmarked stamp: the check names it by the folder behind the package + /// path, as the PDB records it, from the package roots it was given. The PDB records the + /// package's sources relative to the compiling project, so the package root is moved under + /// the temporary root, as the real one lies under the real project. + /// + [Test] + public void CaptureAtomically_APackageSourceWrittenSinceTheStartThatMatchesThePdb_RecordsAnUnmarkedStamp() + { + byte[] realBytes = File.ReadAllBytes(Path.Combine(_realProjectRoot, PackageSourcePhysicalPath)); + PlantSource(PackageSourceAssetPath, realBytes, SinceTheStartUtc); + + HotReloadSourceStampManifest manifest = Capture( + PackageSourceAssetPath, + PausePointsRuntimeAssemblyName, + PackageRootsMovedUnderTheTemporaryRoot()); + + string snapshotFileName = SnapshotFileName(PackageSourceAssetPath); + Assert.That(manifest.IsEditedAfterCompile(snapshotFileName), Is.False); + Assert.That(manifest.TryGetStamp(snapshotFileName, out long _, out long _), Is.True); + } + /// /// Verifies that a source written since the compile started whose bytes differ from the PDB is /// marked, and that its copy is still written with the edited bytes. @@ -76,7 +106,7 @@ public void CaptureAtomically_ASourceWrittenSinceTheStartThatDiffersFromThePdb_M byte[] editedBytes = EditedBytes(FixtureProjectRelativePath); PlantSource(FixtureProjectRelativePath, editedBytes, SinceTheStartUtc); - HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath); + HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent()); string snapshotFileName = SnapshotFileName(FixtureProjectRelativePath); string snapshotPath = Path.Combine(_snapshotDirectory, snapshotFileName); @@ -94,7 +124,7 @@ public void CaptureAtomically_ASourceWrittenBeforeTheStart_IsNotCheckedAgainstTh { PlantSource(FixtureProjectRelativePath, EditedBytes(FixtureProjectRelativePath), BeforeTheStartUtc); - HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath); + HotReloadSourceStampManifest manifest = Capture(FixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent()); Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(FixtureProjectRelativePath)), Is.False); } @@ -111,35 +141,60 @@ public void CaptureAtomically_ASourceThePdbHasNoDocumentFor_MarksIt() RealBytes(BodylessFixtureProjectRelativePath), SinceTheStartUtc); - HotReloadSourceStampManifest manifest = Capture(BodylessFixtureProjectRelativePath); + HotReloadSourceStampManifest manifest = Capture(BodylessFixtureProjectRelativePath, TestAssemblyName, HotReloadSnapshotPackageRoots.ReadCurrent()); Assert.That(manifest.IsEditedAfterCompile(SnapshotFileName(BodylessFixtureProjectRelativePath)), Is.True); } - private HotReloadSourceStampManifest Capture(string projectRelativePath) + private HotReloadSourceStampManifest Capture( + string projectRelativePath, + string assemblyName, + IReadOnlyList packageRoots) { string dllPath = Path.Combine( _realProjectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory, - TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); + assemblyName + HotReloadConstants.CompiledAssemblyExtension); HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck( CompileStartUtcTicks, dllPath, Path.ChangeExtension(dllPath, ".pdb"), HotReloadAssemblyMvid.Read(dllPath), - new HotReloadPdbDocumentIndex(Path.Combine(_tempRoot, "PdbDocuments"))); + new HotReloadPdbDocumentIndex(Path.Combine(_tempRoot, "PdbDocuments")), + packageRoots, + HotReloadSnapshotCaptureInputs.CurrentPathComparison()); HotReloadSourceSnapshotCopier.CaptureAtomically( _tempRoot, _snapshotDirectory, new[] { projectRelativePath }, - TestAssemblyName, + assemblyName, check, new HotReloadSnapshotCaptureStats()); return HotReloadSourceStampManifest.Load(_snapshotDirectory); } + // The registered packages, with every folder under the real project moved to the same place + // under the temporary root. + private List PackageRootsMovedUnderTheTemporaryRoot() + { + string realRootPrefix = _realProjectRoot.Replace('\\', '/').TrimEnd('/') + "/"; + List roots = new List(); + foreach (HotReloadSnapshotPackageRoot root in HotReloadSnapshotPackageRoots.ReadCurrent()) + { + string resolvedPath = root.ResolvedPath.Replace('\\', '/'); + if (resolvedPath.StartsWith(realRootPrefix, StringComparison.Ordinal)) + { + resolvedPath = Path.Combine(_tempRoot, resolvedPath.Substring(realRootPrefix.Length)); + } + + roots.Add(new HotReloadSnapshotPackageRoot(resolvedPath, root.AssetPath, root.IsImmutable)); + } + + return roots; + } + private byte[] RealBytes(string projectRelativePath) { return File.ReadAllBytes(Path.Combine(_realProjectRoot, projectRelativePath)); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs new file mode 100644 index 0000000000..2cd8e835c1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers naming a source the way its PDB records it from package roots read beforehand, and, + /// on this project's real sources, that the name equals the one the Package Manager gives. + /// + public sealed class HotReloadSnapshotLookupPathTests + { + private const int MaxListedMismatches = 5; + + private static readonly HotReloadSnapshotPackageRoot[] Packages = + { + new HotReloadSnapshotPackageRoot("/proj/Packages/src", "Packages/com.example.embedded", false), + new HotReloadSnapshotPackageRoot("/elsewhere/LocalPkg", "Packages/com.example.local", false), + }; + + /// + /// Verifies a source of a package inside the project is named relative to the project, one + /// outside it by its absolute path, and an Assets source by its asset path. + /// + [TestCase("Packages/com.example.embedded/Editor/A.cs", "Packages/src/Editor/A.cs")] + [TestCase("Packages/com.example.local/A.cs", "/elsewhere/LocalPkg/A.cs")] + [TestCase("Assets/A.cs", "Assets/A.cs")] + public void ToPdbLookupPath_NamesTheFileTheWayThePdbRecordsIt(string assetPath, string expected) + { + string lookupPath = HotReloadSnapshotLookupPath.ToPdbLookupPath("/proj", assetPath, Packages, StringComparison.Ordinal); + + Assert.That(lookupPath, Is.EqualTo(expected)); + } + + /// + /// Verifies, for every source of every compilation assembly of this project, that the name + /// built from the package roots equals the one ScriptPackageRoots.ToPhysicalPath builds by + /// asking the Package Manager. + /// + [Test] + public void PdbLookupPath_FromPackageRoots_MatchesThePackageManagerAnswerForEverySource() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; + IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); + StringComparison comparison = HotReloadSnapshotCaptureInputs.CurrentPathComparison(); + List mismatches = new List(); + int checkedSources = 0; + int packageSources = 0; + + foreach (UnityCompilationAssembly compilationAssembly in HotReloadCompilationAssemblies.Current()) + { + foreach (string sourceFile in compilationAssembly.sourceFiles ?? Array.Empty()) + { + string assetPath = sourceFile.Replace('\\', '/'); + string expected = ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, assetPath); + string actual = HotReloadSnapshotLookupPath.ToPdbLookupPath( + compiledProjectRoot, + assetPath, + packageRoots, + comparison); + checkedSources++; + packageSources += expected == assetPath ? 0 : 1; + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + { + mismatches.Add(assetPath + ": package roots give " + actual + ", the Package Manager gives " + expected + "."); + } + } + } + + TestContext.WriteLine( + "Checked " + checkedSources + " sources, " + packageSources + " renamed by a package; " + + mismatches.Count + " mismatches."); + Assert.That(checkedSources, Is.GreaterThan(0), "No source was found."); + Assert.That(packageSources, Is.GreaterThan(0), "No source behind a package folder was found to compare."); + Assert.That( + mismatches, + Is.Empty, + "Checked " + checkedSources + " sources, " + packageSources + " renamed by a package; " + + mismatches.Count + " mismatches:\n" + + string.Join("\n", mismatches.GetRange(0, Math.Min(MaxListedMismatches, mismatches.Count)))); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs.meta new file mode 100644 index 0000000000..c628c599eb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotLookupPathTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5847b035ba7a34ddc867e05ea4877d82 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 860c5ccd70..52fd7ec157 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -180,7 +180,7 @@ public void LoadVerifiedSnapshotSource_ForPredefinedEditorAssembly_ReturnsOnDisk [Test] public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsTheVerifiedSource() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); string dllPath = Path.Combine( projectRoot, @@ -203,7 +203,7 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase() { VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown); string[] fields = @@ -509,7 +509,9 @@ private static HotReloadSnapshotSourceCheck SuspectsNothing(string root) "unused.dll", "unused.pdb", "unused", - new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments"))); + new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments")), + Array.Empty(), + StringComparison.Ordinal); } // Copies the fixture's real snapshot under a temporary root with its first byte flipped. diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs index 49203054ff..aa472aaf80 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotterTests.cs @@ -239,7 +239,9 @@ private static HotReloadSnapshotSourceCheck SuspectsNothing(string root) "unused.dll", "unused.pdb", "unused", - new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments"))); + new HotReloadPdbDocumentIndex(Path.Combine(root, "PdbDocuments")), + Array.Empty(), + StringComparison.Ordinal); } // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while diff --git a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs index 205d93bd26..e2f9dce991 100644 --- a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs +++ b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs @@ -55,7 +55,7 @@ public void TearDown() [Test] public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedFileBasis() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); PausePointResponse response = Enable(FixtureAssetPath, SecondStatementLine); @@ -69,7 +69,7 @@ public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedF [Test] public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAssetPath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); PausePointResponse response = Enable(FixturePhysicalPath, SecondStatementLine); @@ -82,7 +82,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAss [Test] public void Enable_PackageSourceGivenByItsAbsolutePhysicalPath_ArmsTheSameMarkerAsTheAssetPath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); PausePointResponse response = Enable(ToAbsolutePath(FixturePhysicalPath), SecondStatementLine); @@ -130,7 +130,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPathWithAShiftedSnapshot_MapsT [Test] public void Enable_AssetsSourceGivenByItsAbsolutePath_ArmsTheSameMarkerAsTheRelativePath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); int line = FindLineNumber(File.ReadAllText(ToAbsolutePath(AssetsFixturePath)), AssetsFixtureStatementText); Assert.That(line, Is.GreaterThan(0)); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 5c376c8d83..2c0b0e4cce 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -72,7 +72,8 @@ internal static HotReloadServices CreateProductionServicesWith( TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(HotReloadSourceSnapshotter.CaptureAfterDomainReload), + new HotReloadSourceSnapshotCapture( + () => HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread())), warmUp, compiledCallers); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs new file mode 100644 index 0000000000..8aa1f0701f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One compiled assembly as the snapshot capture sees it: its name, the asset paths of its + /// sources, and whether hot reload treats it as a read-only package's, which the capture skips. + /// + internal sealed class HotReloadCaptureAssembly + { + internal HotReloadCaptureAssembly( + string name, + IReadOnlyList sourceAssetPaths, + bool isImmutablePackageAssembly) + { + Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty."); + Debug.Assert(sourceAssetPaths != null, "sourceAssetPaths must not be null."); + + Name = name; + SourceAssetPaths = sourceAssetPaths; + IsImmutablePackageAssembly = isImmutablePackageAssembly; + } + + internal string Name { get; } + + internal IReadOnlyList SourceAssetPaths { get; } + + internal bool IsImmutablePackageAssembly { get; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs.meta new file mode 100644 index 0000000000..13d7c95b69 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f8521cce78a084bdab7ef458bca19dd3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs index 19977800f8..44f68a9648 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs @@ -97,8 +97,8 @@ internal static HotReloadCompiledSources FromDocuments( pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase, "pathComparison must be Ordinal or OrdinalIgnoreCase."); - string compilingRoot = NormalizeRoot(compilingProjectRoot); - string ownRoot = NormalizeRoot(projectRoot); + string compilingRoot = HotReloadPackageFolderMatch.NormalizeFolder(compilingProjectRoot); + string ownRoot = HotReloadPackageFolderMatch.NormalizeFolder(projectRoot); List packages = NormalizePackages(packageRoots, compilingRoot, ownRoot, pathComparison); List assetPaths = new List(); HashSet seen = new HashSet( @@ -177,8 +177,8 @@ private static bool TryMapProjectRelative( foreach (NormalizedPackageRoot package in packages) { int length = Math.Max( - PrefixLength(relativePath, package.RelativeToCompilingRoot, comparison), - PrefixLength(relativePath, package.RelativeToOwnRoot, comparison)); + HotReloadPackageFolderMatch.PrefixLength(relativePath, package.RelativeToCompilingRoot, comparison), + HotReloadPackageFolderMatch.PrefixLength(relativePath, package.RelativeToOwnRoot, comparison)); if (length > bestLength) { bestLength = length; @@ -196,7 +196,7 @@ private static bool TryMapAbsolute( StringComparison comparison, out MappedSource mapped) { - int rootLength = PrefixLength(absolutePath, compilingRoot, comparison); + int rootLength = HotReloadPackageFolderMatch.PrefixLength(absolutePath, compilingRoot, comparison); if (rootLength >= 0) { return TryMapProjectRelative(absolutePath.Substring(rootLength + 1), packages, comparison, out mapped); @@ -206,7 +206,7 @@ private static bool TryMapAbsolute( NormalizedPackageRoot best = default(NormalizedPackageRoot); foreach (NormalizedPackageRoot package in packages) { - int length = PrefixLength(absolutePath, package.ResolvedPath, comparison); + int length = HotReloadPackageFolderMatch.PrefixLength(absolutePath, package.ResolvedPath, comparison); if (length > bestLength) { bestLength = length; @@ -234,23 +234,6 @@ private static bool TryMapUnderPackage( return true; } - // The length of folder when path is inside it, so that the character after it is "/"; -1 - // otherwise. Why the "/": "/a/LocalPkg2/x.cs" must not count as inside "/a/LocalPkg". - private static int PrefixLength(string path, string folder, StringComparison comparison) - { - if (folder == null || path.Length <= folder.Length + 1) - { - return -1; - } - - if (path[folder.Length] != '/' || !path.StartsWith(folder, comparison)) - { - return -1; - } - - return folder.Length; - } - // Why not Path.IsPathRooted: on macOS and Linux it does not take "C:/proj/..." for rooted, // so a PDB written on Windows would lose its sources there. private static bool IsRooted(string url) @@ -277,12 +260,12 @@ private static List NormalizePackages( List packages = new List(packageRoots.Count); foreach (HotReloadSnapshotPackageRoot root in packageRoots) { - string resolvedPath = NormalizeRoot(root.ResolvedPath); + string resolvedPath = HotReloadPackageFolderMatch.NormalizeFolder(root.ResolvedPath); packages.Add(new NormalizedPackageRoot( resolvedPath, RelativeTo(resolvedPath, compilingRoot, comparison), RelativeTo(resolvedPath, ownRoot, comparison), - NormalizeRoot(root.AssetPath), + HotReloadPackageFolderMatch.NormalizeFolder(root.AssetPath), root.IsImmutable)); } @@ -292,13 +275,8 @@ private static List NormalizePackages( // The rest of path after root and "/", or null when path is not inside root. private static string RelativeTo(string path, string root, StringComparison comparison) { - int length = PrefixLength(path, root, comparison); + int length = HotReloadPackageFolderMatch.PrefixLength(path, root, comparison); return length < 0 ? null : path.Substring(length + 1); } - - private static string NormalizeRoot(string path) - { - return path.Replace('\\', '/').TrimEnd('/'); - } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs new file mode 100644 index 0000000000..1ed87b328f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Tells which package folder a slash-separated path lies in, and whether hot reload treats that + /// package's sources as read-only. Does no IO and asks nothing of the Package Manager, so it can + /// run on any thread. + /// + internal static class HotReloadPackageFolderMatch + { + /// + /// Returns the length of when is inside it, + /// so that the character after it is "/"; -1 otherwise, and -1 for a null folder. Why the + /// "/": "/a/LocalPkg2/x.cs" must not count as inside "/a/LocalPkg". + /// + internal static int PrefixLength(string path, string folder, StringComparison comparison) + { + Debug.Assert(path != null, "path must not be null."); + + if (folder == null || path.Length <= folder.Length + 1) + { + return -1; + } + + if (path[folder.Length] != '/' || !path.StartsWith(folder, comparison)) + { + return -1; + } + + return folder.Length; + } + + /// + /// Returns whether the asset path lies in a package hot reload treats as read-only: the + /// of the package whose asset path + /// contains it, or false when no package does, as for Assets. The same rule the PDB source + /// map applies to the first source of an assembly. + /// + internal static bool IsInImmutablePackage( + string slashNormalizedAssetPath, + IReadOnlyList packageRoots, + StringComparison comparison) + { + Debug.Assert(!string.IsNullOrEmpty(slashNormalizedAssetPath), "slashNormalizedAssetPath must not be null or empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + + int bestLength = -1; + bool bestIsImmutable = false; + foreach (HotReloadSnapshotPackageRoot root in packageRoots) + { + int length = PrefixLength(slashNormalizedAssetPath, NormalizeFolder(root.AssetPath), comparison); + if (length > bestLength) + { + bestLength = length; + bestIsImmutable = root.IsImmutable; + } + } + + return bestIsImmutable; + } + + /// + /// Returns the folder with forward slashes and without a trailing slash, the form + /// expects. + /// + internal static string NormalizeFolder(string path) + { + return path.Replace('\\', '/').TrimEnd('/'); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs.meta new file mode 100644 index 0000000000..2273eb0cf5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2eeca0b7f89a447aca825430aec932e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs new file mode 100644 index 0000000000..9fdc288ca5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Everything the snapshot capture needs from Unity, read once on the main thread and held as + /// values, so that the capture itself asks Unity nothing. + /// + internal sealed class HotReloadSnapshotCaptureInputs + { + internal HotReloadSnapshotCaptureInputs( + string projectRoot, + HotReloadCompileStart compileStart, + IReadOnlyList packageRoots, + StringComparison pathComparison, + IReadOnlyList assemblies, + long assemblyListingMs) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + Debug.Assert( + pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase, + "pathComparison must be Ordinal or OrdinalIgnoreCase."); + Debug.Assert(assemblies != null, "assemblies must not be null."); + + ProjectRoot = projectRoot; + CompileStart = compileStart; + PackageRoots = packageRoots; + PathComparison = pathComparison; + Assemblies = assemblies; + AssemblyListingMs = assemblyListingMs; + } + + /// The root of this Editor's project, which owns the snapshots. + internal string ProjectRoot { get; } + + /// When the compile behind the current assemblies started, as far as this session knows. + internal HotReloadCompileStart CompileStart { get; } + + /// The registered packages, with whether hot reload treats each as read-only. + internal IReadOnlyList PackageRoots { get; } + + /// How paths compare on this platform: case-insensitive on Windows only. + internal StringComparison PathComparison { get; } + + /// The compiled assemblies to capture. + internal IReadOnlyList Assemblies { get; } + + /// How long listing the assemblies took, in milliseconds. + internal long AssemblyListingMs { get; } + + /// + /// Reads the inputs from Unity. Main thread only: the compile start lives in SessionState, the + /// package roots come from the Package Manager, and the assemblies from + /// CompilationPipeline.GetAssemblies, all of which Unity allows on the main thread alone. + /// + internal static HotReloadSnapshotCaptureInputs ReadOnMainThread() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + HotReloadCompileStart compileStart = HotReloadCompileStartRecord.Read(); + IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); + StringComparison pathComparison = CurrentPathComparison(); + System.Diagnostics.Stopwatch listingWatch = System.Diagnostics.Stopwatch.StartNew(); + IReadOnlyList compilationAssemblies = HotReloadCompilationAssemblies.Current(); + listingWatch.Stop(); + List assemblies = new List(compilationAssemblies.Count); + foreach (UnityCompilationAssembly compilationAssembly in compilationAssemblies) + { + assemblies.Add(ToCaptureAssembly(compilationAssembly, packageRoots, pathComparison)); + } + + return new HotReloadSnapshotCaptureInputs( + projectRoot, + compileStart, + packageRoots, + pathComparison, + assemblies, + listingWatch.ElapsedMilliseconds); + } + + /// + /// Returns the comparison the package paths use on this platform, the same one + /// ScriptPackageRoots.ToPhysicalPath uses: case-insensitive on Windows only. + /// + internal static StringComparison CurrentPathComparison() + { + return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + + /// + /// Turns one of Unity's compilation assemblies into what the capture reads, classifying it by + /// its first source because an asmdef keeps one assembly inside one package. + /// + internal static HotReloadCaptureAssembly ToCaptureAssembly( + UnityCompilationAssembly compilationAssembly, + IReadOnlyList packageRoots, + StringComparison pathComparison) + { + Debug.Assert(compilationAssembly != null, "compilationAssembly must not be null."); + + string[] sourceFiles = compilationAssembly.sourceFiles ?? Array.Empty(); + bool isImmutable = sourceFiles.Length > 0 && HotReloadPackageFolderMatch.IsInImmutablePackage( + sourceFiles[0].Replace('\\', '/'), + packageRoots, + pathComparison); + return new HotReloadCaptureAssembly(compilationAssembly.name, sourceFiles, isImmutable); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs.meta new file mode 100644 index 0000000000..8416ac3dd3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6e7c8fd81a9e480698a38feff62c9fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs new file mode 100644 index 0000000000..239890c61c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Names a source the way its assembly's PDB records it, from package roots read earlier on the + /// main thread, so that the snapshot capture can check a copy against the PDB without asking the + /// Package Manager. + /// + internal static class HotReloadSnapshotLookupPath + { + /// + /// Returns the same path as ScriptPackageRoots.ToPhysicalPath: project-relative when the file + /// is inside , absolute when its package lives outside + /// it, and the input itself when no package owns the path, as for Assets. Does no IO. + /// + internal static string ToPdbLookupPath( + string compiledProjectRoot, + string slashNormalizedAssetPath, + IReadOnlyList packageRoots, + StringComparison comparison) + { + Debug.Assert(!string.IsNullOrEmpty(compiledProjectRoot), "compiledProjectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(slashNormalizedAssetPath), "slashNormalizedAssetPath must not be null or empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + + List roots = new List(packageRoots.Count); + foreach (HotReloadSnapshotPackageRoot packageRoot in packageRoots) + { + roots.Add(new ScriptPackageRoot(packageRoot.ResolvedPath, packageRoot.AssetPath)); + } + + return ScriptPathNormalizer.ToPhysicalProjectRelative( + slashNormalizedAssetPath, + compiledProjectRoot, + roots, + comparison); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs.meta new file mode 100644 index 0000000000..a667a3151d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotLookupPath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 35bdf8ef1cb2a4adf9cf78bac5b907b2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs index 63b40820c0..f2f9b96607 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -12,24 +15,31 @@ internal sealed class HotReloadSnapshotSourceCheck private readonly string _pdbPath; private readonly string _moduleVersionId; private readonly HotReloadPdbDocumentIndex _documentIndex; + private readonly IReadOnlyList _packageRoots; + private readonly StringComparison _pathComparison; internal HotReloadSnapshotSourceCheck( long suspectWritesFromUtcTicks, string dllPath, string pdbPath, string moduleVersionId, - HotReloadPdbDocumentIndex documentIndex) + HotReloadPdbDocumentIndex documentIndex, + IReadOnlyList packageRoots, + StringComparison pathComparison) { Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(moduleVersionId), "moduleVersionId must not be null or empty."); Debug.Assert(documentIndex != null, "documentIndex must not be null."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); SuspectWritesFromUtcTicks = suspectWritesFromUtcTicks; _dllPath = dllPath; _pdbPath = pdbPath; _moduleVersionId = moduleVersionId; _documentIndex = documentIndex; + _packageRoots = packageRoots; + _pathComparison = pathComparison; } /// @@ -40,15 +50,24 @@ internal HotReloadSnapshotSourceCheck( /// /// Returns whether the bytes match the checksum the compiled assembly's PDB recorded for the - /// source. A source the PDB has no usable checksum for is not confirmed. Main thread only. + /// source. A source the PDB has no usable checksum for is not confirmed. Asks nothing of the + /// Package Manager: the package roots are the ones the caller read on the main thread. /// internal bool IsCompiledSource(string projectRoot, string slashNormalizedRelativePath, byte[] sourceBytes) { + // Why the root that compiled the assembly: a Virtual Player reads the main project's + // assemblies, whose PDB names sources relative to the main project (see + // HotReloadSourceBaseline.CompareWithCompiledDocument). + string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; + string pdbLookupPath = HotReloadSnapshotLookupPath.ToPdbLookupPath( + compiledProjectRoot, + slashNormalizedRelativePath, + _packageRoots, + _pathComparison); // Why NoDocumentInPdb counts as not compiled: a file without method bodies has no document, // and a copy that cannot be confirmed is safer treated as edited than trusted. - return HotReloadSourceBaseline.CompareWithCompiledDocument( - projectRoot, - slashNormalizedRelativePath, + return HotReloadSourceBaseline.CompareWithCompiledDocumentAtLookupPath( + pdbLookupPath, _dllPath, _pdbPath, _moduleVersionId, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs index 5f052ad6e1..340cea85a8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -256,8 +256,9 @@ internal static HotReloadSnapshotMissReason CompareWithCompiledDocument( // Packages/ path. Why the Package Manager is asked rather than the package roots a // run captures: those live in the main hot-reload assembly, which this one cannot see, and // every caller already runs on the Unity main thread the Package Manager requires: a run's - // group step, the pause-point port, which the pause-point tools and a run's patch step call, - // and the snapshot capture after a domain reload. + // group step, and the pause-point port, which the pause-point tools and a run's patch step + // call. The snapshot capture names the source from package roots it was handed instead and + // calls the second stage directly, so that it asks the Package Manager nothing. // Why the root that compiled the assembly rather than the snapshot owner's: the PDB spells a // source inside the compiling project as a path relative to that project's root (behind a // leading "./"), which the lookup matches as the end of the url. A Multiplayer Play Mode @@ -267,6 +268,32 @@ internal static HotReloadSnapshotMissReason CompareWithCompiledDocument( // document ends with. For an ordinary project the two roots are the same. string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; string pdbLookupPath = ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, slashNormalizedRelativePath); + return CompareWithCompiledDocumentAtLookupPath( + pdbLookupPath, + targetDllPath, + pdbPath, + moduleVersionId, + sourceBytes, + documentIndex); + } + + /// + /// Answers the same as for a source already named + /// the way the PDB records it. Asks nothing of the Package Manager, so it can run off the + /// main thread. + /// + internal static HotReloadSnapshotMissReason CompareWithCompiledDocumentAtLookupPath( + string pdbLookupPath, + string targetDllPath, + string pdbPath, + string moduleVersionId, + byte[] sourceBytes, + HotReloadPdbDocumentIndex documentIndex) + { + Debug.Assert(!string.IsNullOrEmpty(pdbLookupPath), "pdbLookupPath must not be null or empty."); + Debug.Assert(sourceBytes != null, "sourceBytes must not be null."); + Debug.Assert(documentIndex != null, "documentIndex must not be null."); + if (!documentIndex.TryFindDocument( targetDllPath, pdbPath, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs index 62db464106..5a20342e70 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs @@ -15,7 +15,7 @@ internal static void CaptureIncrementally( string projectRoot, string assemblySnapshotDirectory, string previousSnapshotDirectory, - string[] sourceFiles, + IReadOnlyList sourceFiles, string assemblyName, HotReloadSnapshotSourceCheck check, HotReloadSnapshotCaptureStats stats) @@ -46,7 +46,7 @@ internal static void CaptureIncrementally( HashSet listedNames = new HashSet(StringComparer.Ordinal); SourceCopyTally tally = new SourceCopyTally(); - List manifestLines = new List(sourceFiles.Length); + List manifestLines = new List(sourceFiles.Count); foreach (string projectRelativeSourcePath in sourceFiles) { string normalizedRelativePath = projectRelativeSourcePath.Replace('\\', '/'); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs index 40886d6be1..407b3a22b4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs @@ -18,7 +18,7 @@ internal static partial class HotReloadSourceSnapshotCopier internal static void CaptureAtomically( string projectRoot, string assemblySnapshotDirectory, - string[] sourceFiles, + IReadOnlyList sourceFiles, string assemblyName, HotReloadSnapshotSourceCheck check, HotReloadSnapshotCaptureStats stats) @@ -39,7 +39,7 @@ internal static void CaptureAtomically( Directory.CreateDirectory(temporaryDirectory); stats.PublishTicks += prepareWatch.Elapsed.Ticks; SourceCopyTally tally = new SourceCopyTally(); - List manifestLines = new List(sourceFiles.Length); + List manifestLines = new List(sourceFiles.Count); foreach (string projectRelativeSourcePath in sourceFiles) { SourceCopyOutcome outcome = CopySourceFileByteExact( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 54d3b0e8d3..bdae7bfdc5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -8,7 +8,6 @@ using UnityEngine; -using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; using PackageManagerPackageInfo = UnityEditor.PackageManager.PackageInfo; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -27,51 +26,47 @@ internal static partial class HotReloadSourceSnapshotter /// 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). + /// Returns false when the inputs list no compilation assembly (as while Unity compiles). + /// Asks Unity nothing beyond what carries. /// - internal static bool CaptureAfterDomainReload() + internal static bool CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs inputs) { - System.Diagnostics.Stopwatch totalWatch = System.Diagnostics.Stopwatch.StartNew(); - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - System.Diagnostics.Stopwatch getAssembliesWatch = System.Diagnostics.Stopwatch.StartNew(); - IReadOnlyList assemblies = HotReloadCompilationAssemblies.Current(); - getAssembliesWatch.Stop(); - HotReloadSnapshotCaptureStats stats = CaptureAssemblies( - projectRoot, - assemblies, - HotReloadCompileStartRecord.Read(), - HotReloadPdbDocumentIndex.Shared); - totalWatch.Stop(); + Debug.Assert(inputs != null, "inputs must not be null."); + + System.Diagnostics.Stopwatch captureWatch = System.Diagnostics.Stopwatch.StartNew(); + HotReloadSnapshotCaptureStats stats = CaptureAssemblies(inputs, HotReloadPdbDocumentIndex.Shared); + captureWatch.Stop(); + // Why the listing time is added: the assemblies are now listed while the inputs are read, + // before this method runs, and totalMs has always included that listing. VibeLogger.LogInfo( HotReloadConstants.VibeLogSourceSnapshotBreakdown, "Hot reload source snapshot capture breakdown.", - stats.ToVibeLogContext(getAssembliesWatch.ElapsedMilliseconds, totalWatch.ElapsedMilliseconds)); - return assemblies.Count > 0; + stats.ToVibeLogContext( + inputs.AssemblyListingMs, + inputs.AssemblyListingMs + captureWatch.ElapsedMilliseconds)); + return inputs.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. + // Why separate from CaptureAfterDomainReload: taking the document index as an argument lets + // tests drive the per-assembly loop in a temporary root without writing the shared index. // Returns how long each phase took and how much the capture did. internal static HotReloadSnapshotCaptureStats CaptureAssemblies( - string projectRoot, - IEnumerable assemblies, - HotReloadCompileStart compileStart, + HotReloadSnapshotCaptureInputs inputs, HotReloadPdbDocumentIndex documentIndex) { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); - Debug.Assert(assemblies != null, "assemblies must not be null."); + Debug.Assert(inputs != null, "inputs must not be null."); Debug.Assert(documentIndex != null, "documentIndex must not be null."); - string snapshotRoot = HotReloadSourceSnapshotLayout.Root(projectRoot); + string snapshotRoot = HotReloadSourceSnapshotLayout.Root(inputs.ProjectRoot); Directory.CreateDirectory(snapshotRoot); HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats(); - foreach (UnityCompilationAssembly assembly in assemblies) + foreach (HotReloadCaptureAssembly assembly in inputs.Assemblies) { stats.Assemblies++; try { - CaptureAssemblyIfNeeded(projectRoot, snapshotRoot, assembly, compileStart, documentIndex, stats); + CaptureAssemblyIfNeeded(inputs, snapshotRoot, assembly, documentIndex, stats); } catch (Exception ex) when (IsSkippableAssemblyCaptureException(ex)) { @@ -80,7 +75,7 @@ internal static HotReloadSnapshotCaptureStats CaptureAssemblies( // Continuing preserves the fail-closed invariant because the method-diff baseline // rejects missing snapshots by PDB checksum rather than using them for an incorrect diff. UnityEngine.Debug.LogWarning( - $"[UnityCliLoop] Snapshot capture failed for assembly {assembly.name}: {ex.Message}"); + $"[UnityCliLoop] Snapshot capture failed for assembly {assembly.Name}: {ex.Message}"); } } @@ -97,21 +92,23 @@ ex is UnauthorizedAccessException || } private static void CaptureAssemblyIfNeeded( - string projectRoot, + HotReloadSnapshotCaptureInputs inputs, string snapshotRoot, - UnityCompilationAssembly assembly, - HotReloadCompileStart compileStart, + HotReloadCaptureAssembly assembly, HotReloadPdbDocumentIndex documentIndex, HotReloadSnapshotCaptureStats stats) { - string[] sourceFiles = assembly.sourceFiles; - if (sourceFiles == null || sourceFiles.Length == 0) + IReadOnlyList sourceFiles = assembly.SourceAssetPaths; + if (sourceFiles.Count == 0) { return; } + string projectRoot = inputs.ProjectRoot; + // Why only a read: the inputs classified the assembly from the package roots, so the + // capture no longer asks the Package Manager here. System.Diagnostics.Stopwatch packageLookupWatch = System.Diagnostics.Stopwatch.StartNew(); - bool immutable = ShouldSkipImmutablePackageSources(sourceFiles); + bool immutable = assembly.IsImmutablePackageAssembly; stats.PackageLookupTicks += packageLookupWatch.Elapsed.Ticks; if (immutable) { @@ -122,8 +119,8 @@ private static void CaptureAssemblyIfNeeded( // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main // project for a Virtual Player; its snapshots stay under its own root. CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); - string dllPath = layout.DllPath(assembly.name); - string pdbPath = layout.PdbPath(assembly.name); + string dllPath = layout.DllPath(assembly.Name); + string pdbPath = layout.PdbPath(assembly.Name); if (!File.Exists(dllPath) || !File.Exists(pdbPath)) { return; @@ -132,7 +129,7 @@ private static void CaptureAssemblyIfNeeded( FileInfo dllInfo = new FileInfo(dllPath); long dllMtimeTicks = dllInfo.LastWriteTimeUtc.Ticks; long dllByteLength = dllInfo.Length; - string stampPath = StampPath(snapshotRoot, assembly.name); + string stampPath = StampPath(snapshotRoot, assembly.Name); // Why stamp short-circuits Cecil: a false stamp (identical mtime+length with different // bytes) is vanishingly rare, and even then LoadVerifiedSnapshotSource rejects via PDB @@ -149,20 +146,22 @@ private static void CaptureAssemblyIfNeeded( 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); + string assemblySnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assembly.Name, mvid); if (!Directory.Exists(assemblySnapshotDirectory)) { HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck( - compileStart.SuspectWritesFrom(dllMtimeTicks), + inputs.CompileStart.SuspectWritesFrom(dllMtimeTicks), dllPath, pdbPath, mvid, - documentIndex); + documentIndex, + inputs.PackageRoots, + inputs.PathComparison); System.Diagnostics.Stopwatch previousWatch = System.Diagnostics.Stopwatch.StartNew(); string previousSnapshotDirectory = FindReusablePreviousSnapshotDirectory( projectRoot, stampPath, - assembly.name, + assembly.Name, assemblySnapshotDirectory); stats.StampTicks += previousWatch.Elapsed.Ticks; if (previousSnapshotDirectory == null) @@ -171,7 +170,7 @@ private static void CaptureAssemblyIfNeeded( projectRoot, assemblySnapshotDirectory, sourceFiles, - assembly.name, + assembly.Name, check, stats); } @@ -182,14 +181,14 @@ private static void CaptureAssemblyIfNeeded( assemblySnapshotDirectory, previousSnapshotDirectory, sourceFiles, - assembly.name, + assembly.Name, check, stats); } stats.AssembliesCaptured++; System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew(); - DeleteStaleSnapshotDirectories(snapshotRoot, assembly.name, assemblySnapshotDirectory); + DeleteStaleSnapshotDirectories(snapshotRoot, assembly.Name, assemblySnapshotDirectory); stats.CleanupTicks += cleanupWatch.Elapsed.Ticks; } diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index b750dc519d..3ca5cb287c 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -103,8 +103,11 @@ command ran against: 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. + - `getAssembliesMs`: listing the compilation assemblies from Unity, which happens while the + capture's inputs are read on the main thread, before any copying starts. + - `packageLookupMs`: reading whether each assembly's sources are immutable. The inputs decide + that from the package roots they read from Package Manager once, so this is close to 0; it + no longer asks Package Manager for each assembly. - `stampMs`: reading and writing the stamps beside the snapshots, and reading the previous build's manifest. - `mvidMs`: reading the MVID of each compiled dll whose stamp did not match. @@ -117,7 +120,8 @@ command ran against: previous build's directory there, writing the manifest, and moving the directory into place. - `cleanupMs`: deleting the snapshot directories of earlier builds, and the copies of sources the assembly no longer lists. - - `totalMs`: the whole capture. + - `totalMs`: listing the assemblies plus the whole per-assembly loop. Reading the compile start + and the package roots for the inputs is not counted here. - `assemblies`: the compilation assemblies listed. - `assembliesImmutable`: those skipped because their sources belong to an immutable package. - `assembliesUnchanged`: those skipped because their stamp matched the compiled dll. @@ -126,7 +130,7 @@ command ran against: - `filesCopied` and `bytesCopied`: the sources copied and their total size. - `filesChecked`: the sources checked against the PDB. The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry - also includes writing this breakdown entry. + also includes reading the compile start and the package roots, and writing this breakdown entry. 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 2f72bb1a342da26205ae65110801561eb75a3db7 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 12 Oct 2026 00:54:03 +0900 Subject: [PATCH 09/15] perf: Hot reload's source snapshot capture lists assemblies from the compiled files instead of asking Unity on the main thread (#3283) --- ...HotReloadCaptureAssemblyEnumeratorTests.cs | 163 ++++++++++++ ...loadCaptureAssemblyEnumeratorTests.cs.meta | 11 + ...otReloadIncrementalSnapshotCaptureTests.cs | 29 +-- ...tReloadSnapshotAssemblyEnumerationTests.cs | 244 ++++++++++++------ .../HotReloadSnapshotCaptureInputsTests.cs | 137 +--------- .../HotReload/HotReloadSourceSnapshotTests.cs | 4 +- .../HotReload/HotReloadEditorStartup.cs | 5 + .../HotReloadCaptureAssemblyEnumerator.cs | 99 +++++++ ...HotReloadCaptureAssemblyEnumerator.cs.meta | 11 + .../Shared/HotReloadCompilationAssemblies.cs | 13 +- .../HotReloadCompilationAssemblyCache.cs | 8 + .../HotReload/Shared/HotReloadConstants.cs | 1 + .../Shared/HotReloadPackageFolderMatch.cs | 35 +-- .../Shared/HotReloadSnapshotCaptureInputs.cs | 63 +---- .../Shared/HotReloadSnapshotCaptureStats.cs | 9 +- .../Shared/HotReloadSourceSnapshotLayout.cs | 12 + .../HotReloadSourceSnapshotter.Stamp.cs | 20 ++ .../Shared/HotReloadSourceSnapshotter.cs | 165 ++++++++---- docs/hot-reload.md | 5 +- docs/vibe-logs.md | 40 +-- 20 files changed, 682 insertions(+), 392 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs new file mode 100644 index 0000000000..1b0cca35ee --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers listing the compiled assemblies from the dlls and PDBs on disk, and, on this project's + /// real assemblies, that the listing and the sources read from each PDB agree with what Unity's + /// compilation pipeline and the Package Manager report. + /// + public sealed class HotReloadCaptureAssemblyEnumeratorTests + { + private const int MaxListedMismatches = 5; + + private string _projectRoot; + + [SetUp] + public void SetUp() + { + _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(_projectRoot, "Assets")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectRoot)) + { + Directory.Delete(_projectRoot, true); + } + } + + /// + /// Verifies a dll without a PDB beside it is not listed, and the dlls that have one are listed + /// in ordinal order with their paths. + /// + [Test] + public void ListCompiled_DllWithoutPdb_IsNotListed() + { + // Why not Path.Combine with the "Library/ScriptAssemblies" constant: on Windows that keeps + // the "/" inside the constant, while the listing returns paths with "\" throughout. + string directory = CompiledAssemblyLayout.Resolve(_projectRoot).CompiledAssembliesDirectory; + Directory.CreateDirectory(directory); + WriteEmptyFiles(directory, "b.dll", "b.pdb", "A.dll", "A.pdb", "NoPdb.dll", "notes.txt"); + + IReadOnlyList candidates = + HotReloadCaptureAssemblyEnumerator.ListCompiled(_projectRoot); + + Assert.That(candidates.Select(candidate => candidate.Name), Is.EqualTo(new[] { "A", "b" })); + Assert.That(candidates[0].DllPath, Is.EqualTo(Path.Combine(directory, "A.dll"))); + Assert.That(candidates[0].PdbPath, Is.EqualTo(Path.Combine(directory, "A.pdb"))); + } + + /// + /// Verifies a project whose compiled assemblies directory does not exist lists nothing. + /// + [Test] + public void ListCompiled_WithoutTheCompiledAssembliesDirectory_ListsNothing() + { + IReadOnlyList candidates = + HotReloadCaptureAssemblyEnumerator.ListCompiled(_projectRoot); + + Assert.That(candidates, Is.Empty); + } + + /// + /// Verifies, on this project, that the listed assemblies are the compilation assemblies Unity + /// lists with a PDB beside their dll, that each read-only classification is the Package + /// Manager's, and that for each editable one the sources read from the PDB are Unity's source files. + /// + [Test] + public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAssemblies() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread(); + StringComparer comparer = HotReloadSourcePathNormalizer.ProjectRelativePathComparer(); + Dictionary fromUnity = new Dictionary(StringComparer.Ordinal); + foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current()) + { + if (File.Exists(layout.DllPath(assembly.name)) && File.Exists(layout.PdbPath(assembly.name))) + { + fromUnity[assembly.name] = assembly; + } + } + + IReadOnlyList candidates = HotReloadCaptureAssemblyEnumerator.ListCompiled(projectRoot); + List mismatches = new List(); + int immutableAssemblies = 0; + int editableSources = 0; + foreach (HotReloadCaptureAssemblyCandidate candidate in candidates) + { + if (!fromUnity.TryGetValue(candidate.Name, out UnityCompilationAssembly unityAssembly)) + { + mismatches.Add(candidate.Name + ": listed from disk but not by Unity."); + continue; + } + + HotReloadCaptureAssembly assembly = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, inputs); + string[] sourceFiles = unityAssembly.sourceFiles; + bool expectedImmutable = sourceFiles.Length > 0 + && HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); + immutableAssemblies += expectedImmutable ? 1 : 0; + if (assembly.IsImmutablePackageAssembly != expectedImmutable) + { + mismatches.Add(candidate.Name + ": read-only is " + assembly.IsImmutablePackageAssembly + + " but the Package Manager says " + expectedImmutable + "."); + continue; + } + + // Why the sources of a read-only package's assembly are not compared: the capture + // copies none of them, and some such packages list files their PDB does not. + if (expectedImmutable) + { + continue; + } + + editableSources += assembly.SourceAssetPaths.Count; + HashSet fromPdb = new HashSet(assembly.SourceAssetPaths, comparer); + if (!fromPdb.SetEquals(sourceFiles.Select(HotReloadSourcePathNormalizer.ToForwardSlashes))) + { + mismatches.Add(candidate.Name + ": the sources differ from Unity's."); + } + } + + foreach (string name in fromUnity.Keys) + { + if (!candidates.Any(candidate => candidate.Name == name)) + { + mismatches.Add(name + ": listed by Unity but not from disk."); + } + } + + string summary = "Listed " + candidates.Count + " assemblies (Unity: " + fromUnity.Count + "), " + + immutableAssemblies + " read-only, " + editableSources + " sources of editable ones; " + mismatches.Count + " mismatches"; + TestContext.WriteLine(summary + "."); + Assert.That(candidates.Count, Is.GreaterThan(0), "No compiled assembly with a PDB was listed."); + Assert.That(immutableAssemblies, Is.GreaterThan(0), "No read-only package assembly was found to compare."); + Assert.That( + mismatches, + Is.Empty, + summary + ":\n" + string.Join("\n", mismatches.Take(MaxListedMismatches))); + } + + private static void WriteEmptyFiles(string directory, params string[] fileNames) + { + foreach (string fileName in fileNames) + { + File.WriteAllBytes(Path.Combine(directory, fileName), Array.Empty()); + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta new file mode 100644 index 0000000000..68ad3f3e4a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 97fe28077814042f3851e56cd050e57f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs index 13135c996b..aac139d38f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs @@ -4,18 +4,15 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using NUnit.Framework; -using UnityEditor.Compilation; - using UnityEngine; using UnityEngine.TestTools; using io.github.hatayama.UnityCliLoop.FirstPartyTools; -using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; - namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// @@ -525,32 +522,20 @@ private HotReloadSnapshotCaptureStats CaptureIncrementally(HotReloadSnapshotSour return stats; } + // Why a reader that names the sources: the planted PDB is this test assembly's, whose sources + // live under the real project, so none of them maps to an asset path under the temporary root. private HotReloadSnapshotCaptureStats CaptureAssemblies(params string[] sources) { - UnityCompilationAssembly assembly = new UnityCompilationAssembly( - AssemblyName, - HotReloadConstants.ScriptAssembliesRelativeDirectory + "/" + AssemblyName + HotReloadConstants.CompiledAssemblyExtension, - sources, - Array.Empty(), - Array.Empty(), - Array.Empty(), - AssemblyFlags.EditorAssembly); HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( _root, HotReloadCompileStart.Unknown, Array.Empty(), - StringComparison.Ordinal, - new[] - { - HotReloadSnapshotCaptureInputs.ToCaptureAssembly( - assembly, - Array.Empty(), - StringComparison.Ordinal) - }, - 0); + StringComparison.Ordinal); return HotReloadSourceSnapshotter.CaptureAssemblies( inputs, - new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments"))); + new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")), + (candidate, capturedInputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false), + CancellationToken.None); } // A check whose suspect window starts at the planted compile start, against the given build. diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index 116440f3a0..0c3b551d85 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.IO; using System.Text.RegularExpressions; +using System.Threading; using NUnit.Framework; @@ -65,9 +66,7 @@ public void CaptureAssemblies_WithoutStamp_CapturesSourcesRemovesStaleSnapshotAn string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staleDirectory); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True); Assert.That(Directory.Exists(staleDirectory), Is.False); @@ -85,9 +84,7 @@ public void CaptureAssemblies_WhenStampMatches_SkipsCapture() string stamp = ExpectedStamp(dllPath, mvid); File.WriteAllText(StampPath("Fixture"), stamp); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(Directory.Exists(Path.Combine(_snapshotRoot, "Fixture-" + mvid)), Is.False); Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(stamp)); @@ -107,9 +104,7 @@ public void CaptureAssemblies_WhenMvidDirectoryExists_KeepsSnapshotsAndWritesSta string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staleDirectory); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(Directory.GetFiles(currentDirectory), Is.Empty); Assert.That(Directory.Exists(staleDirectory), Is.True); @@ -147,9 +142,7 @@ public void CaptureAssemblies_WhenStampIsMalformedOrDiffers_CapturesAgain(string .Replace("{length}", length.ToString(CultureInfo.InvariantCulture)); File.WriteAllText(StampPath("Fixture"), stamp); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.True); Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(ExpectedStamp(dllPath, mvid))); @@ -175,9 +168,7 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories() Directory.CreateDirectory(sibling); Directory.CreateDirectory(nonMvid); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(Directory.Exists(orphanTemporary), Is.False); Assert.That(Directory.Exists(stale), Is.False); @@ -187,7 +178,9 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories() } /// - /// Verifies an assembly without sources is skipped before anything is written: no snapshot directory and no stamp. + /// Verifies an assembly whose PDB names no source that maps to an asset path is skipped before + /// anything is written: no snapshot directory, no stamp and no marker. The planted PDB is this + /// test assembly's, whose sources live under the real project and so map to nothing here. /// [Test] public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing() @@ -195,35 +188,132 @@ public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing() string dllPath = PlantCompiledAssembly("NoSources"); string mvid = HotReloadAssemblyMvid.Read(dllPath); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssemblyWithSources("NoSources", Array.Empty()) }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, HotReloadCaptureAssemblyEnumerator.ReadSources); Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "NoSources", mvid)), Is.False); Assert.That(File.Exists(StampPath("NoSources")), Is.False); + Assert.That(File.Exists(MarkerPath("NoSources")), Is.False); } /// - /// Verifies an assembly the inputs classified as an immutable package's is counted and skipped - /// before anything is written, though its DLL and PDB are there: no snapshot directory and no stamp. + /// Verifies a read-only package's marker that matches the compiled dll skips the assembly + /// without reading its PDB, and counts it as read-only. /// [Test] - public void CaptureAssemblies_ImmutablePackageAssembly_CountsItAndWritesNothing() + public void CaptureAssemblies_ImmutableMarkerMatchingTheDll_SkipsWithoutReadingThePdb() { - PlantCompiledAssembly("Fixture"); - HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( + string dllPath = PlantCompiledAssembly("Fixture"); + File.WriteAllText(MarkerPath("Fixture"), DllStampText("immutable", dllPath, 0)); + int reads = 0; + + HotReloadSnapshotCaptureStats stats = Capture( _projectRoot, HotReloadCompileStart.Unknown, - Array.Empty(), - StringComparison.Ordinal, - new[] { new HotReloadCaptureAssembly("Fixture", new[] { SourceRelativePath }, true) }, - 0); - - HotReloadSnapshotCaptureStats stats = HotReloadSourceSnapshotter.CaptureAssemblies(inputs, CreateDocumentIndex()); + CountingReads(() => reads++, false)); + Assert.That(reads, Is.EqualTo(0), "PDB reads"); Assert.That(stats.AssembliesImmutable, Is.EqualTo(1), "immutable"); + Assert.That(File.Exists(StampPath("Fixture")), Is.False); + } + + /// + /// Verifies a stamp that matches the compiled dll skips the assembly without reading its PDB. + /// + [Test] + public void CaptureAssemblies_UnchangedStamp_SkipsWithoutReadingThePdb() + { + string dllPath = PlantCompiledAssembly("Fixture"); + File.WriteAllText(StampPath("Fixture"), ExpectedStamp(dllPath, HotReloadAssemblyMvid.Read(dllPath))); + int reads = 0; + + HotReloadSnapshotCaptureStats stats = Capture( + _projectRoot, + HotReloadCompileStart.Unknown, + CountingReads(() => reads++, false)); + + Assert.That(reads, Is.EqualTo(0), "PDB reads"); + Assert.That(stats.AssembliesUnchanged, Is.EqualTo(1), "unchanged"); + } + + /// + /// Verifies an assembly whose PDB says it is a read-only package's gets a marker recording its + /// dll and nothing else: no snapshot directory and no stamp. The next capture of the same dll + /// does not read the PDB again. + /// + [Test] + public void CaptureAssemblies_ImmutablePackageAssembly_WritesTheMarkerAndCopiesNothing() + { + string dllPath = PlantCompiledAssembly("Fixture"); + int reads = 0; + + HotReloadSnapshotCaptureStats first = Capture( + _projectRoot, + HotReloadCompileStart.Unknown, + CountingReads(() => reads++, true)); + HotReloadSnapshotCaptureStats second = Capture( + _projectRoot, + HotReloadCompileStart.Unknown, + CountingReads(() => reads++, true)); + + Assert.That(File.ReadAllText(MarkerPath("Fixture")), Is.EqualTo(DllStampText("immutable", dllPath, 0))); Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty); Assert.That(File.Exists(StampPath("Fixture")), Is.False); + Assert.That(first.AssembliesImmutable, Is.EqualTo(1), "first: immutable"); + Assert.That(second.AssembliesImmutable, Is.EqualTo(1), "second: immutable"); + Assert.That(reads, Is.EqualTo(1), "PDB reads over both captures"); + } + + /// + /// Verifies a marker left from an earlier dll, as when a package was made editable and its + /// assembly rebuilt, does not skip the assembly: its PDB is read and its sources are captured. + /// + [Test] + public void CaptureAssemblies_StaleImmutableMarker_IsIgnoredWhenTheDllChanged() + { + string dllPath = PlantCompiledAssembly("Fixture"); + string mvid = HotReloadAssemblyMvid.Read(dllPath); + File.WriteAllText(MarkerPath("Fixture"), DllStampText("immutable", dllPath, -1)); + int reads = 0; + + HotReloadSnapshotCaptureStats stats = Capture( + _projectRoot, + HotReloadCompileStart.Unknown, + CountingReads(() => reads++, false)); + + Assert.That(reads, Is.EqualTo(1), "PDB reads"); + Assert.That(stats.AssembliesCaptured, Is.EqualTo(1), "captured"); + Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True); + } + + /// + /// Verifies an assembly whose PDB belongs to another build of its dll, as while a compile + /// replaces them, is skipped and counted without a stamp, while the assembly after it is captured. + /// + [Test] + public void CaptureAssemblies_PdbOfAnotherBuild_SkipsThatAssemblyAndCapturesTheRest() + { + string otherBuildDll = PlantCompiledAssembly("Another"); + string otherAssemblyPdb = Path.ChangeExtension(typeof(HotReloadSourceSnapshotter).Assembly.Location, ".pdb"); + File.Copy(otherAssemblyPdb, Path.ChangeExtension(otherBuildDll, ".pdb"), true); + string dllPath = PlantCompiledAssembly("Fixture"); + string mvid = HotReloadAssemblyMvid.Read(dllPath); + + // Why the real document table is read before the sources are named: it is what checks + // that the PDB belongs to the dll. + HotReloadSnapshotCaptureStats stats = Capture( + _projectRoot, + HotReloadCompileStart.Unknown, + (candidate, inputs) => + { + HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath); + return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, false); + }); + + Assert.That(stats.AssembliesSkippedPdbMismatch, Is.EqualTo(1), "skipped for another build's PDB"); + Assert.That(File.Exists(StampPath("Another")), Is.False); + Assert.That(Directory.GetDirectories(_snapshotRoot, "Another-*"), Is.Empty); + Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True); + Assert.That(File.Exists(StampPath("Fixture")), Is.True); } /// @@ -236,9 +326,7 @@ public void CaptureAssemblies_WhenCompiledOutputIsMissing_WritesNothingAndDoesNo string dllPath = PlantCompiledAssembly("Fixture"); File.Delete(Path.ChangeExtension(dllPath, missingExtension)); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty); Assert.That(File.Exists(StampPath("Fixture")), Is.False); @@ -260,9 +348,7 @@ public void CaptureAssemblies_WhenStampMatches_DoesNotOpenTheCompiledAssembly() string stamp = ExpectedStamp(dllPath, mvid); File.WriteAllText(StampPath("Fixture"), stamp); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.False); Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(stamp)); @@ -286,9 +372,7 @@ public void CaptureAssemblies_WhenOneAssemblyCannotBeRead_WarnsAndCapturesTheRes string mvid = HotReloadAssemblyMvid.Read(dllPath); LogAssert.Expect(LogType.Warning, new Regex("Snapshot capture failed for assembly Broken")); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Broken"), CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); Assert.That(File.Exists(StampPath("Broken")), Is.False); Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True); @@ -347,9 +431,7 @@ public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSna File.SetLastWriteTimeUtc(playerFixturePath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); string mvid = HotReloadAssemblyMvid.Read(dllPath); - HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(playerRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + Capture(playerRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath)); string playerSnapshotSource = HotReloadSourceSnapshotLayout.SourcePath( HotReloadSourceSnapshotLayout.AssemblyDirectory(playerRoot, "Fixture", mvid), @@ -414,17 +496,11 @@ public void CaptureAssemblies_ReturnsWhatItCopiedAndWhatItSkipped() PlantCompiledAssembly("Fixture"); WriteSourceWrittenBeforeTheCompile("Assets/Three.cs", "abc"); WriteSourceWrittenBeforeTheCompile("Assets/Five.cs", "abcde"); - UnityCompilationAssembly[] assemblies = - { - CreateAssemblyWithSources("Fixture", new[] { "Assets/Three.cs", "Assets/Five.cs", "Assets/Missing.cs" }) - }; + Func assemblies = + ReadsSources("Assets/Three.cs", "Assets/Five.cs", "Assets/Missing.cs"); - HotReloadSnapshotCaptureStats first = HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, assemblies, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); - HotReloadSnapshotCaptureStats second = HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, assemblies, HotReloadCompileStart.Unknown), - CreateDocumentIndex()); + HotReloadSnapshotCaptureStats first = Capture(_projectRoot, HotReloadCompileStart.Unknown, assemblies); + HotReloadSnapshotCaptureStats second = Capture(_projectRoot, HotReloadCompileStart.Unknown, assemblies); Assert.That(first.Assemblies, Is.EqualTo(1), "first: assemblies"); Assert.That(first.AssembliesCaptured, Is.EqualTo(1), "first: captured"); @@ -450,9 +526,7 @@ private string CaptureWithSourceSavedBetweenStartAndDll( new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); string mvid = HotReloadAssemblyMvid.Read(dllPath); - stats = HotReloadSourceSnapshotter.CaptureAssemblies( - CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, compileStart), - CreateDocumentIndex()); + stats = Capture(_projectRoot, compileStart, ReadsSources(SourceRelativePath)); return Path.Combine(_snapshotRoot, "Fixture-" + mvid); } @@ -508,29 +582,53 @@ private static UnityCompilationAssembly CreateAssemblyWithSources(string assembl AssemblyFlags.EditorAssembly); } - // The capture's inputs for a temporary root: no package is registered there, so every - // assembly reads as editable. - private static HotReloadSnapshotCaptureInputs CreateInputs( + // Captures the compiled assemblies planted under the root, with a PDB source reader the test + // chooses. No package is registered under a temporary root, so every path reads as editable. + private HotReloadSnapshotCaptureStats Capture( string projectRoot, - IEnumerable assemblies, - HotReloadCompileStart compileStart) + HotReloadCompileStart compileStart, + Func readSources) { - List captureAssemblies = new List(); - foreach (UnityCompilationAssembly assembly in assemblies) - { - captureAssemblies.Add(HotReloadSnapshotCaptureInputs.ToCaptureAssembly( - assembly, - Array.Empty(), - StringComparison.Ordinal)); - } - - return new HotReloadSnapshotCaptureInputs( + HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( projectRoot, compileStart, Array.Empty(), - StringComparison.Ordinal, - captureAssemblies, - 0); + StringComparison.Ordinal); + return HotReloadSourceSnapshotter.CaptureAssemblies(inputs, CreateDocumentIndex(), readSources, CancellationToken.None); + } + + // A PDB source reader that names the given sources for every assembly. Why not the real one: + // the planted PDB is this test assembly's, whose sources live under the real project, so + // none of them maps to an asset path under a temporary root. + private static Func ReadsSources( + params string[] sources) + { + return (candidate, inputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false); + } + + // A PDB source reader that counts its calls and names the fixture source, with the read-only + // classification the test chooses. + private static Func CountingReads( + Action onRead, + bool isImmutablePackageAssembly) + { + return (candidate, inputs) => + { + onRead(); + return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, isImmutablePackageAssembly); + }; + } + + private string MarkerPath(string assemblyName) + { + return HotReloadSourceSnapshotLayout.ImmutableMarkerPath(_projectRoot, assemblyName); + } + + // The stamp form for the dll: the tag, then its write time shifted by the given ticks, then its length. + private static string DllStampText(string tag, string dllPath, long mtimeOffsetTicks) + { + FileInfo dllInfo = new FileInfo(dllPath); + return tag + "," + (dllInfo.LastWriteTimeUtc.Ticks + mtimeOffsetTicks) + "," + dllInfo.Length; } private string SnapshotSourcePath(string assemblyName, string mvid) diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs index 41f7ac0e8c..804fe5b8cc 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs @@ -1,158 +1,29 @@ -using System; -using System.Collections.Generic; using System.IO; using NUnit.Framework; -using UnityEditor.Compilation; - using UnityEngine; using io.github.hatayama.UnityCliLoop.FirstPartyTools; -using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; - namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// Covers the values the snapshot capture reads from Unity before it starts: how one of Unity's - /// compilation assemblies is classified from the package roots, and, on this project's real - /// assemblies, that the classification agrees with the Package Manager's answer. + /// Covers the values the snapshot capture reads from Unity on the main thread before it starts. /// public sealed class HotReloadSnapshotCaptureInputsTests { - private const int MaxListedMismatches = 5; - - private static readonly HotReloadSnapshotPackageRoot[] Packages = - { - new HotReloadSnapshotPackageRoot("/proj/Packages/src", "Packages/com.example.embedded", false), - new HotReloadSnapshotPackageRoot("/elsewhere/LocalPkg", "Packages/com.example.local", false), - new HotReloadSnapshotPackageRoot("/proj/Library/PackageCache/com.example.reg@1.0.0", "Packages/com.example.reg", true), - }; - - /// - /// Verifies an assembly whose first source lies in an immutable package is classified as - /// immutable, and keeps its name and sources. - /// - [Test] - public void ToCaptureAssembly_FirstSourceInAnImmutablePackage_IsImmutable() - { - HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs"); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.True); - Assert.That(assembly.Name, Is.EqualTo("Fixture")); - Assert.That(assembly.SourceAssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs" })); - } - - /// - /// Verifies sources of an embedded package, a local package and Assets are editable. - /// - [TestCase("Packages/com.example.embedded/Editor/A.cs")] - [TestCase("Packages/com.example.local/A.cs")] - [TestCase("Assets/A.cs")] - public void ToCaptureAssembly_FirstSourceEditable_IsNotImmutable(string firstSource) - { - HotReloadCaptureAssembly assembly = Classify(firstSource); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.False); - } - - /// - /// Verifies a package whose name only starts with an immutable package's name is not taken for it. - /// - [Test] - public void ToCaptureAssembly_PackageNameExtendingAnImmutableOne_IsNotImmutable() - { - HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg2/A.cs"); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.False); - } - - /// - /// Verifies an assembly without sources is classified as editable with an empty source list. - /// - [Test] - public void ToCaptureAssembly_WithoutSources_IsNotImmutableAndHasNoSources() - { - HotReloadCaptureAssembly assembly = Classify(); - - Assert.That(assembly.IsImmutablePackageAssembly, Is.False); - Assert.That(assembly.SourceAssetPaths, Is.Empty); - } - - /// - /// Verifies, for every compilation assembly of this project that has sources, that the - /// classification from the package roots equals the Package Manager's answer the capture - /// asked before. - /// - [Test] - public void ToCaptureAssembly_FromPackageRoots_MatchesThePackageManagerAnswerForEveryAssembly() - { - IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); - StringComparison comparison = HotReloadSnapshotCaptureInputs.CurrentPathComparison(); - List mismatches = new List(); - int checkedAssemblies = 0; - int immutableAssemblies = 0; - - foreach (UnityCompilationAssembly compilationAssembly in HotReloadCompilationAssemblies.Current()) - { - string[] sourceFiles = compilationAssembly.sourceFiles; - if (sourceFiles == null || sourceFiles.Length == 0) - { - continue; - } - - checkedAssemblies++; - bool expected = HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); - immutableAssemblies += expected ? 1 : 0; - bool actual = HotReloadSnapshotCaptureInputs - .ToCaptureAssembly(compilationAssembly, packageRoots, comparison) - .IsImmutablePackageAssembly; - if (actual != expected) - { - mismatches.Add(compilationAssembly.name + " (" + sourceFiles[0] + "): package roots say " - + actual + ", the Package Manager says " + expected + "."); - } - } - - TestContext.WriteLine( - "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; " - + mismatches.Count + " mismatches."); - Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compilation assembly with sources was found."); - Assert.That(immutableAssemblies, Is.GreaterThan(0), "No immutable package assembly was found to compare."); - Assert.That( - mismatches, - Is.Empty, - "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; " - + mismatches.Count + " mismatches:\n" - + string.Join("\n", mismatches.GetRange(0, Math.Min(MaxListedMismatches, mismatches.Count)))); - } - /// - /// Verifies the inputs read on the main thread name this project's root and carry one entry - /// for each compilation assembly Unity lists. + /// Verifies the inputs read on the main thread name this project's root and carry the + /// registered packages. /// [Test] - public void ReadOnMainThread_CarriesTheProjectRootAndEveryCompilationAssembly() + public void ReadOnMainThread_CarriesTheProjectRootAndThePackageRoots() { HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread(); Assert.That(inputs.ProjectRoot, Is.EqualTo(Path.GetFullPath(Path.Combine(Application.dataPath, "..")))); - Assert.That(inputs.Assemblies.Count, Is.EqualTo(HotReloadCompilationAssemblies.Current().Count)); Assert.That(inputs.PackageRoots, Is.Not.Empty); } - - private static HotReloadCaptureAssembly Classify(params string[] sourceFiles) - { - UnityCompilationAssembly compilationAssembly = new UnityCompilationAssembly( - "Fixture", - "Library/ScriptAssemblies/Fixture.dll", - sourceFiles, - Array.Empty(), - Array.Empty(), - Array.Empty(), - AssemblyFlags.EditorAssembly); - return HotReloadSnapshotCaptureInputs.ToCaptureAssembly(compilationAssembly, Packages, StringComparison.Ordinal); - } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 52fd7ec157..8c4067726a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -208,9 +208,9 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase() JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown); string[] fields = { - "getAssembliesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs", + "getAssembliesMs", "pdbSourcesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs", "pdbCheckMs", "publishMs", "cleanupMs", "totalMs", "assemblies", "assembliesImmutable", - "assembliesUnchanged", "assembliesCaptured", "filesCopied", "bytesCopied", "filesChecked" + "assembliesUnchanged", "assembliesCaptured", "assembliesSkippedPdbMismatch", "filesCopied", "bytesCopied", "filesChecked" }; foreach (string field in fields) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index b3e05786d1..3382ae4d52 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -16,6 +16,11 @@ public static void Initialize(Func isToolEnabled) { Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + // Why first: the snapshot capture no longer touches the compilation assembly list, so + // the list's static constructor, which subscribes to compilationStarted, would otherwise + // run for whichever caller touches the list first, which need not be on the main thread. + HotReloadCompilationAssemblies.EnsureInitialized(); + // Why not EditorApplication.delayCall: a cold-start session that hits Unity's native // "Scripts have compiler errors" dialog never flushes delayCall again for the rest of // that process's lifetime, even for later registrations — while diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs new file mode 100644 index 0000000000..b6f371a5b0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// A compiled assembly found on disk with a PDB beside it, before its sources are read. + /// + internal sealed class HotReloadCaptureAssemblyCandidate + { + internal HotReloadCaptureAssemblyCandidate(string name, string dllPath, string pdbPath) + { + Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + + Name = name; + DllPath = dllPath; + PdbPath = pdbPath; + } + + internal string Name { get; } + + internal string DllPath { get; } + + internal string PdbPath { get; } + } + + /// + /// Lists the project's compiled assemblies from the dlls and PDBs on disk, and reads an + /// assembly's sources from its PDB, instead of asking Unity's compilation pipeline, which only + /// answers on the main thread. Asks Unity nothing, so it can run on any thread. + /// + internal static class HotReloadCaptureAssemblyEnumerator + { + private const string DllSearchPattern = "*.dll"; + + /// + /// Returns every compiled dll that has a PDB beside it, in ordinal order of the file name. + /// A dll without a PDB is left out, because no snapshot can be checked against it. Returns + /// an empty list when the compiled assemblies directory does not exist. + /// + internal static IReadOnlyList ListCompiled(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + string directory = layout.CompiledAssembliesDirectory; + if (!Directory.Exists(directory)) + { + return Array.Empty(); + } + + string[] dllPaths = Directory.GetFiles(directory, DllSearchPattern); + Array.Sort(dllPaths, StringComparer.Ordinal); + List candidates = new List(dllPaths.Length); + foreach (string dllPath in dllPaths) + { + string name = Path.GetFileNameWithoutExtension(dllPath); + string pdbPath = layout.PdbPath(name); + if (!File.Exists(pdbPath)) + { + continue; + } + + candidates.Add(new HotReloadCaptureAssemblyCandidate(name, dllPath, pdbPath)); + } + + return candidates; + } + + /// + /// Reads the sources of one compiled assembly from its PDB's document table, named by their + /// asset paths, and whether it is a read-only package's. Throws InvalidOperationException when + /// the PDB belongs to another build of the dll, as while a compile replaces them. + /// + internal static HotReloadCaptureAssembly ReadSources( + HotReloadCaptureAssemblyCandidate candidate, + HotReloadSnapshotCaptureInputs inputs) + { + Debug.Assert(candidate != null, "candidate must not be null."); + Debug.Assert(inputs != null, "inputs must not be null."); + + List documents = HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath); + // Why the main project's root: a Virtual Player reads the main project's assemblies, + // whose PDB names sources from the main project's compile. + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + CompiledAssemblyLayout.Resolve(inputs.ProjectRoot).MainProjectRoot, + inputs.ProjectRoot, + inputs.PackageRoots, + inputs.PathComparison); + return new HotReloadCaptureAssembly(candidate.Name, sources.AssetPaths, sources.IsImmutablePackageAssembly); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta new file mode 100644 index 0000000000..fa815d3e7b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 863665f156a0043eaba0b28d6335329f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index 8f38993d17..d502dd2bb5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -16,8 +16,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// Why: on a project with several hundred assemblies one CompilationPipeline.GetAssemblies() call /// takes 340-620 ms, while the list only changes through a compile, or through an import that /// changes the script set without compiling (see HotReloadCompilationAssemblyListPostprocessor). - /// The startup snapshot capture is the first caller after a domain reload, so runs find the list - /// already kept. + /// Whichever caller first needs the list after a domain reload asks Unity for it; later callers + /// in the domain find it kept. /// A list rebuilt at startup from the saved list holds only the fields hot reload reads: name, /// outputPath, sourceFiles, defines, assemblyReferences and compiledAssemblyReferences. /// compilerOptions, flags and rootNamespace keep their defaults, so code that needs them reads @@ -42,6 +42,15 @@ static HotReloadCompilationAssemblies() CompilationPipeline.compilationStarted += OnCompilationStarted; } + /// + /// Does nothing but make sure the static constructor has run, so the subscription to + /// compilationStarted is made on the main thread when the domain loads rather than by + /// whichever caller first asks for the list. + /// + internal static void EnsureInitialized() + { + } + /// /// Returns the compilation assemblies. The list is shared; callers only read it. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs index b025a41a34..57d7bd99bf 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Threading; +using io.github.hatayama.UnityCliLoop.ToolContracts; + using UnityEngine; using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; @@ -43,8 +45,14 @@ internal IReadOnlyList Current() return _assemblies; } + System.Diagnostics.Stopwatch fetchWatch = System.Diagnostics.Stopwatch.StartNew(); UnityCompilationAssembly[] fetched = _fetch(); + fetchWatch.Stop(); Debug.Assert(fetched != null, "fetch must not return null."); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationAssembliesFetched, + "Hot reload asked Unity for the compilation assemblies.", + new { fetchMs = fetchWatch.ElapsedMilliseconds, assemblyCount = fetched.Length }); // Why not memoize an empty result: GetAssemblies() answers nothing while a compile is in // flight, and keeping that would hide every assembly for the rest of the domain's life. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 8d0f5547f7..f38b407e57 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -681,6 +681,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogPublicizedCopyWritten = "hot_reload_publicized_copy_written"; public const string VibeLogSourceSnapshotChecked = "hot_reload_source_snapshot_checked"; public const string VibeLogSourceSnapshotBreakdown = "hot_reload_source_snapshot_breakdown"; + public const string VibeLogCompilationAssembliesFetched = "hot_reload_compilation_assemblies_fetched"; public const string VibeLogShimCompilerTiming = "hot_reload_shim_compiler_timing"; public const string VibeLogShimCompileFailed = "hot_reload_shim_compile_failed"; public const string VibeLogIsolationRetry = "hot_reload_isolation_retry"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs index 1ed87b328f..5cd37293ee 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs @@ -1,14 +1,12 @@ using System; -using System.Collections.Generic; using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Tells which package folder a slash-separated path lies in, and whether hot reload treats that - /// package's sources as read-only. Does no IO and asks nothing of the Package Manager, so it can - /// run on any thread. + /// Tells whether a slash-separated path lies in a folder, respecting folder boundaries. Does no + /// IO, so it can run on any thread. /// internal static class HotReloadPackageFolderMatch { @@ -34,35 +32,6 @@ internal static int PrefixLength(string path, string folder, StringComparison co return folder.Length; } - /// - /// Returns whether the asset path lies in a package hot reload treats as read-only: the - /// of the package whose asset path - /// contains it, or false when no package does, as for Assets. The same rule the PDB source - /// map applies to the first source of an assembly. - /// - internal static bool IsInImmutablePackage( - string slashNormalizedAssetPath, - IReadOnlyList packageRoots, - StringComparison comparison) - { - Debug.Assert(!string.IsNullOrEmpty(slashNormalizedAssetPath), "slashNormalizedAssetPath must not be null or empty."); - Debug.Assert(packageRoots != null, "packageRoots must not be null."); - - int bestLength = -1; - bool bestIsImmutable = false; - foreach (HotReloadSnapshotPackageRoot root in packageRoots) - { - int length = PrefixLength(slashNormalizedAssetPath, NormalizeFolder(root.AssetPath), comparison); - if (length > bestLength) - { - bestLength = length; - bestIsImmutable = root.IsImmutable; - } - } - - return bestIsImmutable; - } - /// /// Returns the folder with forward slashes and without a trailing slash, the form /// expects. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs index 9fdc288ca5..3dec679741 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs @@ -4,8 +4,6 @@ using UnityEngine; -using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; - namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -18,23 +16,18 @@ internal HotReloadSnapshotCaptureInputs( string projectRoot, HotReloadCompileStart compileStart, IReadOnlyList packageRoots, - StringComparison pathComparison, - IReadOnlyList assemblies, - long assemblyListingMs) + StringComparison pathComparison) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(packageRoots != null, "packageRoots must not be null."); Debug.Assert( pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase, "pathComparison must be Ordinal or OrdinalIgnoreCase."); - Debug.Assert(assemblies != null, "assemblies must not be null."); ProjectRoot = projectRoot; CompileStart = compileStart; PackageRoots = packageRoots; PathComparison = pathComparison; - Assemblies = assemblies; - AssemblyListingMs = assemblyListingMs; } /// The root of this Editor's project, which owns the snapshots. @@ -49,39 +42,18 @@ internal HotReloadSnapshotCaptureInputs( /// How paths compare on this platform: case-insensitive on Windows only. internal StringComparison PathComparison { get; } - /// The compiled assemblies to capture. - internal IReadOnlyList Assemblies { get; } - - /// How long listing the assemblies took, in milliseconds. - internal long AssemblyListingMs { get; } - /// - /// Reads the inputs from Unity. Main thread only: the compile start lives in SessionState, the - /// package roots come from the Package Manager, and the assemblies from - /// CompilationPipeline.GetAssemblies, all of which Unity allows on the main thread alone. + /// Reads the inputs from Unity. Main thread only: the compile start lives in SessionState and + /// the package roots come from the Package Manager, both of which Unity allows on the main + /// thread alone. /// internal static HotReloadSnapshotCaptureInputs ReadOnMainThread() { - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadCompileStart compileStart = HotReloadCompileStartRecord.Read(); - IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); - StringComparison pathComparison = CurrentPathComparison(); - System.Diagnostics.Stopwatch listingWatch = System.Diagnostics.Stopwatch.StartNew(); - IReadOnlyList compilationAssemblies = HotReloadCompilationAssemblies.Current(); - listingWatch.Stop(); - List assemblies = new List(compilationAssemblies.Count); - foreach (UnityCompilationAssembly compilationAssembly in compilationAssemblies) - { - assemblies.Add(ToCaptureAssembly(compilationAssembly, packageRoots, pathComparison)); - } - return new HotReloadSnapshotCaptureInputs( - projectRoot, - compileStart, - packageRoots, - pathComparison, - assemblies, - listingWatch.ElapsedMilliseconds); + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadCompileStartRecord.Read(), + HotReloadSnapshotPackageRoots.ReadCurrent(), + CurrentPathComparison()); } /// @@ -92,24 +64,5 @@ internal static StringComparison CurrentPathComparison() { return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } - - /// - /// Turns one of Unity's compilation assemblies into what the capture reads, classifying it by - /// its first source because an asmdef keeps one assembly inside one package. - /// - internal static HotReloadCaptureAssembly ToCaptureAssembly( - UnityCompilationAssembly compilationAssembly, - IReadOnlyList packageRoots, - StringComparison pathComparison) - { - Debug.Assert(compilationAssembly != null, "compilationAssembly must not be null."); - - string[] sourceFiles = compilationAssembly.sourceFiles ?? Array.Empty(); - bool isImmutable = sourceFiles.Length > 0 && HotReloadPackageFolderMatch.IsInImmutablePackage( - sourceFiles[0].Replace('\\', '/'), - packageRoots, - pathComparison); - return new HotReloadCaptureAssembly(compilationAssembly.name, sourceFiles, isImmutable); - } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs index 3f939b7bf4..997e07e28f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs @@ -9,6 +9,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal sealed class HotReloadSnapshotCaptureStats { // Times are TimeSpan ticks, summed over every assembly and source of the capture. + internal long ListingTicks; + internal long PdbSourcesTicks; internal long PackageLookupTicks; internal long StampTicks; internal long MvidTicks; @@ -22,16 +24,18 @@ internal sealed class HotReloadSnapshotCaptureStats internal int AssembliesImmutable; internal int AssembliesUnchanged; internal int AssembliesCaptured; + internal int AssembliesSkippedPdbMismatch; internal int FilesReused; internal int FilesCopied; internal long BytesCopied; internal int FilesChecked; - internal object ToVibeLogContext(long getAssembliesMs, long totalMs) + internal object ToVibeLogContext(long totalMs) { return new { - getAssembliesMs, + getAssembliesMs = ToMilliseconds(ListingTicks), + pdbSourcesMs = ToMilliseconds(PdbSourcesTicks), packageLookupMs = ToMilliseconds(PackageLookupTicks), stampMs = ToMilliseconds(StampTicks), mvidMs = ToMilliseconds(MvidTicks), @@ -45,6 +49,7 @@ internal object ToVibeLogContext(long getAssembliesMs, long totalMs) assembliesImmutable = AssembliesImmutable, assembliesUnchanged = AssembliesUnchanged, assembliesCaptured = AssembliesCaptured, + assembliesSkippedPdbMismatch = AssembliesSkippedPdbMismatch, filesReused = FilesReused, filesCopied = FilesCopied, bytesCopied = BytesCopied, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs index 41bb66dfb1..80a5ae76c4 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs @@ -16,11 +16,23 @@ internal static class HotReloadSourceSnapshotLayout // The suffix of an assembly directory that is still being written; only a Move makes it complete. internal const string IncompleteDirectorySuffix = ".tmp"; + private const string ImmutableMarkerExtension = ".immutable"; + internal static string Root(string projectRoot) { return Path.Combine(projectRoot, HotReloadConstants.SourceSnapshotRelativeDirectory); } + /// + /// The file that records the compiled dll of a read-only package's assembly, in the stamp's + /// form, so a later capture skips that dll without reading its PDB. A file rather than a + /// directory, so the cleanup of earlier builds' directories leaves it alone. + /// + internal static string ImmutableMarkerPath(string projectRoot, string assemblyName) + { + return Path.Combine(Root(projectRoot), assemblyName + ImmutableMarkerExtension); + } + internal static string AssemblyDirectoryName(string assemblyName, string mvid) { return assemblyName + "-" + mvid; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs index 41f96e320c..2360eda6d1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs @@ -9,6 +9,10 @@ internal static partial class HotReloadSourceSnapshotter { private const string StampFileExtension = ".stamp"; + // Why a tag where a stamp has the MVID: the marker of a read-only package's assembly names + // no snapshot directory, and reading the MVID would open a dll the capture otherwise skips. + private const string ImmutableMarkerTag = "immutable"; + private static string StampPath(string snapshotRoot, string assemblyName) { return Path.Combine(snapshotRoot, assemblyName + StampFileExtension); @@ -95,4 +99,20 @@ private static void WriteStamp(string stampPath, string mvid, long dllMtimeTicks File.WriteAllText(stampPath, mvid + "," + dllMtimeTicks + "," + dllByteLength); } } + + /// + /// The write time and length of a compiled dll, which the stamp and the marker beside its + /// snapshots record. + /// + internal readonly struct HotReloadCompiledDllStamp + { + internal readonly long MtimeTicks; + internal readonly long ByteLength; + + internal HotReloadCompiledDllStamp(long mtimeTicks, long byteLength) + { + MtimeTicks = mtimeTicks; + ByteLength = byteLength; + } + } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index bdae7bfdc5..413df68c93 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -20,53 +21,63 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static partial class HotReloadSourceSnapshotter { /// - /// Captures snapshots for project assemblies that have adjacent portable PDBs. + /// Captures snapshots for the project's compiled assemblies that have adjacent portable PDBs, + /// listed from the dlls on disk and each read from its PDB. /// A copy of a source written since the compile started is checked against the PDB checksum /// when it is captured, and marked in the manifest when it does not match, so the default file /// selection, the sibling scan and the skip check treat that file as changed. The method-diff /// baseline checks every copy against the PDB when it uses one. - /// Logs how long each phase took, even when the list is empty. - /// Returns false when the inputs list no compilation assembly (as while Unity compiles). + /// Logs how long each phase took, even when nothing is listed. + /// Returns false when no compiled dll with a PDB was listed. /// Asks Unity nothing beyond what carries. /// internal static bool CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs inputs) { Debug.Assert(inputs != null, "inputs must not be null."); - System.Diagnostics.Stopwatch captureWatch = System.Diagnostics.Stopwatch.StartNew(); - HotReloadSnapshotCaptureStats stats = CaptureAssemblies(inputs, HotReloadPdbDocumentIndex.Shared); - captureWatch.Stop(); - // Why the listing time is added: the assemblies are now listed while the inputs are read, - // before this method runs, and totalMs has always included that listing. + System.Diagnostics.Stopwatch totalWatch = System.Diagnostics.Stopwatch.StartNew(); + HotReloadSnapshotCaptureStats stats = CaptureAssemblies( + inputs, + HotReloadPdbDocumentIndex.Shared, + HotReloadCaptureAssemblyEnumerator.ReadSources, + CancellationToken.None); + totalWatch.Stop(); VibeLogger.LogInfo( HotReloadConstants.VibeLogSourceSnapshotBreakdown, "Hot reload source snapshot capture breakdown.", - stats.ToVibeLogContext( - inputs.AssemblyListingMs, - inputs.AssemblyListingMs + captureWatch.ElapsedMilliseconds)); - return inputs.Assemblies.Count > 0; + stats.ToVibeLogContext(totalWatch.ElapsedMilliseconds)); + return stats.Assemblies > 0; } - // Why separate from CaptureAfterDomainReload: taking the document index as an argument lets - // tests drive the per-assembly loop in a temporary root without writing the shared index. + // Why separate from CaptureAfterDomainReload: taking the document index and the PDB source + // reader as arguments lets tests drive the per-assembly loop in a temporary root, without + // writing the shared index, and see whether a PDB was read. // Returns how long each phase took and how much the capture did. internal static HotReloadSnapshotCaptureStats CaptureAssemblies( HotReloadSnapshotCaptureInputs inputs, - HotReloadPdbDocumentIndex documentIndex) + HotReloadPdbDocumentIndex documentIndex, + Func readSources, + CancellationToken cancellationToken) { Debug.Assert(inputs != null, "inputs must not be null."); Debug.Assert(documentIndex != null, "documentIndex must not be null."); + Debug.Assert(readSources != null, "readSources must not be null."); string snapshotRoot = HotReloadSourceSnapshotLayout.Root(inputs.ProjectRoot); Directory.CreateDirectory(snapshotRoot); HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats(); - foreach (HotReloadCaptureAssembly assembly in inputs.Assemblies) + System.Diagnostics.Stopwatch listingWatch = System.Diagnostics.Stopwatch.StartNew(); + IReadOnlyList candidates = + HotReloadCaptureAssemblyEnumerator.ListCompiled(inputs.ProjectRoot); + stats.ListingTicks += listingWatch.Elapsed.Ticks; + foreach (HotReloadCaptureAssemblyCandidate candidate in candidates) { + cancellationToken.ThrowIfCancellationRequested(); stats.Assemblies++; try { - CaptureAssemblyIfNeeded(inputs, snapshotRoot, assembly, documentIndex, stats); + CaptureCandidateIfNeeded(inputs, snapshotRoot, candidate, documentIndex, readSources, stats); } catch (Exception ex) when (IsSkippableAssemblyCaptureException(ex)) { @@ -75,7 +86,7 @@ internal static HotReloadSnapshotCaptureStats CaptureAssemblies( // Continuing preserves the fail-closed invariant because the method-diff baseline // rejects missing snapshots by PDB checksum rather than using them for an incorrect diff. UnityEngine.Debug.LogWarning( - $"[UnityCliLoop] Snapshot capture failed for assembly {assembly.Name}: {ex.Message}"); + $"[UnityCliLoop] Snapshot capture failed for assembly {candidate.Name}: {ex.Message}"); } } @@ -91,68 +102,114 @@ ex is UnauthorizedAccessException || ex is BadImageFormatException; } - private static void CaptureAssemblyIfNeeded( + // Why the stamps are checked before the PDB is read: after an ordinary edit only one dll has + // changed, and after files are added or removed a few dozen, so only those PDBs are read. + private static void CaptureCandidateIfNeeded( HotReloadSnapshotCaptureInputs inputs, string snapshotRoot, - HotReloadCaptureAssembly assembly, + HotReloadCaptureAssemblyCandidate candidate, HotReloadPdbDocumentIndex documentIndex, + Func readSources, HotReloadSnapshotCaptureStats stats) { - IReadOnlyList sourceFiles = assembly.SourceAssetPaths; - if (sourceFiles.Count == 0) + FileInfo dllInfo = new FileInfo(candidate.DllPath); + HotReloadCompiledDllStamp dll = new HotReloadCompiledDllStamp(dllInfo.LastWriteTimeUtc.Ticks, dllInfo.Length); + string markerPath = HotReloadSourceSnapshotLayout.ImmutableMarkerPath(inputs.ProjectRoot, candidate.Name); + string stampPath = StampPath(snapshotRoot, candidate.Name); + + // Why stamp short-circuits Cecil: a false stamp (identical mtime+length with different + // bytes) is vanishingly rare, and even then LoadVerifiedSnapshotSource rejects via PDB + // checksum — so stamp lies degrade to fallback, never to a wrong method diff. The marker of + // a read-only package's assembly works the same way: a dll rebuilt after the package was + // made editable no longer matches it. + System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew(); + bool markerMatches = HasMatchingStamp(markerPath, dll.MtimeTicks, dll.ByteLength); + bool stampMatches = !markerMatches && HasMatchingStamp(stampPath, dll.MtimeTicks, dll.ByteLength); + stats.StampTicks += stampWatch.Elapsed.Ticks; + if (markerMatches) { + stats.AssembliesImmutable++; + return; + } + + if (stampMatches) + { + stats.AssembliesUnchanged++; + return; + } + + HotReloadCaptureAssembly assembly = ReadSourcesOrNull(candidate, inputs, readSources, stats); + if (assembly == null || assembly.SourceAssetPaths.Count == 0) + { + // No source maps to an asset path: nothing is written, as for an assembly Unity listed + // without sources, so the next capture reads the PDB again. return; } - string projectRoot = inputs.ProjectRoot; - // Why only a read: the inputs classified the assembly from the package roots, so the - // capture no longer asks the Package Manager here. System.Diagnostics.Stopwatch packageLookupWatch = System.Diagnostics.Stopwatch.StartNew(); bool immutable = assembly.IsImmutablePackageAssembly; stats.PackageLookupTicks += packageLookupWatch.Elapsed.Ticks; if (immutable) { + System.Diagnostics.Stopwatch writeMarkerWatch = System.Diagnostics.Stopwatch.StartNew(); + WriteStamp(markerPath, ImmutableMarkerTag, dll.MtimeTicks, dll.ByteLength); + stats.StampTicks += writeMarkerWatch.Elapsed.Ticks; stats.AssembliesImmutable++; return; } - // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main - // project for a Virtual Player; its snapshots stay under its own root. - CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); - string dllPath = layout.DllPath(assembly.Name); - string pdbPath = layout.PdbPath(assembly.Name); - if (!File.Exists(dllPath) || !File.Exists(pdbPath)) + CaptureChangedAssembly(inputs, snapshotRoot, candidate, assembly, dll, stampPath, documentIndex, stats); + } + + // The assembly's sources from its PDB, or null when the PDB belongs to another build of the + // dll. Why that build is skipped rather than failing the capture: it happens when the capture + // reads while a compile replaces the dll and the PDB; no stamp is written, so the next domain + // captures that assembly. + private static HotReloadCaptureAssembly ReadSourcesOrNull( + HotReloadCaptureAssemblyCandidate candidate, + HotReloadSnapshotCaptureInputs inputs, + Func readSources, + HotReloadSnapshotCaptureStats stats) + { + System.Diagnostics.Stopwatch readWatch = System.Diagnostics.Stopwatch.StartNew(); + try { - return; + return readSources(candidate, inputs); } - - FileInfo dllInfo = new FileInfo(dllPath); - long dllMtimeTicks = dllInfo.LastWriteTimeUtc.Ticks; - long dllByteLength = dllInfo.Length; - string stampPath = StampPath(snapshotRoot, assembly.Name); - - // Why stamp short-circuits Cecil: a false stamp (identical mtime+length with different - // bytes) is vanishingly rare, and even then LoadVerifiedSnapshotSource rejects via PDB - // checksum — so stamp lies degrade to fallback, never to a wrong method diff. - System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew(); - bool stampMatches = HasMatchingStamp(stampPath, dllMtimeTicks, dllByteLength); - stats.StampTicks += stampWatch.Elapsed.Ticks; - if (stampMatches) + catch (InvalidOperationException) { - stats.AssembliesUnchanged++; - return; + stats.AssembliesSkippedPdbMismatch++; + return null; } + finally + { + stats.PdbSourcesTicks += readWatch.Elapsed.Ticks; + } + } + private static void CaptureChangedAssembly( + HotReloadSnapshotCaptureInputs inputs, + string snapshotRoot, + HotReloadCaptureAssemblyCandidate candidate, + HotReloadCaptureAssembly assembly, + HotReloadCompiledDllStamp dll, + string stampPath, + HotReloadPdbDocumentIndex documentIndex, + HotReloadSnapshotCaptureStats stats) + { + // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main + // project for a Virtual Player; its snapshots stay under its own root. + string projectRoot = inputs.ProjectRoot; System.Diagnostics.Stopwatch mvidWatch = System.Diagnostics.Stopwatch.StartNew(); - string mvid = HotReloadAssemblyMvid.Read(dllPath); + string mvid = HotReloadAssemblyMvid.Read(candidate.DllPath); stats.MvidTicks += mvidWatch.Elapsed.Ticks; string assemblySnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assembly.Name, mvid); if (!Directory.Exists(assemblySnapshotDirectory)) { HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck( - inputs.CompileStart.SuspectWritesFrom(dllMtimeTicks), - dllPath, - pdbPath, + inputs.CompileStart.SuspectWritesFrom(dll.MtimeTicks), + candidate.DllPath, + candidate.PdbPath, mvid, documentIndex, inputs.PackageRoots, @@ -169,7 +226,7 @@ private static void CaptureAssemblyIfNeeded( HotReloadSourceSnapshotCopier.CaptureAtomically( projectRoot, assemblySnapshotDirectory, - sourceFiles, + assembly.SourceAssetPaths, assembly.Name, check, stats); @@ -180,7 +237,7 @@ private static void CaptureAssemblyIfNeeded( projectRoot, assemblySnapshotDirectory, previousSnapshotDirectory, - sourceFiles, + assembly.SourceAssetPaths, assembly.Name, check, stats); @@ -197,7 +254,7 @@ private static void CaptureAssemblyIfNeeded( // because the method-diff baseline requires a PDB checksum match, and a changed DLL // invalidates this stamp through its mtime or length before the next capture. System.Diagnostics.Stopwatch writeStampWatch = System.Diagnostics.Stopwatch.StartNew(); - WriteStamp(stampPath, mvid, dllMtimeTicks, dllByteLength); + WriteStamp(stampPath, mvid, dll.MtimeTicks, dll.ByteLength); stats.StampTicks += writeStampWatch.Elapsed.Ticks; } diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 86b0fc72ee..0c40937287 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -84,7 +84,10 @@ written since the compile started; it deletes the copies of sources no longer in When both the hot-reload and pause-point tools are disabled in the tool settings, the capture after a domain reload is skipped; the first hot reload run or pause point after either tool is enabled captures it, and any source written since the compile started is then checked against -the PDB), +the PDB. The capture lists the assemblies from the compiled dlls that have a PDB beside them and +reads an assembly's sources from its PDB only when its dll changed; a read-only package's +assembly gets a `.immutable` marker beside the snapshots instead, which skips its +PDB until the dll changes), `Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the first run after a domain reload does not walk the PDB again while they still match), and diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 3ca5cb287c..d94951c8ca 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -92,7 +92,7 @@ command ran against: and `exceptionMessage`, logged as a warning). `hot_reload_source_snapshot_captured` appears once per domain when the source snapshot capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or - `apply` only when the capture at domain load threw or Unity listed no compilation assembly + `apply` only when the capture at domain load threw or found no compiled dll with a PDB yet; `apply` or `pause_point` when the captures at startup were skipped and a hot reload run or a pause point is the first to read the snapshot) and `captureMs`. When both the hot-reload and pause-point tools are disabled in the tool settings, the captures at `domain_load` and @@ -100,16 +100,18 @@ command ran against: `reason` `tools_disabled` instead, and the first hot reload run or pause point after either tool is enabled captures it. `hot_reload_source_snapshot_breakdown` appears once each time the capture runs to the end, - just before `hot_reload_source_snapshot_captured`. It also appears when Unity listed no - compilation assembly, in which case no captured entry follows, and it does not appear when the - capture threw. It says where the capture spent its time: - - `getAssembliesMs`: listing the compilation assemblies from Unity, which happens while the - capture's inputs are read on the main thread, before any copying starts. - - `packageLookupMs`: reading whether each assembly's sources are immutable. The inputs decide - that from the package roots they read from Package Manager once, so this is close to 0; it - no longer asks Package Manager for each assembly. - - `stampMs`: reading and writing the stamps beside the snapshots, and reading the previous - build's manifest. + just before `hot_reload_source_snapshot_captured`. It also appears when no compiled dll with a + PDB beside it was found, in which case no captured entry follows, and it does not appear when + the capture threw. The capture lists the compiled assemblies from the dlls and PDBs in the + compiled assemblies folder and reads an assembly's sources from its PDB, not from Unity's + compilation assembly list. It says where the capture spent its time: + - `getAssembliesMs`: listing the compiled dlls that have a PDB beside them. + - `pdbSourcesMs`: reading the sources of each assembly whose dll changed from its PDB. + - `packageLookupMs`: reading whether each assembly's sources are immutable, which the PDB's + sources and the package roots read once from Package Manager decide, so this is close to 0; + it no longer asks Package Manager for each assembly. + - `stampMs`: reading and writing the stamps and the read-only markers beside the snapshots, and + reading the previous build's manifest. - `mvidMs`: reading the MVID of each compiled dll whose stamp did not match. - `statMs`: reading each source's length and write time before and after it is copied, and checking whether a source and its previous copy are unchanged. @@ -120,17 +122,25 @@ command ran against: previous build's directory there, writing the manifest, and moving the directory into place. - `cleanupMs`: deleting the snapshot directories of earlier builds, and the copies of sources the assembly no longer lists. - - `totalMs`: listing the assemblies plus the whole per-assembly loop. Reading the compile start - and the package roots for the inputs is not counted here. - - `assemblies`: the compilation assemblies listed. - - `assembliesImmutable`: those skipped because their sources belong to an immutable package. + - `totalMs`: the whole capture. Reading the compile start and the package roots before it is + not counted here. + - `assemblies`: the compiled assemblies listed. + - `assembliesImmutable`: those skipped because their sources belong to an immutable package: + either their read-only marker matched the compiled dll, or their PDB showed it and the marker + was written. - `assembliesUnchanged`: those skipped because their stamp matched the compiled dll. - `assembliesCaptured`: those whose sources were copied into a new snapshot directory. + - `assembliesSkippedPdbMismatch`: those skipped because their PDB belonged to another build of + the dll, as while a compile replaces them; no stamp is written, so the next domain captures them. - `filesReused`: the sources whose previous copy was kept without reading them. - `filesCopied` and `bytesCopied`: the sources copied and their total size. - `filesChecked`: the sources checked against the PDB. The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry also includes reading the compile start and the package roots, and writing this breakdown entry. + `hot_reload_compilation_assemblies_fetched` appears each time hot reload asks Unity for the + compilation assembly list, with `fetchMs` and `assemblyCount`. The list is kept for the rest of + the domain once it is not empty, so this normally appears once per domain, from whichever of the + warm-up or the first hot reload run needs the list first. After a domain reload, the warm-up writes one `hot_reload_warm_up_complete` with the target assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy` (`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one From 39aa56db7249f24988ebe11e5c8be09e8ed146cd Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 12 Oct 2026 07:43:05 +0900 Subject: [PATCH 10/15] perf: Hot reload's source snapshot capture after a compile no longer freezes the Editor (#3285) --- .../HotReloadCompositionRootTests.cs | 6 +- .../HotReload/HotReloadDefaultFilesTests.cs | 6 +- .../Editor/HotReload/HotReloadDomainTests.cs | 2 +- .../HotReload/HotReloadEditorStartupTests.cs | 39 +- .../HotReloadPatcherContractTests.cs | 2 +- .../Editor/HotReload/HotReloadPatcherTests.cs | 2 +- ...tReloadSourceSnapshotCaptureTestDoubles.cs | 96 ++++ ...adSourceSnapshotCaptureTestDoubles.cs.meta | 11 + .../HotReloadSourceSnapshotCaptureTests.cs | 440 +++++++++++++++--- .../HotReload/HotReloadSourceSnapshotTests.cs | 5 +- .../PausePointScriptPathFormTests.cs | 9 +- .../FirstPartyToolsEditorStartup.cs | 7 +- .../HotReload/HotReloadCompositionRoot.cs | 21 +- .../HotReload/HotReloadEditorStartup.cs | 24 +- .../HotReload/HotReloadTools.cs | 11 +- .../HotReload/HotReloadWarmUpEditorHooks.cs | 9 +- .../HotReload/Shared/HotReloadConstants.cs | 9 +- .../Shared/HotReloadSourceSnapshotCapture.cs | 264 ++++++++++- .../Shared/HotReloadSourceSnapshotter.cs | 12 +- ...oad-source-snapshot-off-the-main-thread.md | 70 +++ docs/hot-reload.md | 5 + docs/vibe-logs.md | 35 +- 22 files changed, 925 insertions(+), 160 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta create mode 100644 docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs index 2707d8012b..ed3ec1cdf4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs @@ -112,7 +112,7 @@ public void Install_PausePointSide_CapturesThroughTheInstalledGateWithThePausePo { VibeLogger.ClearMemoryLogs(); int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; @@ -213,7 +213,7 @@ private static HotReloadServices CreateServicesSharing(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), installed.WarmUp, installed.CompiledCallers); } @@ -228,7 +228,7 @@ private static HotReloadServices CreateServicesOwning(HotReloadDomain domain) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), HotReloadWarmUpTestDoubles.CreateInert(), HotReloadCompiledCallers.CreateProduction()); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs index 77b610072f..dc3189b1cd 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDefaultFilesTests.cs @@ -358,7 +358,7 @@ public async Task ExecuteAsync_WhenFilesAreOmitted_CapturesTheSourceSnapshotBefo { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; })); int captureCountAtDetection = -1; using IDisposable detectorScope = HotReloadServicesTestScope.BeginWithChangeDetector( new HotReloadStubChangeDetector(() => @@ -387,7 +387,7 @@ public async Task ExecuteAsync_WithFiles_CapturesTheSourceSnapshotBeforeTheRun() { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; })); int captureCountAtRun = -1; using IDisposable orchestratorScope = HotReloadServicesTestScope.BeginWithOrchestrator( new HotReloadStubOrchestrator((files, ignoredCt) => @@ -409,7 +409,7 @@ public async Task ExecuteAsync_ForStatus_DoesNotCaptureTheSourceSnapshot() { int captureCount = 0; using IDisposable captureScope = HotReloadServicesTestScope.BeginWithSourceSnapshotCapture( - new HotReloadSourceSnapshotCapture(() => { captureCount++; return true; })); + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; })); HotReloadResponse response = await ExecuteAsync(new JObject { ["Status"] = true }); diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index 06638df512..6575ff9e72 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -1078,7 +1078,7 @@ public void GetVerifiedSnapshotSource_WhileCaptureRuns_ReturnsTheLiveFileWhenItM /// makes the port wait for the capture once and read the snapshot instead. /// [Test] - public void GetVerifiedSnapshotSource_WhileCaptureRuns_WaitsWhenTheLiveFileDiffersFromThePdb() + public void GetVerifiedSnapshotSource_WhileCaptureRuns_WaitsWhenThePdbCannotConfirmTheLiveFile() { int ensureCount = 0; HotReloadPausePointPort port = new HotReloadPausePointPort( diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs index 4a0288dae7..def3042a44 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -20,11 +20,11 @@ public class HotReloadEditorStartupTests /// What: the capture before serving commands runs the installed capture once and records the domain-load trigger. /// [Test] - public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOnceWithTheDomainLoadTrigger() + public void StartSourceSnapshotCaptureBeforeServingCommands_RunsTheInstalledCaptureOnceWithTheDomainLoadTrigger() { VibeLogger.ClearMemoryLogs(); int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; return true; @@ -32,7 +32,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(AllToolsEnabled); } Assert.That(captureCount, Is.EqualTo(1), "captures"); @@ -41,15 +41,16 @@ public void CaptureSourceSnapshotBeforeServingCommands_RunsTheInstalledCaptureOn } /// - /// What: a capture that throws before serving commands passes the exception on and stays - /// undone, so the first update tick's capture runs it again. + /// What: a capture that fails before serving commands does not throw into the domain load, + /// which never waits for it; it logs one failed entry and stays undone, so the first update + /// tick's capture runs it again. /// [Test] - public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_ThrowsAndLeavesItForTheNextCall() + public void StartSourceSnapshotCaptureBeforeServingCommands_WhenTheCaptureFails_LogsFailedAndLeavesItForTheNextCall() { VibeLogger.ClearMemoryLogs(); int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture capture = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { captureCount++; if (captureCount == 1) @@ -62,14 +63,18 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - Assert.Throws( - () => HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(AllToolsEnabled)); + Assert.DoesNotThrow( + () => HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(AllToolsEnabled)); + JObject failed = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptureFailed); + Assert.That((string)failed["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( - HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + HotReloadEditorStartup.CaptureAtStartupUnlessReadersAreDisabled( + HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick, AllToolsEnabled); } Assert.That(captureCount, Is.EqualTo(2), "captures"); + Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(1), "captured entries"); } /// @@ -78,7 +83,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenTheCaptureThrows_Thro /// reader that runs later captures through the gate. /// [Test] - public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader() + public void StartSourceSnapshotCaptureBeforeServingCommands_WhenNeitherReaderToolIsEnabled_SkipsAndLeavesTheCaptureForAReader() { VibeLogger.ClearMemoryLogs(); int captureCount = 0; @@ -86,7 +91,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(NoToolEnabled); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(NoToolEnabled); Assert.That(captureCount, Is.EqualTo(0), "captures while both readers are disabled"); JObject skipped = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotSkipped); @@ -94,7 +99,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn Assert.That((string)skipped["reason"], Is.EqualTo(HotReloadConstants.SourceSnapshotSkipReasonToolsDisabled)); Assert.That(CountVibeEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0), "captured entries"); - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured( + HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCapturedBlocking( HotReloadConstants.SourceSnapshotCaptureTriggerApply); } @@ -107,7 +112,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenNeitherReaderToolIsEn /// [TestCase(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)] [TestCase(UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT)] - public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool) + public void StartSourceSnapshotCaptureBeforeServingCommands_WhenOnlyOneReaderToolIsEnabled_Captures(string enabledTool) { VibeLogger.ClearMemoryLogs(); int captureCount = 0; @@ -115,7 +120,7 @@ public void CaptureSourceSnapshotBeforeServingCommands_WhenOnlyOneReaderToolIsEn using (HotReloadServicesTestScope.BeginWithSourceSnapshotCapture(capture)) { - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(name => name == enabledTool); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(name => name == enabledTool); } Assert.That(captureCount, Is.EqualTo(1), "captures"); @@ -215,7 +220,7 @@ public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsEnabled_StartsOn private static HotReloadSourceSnapshotCapture CountingCapture(Action onCapture) { - return new HotReloadSourceSnapshotCapture(() => + return HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { onCapture(); return true; diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs index 18dd89378f..6a7257e472 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherContractTests.cs @@ -216,7 +216,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), HotReloadWarmUpTestDoubles.CreateInert(), HotReloadCompiledCallers.CreateProduction())); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs index 3f9b226e69..69fb926358 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPatcherTests.cs @@ -90,7 +90,7 @@ private static IDisposable BeginReplacementWith(IHotReloadHarmony harmony) TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture(() => true), + HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => true), HotReloadWarmUpTestDoubles.CreateInert(), HotReloadCompiledCallers.CreateProduction())); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs new file mode 100644 index 0000000000..2dd24a4f00 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Ways to build the source snapshot capture gate for tests, so no test runs a capture on a + /// pool thread. + /// + internal static class HotReloadSourceSnapshotCaptureTestDoubles + { + /// + /// A gate that runs on the calling thread the moment it starts, so + /// the capture has finished when the call that started it returns. + /// + internal static HotReloadSourceSnapshotCapture CompletingSynchronously(Func capture) + { + return new HotReloadSourceSnapshotCapture( + FixedInputs, + (inputs, cancellationToken) => capture(), + RunNow, + () => 0); + } + + /// Inputs that no test capture reads. + internal static HotReloadSnapshotCaptureInputs FixedInputs() + { + return new HotReloadSnapshotCaptureInputs( + "project-root", + HotReloadCompileStart.Unknown, + Array.Empty(), + StringComparison.Ordinal); + } + + // Why the exception goes into the task: Task.Run does the same, and the gate must see a + // failed task rather than a throwing start. + private static Task RunNow(Func work) + { + try + { + return Task.FromResult(work()); + } + catch (Exception exception) + { + return Task.FromException(exception); + } + } + } + + /// + /// A stand-in for the pool thread that holds the started work until the test runs it, so a + /// test controls when a capture is in flight and when it finishes. + /// + internal sealed class HeldBackgroundRun + { + private Func _work; + private TaskCompletionSource _completion; + + internal int StartCount { get; private set; } + + internal Task Run(Func work) + { + StartCount++; + _work = work; + _completion = new TaskCompletionSource(); + return _completion.Task; + } + + /// + /// Runs the held work on the calling thread and finishes its task with the outcome, the way + /// Task.Run would: a thrown exception, cancellation included, faults the task. + /// + internal void RunHeldWork() + { + NUnit.Framework.Assert.That(_work, NUnit.Framework.Is.Not.Null, "no work was started"); + Func work = _work; + TaskCompletionSource completion = _completion; + _work = null; + _completion = null; + bool result; + try + { + result = work(); + } + catch (Exception exception) + { + completion.SetException(exception); + return; + } + + completion.SetResult(result); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta new file mode 100644 index 0000000000..cb41f65622 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTestDoubles.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8ffcd35b460eb4d97994e62b120fb217 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs index 1da3d54d13..af2b5c0a63 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotCaptureTests.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json.Linq; @@ -10,42 +12,304 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// EditMode coverage for capturing the source snapshot once per domain. + /// EditMode coverage for the gate that captures the source snapshot once per domain off the + /// main thread. The pool thread is a held stand-in, so each test decides when a capture ends. /// public class HotReloadSourceSnapshotCaptureTests { + private const string Trigger = HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad; + private static readonly long HoldCapTicks = TimeSpan.FromSeconds(30).Ticks; + + private HeldBackgroundRun _background; + private int _inputReads; + private int _captureRuns; + private Func _captureBody; + private long _nowTicks; + + [SetUp] + public void SetUp() + { + VibeLogger.ClearMemoryLogs(); + _background = new HeldBackgroundRun(); + _inputReads = 0; + _captureRuns = 0; + _captureBody = _ => true; + _nowTicks = 0; + } + + /// + /// What: starting reads the inputs once on the calling thread, hands the capture to the + /// background, and returns before the capture runs, logging how long the main thread spent. + /// + [Test] + public void StartIfNeeded_NotStarted_ReadsInputsOnceAndReturnsBeforeTheCaptureFinishes() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + + Task task = gate.StartIfNeeded(Trigger); + + Assert.That(_inputReads, Is.EqualTo(1), "input reads"); + Assert.That(_captureRuns, Is.EqualTo(0), "captures run before the background ran them"); + Assert.That(task.IsCompleted, Is.False); + Assert.That(gate.IsInFlight, Is.True); + JObject started = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptureStarted); + Assert.That((string)started["trigger"], Is.EqualTo(Trigger)); + Assert.That(started["mainThreadMs"].Type, Is.EqualTo(JTokenType.Integer)); + + _background.RunHeldWork(); + + Assert.That(task.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(_captureRuns, Is.EqualTo(1)); + Assert.That(gate.IsInFlight, Is.False); + } + + /// + /// What: a second caller while a capture runs gets the running capture's task and starts no + /// second capture, so two captures never touch one snapshot directory at once. + /// + [Test] + public void StartIfNeeded_WhileInFlight_ReturnsTheSameTaskAndDoesNotStartAnother() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + + Task first = gate.StartIfNeeded(Trigger); + Task second = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(second, Is.SameAs(first)); + Assert.That(_background.StartCount, Is.EqualTo(1)); + Assert.That(_inputReads, Is.EqualTo(1)); + _background.RunHeldWork(); + } + + /// + /// What: after a capture that threw, the next caller starts a new one, and the failure is + /// logged with its exception type and leaves no captured entry. + /// + [Test] + public void StartIfNeeded_AfterAFailedCapture_StartsANewOne() + { + _captureBody = _ => throw new InvalidOperationException("The capture failed."); + HotReloadSourceSnapshotCapture gate = CreateGate(); + Task failed = gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + _captureBody = _ => true; + Task retried = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(failed.IsFaulted, Is.True); + Assert.That(retried, Is.Not.SameAs(failed)); + Assert.That(_background.StartCount, Is.EqualTo(2)); + JObject failure = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptureFailed); + Assert.That((string)failure["trigger"], Is.EqualTo(Trigger)); + Assert.That((string)failure["error"], Is.EqualTo(nameof(InvalidOperationException))); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + _background.RunHeldWork(); + } + + /// + /// What: after a capture that found no compiled assembly, the next caller starts a new one, + /// and the empty capture is logged as such rather than as captured. + /// + [Test] + public void StartIfNeeded_AfterAnEmptyCapture_StartsANewOne() + { + _captureBody = _ => false; + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(_background.StartCount, Is.EqualTo(2)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureEmpty), Is.EqualTo(1)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + _background.RunHeldWork(); + } + + /// + /// What: after a cancelled capture, the next caller starts a new one, and the cancellation + /// is logged as cancelled rather than as a failure. + /// + [Test] + public void StartIfNeeded_AfterACancelledCapture_StartsANewOne() + { + _captureBody = cancellationToken => + { + cancellationToken.ThrowIfCancellationRequested(); + return true; + }; + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + gate.CancelInFlight(); + _background.RunHeldWork(); + + gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + + Assert.That(_background.StartCount, Is.EqualTo(2)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled), Is.EqualTo(1)); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureFailed), Is.EqualTo(0)); + _background.RunHeldWork(); + } + + /// + /// What: once a capture succeeded, later callers get a completed task without reading the + /// inputs or starting anything, and the single captured entry keeps the first trigger. + /// + [Test] + public void StartIfNeeded_AfterSuccess_ReturnsACompletedTaskWithoutReadingInputs() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + Task later = gate.StartIfNeeded(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + + Assert.That(later.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(_inputReads, Is.EqualTo(1)); + Assert.That(_background.StartCount, Is.EqualTo(1)); + JObject captured = HotReloadWarmUpTestDoubles.ReadSingleVibeContext( + HotReloadConstants.VibeLogSourceSnapshotCaptured); + Assert.That((string)captured["trigger"], Is.EqualTo(Trigger)); + Assert.That(captured["captureMs"].Type, Is.EqualTo(JTokenType.Integer)); + } + + /// + /// What: an exception while reading the inputs reaches the caller, and the gate stays not + /// started, so neither a capture nor a hold is left behind and the next caller tries again. + /// + [Test] + public void StartIfNeeded_WhenReadingInputsThrows_RethrowsAndStaysNotStarted() + { + InvalidOperationException failure = new InvalidOperationException("The inputs could not be read."); + bool throwOnRead = true; + HotReloadSourceSnapshotCapture gate = new HotReloadSourceSnapshotCapture( + () => + { + _inputReads++; + if (throwOnRead) + { + throw failure; + } + + return HotReloadSourceSnapshotCaptureTestDoubles.FixedInputs(); + }, + RunCapture, + _background.Run, + () => _nowTicks); + + InvalidOperationException thrown = + Assert.Throws(() => gate.StartIfNeeded(Trigger)); + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(gate.IsInFlight, Is.False); + Assert.That(gate.IsHoldingCompileCompletion, Is.False); + Assert.That(_background.StartCount, Is.EqualTo(0)); + + throwOnRead = false; + gate.StartIfNeeded(Trigger); + Assert.That(_background.StartCount, Is.EqualTo(1)); + _background.RunHeldWork(); + } + + /// + /// What: an apply that arrives while a capture runs completes only after that capture does. + /// + [Test] + public async Task EnsureCapturedAsync_WhileInFlight_CompletesOnlyAfterTheCapture() + { + HotReloadSourceSnapshotCapture gate = CreateGate(); + _ = gate.StartIfNeeded(Trigger); + + Task ensured = gate.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + Assert.That(ensured.IsCompleted, Is.False); + + _background.RunHeldWork(); + await ensured; + + Assert.That(ensured.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(_background.StartCount, Is.EqualTo(1)); + } + + /// + /// What: an apply waiting on a capture that throws gets that exception, and the gate is not + /// marked captured, so the next caller starts again. + /// + [Test] + public async Task EnsureCapturedAsync_WhenTheCaptureThrows_RethrowsAndLeavesItUnmarked() + { + InvalidOperationException failure = new InvalidOperationException("The capture failed."); + _captureBody = _ => throw failure; + HotReloadSourceSnapshotCapture gate = CreateGate(); + Task ensured = gate.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + + _background.RunHeldWork(); + Exception thrown = null; + try + { + await ensured; + } + catch (InvalidOperationException exception) + { + thrown = exception; + } + + Assert.That(thrown, Is.SameAs(failure)); + _ = gate.StartIfNeeded(Trigger); + Assert.That(_background.StartCount, Is.EqualTo(2)); + _background.RunHeldWork(); + } + /// - /// What: once a capture has completed, a later call does not run it again. + /// What: a pause point that blocks after the capture finished returns without starting + /// another capture or reading the inputs again. /// [Test] - public void EnsureCaptured_CalledTwice_RunsTheCaptureOnce() + public void EnsureCapturedBlocking_AfterTheCaptureFinished_ReturnsWithoutStartingAnother() { - int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); + _background.RunHeldWork(); + + gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint); + + Assert.That(_background.StartCount, Is.EqualTo(1)); + Assert.That(_inputReads, Is.EqualTo(1)); + } + + /// + /// What: a pause point that finds no capture started starts one and returns once it ran. + /// + [Test] + public void EnsureCapturedBlocking_NotStarted_StartsAndWaits() + { + int captureRuns = 0; + HotReloadSourceSnapshotCapture gate = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { - captureCount++; + captureRuns++; return true; }); - capture.EnsureCaptured("test"); - capture.EnsureCaptured("test"); + gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint); - Assert.That(captureCount, Is.EqualTo(1)); + Assert.That(captureRuns, Is.EqualTo(1)); + Assert.That(gate.IsInFlight, Is.False); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(1)); } /// - /// What: a capture that throws passes the exception to the caller and is not marked done, so - /// the next call runs it again; once that run completes, later calls do not. + /// What: a blocking reader waits for the capture it starts and gets its exception; the + /// capture ran once and left the gate unmarked, so the next caller runs it again. /// [Test] - public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall() + public void EnsureCapturedBlocking_WhenTheCaptureThrows_RethrowsAndLeavesItUnmarked() { - int captureCount = 0; InvalidOperationException failure = new InvalidOperationException("The capture failed."); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + int captureRuns = 0; + HotReloadSourceSnapshotCapture gate = HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => { - captureCount++; - if (captureCount == 1) + captureRuns++; + if (captureRuns == 1) { throw failure; } @@ -53,96 +317,136 @@ public void EnsureCaptured_WhenTheCaptureThrows_RunsItAgainOnTheNextCall() return true; }); - InvalidOperationException thrown = - Assert.Throws(() => capture.EnsureCaptured("test")); - Assert.That(thrown, Is.SameAs(failure)); - - Assert.DoesNotThrow(() => capture.EnsureCaptured("test")); - Assert.That(captureCount, Is.EqualTo(2)); + InvalidOperationException thrown = Assert.Throws( + () => gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint)); - capture.EnsureCaptured("test"); - Assert.That(captureCount, Is.EqualTo(2)); + Assert.That(thrown, Is.SameAs(failure)); + Assert.That(captureRuns, Is.EqualTo(1), "captures run by the blocking call"); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + gate.EnsureCapturedBlocking(HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint); + Assert.That(captureRuns, Is.EqualTo(2), "captures after the failure"); } /// - /// What: a capture that runs to completion writes one captured entry naming its trigger and how long it took. + /// What: the compile completion is held while a capture has run for less than 30 seconds. /// [Test] - public void EnsureCaptured_WhenTheCaptureRuns_WritesOneCapturedEntryWithItsTrigger() + public void IsHoldingCompileCompletion_WithinTheCap_IsTrue() { - VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true); + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); + _nowTicks = HoldCapTicks - 1; - JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); - Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); - Assert.That(context["captureMs"].Type, Is.EqualTo(JTokenType.Integer), "captureMs type"); - Assert.That((long)context["captureMs"], Is.GreaterThanOrEqualTo(0), "captureMs"); + Assert.That(gate.IsHoldingCompileCompletion, Is.True); + _background.RunHeldWork(); } /// - /// What: a call that finds the capture already done writes no second entry, so the entry keeps the first trigger. + /// What: once a capture has run for 30 seconds the compile completion is no longer held, and + /// that is logged once however often it is asked; the capture still counts as in flight. /// [Test] - public void EnsureCaptured_WhenAlreadyCaptured_WritesNoSecondEntry() + public void IsHoldingCompileCompletion_PastTheCap_IsFalseAndLogsOnce() { - VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => true); + HotReloadSourceSnapshotCapture gate = CreateGate(); + gate.StartIfNeeded(Trigger); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + _nowTicks = HoldCapTicks; - JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); - Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + Assert.That(gate.IsHoldingCompileCompletion, Is.False); + Assert.That(gate.IsHoldingCompileCompletion, Is.False); + Assert.That(gate.IsInFlight, Is.True); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureHoldCapExceeded), Is.EqualTo(1)); + _background.RunHeldWork(); } /// - /// What: a capture that throws writes no captured entry. + /// What: nothing holds the compile completion before a capture starts, after one succeeded, + /// after one failed, or after one was cancelled. /// [Test] - public void EnsureCaptured_WhenTheCaptureThrows_WritesNoEntry() + public void IsHoldingCompileCompletion_WhenNothingRuns_IsFalse() { - VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => - { - throw new InvalidOperationException("The capture failed."); - }); + HotReloadSourceSnapshotCapture notStarted = CreateGate(); + Assert.That(notStarted.IsHoldingCompileCompletion, Is.False, "not started"); + + HotReloadSourceSnapshotCapture succeeded = CreateGate(); + succeeded.StartIfNeeded(Trigger); + _background.RunHeldWork(); + Assert.That(succeeded.IsHoldingCompileCompletion, Is.False, "succeeded"); - Assert.Throws( - () => capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad)); + _captureBody = _ => throw new InvalidOperationException("The capture failed."); + HotReloadSourceSnapshotCapture failed = CreateGate(); + failed.StartIfNeeded(Trigger); + _background.RunHeldWork(); + Assert.That(failed.IsHoldingCompileCompletion, Is.False, "failed"); - Assert.That(CountCapturedEntries(), Is.EqualTo(0)); + _captureBody = cancellationToken => + { + cancellationToken.ThrowIfCancellationRequested(); + return true; + }; + HotReloadSourceSnapshotCapture cancelled = CreateGate(); + cancelled.StartIfNeeded(Trigger); + cancelled.CancelInFlight(); + _background.RunHeldWork(); + Assert.That(cancelled.IsHoldingCompileCompletion, Is.False, "cancelled"); } /// - /// What: a capture that saw no compilation assembly is not marked done and writes no entry, - /// so the next call runs it again and the entry names that call's trigger. + /// What: cancelling signals the running capture's token without waiting for it, and the + /// capture that stops leaves the gate not captured and not in flight. /// [Test] - public void EnsureCaptured_WhenTheCaptureSawNoAssembly_RunsItAgainOnTheNextCall() + public void CancelInFlight_CancelsTheTokenAndLeavesTheGateUnmarked() { - VibeLogger.ClearMemoryLogs(); - int captureCount = 0; - HotReloadSourceSnapshotCapture capture = new HotReloadSourceSnapshotCapture(() => + CancellationToken seenToken = CancellationToken.None; + _captureBody = cancellationToken => { - captureCount++; - return captureCount > 1; - }); + seenToken = cancellationToken; + cancellationToken.ThrowIfCancellationRequested(); + return true; + }; + HotReloadSourceSnapshotCapture gate = CreateGate(); + Task task = gate.StartIfNeeded(Trigger); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad); - Assert.That(CountCapturedEntries(), Is.EqualTo(0), "entries after the first call"); + gate.CancelInFlight(); + Assert.That(gate.IsInFlight, Is.True, "in flight until the capture stops"); + _background.RunHeldWork(); - capture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick); + Assert.That(seenToken.IsCancellationRequested, Is.True); + // Faulted, not Canceled: the background runner is given no token, as Task.Run is not, + // so the token's exception faults the task the way it does in production. + Assert.That(task.IsFaulted, Is.True); + Assert.That(task.Exception.GetBaseException(), Is.InstanceOf()); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled), Is.EqualTo(1)); + Assert.That(gate.IsInFlight, Is.False); + Assert.That(CountEntries(HotReloadConstants.VibeLogSourceSnapshotCaptured), Is.EqualTo(0)); + } - Assert.That(captureCount, Is.EqualTo(2), "captures"); - JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotCaptured); - Assert.That((string)context["trigger"], Is.EqualTo(HotReloadConstants.SourceSnapshotCaptureTriggerFirstUpdateTick)); + private HotReloadSourceSnapshotCapture CreateGate() + { + return new HotReloadSourceSnapshotCapture( + () => + { + _inputReads++; + return HotReloadSourceSnapshotCaptureTestDoubles.FixedInputs(); + }, + RunCapture, + _background.Run, + () => _nowTicks); + } + + private bool RunCapture(HotReloadSnapshotCaptureInputs inputs, CancellationToken cancellationToken) + { + _captureRuns++; + return _captureBody(cancellationToken); } - private static int CountCapturedEntries() + private static int CountEntries(string operation) { - return JArray.Parse(VibeLogger.GetLogsForAi(HotReloadConstants.VibeLogSourceSnapshotCaptured)).Count; + return JArray.Parse(VibeLogger.GetLogsForAi(operation)).Count; } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 8c4067726a..1e4cb6554b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Threading; using Mono.Cecil; using Mono.Cecil.Cil; @@ -180,7 +181,7 @@ public void LoadVerifiedSnapshotSource_ForPredefinedEditorAssembly_ReturnsOnDisk [Test] public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsTheVerifiedSource() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); string dllPath = Path.Combine( projectRoot, @@ -203,7 +204,7 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase() { VibeLogger.ClearMemoryLogs(); - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown); string[] fields = diff --git a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs index e2f9dce991..11a13c8176 100644 --- a/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs +++ b/Assets/Tests/Editor/HotReload/PausePointScriptPathFormTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using NUnit.Framework; @@ -55,7 +56,7 @@ public void TearDown() [Test] public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedFileBasis() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); PausePointResponse response = Enable(FixtureAssetPath, SecondStatementLine); @@ -69,7 +70,7 @@ public void Enable_PackageSourceGivenByItsAssetPath_ArmsTheStatementOnTheEditedF [Test] public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAssetPath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); PausePointResponse response = Enable(FixturePhysicalPath, SecondStatementLine); @@ -82,7 +83,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPath_ArmsTheSameMarkerAsTheAss [Test] public void Enable_PackageSourceGivenByItsAbsolutePhysicalPath_ArmsTheSameMarkerAsTheAssetPath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); PausePointResponse response = Enable(ToAbsolutePath(FixturePhysicalPath), SecondStatementLine); @@ -130,7 +131,7 @@ public void Enable_PackageSourceGivenByItsPhysicalPathWithAShiftedSnapshot_MapsT [Test] public void Enable_AssetsSourceGivenByItsAbsolutePath_ArmsTheSameMarkerAsTheRelativePath() { - HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); int line = FindLineNumber(File.ReadAllText(ToAbsolutePath(AssetsFixturePath)), AssetsFixtureStatementText); Assert.That(line, Is.GreaterThan(0)); diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index 2bc77b7572..0d23813692 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -40,8 +40,9 @@ public static void Initialize(Func isToolEnabled) } /// - /// Runs the first-party tool work that has to finish before this domain answers its first - /// uloop command. Called once, at the end of the Editor startup. + /// Starts the first-party tool work that has to begin before this domain answers its first + /// uloop command: the source snapshot capture, which then runs in the background. Called + /// once, at the end of the Editor startup. /// answers whether the user enabled a tool in the tool settings, by its settings name. /// public static void PrepareBeforeServingCommands(Func isToolEnabled) @@ -54,7 +55,7 @@ public static void PrepareBeforeServingCommands(Func isToolEnabled return; } - HotReloadEditorStartup.CaptureSourceSnapshotBeforeServingCommands(isToolEnabled); + HotReloadEditorStartup.StartSourceSnapshotCaptureBeforeServingCommands(isToolEnabled); } public static void ResetServerScopedServices() diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 2c0b0e4cce..953e825da3 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using HarmonyLib; @@ -72,12 +73,22 @@ internal static HotReloadServices CreateProductionServicesWith( TransformWorkerHost.Shared, HotReloadGroupProcessorDependencies.CreateProduction, new HotReloadApplicationPlayModeQuery(), - new HotReloadSourceSnapshotCapture( - () => HotReloadSourceSnapshotter.CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs.ReadOnMainThread())), + CreateProductionSourceSnapshotCapture(), warmUp, compiledCallers); } + // Why only the inputs are read before the pool: they are the capture's only Unity calls, and + // Unity allows them on the main thread alone. The capture itself reads files and PDBs. + private static HotReloadSourceSnapshotCapture CreateProductionSourceSnapshotCapture() + { + return new HotReloadSourceSnapshotCapture( + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + HotReloadSourceSnapshotter.CaptureAfterDomainReload, + work => Task.Run(work), + () => DateTime.UtcNow.Ticks); + } + private static HotReloadWarmUp CreateProductionWarmUp(HotReloadCompiledCallers compiledCallers) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); @@ -273,11 +284,9 @@ private static void Install(HotReloadServices services) new HotReloadPausePointPort( domain, path => new HotReloadSourceContentHasher().TryComputeContentHashOfFileOrNull(path), - () => services.SourceSnapshotCapture.EnsureCaptured( + () => services.SourceSnapshotCapture.EnsureCapturedBlocking( HotReloadConstants.SourceSnapshotCaptureTriggerPausePoint), - // The capture still runs synchronously inside EnsureCaptured, so none is ever in - // flight when a pause point asks. - () => false); + () => services.SourceSnapshotCapture.IsInFlight); // Attaching last keeps the invariant across the gap: the resolver only starts // answering binds once every gateway already points at the domain behind it. domain.IntroducedTypeResolver.Resume(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 3382ae4d52..49fa426feb 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -25,9 +25,9 @@ public static void Initialize(Func isToolEnabled) // "Scripts have compiler errors" dialog never flushes delayCall again for the rest of // that process's lifetime, even for later registrations — while // EditorApplication.update keeps ticking (see SetupWizardWindow.cs:56-70). The - // snapshot is captured at the end of the domain load; this tick retries only when that - // capture threw or Unity listed no compilation assembly yet, and the gate does nothing - // when it already succeeded. + // capture starts at the end of the domain load; this tick starts another only when that + // one failed, found no compiled assembly, or was cancelled, and the gate does nothing + // when it is still running or already succeeded. void CaptureOnFirstUpdateTick() { EditorApplication.update -= CaptureOnFirstUpdateTick; @@ -68,6 +68,10 @@ void StartWarmUpOnFirstUpdateTick() // null delegate as "nothing to lose". HotReloadRuntimeChangeCoordination.GetActiveRuntimeChangeCount = () => HotReloadCompositionRoot.Services.Domain.CountActiveChanges().RuntimeChangeTotal; + // Why here, like the count above: get-compile-status reads it on every poll, which can + // come before anything in this assembly is touched. + HotReloadSnapshotCaptureCoordination.IsHoldingCompileCompletion = + () => HotReloadCompositionRoot.Services.SourceSnapshotCapture.IsHoldingCompileCompletion; // Why here, like the count above: the dynamic-code tool reads it after each server // reset, which can come before anything in this assembly is touched. string warmUpProjectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); @@ -98,17 +102,19 @@ void StartWarmUpOnFirstUpdateTick() } /// - /// Captures the source snapshot of the current compile before this domain answers any uloop - /// command, so an edit made after the compile returned is never captured as compiled source. + /// Starts the source snapshot capture of the current compile before this domain answers any + /// uloop command, without waiting for it. The guarantee that an edit made after the compile + /// returned is never captured as compiled source comes from get-compile-status holding the + /// compile completion while the capture runs (up to 30 seconds). /// - internal static void CaptureSourceSnapshotBeforeServingCommands(Func isToolEnabled) + internal static void StartSourceSnapshotCaptureBeforeServingCommands(Func isToolEnabled) { CaptureAtStartupUnlessReadersAreDisabled( HotReloadConstants.SourceSnapshotCaptureTriggerDomainLoad, isToolEnabled); } /// - /// Captures the source snapshot at a startup trigger unless both tools that read it are + /// Starts the source snapshot capture at a startup trigger unless both tools that read it are /// disabled in the tool settings. /// internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Func isToolEnabled) @@ -133,7 +139,9 @@ internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Fu return; } - HotReloadCompositionRoot.Services.SourceSnapshotCapture.EnsureCaptured(trigger); + // Why the task is not awaited: the domain load must not wait for the capture, and the + // gate logs how it ended. A caller that needs the snapshot waits for it itself. + _ = HotReloadCompositionRoot.Services.SourceSnapshotCapture.StartIfNeeded(trigger); } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs index 53db67b8eb..dba27e1f1f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTools.cs @@ -332,9 +332,14 @@ protected override async Task ExecuteAsync( // selection and response rows, and PackageInfo is main-thread only, which this path is. services.PackageRootCapture.CaptureCurrent(); // Why here: both the default selection below and the run read the snapshot of the compile - // the last reload loaded. The gate does nothing when the domain load captured it; it - // captures here when that capture threw or Unity listed no compilation assembly yet. - services.SourceSnapshotCapture.EnsureCaptured(HotReloadConstants.SourceSnapshotCaptureTriggerApply); + // the last reload loaded. This waits for the capture the domain load started, or starts + // one when that capture failed, found no compiled assembly, or was cancelled. + await services.SourceSnapshotCapture.EnsureCapturedAsync(HotReloadConstants.SourceSnapshotCaptureTriggerApply) + .ConfigureAwait(false); + // Why switch explicitly: the capture finishes on a pool thread and the wait does not return + // through the synchronization context, while the selection below reads Unity's compilation + // assemblies, which only the main thread may. + await MainThreadSwitcher.SwitchToMainThread(ct); HotReloadDefaultFileSelection selection = HotReloadDefaultFileSelector.Resolve( parameters.Files, services.ChangeDetector.Detect, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs index 5e67dc9f5e..f96b1fd96a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpEditorHooks.cs @@ -6,9 +6,10 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Stops the installed warm-up and the background reads of the compiled-caller analysis when - /// the Editor is about to reload the domain or starts a compile: they would read dlls that are - /// being rewritten, or work for a domain that is going away. + /// Stops the installed warm-up, the background reads of the compiled-caller analysis, and the + /// running source snapshot capture when the Editor is about to reload the domain or starts a + /// compile: they would read dlls that are being rewritten, or work for a domain that is going + /// away. /// internal static class HotReloadWarmUpEditorHooks { @@ -35,12 +36,14 @@ internal static void ShutdownForReload() { _ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload); _ = GetServices().CompiledCallers.StopBackgroundReadsAsync(HotReloadConstants.WarmUpShutdownTriggerBeforeAssemblyReload); + GetServices().SourceSnapshotCapture.CancelInFlight(); } internal static void ShutdownForCompile(object context) { _ = GetServices().WarmUp.Shutdown(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); _ = GetServices().CompiledCallers.StopBackgroundReadsAsync(HotReloadConstants.WarmUpShutdownTriggerCompilationStarted); + GetServices().SourceSnapshotCapture.CancelInFlight(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index f38b407e57..8e62c4ef32 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -695,9 +695,16 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogWarmUpSkipped = "hot_reload_warm_up_skipped"; public const string VibeLogCallerNoteBackfillComplete = "hot_reload_caller_note_backfill_complete"; public const string VibeLogSourceSnapshotCaptured = "hot_reload_source_snapshot_captured"; + // The other entries of a source snapshot capture that runs off the main thread: its start, + // the ways it can end without capturing, and the compile completion giving up on it. + public const string VibeLogSourceSnapshotCaptureStarted = "hot_reload_source_snapshot_capture_started"; + public const string VibeLogSourceSnapshotCaptureCancelled = "hot_reload_source_snapshot_capture_cancelled"; + public const string VibeLogSourceSnapshotCaptureFailed = "hot_reload_source_snapshot_capture_failed"; + public const string VibeLogSourceSnapshotCaptureEmpty = "hot_reload_source_snapshot_capture_empty"; + public const string VibeLogSourceSnapshotCaptureHoldCapExceeded = "hot_reload_source_snapshot_capture_hold_cap_exceeded"; // What captured the source snapshot of a domain, as named in the trigger field of - // hot_reload_source_snapshot_captured. + // hot_reload_source_snapshot_captured and the other capture entries. public const string SourceSnapshotCaptureTriggerDomainLoad = "domain_load"; public const string SourceSnapshotCaptureTriggerFirstUpdateTick = "first_update_tick"; public const string SourceSnapshotCaptureTriggerApply = "apply"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs index bbeadc3eed..d740988f03 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCapture.cs @@ -1,5 +1,7 @@ using System; using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -8,54 +10,270 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Captures the source snapshot of the current compile once per domain, at the end of the - /// domain load. When that capture throws or Unity listed no compilation assembly yet, the - /// first update tick or the hot reload apply entry captures again. + /// Captures the source snapshot of the current compile once per domain, off the main thread. + /// The domain load starts the capture without waiting for it; a hot reload apply awaits it and + /// a pause point blocks on it. At most one capture runs at a time. A capture that throws, saw + /// no compiled assembly, or was cancelled leaves the domain uncaptured, so the next caller + /// starts a new one. /// internal sealed class HotReloadSourceSnapshotCapture { - private readonly Func _capture; + // Why a cap on the hold: if a capture never finishes, compile must still return. Readers + // keep waiting for the capture; only the compile completion stops waiting for it. + private static readonly long HoldCapTicks = TimeSpan.FromSeconds(30).Ticks; + + private readonly Func _readInputsOnMainThread; + private readonly Func _capture; + private readonly Func, Task> _runInBackground; + private readonly Func _utcNowTicks; + // Why a lock: the capture finishes on a pool thread while the main thread asks whether it + // runs, and a stale answer could start a second capture that collides with the first. + private readonly object _lock = new object(); private bool _captured; + private Task _inFlight; + private CancellationTokenSource _inFlightCancellation; + private long _inFlightStartedUtcTicks; + private bool _holdCapLogged; + /// Reads what the capture needs from Unity on the main thread. /// - /// Captures the snapshot and returns whether Unity listed at least one compilation assembly. + /// Captures the snapshot without touching Unity APIs and returns whether it found at least + /// one compiled assembly. /// - internal HotReloadSourceSnapshotCapture(Func capture) + /// Runs the capture off the main thread. + /// Reads the current UTC time, for the cap on the compile hold. + internal HotReloadSourceSnapshotCapture( + Func readInputsOnMainThread, + Func capture, + Func, Task> runInBackground, + Func utcNowTicks) { + Debug.Assert(readInputsOnMainThread != null, "readInputsOnMainThread must not be null."); Debug.Assert(capture != null, "capture must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + Debug.Assert(utcNowTicks != null, "utcNowTicks must not be null."); + + _readInputsOnMainThread = readInputsOnMainThread; _capture = capture; + _runInBackground = runInBackground; + _utcNowTicks = utcNowTicks; + } + + /// + /// Whether a capture is running now. + /// + internal bool IsInFlight + { + get + { + lock (_lock) + { + return _inFlight != null; + } + } } /// - /// Runs the capture unless it already ran to completion in this domain, and records - /// when this call completes it. Main thread only. A capture that - /// throws or saw no compilation assembly is not marked done, so the next caller runs it again. + /// Whether the compile completion should wait for the running capture. False once it has run + /// for 30 seconds, which is logged once per capture. Main thread only. /// - internal void EnsureCaptured(string trigger) + internal bool IsHoldingCompileCompletion + { + get + { + bool logCapExceeded; + lock (_lock) + { + if (_inFlight == null) + { + return false; + } + + if (_utcNowTicks() - _inFlightStartedUtcTicks < HoldCapTicks) + { + return true; + } + + logCapExceeded = !_holdCapLogged; + _holdCapLogged = true; + } + + if (logCapExceeded) + { + VibeLogger.LogWarning( + HotReloadConstants.VibeLogSourceSnapshotCaptureHoldCapExceeded, + "Hot reload stopped holding the compile completion for a source snapshot capture that has not finished.", + new { holdCapSeconds = TimeSpan.FromTicks(HoldCapTicks).TotalSeconds }); + } + + return false; + } + } + + /// + /// Returns a completed task when this domain is captured, the running capture's task when one + /// runs, and otherwise the task of a capture started now. Main thread only, because the + /// inputs are read from Unity here. An exception while reading them reaches the caller and + /// starts nothing. + /// + internal Task StartIfNeeded(string trigger) { Debug.Assert(!string.IsNullOrEmpty(trigger), "trigger must not be null or empty."); - if (_captured) + + lock (_lock) + { + if (_captured) + { + return Task.FromResult(true); + } + + if (_inFlight != null) + { + return _inFlight; + } + } + + Stopwatch mainThreadWatch = Stopwatch.StartNew(); + HotReloadSnapshotCaptureInputs inputs = _readInputsOnMainThread(); + CancellationTokenSource cancellation = new CancellationTokenSource(); + Stopwatch poolWatch = new Stopwatch(); + Task task = _runInBackground(() => + { + poolWatch.Start(); + try + { + return _capture(inputs, cancellation.Token); + } + finally + { + poolWatch.Stop(); + } + }); + Debug.Assert(task != null, "runInBackground must return a task."); + lock (_lock) + { + _inFlight = task; + _inFlightCancellation = cancellation; + _inFlightStartedUtcTicks = _utcNowTicks(); + _holdCapLogged = false; + } + + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptureStarted, + "Hot reload started the source snapshot capture of this domain.", + new { trigger, mainThreadMs = mainThreadWatch.ElapsedMilliseconds }); + // Why the continuation is attached after the task is published: a task that already + // finished then runs it right here, and it finds the published task to clear. + // Why ExecuteSynchronously on the default scheduler: the bookkeeping must not wait for the + // main thread, which a pause point may be blocking on this very task. + task.ContinueWith( + finished => OnFinished(finished, cancellation, trigger, poolWatch), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return task; + } + + /// + /// Completes when this domain's capture has run, passing on the exception of a capture that + /// failed. Main thread only. It may complete on a pool thread, so a caller that continues with + /// main-thread work switches back itself. + /// + internal async Task EnsureCapturedAsync(string trigger) + { + await StartIfNeeded(trigger).ConfigureAwait(false); + } + + /// + /// Blocks the main thread until this domain's capture has run, passing on the exception of a + /// capture that failed. For synchronous readers only. It cannot dead-lock, because the + /// capture and its bookkeeping never wait for the main thread. + /// + internal void EnsureCapturedBlocking(string trigger) + { + StartIfNeeded(trigger).GetAwaiter().GetResult(); + } + + /// + /// Asks the running capture to stop, without waiting for it. Its assemblies that were not + /// finished get no stamp, so the next capture redoes them. + /// + internal void CancelInFlight() + { + lock (_lock) + { + _inFlightCancellation?.Cancel(); + } + } + + private void OnFinished( + Task task, + CancellationTokenSource cancellation, + string trigger, + Stopwatch poolWatch) + { + // Why the exception is read on every path that has one: the domain load and the first + // update tick never await the task, and an exception nobody reads is reported later as + // an unobserved task exception. Why here and not in a log argument: the VibeLogger + // calls are conditional on ULOOP_DEBUG, so an ordinary build drops them with their + // arguments, and a read inside one would never run. + Exception failure = task.IsFaulted ? task.Exception.GetBaseException() : null; + bool cancelled = task.IsCanceled + || (failure is OperationCanceledException && cancellation.IsCancellationRequested); + bool captured = task.Status == TaskStatus.RanToCompletion && task.Result; + lock (_lock) + { + if (!ReferenceEquals(_inFlightCancellation, cancellation)) + { + return; + } + + _inFlight = null; + _inFlightCancellation = null; + // Marked only after the capture returns true: a capture that threw may have left + // assemblies without a snapshot, and one that found no compiled assembly ran while + // Unity was compiling. Marking either done would keep every later reader from + // capturing them. + _captured = captured; + } + + cancellation.Dispose(); + LogOutcome(trigger, captured, cancelled, failure, poolWatch.ElapsedMilliseconds); + } + + private static void LogOutcome(string trigger, bool captured, bool cancelled, Exception failure, long captureMs) + { + if (captured) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptured, + "Hot reload captured the source snapshot of this domain.", + new { trigger, captureMs }); + return; + } + + if (cancelled) { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotCaptureCancelled, + "Hot reload cancelled the source snapshot capture of this domain.", + new { trigger }); return; } - Stopwatch watch = Stopwatch.StartNew(); - // Marked only after the capture returns: a capture that threw may have left assemblies - // without a snapshot, and marking it done first would keep every later reader from - // capturing them. - bool sawAssemblies = _capture(); - // Why an empty list is not done: Unity lists none while it compiles, and a domain marked - // captured from an empty list would never be captured by any of its readers. - if (!sawAssemblies) + if (failure != null) { + VibeLogger.LogWarning( + HotReloadConstants.VibeLogSourceSnapshotCaptureFailed, + "Hot reload failed to capture the source snapshot of this domain.", + new { trigger, error = failure.GetType().Name, message = failure.Message }); return; } - _captured = true; VibeLogger.LogInfo( - HotReloadConstants.VibeLogSourceSnapshotCaptured, - "Hot reload captured the source snapshot of this domain.", - new { trigger, captureMs = watch.ElapsedMilliseconds }); + HotReloadConstants.VibeLogSourceSnapshotCaptureEmpty, + "Hot reload found no compiled assembly to capture the source snapshot from.", + new { trigger }); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 413df68c93..39172fb5a1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -29,18 +29,24 @@ internal static partial class HotReloadSourceSnapshotter /// baseline checks every copy against the PDB when it uses one. /// Logs how long each phase took, even when nothing is listed. /// Returns false when no compiled dll with a PDB was listed. - /// Asks Unity nothing beyond what carries. + /// Asks Unity nothing beyond what carries, so it may run on a pool + /// thread. Throws once + /// is cancelled. /// - internal static bool CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs inputs) + internal static bool CaptureAfterDomainReload( + HotReloadSnapshotCaptureInputs inputs, + CancellationToken cancellationToken) { Debug.Assert(inputs != null, "inputs must not be null."); + cancellationToken.ThrowIfCancellationRequested(); + System.Diagnostics.Stopwatch totalWatch = System.Diagnostics.Stopwatch.StartNew(); HotReloadSnapshotCaptureStats stats = CaptureAssemblies( inputs, HotReloadPdbDocumentIndex.Shared, HotReloadCaptureAssemblyEnumerator.ReadSources, - CancellationToken.None); + cancellationToken); totalWatch.Stop(); VibeLogger.LogInfo( HotReloadConstants.VibeLogSourceSnapshotBreakdown, diff --git a/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md new file mode 100644 index 0000000000..299626f038 --- /dev/null +++ b/docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md @@ -0,0 +1,70 @@ +# Capture the Hot Reload Source Snapshot Off the Main Thread + +Date: 2026-10-11 + +## Decision + +After every domain reload, hot reload copies the sources of the compile into a snapshot so it can +later tell which methods were edited. That capture now runs on a pool thread: + +- The domain load reads on the main thread only the values the capture needs from Unity (the + project root, the compile start record, and the package roots). It hands them to the pool and + returns without waiting. +- At most one capture runs per domain. A hot reload run awaits it. A pause point blocks on it, + except while a capture runs and the file on disk matches the checksum the compiled assembly's + PDB recorded: those are the bytes the compiler read, so the pause point reads that file + instead of waiting. +- `get-compile-status` reports not ready while a capture runs, so `uloop compile` returns only + after the capture, up to 30 seconds. The hold lives in the internal bridge command, because + the CLI waits for a compile by polling it, outside the tool execution path. +- A domain reload or a compile start cancels the running capture without waiting. A capture that + failed, found no compiled dll with a PDB, or was cancelled leaves the domain uncaptured, and the + next reader starts a new one. + +## Context + +Issue #3222: on a large project the capture ran synchronously on the main thread after every +script compile and took about 1.9–2.3 s, writing 781 files (3.3 MB) for each compile, even when +hot reload was not used. Copying only the changed sources and skipping the capture when both +readers are disabled shortened it, but the main thread still waited on it, most of all on +listing the compilation assemblies from Unity. + +The capture used to list the assemblies with `CompilationPipeline.GetAssemblies`, which Unity +allows on the main thread only. #3283 made it list them from the compiled dlls and their PDBs +instead, so what stays on the main thread is the compile start record (`SessionState`) and the +package list (the Package Manager). Everything else the capture does is file IO and PDB reads, +which the pool can do. + +The guarantee that an edit made right after `uloop compile` returns is never captured as +compiled source used to hold because the capture finished inside the domain load, before the +command listener opened. Holding `get-compile-status` keeps that guarantee. A source written +after the compile started is still checked against the PDB, so a capture that ends after the +30-second cap stays correct; only the timing guarantee of the compile response is lost. + +## Considered Options + +- **1. Keep `GetAssemblies` on the main thread and stop at the incremental copy.** Rejected: the + main thread still stalls. +- **2. Build only the capture's assembly list from the dlls and PDBs.** Not enough alone: the + cost of `GetAssemblies` only moves to the warm-up or the first run. Adopted together with 4. +- **3. Move the capture, the warm-up's reference graph, the default file selection, and the + patch target resolution all to lists built from dlls and PDBs.** Too large. Only the part that + keeps the warm-up from calling `GetAssemblies` at startup (rebuilding the list from a saved + one and the PDBs) was adopted. +- **4. Run the capture on a pool thread.** Not possible alone: `GetAssemblies` is main-thread + only, and while the capture moves the previous snapshot directory nothing is published, so + every reader has to wait on the capture. Adopted together with 2, with the gate below. +- **Waiting for the capture in the tool execution path.** Rejected: `get-compile-status` is an + internal bridge command that never enters it, so the compile response would not wait. The + hold is in its `Ready` instead. +- **Making the pause point wait for the capture in every case.** Rejected: a pause point + re-armed on the first update tick after a reload would block the main thread for the whole + capture, which is the stall this change removes. +- **Re-arming pause points after the capture instead.** Rejected: the re-arm might not finish + before Play Mode starts. + +## Revisit When + +- Unity makes `CompilationPipeline.GetAssemblies` safe to call off the main thread, or +- the way the CLI waits for a compile changes, so the hold in `get-compile-status` no longer + stops the compile response. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 0c40937287..78c4701803 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -81,6 +81,11 @@ and the default selection, the sibling scan and the skip check treat a marked so changed; a capture moves the previous build's directory to the new MVID's and rewrites only the sources whose length or write time changed, that carry no unmarked manifest line, or that were written since the compile started; it deletes the copies of sources no longer in the assembly. +The capture runs on a pool thread after the domain load reads its inputs on the main thread, one +capture at a time; `uloop compile` returns only after it finishes (or after 30 seconds), a hot +reload run waits for it, and a pause point waits for it unless the file on disk matches the PDB +checksum, in which case it reads that file. A domain reload or a compile start cancels it, and +the next reader starts it again (`docs/adr/0014-capture-the-hot-reload-source-snapshot-off-the-main-thread.md`). When both the hot-reload and pause-point tools are disabled in the tool settings, the capture after a domain reload is skipped; the first hot reload run or pause point after either tool is enabled captures it, and any source written since the compile started is then checked against diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index d94951c8ca..8536dec40a 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -90,21 +90,36 @@ command ran against: sent), `start_failed` or `request_failed` (with `failureReason`; the worker is stopped and the next compile starts it again), or `failed` (an exception, with `failureReason` set to its type and `exceptionMessage`, logged as a warning). - `hot_reload_source_snapshot_captured` appears once per domain when the source snapshot - capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or - `apply` only when the capture at domain load threw or found no compiled dll with a PDB - yet; `apply` or `pause_point` when the captures at startup were skipped and a hot reload run - or a pause point is the first to read the snapshot) and `captureMs`. When both the hot-reload - and pause-point tools are disabled in the tool settings, the captures at `domain_load` and + The source snapshot capture reads its inputs on the main thread and then runs on a pool + thread, one capture at a time. Each start writes `hot_reload_source_snapshot_capture_started` + with its `trigger` and `mainThreadMs` (the time the main thread spent reading the inputs and + handing the capture over). How it ended follows as one of these, with the same `trigger`: + - `hot_reload_source_snapshot_captured`, once per domain, with `captureMs` (the time on the + pool thread). `trigger` is `domain_load` normally; `first_update_tick`, `apply` or + `pause_point` when the capture at domain load failed, found no compiled dll with a PDB, + or was cancelled, or when the captures at startup were skipped and a hot reload run or a + pause point is the first to read the snapshot. + - `hot_reload_source_snapshot_capture_failed` (a warning) with `error` (the exception type) and + `message`. + - `hot_reload_source_snapshot_capture_empty` when no compiled dll with a PDB beside it was + found. + - `hot_reload_source_snapshot_capture_cancelled` when a domain reload or a compile start + stopped it. + After any of the last three, the next reader or the first update tick starts a new capture. + While a capture runs, `get-compile-status` reports not ready, so `uloop compile` returns only + after it. That hold gives up after 30 seconds and writes one + `hot_reload_source_snapshot_capture_hold_cap_exceeded` (a warning). Readers keep waiting for + the capture. `compile_status_query_received` carries `is_snapshot_capture_holding`. When both + the hot-reload and pause-point tools are disabled in the tool settings, the captures at `domain_load` and `first_update_tick` each write `hot_reload_source_snapshot_skipped` with that `trigger` and `reason` `tools_disabled` instead, and the first hot reload run or pause point after either tool is enabled captures it. `hot_reload_source_snapshot_breakdown` appears once each time the capture runs to the end, just before `hot_reload_source_snapshot_captured`. It also appears when no compiled dll with a - PDB beside it was found, in which case no captured entry follows, and it does not appear when - the capture threw. The capture lists the compiled assemblies from the dlls and PDBs in the - compiled assemblies folder and reads an assembly's sources from its PDB, not from Unity's - compilation assembly list. It says where the capture spent its time: + PDB beside it was found, in which case `hot_reload_source_snapshot_capture_empty` follows + instead, and it does not appear when the capture threw or was cancelled. The capture lists the + compiled assemblies from the dlls and PDBs in the compiled assemblies folder and reads an + assembly's sources from its PDB, not from Unity's compilation assembly list. It says where the capture spent its time: - `getAssembliesMs`: listing the compiled dlls that have a PDB beside them. - `pdbSourcesMs`: reading the sources of each assembly whose dll changed from its PDB. - `packageLookupMs`: reading whether each assembly's sources are immutable, which the PDB's From 9be23be775d95b282bdb843c3b53e7a7b7d3897f Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 12 Oct 2026 08:07:08 +0900 Subject: [PATCH 11/15] perf: Hot reload rebuilds its compilation assembly list off the main thread after a domain reload (#3286) --- ...onAssemblyReconstructionCrossCheckTests.cs | 219 ++++++++ ...emblyReconstructionCrossCheckTests.cs.meta | 11 + ...dCompilationAssemblyReconstructionTests.cs | 521 ++++++++++++++++++ ...ilationAssemblyReconstructionTests.cs.meta | 11 + .../HotReload/HotReloadEditorStartupTests.cs | 45 ++ .../HotReload/HotReloadEditorStartup.cs | 28 + .../Shared/HotReloadCompilationAssemblies.cs | 27 + .../HotReloadCompilationAssemblyRebuilder.cs | 332 +++++++++++ ...ReloadCompilationAssemblyRebuilder.cs.meta | 11 + ...ReloadCompilationAssemblyReconstruction.cs | 207 +++++++ ...dCompilationAssemblyReconstruction.cs.meta | 11 + .../Shared/HotReloadCompilationListRefill.cs | 127 +++++ .../HotReloadCompilationListRefill.cs.meta | 11 + .../HotReload/Shared/HotReloadConstants.cs | 14 + .../Shared/HotReloadPdbCompilationInfo.cs | 63 ++- .../Shared/HotReloadSavedCompilationList.cs | 9 + 16 files changed, 1639 insertions(+), 8 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs new file mode 100644 index 0000000000..35dc0bd860 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Cross-checks the rebuilt compilation assembly list against Unity's on every assembly of this + /// project, once from the saved entries and once with every assembly rebuilt from its PDB. + /// Some packages' PDBs are rewritten after the compile and lose the documents no sequence point + /// refers to; those must be refused, not rebuilt with fewer sources. + /// + public sealed class HotReloadCompilationAssemblyReconstructionCrossCheckTests + { + private string _directory; + private string _filePath; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "uloop-compilation-list-cross-check-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + _filePath = Path.Combine(_directory, "CompilationAssemblies.json"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + /// + /// What: the list rebuilt from a clean save with every dll unchanged equals + /// CompilationPipeline.GetAssemblies() in every field hot reload reads, in order. + /// + [Test] + public void ReconstructedList_MatchesGetAssembliesForEveryField() + { + UnityCompilationAssembly[] expected = CompilationPipeline.GetAssemblies(); + HotReloadCompilationAssemblyListStore store = SaveClean(expected); + HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread(); + + HotReloadCompilationListReconstruction reconstruction = + HotReloadCompilationAssemblyReconstruction.Reconstruct(store, inputs, CancellationToken.None); + + Assert.That(reconstruction.FailureReason, Is.Null, "failure"); + Assert.That(Mismatches(expected, reconstruction.Assemblies), Is.Empty); + } + + /// + /// What: rebuilding every compiled assembly from its PDB gives Unity's fields, in order, for + /// every assembly whose PDB lists as many sources as the compiler was given, and + /// pdb_documents_incomplete for every assembly whose PDB lost some of Unity's sources. + /// + [Test] + public void EveryAssemblyRebuiltFromItsPdb_MatchesGetAssemblies_OrIsRefusedWhenThePdbLostSources() + { + UnityCompilationAssembly[] expected = CompilationPipeline.GetAssemblies(); + HotReloadSavedCompilationList saved = SaveClean(expected).Load().List; + HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread(); + HotReloadCompilationAssemblyRebuilder rebuilder = new HotReloadCompilationAssemblyRebuilder(saved, inputs); + Dictionary expectedByName = new Dictionary(); + foreach (UnityCompilationAssembly assembly in expected) + { + expectedByName[assembly.name] = assembly; + } + + List mismatches = new List(); + List refused = new List(); + List lostSources = new List(); + int rebuilt = 0; + foreach (HotReloadCaptureAssemblyCandidate candidate in HotReloadCaptureAssemblyEnumerator.ListCompiled(inputs.ProjectRoot)) + { + if (!expectedByName.TryGetValue(candidate.Name, out UnityCompilationAssembly want)) + { + continue; + } + + HotReloadSavedCompilationAssembly savedAssembly = saved.assemblies.Find(assembly => assembly.name == candidate.Name); + string reason = rebuilder.TryFromPdb(savedAssembly, candidate, out HotReloadRebuiltAssemblyEntry entry); + if (LostSources(want, candidate, inputs)) + { + lostSources.Add(candidate.Name); + } + + if (reason == HotReloadConstants.CompilationListReasonPdbDocumentsIncomplete) + { + refused.Add(candidate.Name); + continue; + } + + Assert.That(reason, Is.Null, candidate.Name + " failure"); + rebuilt++; + AddEntryMismatches(mismatches, want, entry); + } + + Debug.Log("[cross-check] rebuilt " + rebuilt + ", refused " + refused.Count + ", lost sources " + lostSources.Count); + Assert.That(rebuilt, Is.GreaterThan(0), "Precondition: some assembly must be rebuilt."); + Assert.That(mismatches, Is.Empty, "rebuilt entries"); + // Why only a superset: a PDB may also be refused for another shortfall against the + // compiler's count, which the sources Unity lists need not show. + Assert.That(refused, Is.SupersetOf(lostSources), "refused assemblies"); + } + + private HotReloadCompilationAssemblyListStore SaveClean(UnityCompilationAssembly[] fetched) + { + HotReloadCompilationAssemblyListStore store = CreateStore(); + store.SaveInBackgroundIfClean(fetched, 0, Path.GetFullPath(Path.Combine(Application.dataPath, ".."))); + Assert.That(File.Exists(_filePath), Is.True, "Precondition: the list must be saved."); + return store; + } + + // True when some source Unity lists for the assembly is not among the PDB's documents. + private static bool LostSources( + UnityCompilationAssembly want, + HotReloadCaptureAssemblyCandidate candidate, + HotReloadSnapshotCaptureInputs inputs) + { + HashSet inPdb = new HashSet(HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, inputs).SourceAssetPaths); + return !inPdb.IsSupersetOf(want.sourceFiles); + } + + private static void AddEntryMismatches(List mismatches, UnityCompilationAssembly want, HotReloadRebuiltAssemblyEntry got) + { + AddIfDifferent(mismatches, want.name, "outputPath", new[] { want.outputPath }, new[] { got.OutputPath }); + AddIfDifferent(mismatches, want.name, "sourceFiles", want.sourceFiles, got.SourceFiles); + AddIfDifferent(mismatches, want.name, "defines", want.defines, got.Defines); + AddIfDifferent(mismatches, want.name, "assemblyReferences", NamesOf(want.assemblyReferences), got.ProjectReferences.ToArray()); + AddIfDifferent(mismatches, want.name, "compiledAssemblyReferences", want.compiledAssemblyReferences, got.CompiledReferences.ToArray()); + } + + private HotReloadCompilationAssemblyListStore CreateStore() + { + // Why inline: EditMode tests must not start pool work, so the "background" runs here. + return new HotReloadCompilationAssemblyListStore( + _filePath, + () => false, + () => 0, + work => + { + work(); + return Task.CompletedTask; + }); + } + + private static List Mismatches(UnityCompilationAssembly[] expected, UnityCompilationAssembly[] actual) + { + List mismatches = new List(); + if (actual == null || actual.Length != expected.Length) + { + mismatches.Add("count " + expected.Length + " != " + (actual == null ? "null" : actual.Length.ToString())); + return mismatches; + } + + for (int index = 0; index < expected.Length; index++) + { + UnityCompilationAssembly want = expected[index]; + UnityCompilationAssembly got = actual[index]; + AddIfDifferent(mismatches, want.name, "name", new[] { want.name }, new[] { got.name }); + AddIfDifferent(mismatches, want.name, "outputPath", new[] { want.outputPath }, new[] { got.outputPath }); + AddIfDifferent(mismatches, want.name, "sourceFiles", want.sourceFiles, got.sourceFiles); + AddIfDifferent(mismatches, want.name, "defines", want.defines, got.defines); + AddIfDifferent(mismatches, want.name, "assemblyReferences", NamesOf(want.assemblyReferences), NamesOf(got.assemblyReferences)); + AddIfDifferent(mismatches, want.name, "compiledAssemblyReferences", want.compiledAssemblyReferences, got.compiledAssemblyReferences); + AddIfDifferent(mismatches, want.name, "allReferences", want.allReferences, got.allReferences); + } + + return mismatches; + } + + private static string[] NamesOf(UnityCompilationAssembly[] assemblies) + { + string[] names = new string[assemblies.Length]; + for (int index = 0; index < assemblies.Length; index++) + { + names[index] = assemblies[index].name; + } + + return names; + } + + private static void AddIfDifferent(List mismatches, string assemblyName, string field, string[] want, string[] got) + { + if (want.Length == got.Length && Array.TrueForAll(Indexes(want.Length), index => want[index] == got[index])) + { + return; + } + + bool sameSet = new HashSet(want).SetEquals(got); + mismatches.Add(assemblyName + "." + field + (sameSet ? " (order only)" : " (content)")); + } + + private static int[] Indexes(int count) + { + int[] indexes = new int[count]; + for (int index = 0; index < count; index++) + { + indexes[index] = index; + } + + return indexes; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs.meta new file mode 100644 index 0000000000..2de810fe0c --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d0a6db33e0084c7d89ef013a5ac23eb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs new file mode 100644 index 0000000000..164ca82f7d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs @@ -0,0 +1,521 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Mono.Cecil; +using Mono.Cecil.Cil; + +using Newtonsoft.Json; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of rebuilding the compilation assembly list from the saved list and the compiled + /// files: a saved entry is used as it is only while its dll is unchanged, a compiled-again + /// assembly is rebuilt from its PDB, and anything that cannot be shown to match the files on + /// disk gives no list at all. Each case saves this project's real list, then changes the one + /// part of the saved file the case is about; the dlls on disk are never touched. + /// + public sealed class HotReloadCompilationAssemblyReconstructionTests + { + private const string TargetAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string TargetReferenceFileName = "nunit.framework.dll"; + private const string SavedOnlyDefine = "SAVED_ONLY_DEFINE"; + private const string RemovedAssemblyName = "Removed.Assembly"; + + private string _directory; + private string _filePath; + private string _projectRoot; + private int _backgroundRuns; + private bool _isCompilationFailed; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "uloop-compilation-list-rebuild-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + _filePath = Path.Combine(_directory, "CompilationAssemblies.json"); + _projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + _backgroundRuns = 0; + _isCompilationFailed = false; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + /// + /// What: without a saved list the rebuild gives no list and passes on why the load failed. + /// + [Test] + public void Reconstruct_WithoutAUsableSavedList_ReturnsNull() + { + HotReloadCompilationListReconstruction result = Reconstruct(CreateStore()); + + Assert.That(result.Assemblies, Is.Null, "list"); + Assert.That(result.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonNoSavedList)); + } + + /// + /// What: a saved list that names a dll no longer compiled, or lacks one that is, gives no list. + /// + [TestCase(true, TestName = "Reconstruct_WhenTheAssemblySetChanged_ReturnsNull(added)")] + [TestCase(false, TestName = "Reconstruct_WhenTheAssemblySetChanged_ReturnsNull(removed)")] + public void Reconstruct_WhenTheAssemblySetChanged_ReturnsNull(bool savedHasAnExtraDll) + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => + { + if (savedHasAnExtraDll) + { + HotReloadSavedCompilationAssembly extra = JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(Target(saved))); + extra.name = RemovedAssemblyName; + saved.assemblies.Add(extra); + return; + } + + Target(saved).dllStamp = null; + }); + + HotReloadCompilationListReconstruction result = Reconstruct(store); + + Assert.That(result.Assemblies, Is.Null, "list"); + Assert.That(result.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonAssemblySetChanged)); + } + + /// + /// What: when no dll changed, every entry is the saved one, even where the PDB says otherwise. + /// + [Test] + public void Reconstruct_AllStampsUnchanged_RebuildsTheSavedList() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => Target(saved).defines = new[] { SavedOnlyDefine }); + + HotReloadCompilationListReconstruction result = Reconstruct(store); + + Assert.That(result.FailureReason, Is.Null, "failure"); + Assert.That(Find(result.Assemblies, TargetAssemblyName).defines, Is.EqualTo(new[] { SavedOnlyDefine })); + } + + /// + /// What: an assembly whose dll changed takes its sources, defines, project references and + /// precompiled references from its PDB, dropping what the saved entry had but the PDB no + /// longer records, so it equals Unity's entry field by field and in order. + /// + [Test] + public void Reconstruct_RecompiledAssembly_TakesSourcesDefinesAndReferencesFromItsPdb() + { + UnityCompilationAssembly expected = CompilationPipeline.GetAssemblies()[IndexOf(TargetAssemblyName)]; + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => + { + HotReloadSavedCompilationAssembly target = MarkRecompiled(saved); + target.defines = new[] { SavedOnlyDefine }; + target.sourceFiles = Append(target.sourceFiles, "Assets/NotOnDisk/Removed.cs"); + target.assemblyReferences = Append(target.assemblyReferences, RemovedAssemblyName); + target.compiledAssemblyReferenceIndexes = AppendIndex( + target.compiledAssemblyReferenceIndexes, IndexOfReferenceNotIn(saved, target)); + }); + + HotReloadCompilationListReconstruction result = Reconstruct(store); + + Assert.That(result.FailureReason, Is.Null, "failure"); + UnityCompilationAssembly rebuilt = Find(result.Assemblies, TargetAssemblyName); + Assert.That(rebuilt.sourceFiles, Is.EqualTo(expected.sourceFiles), "sourceFiles"); + Assert.That(rebuilt.defines, Is.EqualTo(expected.defines), "defines"); + Assert.That(NamesOf(rebuilt.assemblyReferences), Is.EqualTo(NamesOf(expected.assemblyReferences)), "assemblyReferences"); + Assert.That(rebuilt.compiledAssemblyReferences, Is.EqualTo(expected.compiledAssemblyReferences), "compiledAssemblyReferences"); + } + + /// + /// What: a reference the PDB records under a file name no saved path has gives no list. + /// + [Test] + public void Reconstruct_UnresolvedReference_ReturnsNull() + { + AssertRecompiledTargetGives( + HotReloadConstants.CompilationListReasonUnresolvedReference, + reference => reference.path = reference.path.Replace(TargetReferenceFileName, "renamed.dll")); + } + + /// + /// What: a reference whose saved path no longer exists gives no list. + /// + [Test] + public void Reconstruct_MissingReferenceFile_ReturnsNull() + { + AssertRecompiledTargetGives( + HotReloadConstants.CompilationListReasonReferenceMissing, + reference => + { + reference.path = "Library/NotOnDisk/" + TargetReferenceFileName; + reference.stamp = null; + reference.mvid = null; + }); + } + + /// + /// What: a reference whose file holds another MVID than the PDB records gives no list. The + /// saved MVID stands for the file because its stamp is unchanged. + /// + [Test] + public void Reconstruct_ReferenceWithAnotherMvid_ReturnsNull() + { + AssertRecompiledTargetGives( + HotReloadConstants.CompilationListReasonReferenceChanged, + reference => reference.mvid = Guid.NewGuid().ToString("N")); + } + + /// + /// What: a recompiled assembly whose PDB was rewritten after the compile and lost the + /// documents no sequence point refers to (here by a Cecil round trip, as an IL + /// post-processor does) is refused, instead of being rebuilt with fewer sources. + /// + [Test] + public void TryFromPdb_PdbThatLostSourcesInARewrite_IsRefused() + { + HotReloadSavedCompilationList saved = SaveCleanThenEdit(edit => { }).Load().List; + string scriptAssemblies = Path.Combine(_directory, "Project", HotReloadConstants.ScriptAssembliesRelativeDirectory); + Directory.CreateDirectory(scriptAssemblies); + string dllPath = Path.Combine(scriptAssemblies, TargetAssemblyName + ".dll"); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + RewriteWithCecil(dllPath); + HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(pdbPath); + Assert.That(rewritten.SourceFileCount, Is.Not.Null, "Precondition: the rewritten PDB must keep the compiler's source count."); + Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount.Value), "Precondition: the rewrite must drop some document."); + HotReloadCompilationAssemblyRebuilder rebuilder = + new HotReloadCompilationAssemblyRebuilder(saved, HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + + string reason = rebuilder.TryFromPdb( + Target(saved), + new HotReloadCaptureAssemblyCandidate(TargetAssemblyName, dllPath, pdbPath), + out HotReloadRebuiltAssemblyEntry entry); + + Assert.That(reason, Is.EqualTo(HotReloadConstants.CompilationListReasonPdbDocumentsIncomplete)); + Assert.That(entry, Is.Null, "entry"); + } + + /// + /// What: a recompiled assembly whose PDB belongs to another build of the dll gives no list. + /// + [Test] + public void Reconstruct_PdbOfAnotherBuild_ReturnsNull() + { + string scriptAssemblies = Path.Combine(_directory, "Project", HotReloadConstants.ScriptAssembliesRelativeDirectory); + Directory.CreateDirectory(scriptAssemblies); + CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(_projectRoot); + File.Copy(real.DllPath(TargetAssemblyName), Path.Combine(scriptAssemblies, TargetAssemblyName + ".dll")); + File.Copy(real.PdbPath("UnityCLILoop.ToolContracts"), Path.Combine(scriptAssemblies, TargetAssemblyName + ".pdb")); + File.WriteAllText(_filePath, JsonConvert.SerializeObject(new HotReloadSavedCompilationList + { + formatVersion = HotReloadCompilationAssemblyListStore.FormatVersion, + assemblies = new List + { + new HotReloadSavedCompilationAssembly + { + name = TargetAssemblyName, + outputPath = "Library/ScriptAssemblies/" + TargetAssemblyName + ".dll", + sourceFiles = Array.Empty(), + defines = Array.Empty(), + assemblyReferences = Array.Empty(), + compiledAssemblyReferenceIndexes = Array.Empty(), + dllStamp = new HotReloadSavedFileStamp { length = 1, lastWriteTicks = 1 }, + }, + }, + references = new List(), + })); + HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs( + Path.Combine(_directory, "Project"), + default(HotReloadCompileStart), + Array.Empty(), + StringComparison.Ordinal); + + HotReloadCompilationListReconstruction result = + HotReloadCompilationAssemblyReconstruction.Reconstruct(CreateStore(), inputs, CancellationToken.None); + + Assert.That(result.Assemblies, Is.Null, "list"); + Assert.That(result.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonPdbMismatch)); + } + + /// + /// What: a rebuilt list that fills the empty cache is saved again as the next domain's + /// baseline, and Unity is not asked. + /// + [Test] + public void ReconstructAndFill_WhenFilled_SavesTheRebuiltListAsTheNewBaseline() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + int savesBefore = _backgroundRuns; + int fetches = 0; + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(() => + { + fetches++; + return Array.Empty(); + }); + + Task refill = CreateRefill(store, cache).RunAsync(); + + Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status"); + Assert.That(refill.Result, Is.True, "kept"); + Assert.That(cache.IsFilled, Is.True, "cache filled"); + Assert.That(_backgroundRuns, Is.EqualTo(savesBefore + 1), "saves"); + Assert.That(store.Load().List, Is.Not.Null, "saved list readable"); + cache.Current(); + Assert.That(fetches, Is.EqualTo(0), "Unity asked"); + } + + /// + /// What: when a caller already filled the cache from Unity, the rebuilt list is neither kept + /// nor saved. + /// + [Test] + public void ReconstructAndFill_WhenTheCacheIsAlreadyFilled_KeepsAndSavesNothing() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + int savesBefore = _backgroundRuns; + UnityCompilationAssembly[] fetched = CompilationPipeline.GetAssemblies(); + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(() => fetched); + cache.Current(); + + Task refill = CreateRefill(store, cache).RunAsync(); + + Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status"); + Assert.That(refill.Result, Is.False, "kept"); + Assert.That(cache.Current(), Is.SameAs(fetched), "cache content"); + Assert.That(_backgroundRuns, Is.EqualTo(savesBefore), "saves"); + } + + /// + /// What: a rebuild cancelled while it runs in the background keeps nothing, saves nothing and + /// reports the cancellation instead of faulting. + /// + [Test] + public async Task ReconstructAndFill_CancelledWhileRebuilding_KeepsNothing() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + int savesBefore = _backgroundRuns; + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty); + TaskCompletionSource held = + new TaskCompletionSource(); + Func rebuild = null; + HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill( + store, + cache, + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + () => _isCompilationFailed, + work => + { + rebuild = work; + return held.Task; + }); + VibeLogger.ClearMemoryLogs(); + + Task run = refill.RunAsync(); + HotReloadCompilationListReconstruction rebuilt = rebuild(); + Assert.That(rebuilt.Assemblies, Is.Not.Null, "Precondition: the rebuild must give a list."); + refill.Cancel(); + held.SetResult(rebuilt); + // Why awaited: the rest of the run resumes off this thread after the held rebuild completes. + bool kept = await run; + + Assert.That(run.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status"); + Assert.That(kept, Is.False, "kept"); + Assert.That(cache.IsFilled, Is.False, "cache filled"); + Assert.That(_backgroundRuns, Is.EqualTo(savesBefore), "saves"); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed)["reason"], + Is.EqualTo(HotReloadConstants.CompilationListReasonCancelled)); + } + + /// + /// What: after a failed compile the rebuilt list is neither kept nor saved, because the dlls + /// are older than the sources Unity now lists. + /// + [Test] + public void ReconstructAndFill_WhileCompilationFailed_KeepsAndSavesNothing() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + int savesBefore = _backgroundRuns; + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty); + _isCompilationFailed = true; + VibeLogger.ClearMemoryLogs(); + + Task refill = CreateRefill(store, cache).RunAsync(); + + Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status"); + Assert.That(refill.Result, Is.False, "kept"); + Assert.That(cache.IsFilled, Is.False, "cache filled"); + Assert.That(_backgroundRuns, Is.EqualTo(savesBefore), "saves"); + Assert.That( + (string)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed)["reason"], + Is.EqualTo(HotReloadConstants.CompilationListReasonCompilationFailed)); + } + + private void AssertRecompiledTargetGives(string reason, Action editTargetReference) + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => + { + MarkRecompiled(saved); + int edited = 0; + foreach (HotReloadSavedReference reference in saved.references) + { + if (Path.GetFileName(reference.path) == TargetReferenceFileName) + { + editTargetReference(reference); + edited++; + } + } + + Assert.That(edited, Is.GreaterThan(0), "Precondition: the saved list must reference " + TargetReferenceFileName); + }); + + HotReloadCompilationListReconstruction result = Reconstruct(store); + + Assert.That(result.Assemblies, Is.Null, "list"); + Assert.That(result.FailureReason, Is.EqualTo(reason)); + } + + private HotReloadCompilationListRefill CreateRefill(HotReloadCompilationAssemblyListStore store, HotReloadCompilationAssemblyCache cache) + { + // Why inline: EditMode tests must not start pool work. + return new HotReloadCompilationListRefill( + store, + cache, + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + () => _isCompilationFailed, + rebuild => Task.FromResult(rebuild())); + } + + private HotReloadCompilationListReconstruction Reconstruct(HotReloadCompilationAssemblyListStore store) + { + return HotReloadCompilationAssemblyReconstruction.Reconstruct( + store, HotReloadSnapshotCaptureInputs.ReadOnMainThread(), CancellationToken.None); + } + + private HotReloadCompilationAssemblyListStore SaveCleanThenEdit(Action edit) + { + HotReloadCompilationAssemblyListStore store = CreateStore(); + store.SaveInBackgroundIfClean(CompilationPipeline.GetAssemblies(), 0, _projectRoot); + HotReloadSavedCompilationList saved = store.Load().List; + Assert.That(saved, Is.Not.Null, "Precondition: the list must be saved."); + edit(saved); + File.WriteAllText(_filePath, JsonConvert.SerializeObject(saved)); + return store; + } + + private HotReloadCompilationAssemblyListStore CreateStore() + { + // Why inline: EditMode tests must not start pool work, so the "background" runs here. + return new HotReloadCompilationAssemblyListStore( + _filePath, + () => false, + () => 0, + work => + { + _backgroundRuns++; + work(); + return Task.CompletedTask; + }); + } + + private static HotReloadSavedCompilationAssembly Target(HotReloadSavedCompilationList saved) + { + return saved.assemblies.Find(assembly => assembly.name == TargetAssemblyName); + } + + // A stamp no file has, so the target goes through its PDB. + private static HotReloadSavedCompilationAssembly MarkRecompiled(HotReloadSavedCompilationList saved) + { + HotReloadSavedCompilationAssembly target = Target(saved); + target.dllStamp.lastWriteTicks = 1; + return target; + } + + private static int IndexOfReferenceNotIn(HotReloadSavedCompilationList saved, HotReloadSavedCompilationAssembly target) + { + HashSet targetFileNames = new HashSet(); + foreach (int index in target.compiledAssemblyReferenceIndexes) + { + targetFileNames.Add(Path.GetFileName(saved.references[index].path)); + } + + int found = saved.references.FindIndex(reference => !targetFileNames.Contains(Path.GetFileName(reference.path))); + Assert.That(found, Is.GreaterThanOrEqualTo(0), "Precondition: some reference must be outside the target's."); + return found; + } + + // Reads the target assembly with its symbols and writes both again, as an IL post-processor + // does. Cecil keeps only the documents a method refers to. + private void RewriteWithCecil(string outputDllPath) + { + CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(_projectRoot); + string sourceDll = Path.Combine(_directory, "Source", TargetAssemblyName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(sourceDll)); + File.Copy(real.DllPath(TargetAssemblyName), sourceDll); + File.Copy(real.PdbPath(TargetAssemblyName), Path.ChangeExtension(sourceDll, ".pdb")); + using (DefaultAssemblyResolver resolver = new DefaultAssemblyResolver()) + { + resolver.AddSearchDirectory(real.CompiledAssembliesDirectory); + resolver.AddSearchDirectory(Path.GetDirectoryName(typeof(object).Assembly.Location)); + using (ModuleDefinition module = ModuleDefinition.ReadModule( + sourceDll, + new ReaderParameters { ReadSymbols = true, AssemblyResolver = resolver })) + { + module.Write(outputDllPath, new WriterParameters + { + WriteSymbols = true, + SymbolWriterProvider = new PortablePdbWriterProvider(), + }); + } + } + } + + private static int IndexOf(string assemblyName) + { + return Array.FindIndex(CompilationPipeline.GetAssemblies(), assembly => assembly.name == assemblyName); + } + + private static UnityCompilationAssembly Find(UnityCompilationAssembly[] assemblies, string assemblyName) + { + return Array.Find(assemblies, assembly => assembly.name == assemblyName); + } + + private static string[] NamesOf(UnityCompilationAssembly[] assemblies) + { + return Array.ConvertAll(assemblies, assembly => assembly.name); + } + + private static string[] Append(string[] values, string value) + { + string[] appended = new string[values.Length + 1]; + values.CopyTo(appended, 0); + appended[values.Length] = value; + return appended; + } + + private static int[] AppendIndex(int[] values, int value) + { + int[] appended = new int[values.Length + 1]; + values.CopyTo(appended, 0); + appended[values.Length] = value; + return appended; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs.meta new file mode 100644 index 0000000000..b089533b1e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 48b17e4b0e06f4ddd96a82c16cdac1f2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs index def3042a44..63dce6eacf 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Newtonsoft.Json.Linq; @@ -214,6 +215,50 @@ public void StartWarmUpUnlessHotReloadIsDisabled_WhenHotReloadIsEnabled_StartsOn Assert.That(CountVibeEntries(HotReloadConstants.VibeLogWarmUpSkipped), Is.EqualTo(0), "skipped entries"); } + /// + /// What: with the hot-reload tool disabled the list is not rebuilt even though the + /// pause-point tool is enabled, because only hot reload reads the list. + /// + [Test] + public void ReconstructAndFill_WhenHotReloadIsDisabled_DoesNotStartEvenIfPausePointIsEnabled() + { + VibeLogger.ClearMemoryLogs(); + int startCount = 0; + + Task refill = HotReloadEditorStartup.RefillCompilationListUnlessHotReloadIsDisabled( + name => name == UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT, + () => + { + startCount++; + return Task.FromResult(true); + }); + + Assert.That(startCount, Is.EqualTo(0), "rebuilds started"); + Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status"); + Assert.That(refill.Result, Is.False, "kept"); + JObject logged = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed); + Assert.That((string)logged["reason"], Is.EqualTo(HotReloadConstants.CompilationListReasonToolDisabled)); + } + + /// + /// What: with the hot-reload tool enabled the rebuild is started once. + /// + [Test] + public void ReconstructAndFill_WhenHotReloadIsEnabled_StartsOnce() + { + int startCount = 0; + + HotReloadEditorStartup.RefillCompilationListUnlessHotReloadIsDisabled( + name => name == UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD, + () => + { + startCount++; + return Task.FromResult(true); + }); + + Assert.That(startCount, Is.EqualTo(1), "rebuilds started"); + } + private static bool AllToolsEnabled(string toolName) => true; private static bool NoToolEnabled(string toolName) => false; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 49fa426feb..1badc38157 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading.Tasks; using UnityEditor; using UnityEngine; @@ -20,6 +21,9 @@ public static void Initialize(Func isToolEnabled) // the list's static constructor, which subscribes to compilationStarted, would otherwise // run for whichever caller touches the list first, which need not be on the main thread. HotReloadCompilationAssemblies.EnsureInitialized(); + // Why at domain load: the rebuild then runs while the Editor finishes loading, so the + // first caller of the domain finds the list kept instead of asking Unity on the main thread. + _ = RefillCompilationListUnlessHotReloadIsDisabled(isToolEnabled, HotReloadCompilationAssemblies.RefillInBackground); // Why not EditorApplication.delayCall: a cold-start session that hits Unity's native // "Scripts have compiler errors" dialog never flushes delayCall again for the rest of @@ -144,6 +148,30 @@ internal static void CaptureAtStartupUnlessReadersAreDisabled(string trigger, Fu _ = HotReloadCompositionRoot.Services.SourceSnapshotCapture.StartIfNeeded(trigger); } + /// + /// Starts rebuilding the compilation assembly list unless the hot-reload tool is disabled in + /// the tool settings. Only hot reload reads the list, so the pause-point tool does not count. + /// Completes with whether the rebuilt list was kept. + /// + internal static Task RefillCompilationListUnlessHotReloadIsDisabled( + Func isToolEnabled, + Func> startRefill) + { + Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null."); + Debug.Assert(startRefill != null, "startRefill must not be null."); + + if (!isToolEnabled(UnityCliLoopConstants.TOOL_NAME_HOT_RELOAD)) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListReconstructed, + "Hot reload did not rebuild the compilation assembly list.", + new { filled = false, reason = HotReloadConstants.CompilationListReasonToolDisabled }); + return Task.FromResult(false); + } + + return startRefill(); + } + /// /// Starts the warm-up unless the hot-reload tool is disabled in the tool settings. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index d502dd2bb5..f447ebfdca 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -35,11 +35,20 @@ internal static class HotReloadCompilationAssemblies private static readonly HotReloadCompilationAssemblyCache Cache = new HotReloadCompilationAssemblyCache(FetchAndSaveIfClean); + private static readonly HotReloadCompilationListRefill Refill = + new HotReloadCompilationListRefill( + Store, + Cache, + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + () => EditorUtility.scriptCompilationFailed, + rebuild => Task.Run(rebuild)); + static HotReloadCompilationAssemblies() { // Why: a compile that fails keeps the domain alive, so without this the list would keep // the asmdefs and source files from before that compile until the next reload. CompilationPipeline.compilationStarted += OnCompilationStarted; + AssemblyReloadEvents.beforeAssemblyReload += CancelRefill; } /// @@ -67,6 +76,16 @@ internal static UnityCompilationAssembly FindByName(string assemblyName) return Cache.FindByName(assemblyName); } + /// + /// Starts rebuilding the list from the one a previous domain saved and the compiled files, + /// off the main thread, and keeps it unless a caller asked Unity first or the script set + /// changed meanwhile. Completes with whether the list was kept; never faults. Main thread only. + /// + internal static Task RefillInBackground() + { + return Refill.RunAsync(); + } + // Main thread: the cache asks for the list from Current(). private static UnityCompilationAssembly[] FetchAndSaveIfClean() { @@ -86,9 +105,17 @@ private static string ProjectRoot() private static void OnCompilationStarted(object context) { + CancelRefill(); Cache.Invalidate(); } + // Why the rebuild stops here: a compile rewrites the dlls and PDBs it reads, and a reload + // ends the domain whose cache it would fill. + private static void CancelRefill() + { + Refill.Cancel(); + } + /// /// Drops the kept list because an import changed the script set without a domain reload. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs new file mode 100644 index 0000000000..166eafc309 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One assembly of a rebuilt compilation assembly list, before the Unity objects are made. + /// Project references are named, precompiled ones are paths as Unity lists them. + /// + internal sealed class HotReloadRebuiltAssemblyEntry + { + internal HotReloadRebuiltAssemblyEntry( + string name, + string outputPath, + string[] sourceFiles, + string[] defines, + List projectReferences, + List compiledReferences) + { + Name = name; + OutputPath = outputPath; + SourceFiles = sourceFiles; + Defines = defines; + ProjectReferences = projectReferences; + CompiledReferences = compiledReferences; + } + + internal string Name { get; } + + internal string OutputPath { get; } + + internal string[] SourceFiles { get; } + + internal string[] Defines { get; } + + internal List ProjectReferences { get; } + + internal List CompiledReferences { get; } + } + + /// + /// Makes the entries of a rebuilt list from one saved list: as saved for an assembly whose dll + /// did not change, or from the PDB of one that was compiled again. A PDB-made entry keeps the + /// saved order of every element it still has and appends new ones in the PDB's order, because + /// the PDB lists them in the compiler's order, which is not Unity's. Touches only files, so it + /// can run on a pool thread. + /// + internal sealed class HotReloadCompilationAssemblyRebuilder + { + private const string ReferenceAssemblySuffix = ".ref.dll"; + private const string AssemblySuffix = ".dll"; + + private readonly HotReloadSavedCompilationList _saved; + private readonly HotReloadSnapshotCaptureInputs _inputs; + private readonly HashSet _savedNames = new HashSet(StringComparer.Ordinal); + private readonly HotReloadSavedReferenceResolver _resolver; + private readonly StringComparer _pathComparer; + + internal HotReloadCompilationAssemblyRebuilder(HotReloadSavedCompilationList saved, HotReloadSnapshotCaptureInputs inputs) + { + Debug.Assert(saved != null && saved.IsComplete(), "saved must be a complete list."); + Debug.Assert(inputs != null, "inputs must not be null."); + + _saved = saved; + _inputs = inputs; + foreach (HotReloadSavedCompilationAssembly assembly in saved.assemblies) + { + _savedNames.Add(assembly.name); + } + + _pathComparer = inputs.PathComparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + _resolver = new HotReloadSavedReferenceResolver(saved.references, inputs.ProjectRoot, _pathComparer); + } + + internal HotReloadRebuiltAssemblyEntry FromSaved(HotReloadSavedCompilationAssembly assembly) + { + List compiledReferences = new List(assembly.compiledAssemblyReferenceIndexes.Length); + foreach (int index in assembly.compiledAssemblyReferenceIndexes) + { + compiledReferences.Add(_saved.references[index].path); + } + + return new HotReloadRebuiltAssemblyEntry( + assembly.name, + assembly.outputPath, + assembly.sourceFiles, + assembly.defines, + new List(assembly.assemblyReferences), + compiledReferences); + } + + /// + /// Makes the entry of a compiled-again assembly from its PDB. Returns null and the entry, or + /// the reason no entry can be trusted: a PDB of another build, a reference that does not + /// resolve to a file of the recorded MVID, or a PDB whose documents are not one per syntax + /// tree the compiler emitted. + /// + internal string TryFromPdb( + HotReloadSavedCompilationAssembly assembly, + HotReloadCaptureAssemblyCandidate candidate, + out HotReloadRebuiltAssemblyEntry entry) + { + Debug.Assert(assembly != null, "assembly must not be null."); + Debug.Assert(candidate != null, "candidate must not be null."); + + entry = null; + HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(candidate.PdbPath); + // Why checked before anything is read from it: a compile that is replacing the files can + // leave a PDB of another build beside the dll. + if (info.PdbGuid != HotReloadPdbDocumentIndex.ReadCodeViewGuid(candidate.DllPath)) + { + return HotReloadConstants.CompilationListReasonPdbMismatch; + } + + // Why the compiler's count: the compiler writes one document per syntax tree, while a + // PDB rewritten after the compile (an IL post-processor does this) can drop the + // documents no sequence point refers to and keeps the count. A PDB without the count + // cannot show it is whole, and extra #line documents are refused as well. + if (info.SourceFileCount != info.DocumentCount) + { + return HotReloadConstants.CompilationListReasonPdbDocumentsIncomplete; + } + + List projectReferences = new List(); + Dictionary compiledPathByFileName = new Dictionary(_pathComparer); + foreach (HotReloadPdbReferencedAssembly reference in info.References) + { + string referencedName = AssemblyNameOf(reference.FileName); + if (_savedNames.Contains(referencedName)) + { + projectReferences.Add(referencedName); + continue; + } + + string failure = _resolver.TryResolve(reference, out string path); + if (failure != null) + { + return failure; + } + + compiledPathByFileName[reference.FileName] = path; + } + + IReadOnlyList sources = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, _inputs).SourceAssetPaths; + + entry = new HotReloadRebuiltAssemblyEntry( + assembly.name, + assembly.outputPath, + InSavedOrder(assembly.sourceFiles, sources, _pathComparer).ToArray(), + ToArray(info.Defines), + InSavedOrder(assembly.assemblyReferences, projectReferences, StringComparer.Ordinal), + CompiledReferencesInSavedOrder(assembly, compiledPathByFileName)); + return null; + } + + // Unity lists a precompiled reference once per path, sometimes the same path twice and + // sometimes two paths of one file name, while the PDB records one file name once. So every + // saved path whose file name the PDB still records is kept as saved. + private List CompiledReferencesInSavedOrder( + HotReloadSavedCompilationAssembly assembly, + Dictionary compiledPathByFileName) + { + List ordered = new List(compiledPathByFileName.Count); + HashSet savedFileNames = new HashSet(_pathComparer); + foreach (int index in assembly.compiledAssemblyReferenceIndexes) + { + string path = _saved.references[index].path; + string fileName = Path.GetFileName(path); + savedFileNames.Add(fileName); + if (compiledPathByFileName.ContainsKey(fileName)) + { + ordered.Add(path); + } + } + + foreach (KeyValuePair pair in compiledPathByFileName) + { + if (!savedFileNames.Contains(pair.Key)) + { + ordered.Add(pair.Value); + } + } + + return ordered; + } + + private static List InSavedOrder(string[] saved, IReadOnlyList current, StringComparer comparer) + { + HashSet currentSet = new HashSet(current, comparer); + HashSet savedSet = new HashSet(saved, comparer); + List ordered = new List(current.Count); + foreach (string element in saved) + { + if (currentSet.Contains(element)) + { + ordered.Add(element); + } + } + + foreach (string element in current) + { + if (!savedSet.Contains(element)) + { + ordered.Add(element); + } + } + + return ordered; + } + + private static string[] ToArray(IReadOnlyList values) + { + string[] array = new string[values.Count]; + for (int index = 0; index < array.Length; index++) + { + array[index] = values[index]; + } + + return array; + } + + // A project assembly is referenced through its reference assembly (X.ref.dll), a + // precompiled one through its dll. + private static string AssemblyNameOf(string referenceFileName) + { + if (referenceFileName.EndsWith(ReferenceAssemblySuffix, StringComparison.OrdinalIgnoreCase)) + { + return referenceFileName.Substring(0, referenceFileName.Length - ReferenceAssemblySuffix.Length); + } + + if (referenceFileName.EndsWith(AssemblySuffix, StringComparison.OrdinalIgnoreCase)) + { + return referenceFileName.Substring(0, referenceFileName.Length - AssemblySuffix.Length); + } + + return referenceFileName; + } + } + + /// + /// Finds the saved reference path for a precompiled reference a PDB names by file name and + /// MVID, reading each candidate file's MVID at most once. + /// + internal sealed class HotReloadSavedReferenceResolver + { + private readonly List _references; + private readonly string _projectRoot; + private readonly Dictionary> _indexesByFileName; + private readonly Dictionary _currentMvidByIndex = new Dictionary(); + + internal HotReloadSavedReferenceResolver(List references, string projectRoot, StringComparer pathComparer) + { + _references = references; + _projectRoot = projectRoot; + _indexesByFileName = new Dictionary>(pathComparer); + for (int index = 0; index < references.Count; index++) + { + string fileName = Path.GetFileName(references[index].path); + if (!_indexesByFileName.TryGetValue(fileName, out List indexes)) + { + indexes = new List(); + _indexesByFileName.Add(fileName, indexes); + } + + indexes.Add(index); + } + } + + /// + /// Returns null and the saved path when a saved path with this file name holds a dll of + /// this MVID now; otherwise the reason there is none. + /// + internal string TryResolve(HotReloadPdbReferencedAssembly reference, out string path) + { + path = null; + if (!_indexesByFileName.TryGetValue(reference.FileName, out List indexes)) + { + return HotReloadConstants.CompilationListReasonUnresolvedReference; + } + + bool anyExists = false; + foreach (int index in indexes) + { + Guid? mvid = CurrentMvid(index); + if (mvid == null) + { + continue; + } + + anyExists = true; + if (mvid.Value == reference.Mvid) + { + path = _references[index].path; + return null; + } + } + + return anyExists + ? HotReloadConstants.CompilationListReasonReferenceChanged + : HotReloadConstants.CompilationListReasonReferenceMissing; + } + + // Null when the file is gone. Why the saved MVID is trusted for an unchanged stamp: + // reading every reference's metadata again would cost what the saved list saves. + private Guid? CurrentMvid(int index) + { + if (_currentMvidByIndex.TryGetValue(index, out Guid? known)) + { + return known; + } + + HotReloadSavedReference saved = _references[index]; + string fullPath = Path.GetFullPath(Path.Combine(_projectRoot, saved.path)); + HotReloadSavedFileStamp stamp = HotReloadSavedFileStamp.ReadOrNull(fullPath); + Guid? mvid = null; + if (stamp != null) + { + mvid = stamp.SameAs(saved.stamp) + ? Guid.ParseExact(saved.mvid, "N") + : HotReloadAssemblyMetadataMvid.Read(fullPath); + } + + _currentMvidByIndex.Add(index, mvid); + return mvid; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs.meta new file mode 100644 index 0000000000..32dd7cd141 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ff89cc42bd34c4f27b055a72d87910de +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs new file mode 100644 index 0000000000..0f18826d9d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +using UnityEditor.Compilation; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The outcome of rebuilding the compilation assembly list: the whole list, or why there is none. + /// + internal readonly struct HotReloadCompilationListReconstruction + { + public readonly UnityCompilationAssembly[] Assemblies; + public readonly string FailureReason; + + private HotReloadCompilationListReconstruction(UnityCompilationAssembly[] assemblies, string failureReason) + { + Assemblies = assemblies; + FailureReason = failureReason; + } + + internal static HotReloadCompilationListReconstruction Rebuilt(UnityCompilationAssembly[] assemblies) + { + Debug.Assert(assemblies != null && assemblies.Length > 0, "assemblies must not be null or empty."); + return new HotReloadCompilationListReconstruction(assemblies, null); + } + + internal static HotReloadCompilationListReconstruction Failed(string failureReason) + { + Debug.Assert(!string.IsNullOrEmpty(failureReason), "failureReason must not be null or empty."); + return new HotReloadCompilationListReconstruction(null, failureReason); + } + } + + /// + /// Rebuilds the compilation assembly list of this domain from the list a previous clean domain + /// saved and from the compiled dlls and PDBs, without asking Unity, so it can run on a pool + /// thread. An assembly whose dll did not change keeps its saved entry; one that was compiled + /// again takes its sources, defines and references from its PDB. Returns no list at all when + /// any part cannot be shown to match the files on disk, because a partial list would mislead + /// the default file choice and the patch target lookup. + /// + internal static class HotReloadCompilationAssemblyReconstruction + { + internal static HotReloadCompilationListReconstruction Reconstruct( + HotReloadCompilationAssemblyListStore store, + HotReloadSnapshotCaptureInputs inputs, + CancellationToken ct) + { + Debug.Assert(store != null, "store must not be null."); + Debug.Assert(inputs != null, "inputs must not be null."); + + HotReloadSavedCompilationListLoad load = store.Load(); + if (load.List == null) + { + return HotReloadCompilationListReconstruction.Failed(load.FailureReason); + } + + HotReloadSavedCompilationList saved = load.List; + Dictionary compiled = CompiledByName(inputs.ProjectRoot); + if (!HasTheSavedAssemblySet(saved, compiled)) + { + return HotReloadCompilationListReconstruction.Failed(HotReloadConstants.CompilationListReasonAssemblySetChanged); + } + + HotReloadCompilationAssemblyRebuilder rebuilder = new HotReloadCompilationAssemblyRebuilder(saved, inputs); + List entries = new List(saved.assemblies.Count); + foreach (HotReloadSavedCompilationAssembly assembly in saved.assemblies) + { + ct.ThrowIfCancellationRequested(); + + // Why an unchanged dll keeps its entry: the compile is deterministic, so the same dll + // came from the same sources, defines and references. + if (!compiled.TryGetValue(assembly.name, out HotReloadCaptureAssemblyCandidate candidate) + || assembly.dllStamp.SameAs(HotReloadSavedFileStamp.ReadOrNull(candidate.DllPath))) + { + entries.Add(rebuilder.FromSaved(assembly)); + continue; + } + + string failure = rebuilder.TryFromPdb(assembly, candidate, out HotReloadRebuiltAssemblyEntry rebuilt); + if (failure != null) + { + return HotReloadCompilationListReconstruction.Failed(failure); + } + + entries.Add(rebuilt); + } + + return Build(entries); + } + + private static Dictionary CompiledByName(string projectRoot) + { + Dictionary compiled = + new Dictionary(StringComparer.Ordinal); + foreach (HotReloadCaptureAssemblyCandidate candidate in HotReloadCaptureAssemblyEnumerator.ListCompiled(projectRoot)) + { + compiled[candidate.Name] = candidate; + } + + return compiled; + } + + // Why the set is compared before any entry is used: an added or removed asmdef changes the + // list in ways no single dll shows. + private static bool HasTheSavedAssemblySet( + HotReloadSavedCompilationList saved, + Dictionary compiled) + { + int savedWithDll = 0; + foreach (HotReloadSavedCompilationAssembly assembly in saved.assemblies) + { + if (assembly.dllStamp == null) + { + continue; + } + + if (!compiled.ContainsKey(assembly.name)) + { + return false; + } + + savedWithDll++; + } + + return savedWithDll == compiled.Count; + } + + private static HotReloadCompilationListReconstruction Build(List entries) + { + Dictionary entryByName = + new Dictionary(StringComparer.Ordinal); + foreach (HotReloadRebuiltAssemblyEntry entry in entries) + { + entryByName[entry.Name] = entry; + } + + foreach (HotReloadRebuiltAssemblyEntry entry in entries) + { + foreach (string referencedName in entry.ProjectReferences) + { + if (!entryByName.ContainsKey(referencedName)) + { + return HotReloadCompilationListReconstruction.Failed( + HotReloadConstants.CompilationListReasonUnresolvedReference); + } + } + } + + Dictionary built = + new Dictionary(StringComparer.Ordinal); + UnityCompilationAssembly[] assemblies = new UnityCompilationAssembly[entries.Count]; + for (int index = 0; index < entries.Count; index++) + { + assemblies[index] = BuildAssembly(entries[index], entryByName, built, new HashSet(StringComparer.Ordinal)); + } + + return HotReloadCompilationListReconstruction.Rebuilt(assemblies); + } + + // Why the referenced assemblies are made first: Unity takes assemblyReferences only through + // the constructor. + private static UnityCompilationAssembly BuildAssembly( + HotReloadRebuiltAssemblyEntry entry, + Dictionary entryByName, + Dictionary built, + HashSet inProgress) + { + if (built.TryGetValue(entry.Name, out UnityCompilationAssembly existing)) + { + return existing; + } + + if (!inProgress.Add(entry.Name)) + { + throw new InvalidOperationException("The saved compilation assemblies reference each other in a cycle at " + entry.Name + "."); + } + + UnityCompilationAssembly[] references = new UnityCompilationAssembly[entry.ProjectReferences.Count]; + for (int index = 0; index < references.Length; index++) + { + references[index] = BuildAssembly(entryByName[entry.ProjectReferences[index]], entryByName, built, inProgress); + } + + // Hot reload never reads compiler options, flags or the root namespace, and Unity's + // language version cannot be set from outside, so they keep their defaults. + UnityCompilationAssembly assembly = new UnityCompilationAssembly( + entry.Name, + entry.OutputPath, + entry.SourceFiles, + entry.Defines, + references, + entry.CompiledReferences.ToArray(), + AssemblyFlags.None, + new ScriptCompilerOptions(), + string.Empty); + built.Add(entry.Name, assembly); + return assembly; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs.meta new file mode 100644 index 0000000000..7cd5f678f4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyReconstruction.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 03da534490aed4c8c980d45a14a38825 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs new file mode 100644 index 0000000000..c792b330ca --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs @@ -0,0 +1,127 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using Debug = UnityEngine.Debug; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Fills the compilation assembly cache at startup with a list rebuilt off the main thread from + /// the saved list and the compiled files, so the first caller of the domain does not ask Unity + /// on the main thread. A rebuilt list that was kept is saved as the next domain's baseline. + /// + internal sealed class HotReloadCompilationListRefill + { + private readonly HotReloadCompilationAssemblyListStore _store; + private readonly HotReloadCompilationAssemblyCache _cache; + private readonly Func _readInputs; + private readonly Func _isCompilationFailed; + private readonly Func, Task> _runInBackground; + + // Main thread only. Replaced by each run, so cancelling stops only the latest one. + private CancellationTokenSource _cancellation = new CancellationTokenSource(); + + internal HotReloadCompilationListRefill( + HotReloadCompilationAssemblyListStore store, + HotReloadCompilationAssemblyCache cache, + Func readInputs, + Func isCompilationFailed, + Func, Task> runInBackground) + { + Debug.Assert(store != null, "store must not be null."); + Debug.Assert(cache != null, "cache must not be null."); + Debug.Assert(readInputs != null, "readInputs must not be null."); + Debug.Assert(isCompilationFailed != null, "isCompilationFailed must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + _store = store; + _cache = cache; + _readInputs = readInputs; + _isCompilationFailed = isCompilationFailed; + _runInBackground = runInBackground; + } + + /// + /// Rebuilds the list in the background and keeps it when the cache is still empty and no + /// invalidation happened meanwhile. Returns whether the list was kept. Never throws: the + /// cache asks Unity as before when nothing was kept. Cancels an earlier run that is still + /// going. Call on the main thread. + /// + internal Task RunAsync() + { + _cancellation.Cancel(); + _cancellation = new CancellationTokenSource(); + return RunUntilCancelledAsync(_cancellation.Token); + } + + /// + /// Stops the run in progress, if any: the files it reads are about to change or the domain + /// ends. Main thread only. + /// + internal void Cancel() + { + _cancellation.Cancel(); + } + + private async Task RunUntilCancelledAsync(CancellationToken ct) + { + Stopwatch watch = Stopwatch.StartNew(); + try + { + int generation = _cache.Generation; + HotReloadSnapshotCaptureInputs inputs = _readInputs(); + HotReloadCompilationListReconstruction reconstruction = await _runInBackground( + () => HotReloadCompilationAssemblyReconstruction.Reconstruct(_store, inputs, ct)).ConfigureAwait(false); + + // Why the main thread: the cache has no lock and is kept on the main thread only. + await MainThreadSwitcher.SwitchToMainThread(ct); + // Why not kept after a failed compile: the dlls are then older than the sources, so + // every stamp still matches the saved list while Unity's list already names the + // added and removed files. + if (reconstruction.Assemblies != null && _isCompilationFailed()) + { + LogReconstructed(false, HotReloadConstants.CompilationListReasonCompilationFailed, watch.ElapsedMilliseconds); + return false; + } + + bool filled = reconstruction.Assemblies != null && _cache.TryFill(reconstruction.Assemblies, generation); + if (filled) + { + // Why saved again: a domain whose cache was filled here never asks Unity, so the + // saved list would otherwise age and send more assemblies through their PDBs. + _store.SaveInBackgroundIfClean(reconstruction.Assemblies, generation, inputs.ProjectRoot); + } + + string reason = filled + ? null + : reconstruction.FailureReason ?? HotReloadConstants.CompilationListReasonSuperseded; + LogReconstructed(filled, reason, watch.ElapsedMilliseconds); + return filled; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + LogReconstructed(false, HotReloadConstants.CompilationListReasonCancelled, watch.ElapsedMilliseconds); + return false; + } + catch (Exception ex) + { + // Approved deviation: this is the boundary of work that started at Editor startup, + // and a list that cannot be rebuilt only leaves the cache to ask Unity as before. + string reason = ex.GetType().Name; + LogReconstructed(false, reason, watch.ElapsedMilliseconds); + return false; + } + } + + private static void LogReconstructed(bool filled, string reason, long reconstructMs) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListReconstructed, + "Hot reload rebuilt the compilation assembly list at startup.", + new { filled, reason, reconstructMs }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs.meta new file mode 100644 index 0000000000..2c95e62c60 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a21f131436484bee97e2395eeeacc32 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 8e62c4ef32..ac7ea3594b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -728,6 +728,20 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string CompilationListReasonNoSavedList = "no_saved_list"; public const string CompilationListReasonSavedListUnreadable = "saved_list_unreadable"; + // Why a domain did not rebuild the list from the saved one and the compiled files, or did not + // keep the rebuilt list, as logged in hot_reload_compilation_list_reconstructed. + public const string VibeLogCompilationListReconstructed = "hot_reload_compilation_list_reconstructed"; + public const string CompilationListReasonAssemblySetChanged = "assembly_set_changed"; + public const string CompilationListReasonPdbMismatch = "pdb_mismatch"; + public const string CompilationListReasonPdbDocumentsIncomplete = "pdb_documents_incomplete"; + public const string CompilationListReasonCompilationFailed = "compilation_failed"; + public const string CompilationListReasonUnresolvedReference = "unresolved_reference"; + public const string CompilationListReasonReferenceMissing = "reference_missing"; + public const string CompilationListReasonReferenceChanged = "reference_changed"; + public const string CompilationListReasonSuperseded = "superseded"; + public const string CompilationListReasonCancelled = "cancelled"; + public const string CompilationListReasonToolDisabled = "tool_disabled"; + // What stopped a running warm-up or caller-note backfill, as named in the cancelledBy field // of hot_reload_warm_up_complete and hot_reload_caller_note_backfill_complete. public const string WarmUpCancelledByRun = "run"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs index 2fd9e4111a..392582efa5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Reflection.Metadata; using System.Text; @@ -37,6 +38,7 @@ internal sealed class HotReloadPdbCompilationInfo private static readonly Guid CompilationOptionsKind = new Guid("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8"); private static readonly Guid MetadataReferencesKind = new Guid("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D"); private const string DefineKey = "define"; + private const string SourceFileCountKey = "source-file-count"; private const int MvidLength = 16; // Kind/flags byte, timestamp and image size that sit between the alias and the MVID. private const int BytesBeforeMvid = 1 + 4 + 4; @@ -44,17 +46,34 @@ internal sealed class HotReloadPdbCompilationInfo public readonly IReadOnlyList Defines; public readonly IReadOnlyList References; + // The GUID of the PDB id, which the dll's CodeView entry names when both come from one build. + public readonly Guid PdbGuid; + + // How many syntax trees the compiler emitted, generator output included, or null when the + // PDB does not record it. The compiler writes one document per tree, and a tool that + // rewrites the PDB keeps this record but can drop documents. + public readonly int? SourceFileCount; + + // The rows of the PDB's document table. + public readonly int DocumentCount; + private HotReloadPdbCompilationInfo( IReadOnlyList defines, - IReadOnlyList references) + IReadOnlyList references, + Guid pdbGuid, + int? sourceFileCount, + int documentCount) { Defines = defines; References = references; + PdbGuid = pdbGuid; + SourceFileCount = sourceFileCount; + DocumentCount = documentCount; } /// - /// Reads the defines, in the order the compiler was given them, and the metadata references - /// of the PDB. Throws InvalidOperationException when the PDB lacks either record or a record + /// Reads the defines, in the order the compiler was given them, the compiler's source count, + /// the document count, the metadata references and the id GUID of the PDB. Throws InvalidOperationException when the PDB lacks either record or a record /// is malformed. /// internal static HotReloadPdbCompilationInfo Read(string pdbPath) @@ -67,7 +86,13 @@ internal static HotReloadPdbCompilationInfo Read(string pdbPath) byte[] options = ReadModuleRecord(reader, CompilationOptionsKind, pdbPath); byte[] references = ReadModuleRecord(reader, MetadataReferencesKind, pdbPath); - return new HotReloadPdbCompilationInfo(ParseDefines(options, pdbPath), ParseReferences(references, pdbPath)); + Dictionary optionValues = ParseOptions(options, pdbPath); + return new HotReloadPdbCompilationInfo( + ToDefines(optionValues), + ParseReferences(references, pdbPath), + new BlobContentId(reader.DebugMetadataHeader.Id).Guid, + ToSourceFileCount(optionValues), + reader.Documents.Count); } private static byte[] ReadModuleRecord(MetadataReader reader, Guid kind, string pdbPath) @@ -90,20 +115,42 @@ private static byte[] ReadModuleRecord(MetadataReader reader, Guid kind, string } // The options blob is a sequence of null-terminated UTF-8 key and value pairs. - private static IReadOnlyList ParseDefines(byte[] blob, string pdbPath) + private static Dictionary ParseOptions(byte[] blob, string pdbPath) { + Dictionary values = new Dictionary(StringComparer.Ordinal); int position = 0; while (position < blob.Length) { string key = ReadNullTerminated(blob, ref position, pdbPath); string value = ReadNullTerminated(blob, ref position, pdbPath); - if (key == DefineKey) + if (!values.ContainsKey(key)) { - return value.Length == 0 ? Array.Empty() : value.Split(','); + values.Add(key, value); } } - return Array.Empty(); + return values; + } + + private static IReadOnlyList ToDefines(Dictionary optionValues) + { + if (!optionValues.TryGetValue(DefineKey, out string value) || value.Length == 0) + { + return Array.Empty(); + } + + return value.Split(','); + } + + private static int? ToSourceFileCount(Dictionary optionValues) + { + if (!optionValues.TryGetValue(SourceFileCountKey, out string value) + || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int count)) + { + return null; + } + + return count; } // Each reference is: file name and extern aliases (both null-terminated), a kind/flags diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs index 86e2256d5a..e3ebea157a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs @@ -258,6 +258,15 @@ internal static HotReloadSavedFileStamp ReadOrNull(string fullPath) return new HotReloadSavedFileStamp { length = file.Length, lastWriteTicks = file.LastWriteTimeUtc.Ticks }; } + + /// + /// True when the other stamp has the same length and last write time. False for null, which + /// stands for a file that does not exist. + /// + internal bool SameAs(HotReloadSavedFileStamp other) + { + return other != null && other.length == length && other.lastWriteTicks == lastWriteTicks; + } } /// From b7541193aaedc882681edfc765eda51d0e56d55a Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 12 Oct 2026 08:38:40 +0900 Subject: [PATCH 12/15] perf: Hot reload's warm-ups no longer ask Unity for the assembly list on the main thread after a domain reload (#3288) --- .../HotReloadCompilationAssembliesTests.cs | 18 +++ .../HotReload/HotReloadEditorStartupTests.cs | 38 ++++++ ...otReloadPublicizedTargetWarmUpItemTests.cs | 3 +- ...haredCompilerWarmUpReferenceSourceTests.cs | 112 +++++++++++++++++- ...HotReloadTransformWorkerWarmUpItemTests.cs | 2 +- .../HotReloadWarmUpContextSourceTests.cs | 36 +++++- .../HotReload/HotReloadWarmUpE2ETests.cs | 5 +- .../HotReloadWarmUpEditorHooksTests.cs | 2 +- .../HotReload/HotReloadWarmUpItemsTests.cs | 21 ++++ .../HotReload/HotReloadWarmUpTestDoubles.cs | 72 ++++++++++- .../Editor/HotReload/HotReloadWarmUpTests.cs | 57 ++++++++- .../HotReload/HotReloadCallSiteWarmUpItem.cs | 2 + .../HotReload/HotReloadCompositionRoot.cs | 3 +- .../HotReload/HotReloadEditorStartup.cs | 31 ++++- .../HotReloadPdbDocumentWarmUpItem.cs | 2 + .../HotReloadPublicizedTargetWarmUpItem.cs | 2 + .../HotReloadReferencedMethodSetWarmUpItem.cs | 3 + ...loadSharedCompilerWarmUpReferenceSource.cs | 28 ++++- .../HotReloadTransformWorkerWarmUpItem.cs | 2 + .../HotReload/HotReloadWarmUp.cs | 11 ++ .../HotReload/HotReloadWarmUpContext.cs | 15 ++- .../HotReload/HotReloadWarmUpContextSource.cs | 24 +++- .../HotReload/HotReloadWarmUpItemOutcome.cs | 14 ++- .../HotReload/IHotReloadWarmUpItem.cs | 6 + .../Shared/HotReloadCompilationAssemblies.cs | 9 ++ .../HotReload/Shared/HotReloadConstants.cs | 1 + 26 files changed, 483 insertions(+), 36 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs index ad07952154..bfeced9d97 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssembliesTests.cs @@ -58,6 +58,24 @@ public void Current_CalledTwice_ReturnsSameInstance() Assert.That(second, Is.SameAs(first)); } + /// + /// Verifies IsKept answers whether a list is kept right now: true once a caller asked for + /// it, false after the list is dropped, so the warm-ups skip instead of asking Unity. + /// + [Test] + public void IsKept_FollowsWhetherAListIsKept() + { + Assert.That(HotReloadCompilationAssemblies.Current(), Is.Not.Empty); + Assert.That(HotReloadCompilationAssemblies.IsKept(), Is.True, "after the list was asked for"); + + HotReloadCompilationAssemblies.DropForScriptSetChange(); + bool keptAfterDrop = HotReloadCompilationAssemblies.IsKept(); + // Why ask again: later tests in this domain expect the shared list to be kept. + HotReloadCompilationAssemblies.Current(); + + Assert.That(keptAfterDrop, Is.False, "after the list was dropped"); + } + // Why: CompilationPipeline stores handlers on Delegate fields, but EditorApplication.projectChanged // lives on EventWithPerformanceTracker. A Delegate-only scan cannot see it. private static bool StaticEventHasHandler(Type eventOwner, Func isMatch) diff --git a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs index 63dce6eacf..804a40dda4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadEditorStartupTests.cs @@ -263,6 +263,44 @@ public void ReconstructAndFill_WhenHotReloadIsEnabled_StartsOnce() private static bool NoToolEnabled(string toolName) => false; + /// + /// What: the warm-up does not start, so neither chooses its targets nor runs an item, until + /// the domain's attempt to rebuild the compilation assembly list has ended; then it starts once. + /// + [Test] + public async Task StartWarmUpOnceTheCompilationListIsReady_StartsOnlyAfterTheRebuildEnded() + { + TaskCompletionSource ready = new TaskCompletionSource(); + int startCount = 0; + + Task started = HotReloadEditorStartup.StartWarmUpOnceTheCompilationListIsReady(ready.Task, () => startCount++); + + Assert.That(startCount, Is.EqualTo(0), "started before the rebuild ended"); + Assert.That(started.IsCompleted, Is.False, "completed before the rebuild ended"); + ready.SetResult(false); + await started; + Assert.That(started.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(startCount, Is.EqualTo(1)); + } + + /// + /// What: when the rebuild ends on a pool thread, as a cancelled one does, the warm-up still + /// starts on the main thread, where it reads Unity state and the kept list. + /// + [Test] + public async Task StartWarmUpOnceTheCompilationListIsReady_WhenTheRebuildEndsOnAPoolThread_StartsOnTheMainThread() + { + List startedOnMainThread = new List(); + + // Why a delay: its completion runs on a timer thread, so a missing switch would start there. + Task started = HotReloadEditorStartup.StartWarmUpOnceTheCompilationListIsReady( + Task.Delay(1), () => startedOnMainThread.Add(MainThreadSwitcher.IsMainThread)); + await started; + + Assert.That(started.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(startedOnMainThread, Is.EqualTo(new[] { true })); + } + private static HotReloadSourceSnapshotCapture CountingCapture(Action onCapture) { return HotReloadSourceSnapshotCaptureTestDoubles.CompletingSynchronously(() => diff --git a/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs index 29983e55ae..90eee7accd 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPublicizedTargetWarmUpItemTests.cs @@ -190,7 +190,8 @@ private static HotReloadWarmUpContext ContextFor(string assemblyName) dllPath, Path.ChangeExtension(dllPath, ".pdb"), Array.Empty()) - }); + }, + true); } // The home a run uses for the target right after a reload. diff --git a/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs index 6203bfd66a..7a310eee36 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSharedCompilerWarmUpReferenceSourceTests.cs @@ -94,7 +94,9 @@ public async Task CollectAsync_ListsWhatTheShimCompileBindsAsItIs() HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( projectRoot, _ => new[] { TestAssemblyName }, - HotReloadCompilationAssemblies.FindByName); + HotReloadCompilationAssemblies.FindByName, + Task.CompletedTask, + () => true); IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); @@ -150,7 +152,9 @@ public async Task CollectAsync_SkipsANameWithNoDllWithoutAskingUnity() } return HotReloadCompilationAssemblies.FindByName(name); - }); + }, + Task.CompletedTask, + () => true); IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); @@ -168,7 +172,9 @@ public async Task CollectAsync_SkipsANameWhoseCompilationAssemblyIsGone() HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( ProjectRoot(), _ => new[] { TestAssemblyName }, - _ => null); + _ => null, + Task.CompletedTask, + () => true); IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); @@ -189,7 +195,9 @@ public async Task CollectAsync_WithAnEmptyLedger_ReturnsEmptyWithoutAskingUnity( { finderCalls++; return HotReloadCompilationAssemblies.FindByName(name); - }); + }, + Task.CompletedTask, + () => true); IReadOnlyList listed = await source.CollectAsync(CancellationToken.None); @@ -236,13 +244,103 @@ public async Task CollectAsync_AsksUnityOnTheMainThreadWhenCalledFromAPoolThread { askedOnMainThread.Add(MainThreadSwitcher.IsMainThread); return HotReloadCompilationAssemblies.FindByName(name); - }); + }, + Task.CompletedTask, + () => true); await Task.Run(() => source.CollectAsync(CancellationToken.None)); Assert.That(askedOnMainThread, Is.EqualTo(new[] { true })); } + /// + /// Verifies nothing is looked up until the domain's attempt to rebuild the compilation + /// assembly list has ended, and the kept list is used after it. + /// + [Test] + public async Task CollectAsync_WaitsForTheCompilationListRebuildToEnd() + { + CountingCompilationListFetch fetch = new CountingCompilationListFetch(); + HotReloadCompilationAssemblyCache cache = fetch.CreateFilledCache(); + TaskCompletionSource ready = new TaskCompletionSource(); + int keptChecks = 0; + int ledgerReads = 0; + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => + { + ledgerReads++; + return new[] { TestAssemblyName }; + }, + cache.FindByName, + ready.Task, + () => + { + keptChecks++; + return cache.IsFilled; + }); + + Task> collect = source.CollectAsync(CancellationToken.None); + + Assert.That(collect.IsCompleted, Is.False, "completed before the rebuild ended"); + Assert.That(keptChecks + ledgerReads, Is.EqualTo(0), "looked at the list or the ledger before the rebuild ended"); + ready.SetResult(true); + IReadOnlyList listed = await collect; + Assert.That(collect.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(listed, Is.EqualTo(await CollectFor(new[] { TestAssemblyName }))); + Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list"); + } + + /// + /// Verifies that without a kept list nothing is listed and Unity is not asked for the list. + /// + [Test] + public async Task CollectAsync_WithoutAKeptList_ReturnsEmptyWithoutAskingUnity() + { + CountingCompilationListFetch fetch = new CountingCompilationListFetch(); + HotReloadCompilationAssemblyCache cache = fetch.CreateEmptyCache(); + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => new[] { TestAssemblyName }, + cache.FindByName, + Task.CompletedTask, + () => cache.IsFilled); + + Task> collect = source.CollectAsync(CancellationToken.None); + IReadOnlyList listed = await collect; + + Assert.That(collect.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(listed, Is.Empty); + Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list"); + } + + /// + /// Verifies the list is checked on the main thread when the rebuild ends on a pool thread, as + /// a cancelled one does. + /// + [Test] + public async Task CollectAsync_WhenTheRebuildEndsOnAPoolThread_ChecksTheListOnTheMainThread() + { + List checkedOnMainThread = new List(); + // Why a delay: its completion runs on a timer thread, so a missing switch would check off it. + HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( + ProjectRoot(), + _ => new[] { TestAssemblyName }, + _ => null, + Task.Delay(1), + () => + { + checkedOnMainThread.Add(MainThreadSwitcher.IsMainThread); + return false; + }); + + Task> collect = source.CollectAsync(CancellationToken.None); + await collect; + + Assert.That(collect.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + Assert.That(checkedOnMainThread, Is.EqualTo(new[] { true })); + } + /// /// Verifies the hot-reload startup publishes its reference list to the dynamic-code tool in every domain. /// @@ -257,7 +355,9 @@ private static Task> CollectFor(IReadOnlyList ledg HotReloadSharedCompilerWarmUpReferenceSource source = new HotReloadSharedCompilerWarmUpReferenceSource( ProjectRoot(), _ => ledger, - HotReloadCompilationAssemblies.FindByName); + HotReloadCompilationAssemblies.FindByName, + Task.CompletedTask, + () => true); return source.CollectAsync(CancellationToken.None); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs b/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs index 748811d419..dd989042e5 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadTransformWorkerWarmUpItemTests.cs @@ -371,7 +371,7 @@ private static HotReloadWarmUpContext ContextFor(string projectRoot, params stri Array.Empty())); } - return new HotReloadWarmUpContext(projectRoot, targets); + return new HotReloadWarmUpContext(projectRoot, targets, true); } // Records each request and its token, and answers it with what the test chose for its diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs index 092711dbc8..ebb88d2931 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpContextSourceTests.cs @@ -130,15 +130,44 @@ public void Capture_PutsThePathsAndTheReferencingDllsIntoTheTarget() HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource( _projectRoot, new HotReloadStubEditorStateSnapshotCapture(() => _state), - name => name == "A" ? referencing : Array.Empty()); + name => name == "A" ? referencing : Array.Empty(), + () => true); - HotReloadWarmUpTarget target = source.Capture().Context.Targets[0]; + HotReloadWarmUpContext context = source.Capture().Context; + HotReloadWarmUpTarget target = context.Targets[0]; + Assert.That(context.CompilationListKept, Is.True, "kept"); Assert.That(target.DllPath, Is.EqualTo(CompiledPath("A.dll")), "dll"); Assert.That(target.PdbPath, Is.EqualTo(CompiledPath("A.pdb")), "pdb"); Assert.That(target.ReferencingDllPaths, Is.EqualTo(referencing), "referencing"); } + /// + /// What: without a kept compilation assembly list the referencing dlls are not collected, + /// since collecting them asks Unity for the list, and the context says no list was kept. + /// + [Test] + public void Capture_WithoutAKeptList_CollectsNoReferencingDlls() + { + RecordCompiled("A"); + int collected = 0; + HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource( + _projectRoot, + new HotReloadStubEditorStateSnapshotCapture(() => _state), + name => + { + collected++; + return new[] { "referencing.dll" }; + }, + () => false); + + HotReloadWarmUpContext context = source.Capture().Context; + + Assert.That(collected, Is.EqualTo(0), "collector calls"); + Assert.That(context.Targets[0].ReferencingDllPaths, Is.Empty, "referencing"); + Assert.That(context.CompilationListKept, Is.False, "kept"); + } + /// /// What: a failed last compile does not skip, because the loaded dlls are the last good build. /// @@ -156,7 +185,8 @@ private HotReloadWarmUpCapture Capture() HotReloadWarmUpContextSource source = new HotReloadWarmUpContextSource( _projectRoot, new HotReloadStubEditorStateSnapshotCapture(() => _state), - name => Array.Empty()); + name => Array.Empty(), + () => true); return source.Capture(); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs index 0a00841a1c..96a1c227fe 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpE2ETests.cs @@ -250,7 +250,7 @@ private async Task BeginScope(HotReloadWarmUp warmUp) private HotReloadWarmUp CreateWarmUp(params IHotReloadWarmUpItem[] items) { - return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), items); + return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(true)), items); } private static HotReloadWarmUpCapture CreateTestAssemblyCapture(string testDll) @@ -266,7 +266,8 @@ private static HotReloadWarmUpCapture CreateTestAssemblyCapture(string testDll) testDll, Path.ChangeExtension(testDll, ".pdb"), HotReloadCallSiteScanner.CollectReferencingDllPaths(root, TestAssemblyName())) - })); + }, + true)); } private static async Task RunWithReadReturningAsync(int value) diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs index eb5b6e30dc..b1ebe750ab 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpEditorHooksTests.cs @@ -28,7 +28,7 @@ public void SetUp() _ran = new List(); _pending = new PendingWarmUpItem("a", _ran); _warmUp = new HotReloadWarmUp( - new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), + new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(true)), new IHotReloadWarmUpItem[] { _pending, new RecordingWarmUpItem("b", _ran) }); _scope = HotReloadDomainTestScope.WithWarmUp(_warmUp); HotReloadWarmUpEditorHooks.GetServices = () => HotReloadCompositionRoot.Services; diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs index f37a279a4e..551238f3a6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpItemsTests.cs @@ -29,5 +29,26 @@ public void CreateProduction_ReturnsPublicizedTargetsThenCallSitesThenReferenced names, Is.EqualTo(new[] { "publicized_targets", "call_sites", "referenced_method_sets", "transform_worker", "pdb_documents" })); } + + /// + /// What: exactly the items that read the compilation assembly list say they need it: the + /// publicized copies and the transform worker look their target up in it, and the + /// referenced-method sets read the referencing dlls only it names. These are the items + /// skipped when no list was kept. + /// + [Test] + public void CreateProduction_MarksTheItemsThatReadTheCompilationList() + { + List needing = new List(); + foreach (IHotReloadWarmUpItem item in HotReloadWarmUpItems.CreateProduction(HotReloadCompiledCallers.CreateProduction())) + { + if (item.NeedsCompilationList) + { + needing.Add(item.Name); + } + } + + Assert.That(needing, Is.EqualTo(new[] { "publicized_targets", "referenced_method_sets", "transform_worker" })); + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs index 37c40e37bd..f7997d9875 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTestDoubles.cs @@ -5,9 +5,13 @@ using Newtonsoft.Json.Linq; +using UnityEditor.Compilation; + using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.ToolContracts; +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// @@ -26,7 +30,7 @@ internal static HotReloadWarmUp CreateInert() Array.Empty()); } - internal static HotReloadWarmUpCapture CreateReadyCapture() + internal static HotReloadWarmUpCapture CreateReadyCapture(bool compilationListKept) { return HotReloadWarmUpCapture.Ready( new HotReloadWarmUpContext( @@ -38,7 +42,8 @@ internal static HotReloadWarmUpCapture CreateReadyCapture() TargetAssemblyName + ".dll", TargetAssemblyName + ".pdb", Array.Empty()) - })); + }, + compilationListKept)); } /// The context of the only entry of in the in-memory vibe log. @@ -95,6 +100,8 @@ internal RecordingWarmUpItem(string name, List ran) public string Name { get; } + public bool NeedsCompilationList => false; + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) { _ran.Add(Name); @@ -115,6 +122,8 @@ internal PendingWarmUpItem(string name, List ran) public string Name { get; } + public bool NeedsCompilationList => false; + internal TaskCompletionSource Release { get; } = new TaskCompletionSource(); public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) @@ -141,6 +150,8 @@ internal TwoUnitWarmUpItem(string name, List ran) public string Name { get; } + public bool NeedsCompilationList => false; + internal TaskCompletionSource BetweenUnits { get; } = new TaskCompletionSource(); public async Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) @@ -152,6 +163,61 @@ public async Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) } } + /// + /// An item that needs the compilation assembly list: it looks the target up through the given + /// lookup and appends its name when it runs. + /// + internal sealed class ListLookupWarmUpItem : IHotReloadWarmUpItem + { + private readonly List _ran; + private readonly Func _findByName; + + internal ListLookupWarmUpItem(string name, List ran, Func findByName) + { + Name = name; + _ran = ran; + _findByName = findByName; + } + + public string Name { get; } + + public bool NeedsCompilationList => true; + + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) + { + _findByName(HotReloadWarmUpTestDoubles.TargetAssemblyName); + _ran.Add(Name); + return Task.CompletedTask; + } + } + + /// + /// The fetch of a compilation assembly cache, counting the times the cache asks Unity for the list. + /// + internal sealed class CountingCompilationListFetch + { + internal int Count { get; private set; } + + internal HotReloadCompilationAssemblyCache CreateEmptyCache() + { + return new HotReloadCompilationAssemblyCache(Fetch); + } + + /// A cache that already keeps Unity's list, as after a rebuild that was kept. + internal HotReloadCompilationAssemblyCache CreateFilledCache() + { + HotReloadCompilationAssemblyCache cache = CreateEmptyCache(); + NUnit.Framework.Assert.That(cache.TryFill(CompilationPipeline.GetAssemblies(), cache.Generation), NUnit.Framework.Is.True); + return cache; + } + + private UnityCompilationAssembly[] Fetch() + { + Count++; + return CompilationPipeline.GetAssemblies(); + } + } + /// An item whose task faults with the given exception. internal sealed class ThrowingWarmUpItem : IHotReloadWarmUpItem { @@ -165,6 +231,8 @@ internal ThrowingWarmUpItem(string name, Exception exception) public string Name { get; } + public bool NeedsCompilationList => false; + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) { return Task.FromException(_exception); diff --git a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs index 6ef56bcd29..2499e38dd8 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWarmUpTests.cs @@ -279,9 +279,64 @@ public async Task Shutdown_BeforeStart_DoesNothing_AndStartAfterwardsRuns() Assert.That(_ran, Is.EqualTo(new[] { "a" }), "items run"); } + /// + /// What: without a kept compilation assembly list, the items that look assemblies up in it + /// are reported skipped with compilation_list_unavailable and never ask Unity for the + /// list, while the other items still run. + /// + [Test] + public async Task WarmUp_WhenTheListIsUnavailable_SkipsTheItemsThatNeedItWithoutFetching() + { + CountingCompilationListFetch fetch = new CountingCompilationListFetch(); + HotReloadCompilationAssemblyCache cache = fetch.CreateEmptyCache(); + HotReloadWarmUp warmUp = new HotReloadWarmUp( + new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(false)), + new IHotReloadWarmUpItem[] + { + new ListLookupWarmUpItem("needs_list", _ran, cache.FindByName), + new RecordingWarmUpItem("other", _ran) + }); + + warmUp.Start(); + await warmUp.Completion; + + Assert.That(_ran, Is.EqualTo(new[] { "other" }), "items run"); + Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list"); + Assert.That( + HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), + Is.EqualTo(new[] { "needs_list:skipped", "other:done" })); + JObject skipped = (JObject)HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogWarmUpComplete)["items"][0]; + Assert.That( + (string)skipped["detail"], + Is.EqualTo(HotReloadConstants.WarmUpItemSkipReasonCompilationListUnavailable)); + } + + /// + /// What: with a kept compilation assembly list, every item runs, including those that look + /// assemblies up in it, and none asks Unity for the list. + /// + [Test] + public async Task WarmUp_AfterTheListIsFilled_RunsEveryItem() + { + CountingCompilationListFetch fetch = new CountingCompilationListFetch(); + HotReloadCompilationAssemblyCache cache = fetch.CreateFilledCache(); + HotReloadWarmUp warmUp = CreateReady( + new ListLookupWarmUpItem("needs_list", _ran, cache.FindByName), + new RecordingWarmUpItem("other", _ran)); + + warmUp.Start(); + await warmUp.Completion; + + Assert.That(_ran, Is.EqualTo(new[] { "needs_list", "other" }), "items run"); + Assert.That(fetch.Count, Is.EqualTo(0), "asked Unity for the list"); + Assert.That( + HotReloadWarmUpTestDoubles.ReadCompletedOutcomes(), + Is.EqualTo(new[] { "needs_list:done", "other:done" })); + } + private static HotReloadWarmUp CreateReady(params IHotReloadWarmUpItem[] items) { - return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture()), items); + return new HotReloadWarmUp(new FixedContextSource(HotReloadWarmUpTestDoubles.CreateReadyCapture(true)), items); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs index eb7bc0b671..89cfec676e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteWarmUpItem.cs @@ -22,6 +22,8 @@ internal HotReloadCallSiteWarmUpItem(HotReloadCompiledCallers compiledCallers) public string Name => "call_sites"; + public bool NeedsCompilationList => false; + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) { List dllPaths = new List(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs index 953e825da3..0297501ec7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompositionRoot.cs @@ -96,7 +96,8 @@ private static HotReloadWarmUp CreateProductionWarmUp(HotReloadCompiledCallers c new HotReloadWarmUpContextSource( projectRoot, new HotReloadEditorStateSnapshotCapture(), - assemblyName => compiledCallers.CollectReferencingDllPaths(projectRoot, assemblyName)), + assemblyName => compiledCallers.CollectReferencingDllPaths(projectRoot, assemblyName), + HotReloadCompilationAssemblies.IsKept), HotReloadWarmUpItems.CreateProduction(compiledCallers)); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs index 1badc38157..d5fabec266 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs @@ -23,7 +23,9 @@ public static void Initialize(Func isToolEnabled) HotReloadCompilationAssemblies.EnsureInitialized(); // Why at domain load: the rebuild then runs while the Editor finishes loading, so the // first caller of the domain finds the list kept instead of asking Unity on the main thread. - _ = RefillCompilationListUnlessHotReloadIsDisabled(isToolEnabled, HotReloadCompilationAssemblies.RefillInBackground); + // The warm-ups wait for it, and then skip what needs a list when none was kept. + Task compilationListReady = RefillCompilationListUnlessHotReloadIsDisabled( + isToolEnabled, HotReloadCompilationAssemblies.RefillInBackground); // Why not EditorApplication.delayCall: a cold-start session that hits Unity's native // "Scripts have compiler errors" dialog never flushes delayCall again for the rest of @@ -55,8 +57,10 @@ void SweepArtifactsOnFirstUpdateTick() void StartWarmUpOnFirstUpdateTick() { EditorApplication.update -= StartWarmUpOnFirstUpdateTick; - StartWarmUpUnlessHotReloadIsDisabled( - isToolEnabled, () => HotReloadCompositionRoot.Services.WarmUp.Start()); + _ = StartWarmUpOnceTheCompilationListIsReady( + compilationListReady, + () => StartWarmUpUnlessHotReloadIsDisabled( + isToolEnabled, () => HotReloadCompositionRoot.Services.WarmUp.Start())); } // The services are rebuilt here rather than on first use because the introduced type @@ -82,7 +86,9 @@ void StartWarmUpOnFirstUpdateTick() HotReloadSharedCompilerWarmUpReferenceSource warmUpReferences = new HotReloadSharedCompilerWarmUpReferenceSource( warmUpProjectRoot, HotReloadWarmUpTargetLedger.Read, - HotReloadCompilationAssemblies.FindByName); + HotReloadCompilationAssemblies.FindByName, + compilationListReady, + HotReloadCompilationAssemblies.IsKept); SharedCompilerWarmUpCoordination.CollectWarmUpReferencePaths = warmUpReferences.CollectAsync; // Why the same shape for these two: both run from Editor callbacks that take no // argument, so they have to read whichever services are installed when they fire. @@ -172,6 +178,23 @@ internal static Task RefillCompilationListUnlessHotReloadIsDisabled( return startRefill(); } + /// + /// Starts the warm-up on the main thread once the domain's attempt to rebuild the compilation + /// assembly list has ended, kept or not, so the warm-up sees the list the rebuild kept + /// instead of asking Unity for it. Never faults. + /// + internal static async Task StartWarmUpOnceTheCompilationListIsReady(Task compilationListReady, Action startWarmUp) + { + Debug.Assert(compilationListReady != null, "compilationListReady must not be null."); + Debug.Assert(startWarmUp != null, "startWarmUp must not be null."); + + // Why ConfigureAwait(false) and then a switch: a rebuild cancelled while it runs ends on a + // pool thread, and the warm-up reads Unity state when it starts. + await compilationListReady.ConfigureAwait(false); + await MainThreadSwitcher.SwitchToMainThread(); + startWarmUp(); + } + /// /// Starts the warm-up unless the hot-reload tool is disabled in the tool settings. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs index caaa4ee042..9bdfecc5fd 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPdbDocumentWarmUpItem.cs @@ -12,6 +12,8 @@ internal sealed class HotReloadPdbDocumentWarmUpItem : IHotReloadWarmUpItem { public string Name => "pdb_documents"; + public bool NeedsCompilationList => false; + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) { // Why taken here: the Shared instance reads Application.dataPath when it is first diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs index dd24d885ed..48803f3e68 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPublicizedTargetWarmUpItem.cs @@ -17,6 +17,8 @@ internal sealed class HotReloadPublicizedTargetWarmUpItem : IHotReloadWarmUpItem { public string Name => "publicized_targets"; + public bool NeedsCompilationList => true; + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) { // Why gather every target here first: the compilation assembly lookup may only run on diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs index 4a294b9f4e..4b9b2acd33 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadReferencedMethodSetWarmUpItem.cs @@ -22,6 +22,9 @@ internal HotReloadReferencedMethodSetWarmUpItem(HotReloadCompiledCallers compile public string Name => "referenced_method_sets"; + // Why: its dlls are the referencing ones, which only the compilation assembly list names. + public bool NeedsCompilationList => true; + public Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) { List dllPaths = CollectDllPaths(context.Targets); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs index 38426aa938..1a821cc513 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSharedCompilerWarmUpReferenceSource.cs @@ -22,28 +22,48 @@ internal sealed class HotReloadSharedCompilerWarmUpReferenceSource private readonly string _projectRoot; private readonly Func> _readLedger; private readonly Func _findCompilationAssembly; + private readonly Task _compilationListReady; + private readonly Func _isCompilationListKept; internal HotReloadSharedCompilerWarmUpReferenceSource( string projectRoot, Func> readLedger, - Func findCompilationAssembly) + Func findCompilationAssembly, + Task compilationListReady, + Func isCompilationListKept) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be empty."); Debug.Assert(readLedger != null, "readLedger must not be null."); Debug.Assert(findCompilationAssembly != null, "findCompilationAssembly must not be null."); + Debug.Assert(compilationListReady != null, "compilationListReady must not be null."); + Debug.Assert(isCompilationListKept != null, "isCompilationListKept must not be null."); _projectRoot = projectRoot; _readLedger = readLedger; _findCompilationAssembly = findCompilationAssembly; + _compilationListReady = compilationListReady; + _isCompilationListKept = isCompilationListKept; } /// - /// Any thread. Empty when the ledger names no assembly whose dll exists and whose - /// compilation assembly Unity lists (none is listed while the Editor compiles). + /// Any thread. Waits until the domain's attempt to rebuild the compilation assembly list has + /// ended. Empty when no list is kept then, or when the ledger names no assembly whose dll + /// exists and whose compilation assembly the list holds. /// internal async Task> CollectAsync(CancellationToken ct) { - // Why the main thread: the compilation pipeline answers only there. + // Why wait: this runs at the server start right after a domain load, before the rebuild + // has usually ended, and returning empty then would leave the worker cold. + // Why ConfigureAwait(false) and then a switch: a cancelled rebuild ends on a pool thread. + await _compilationListReady.ConfigureAwait(false); + // Why the main thread: the kept list is read only there. await MainThreadSwitcher.SwitchToMainThread(ct); + // Why not ask Unity instead: GetAssemblies stops the main thread for hundreds of + // milliseconds on a large project, more than this warm-up saves. + if (!_isCompilationListKept()) + { + return Array.Empty(); + } + IReadOnlyList names = _readLedger(_projectRoot); CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot); int count = Math.Min(names.Count, HotReloadWarmUpContextSource.MaxWarmedTargets); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs index b20e216553..1bdc3c1d6f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTransformWorkerWarmUpItem.cs @@ -29,6 +29,8 @@ internal HotReloadTransformWorkerWarmUpItem( public string Name => ItemName; + public bool NeedsCompilationList => true; + public async Task RunAsync(HotReloadWarmUpContext context, CancellationToken ct) { HotReloadTransformWorkerWarmUpRequest request = GatherOnMainThread(context); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs index 033cfa5845..30ec51a57a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUp.cs @@ -165,6 +165,15 @@ private async Task RunAllAsync(CancellationToken ct) continue; } + // Why skip rather than look up: without a kept list the lookup asks Unity on the + // main thread, which is the wait the rebuilt list exists to avoid. + if (item.NeedsCompilationList && !capture.Context.CompilationListKept) + { + outcomes.Add(HotReloadWarmUpItemOutcome.Skipped( + item.Name, HotReloadConstants.WarmUpItemSkipReasonCompilationListUnavailable)); + continue; + } + // Why ConfigureAwait(false): the next item switches to the main thread itself, // and the entry after the last item must not wait for a main thread that a // reload may never give back. @@ -280,6 +289,8 @@ private static string ToOutcomeName(HotReloadWarmUpItemOutcomeKind kind) return "cancelled"; case HotReloadWarmUpItemOutcomeKind.Failed: return "failed"; + case HotReloadWarmUpItemOutcomeKind.Skipped: + return "skipped"; default: throw new ArgumentOutOfRangeException(nameof(kind), kind, null); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs index 0db3c1a4fc..a2601d4367 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContext.cs @@ -5,20 +5,31 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// What one warm-up loads: the project root and at least one target assembly. + /// What one warm-up loads: the project root, at least one target assembly, and whether the + /// compilation assembly list was kept when the targets were chosen. /// internal sealed class HotReloadWarmUpContext { - internal HotReloadWarmUpContext(string projectRoot, IReadOnlyList targets) + internal HotReloadWarmUpContext( + string projectRoot, + IReadOnlyList targets, + bool compilationListKept) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(targets != null && targets.Count > 0, "targets must hold at least one target."); ProjectRoot = projectRoot; Targets = targets; + CompilationListKept = compilationListKept; } internal string ProjectRoot { get; } internal IReadOnlyList Targets { get; } + + /// + /// False when no compilation assembly list was kept: the items that look assemblies up in + /// it are then skipped, so the warm-up never asks Unity for the list on the main thread. + /// + internal bool CompilationListKept { get; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs index 5ccdc81c58..6e424b8595 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpContextSource.cs @@ -8,7 +8,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// /// Chooses what the warm-up loads: the most recent assemblies in the project's ledger whose - /// compiled dll and PDB exist, with the dlls that reference each of them. + /// compiled dll and PDB exist, with the dlls that reference each of them. The referencing dlls + /// are read only from a kept compilation assembly list; without one each target has none. /// internal sealed class HotReloadWarmUpContextSource : IHotReloadWarmUpContextSource { @@ -19,18 +20,22 @@ internal sealed class HotReloadWarmUpContextSource : IHotReloadWarmUpContextSour private readonly string _projectRoot; private readonly IHotReloadEditorStateSnapshotCapture _editorState; private readonly Func> _collectReferencingDllPaths; + private readonly Func _isCompilationListKept; internal HotReloadWarmUpContextSource( string projectRoot, IHotReloadEditorStateSnapshotCapture editorState, - Func> collectReferencingDllPaths) + Func> collectReferencingDllPaths, + Func isCompilationListKept) { Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(editorState != null, "editorState must not be null."); Debug.Assert(collectReferencingDllPaths != null, "collectReferencingDllPaths must not be null."); + Debug.Assert(isCompilationListKept != null, "isCompilationListKept must not be null."); _projectRoot = projectRoot; _editorState = editorState; _collectReferencingDllPaths = collectReferencingDllPaths; + _isCompilationListKept = isCompilationListKept; } /// @@ -57,16 +62,18 @@ public HotReloadWarmUpCapture Capture() return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonNoTargets); } - List targets = CollectTargets(names); + // Read once: the items decide from the same answer the referencing dlls came from. + bool compilationListKept = _isCompilationListKept(); + List targets = CollectTargets(names, compilationListKept); if (targets.Count == 0) { return HotReloadWarmUpCapture.Skipped(HotReloadWarmUpCapture.SkipReasonNoCompiledAssembly); } - return HotReloadWarmUpCapture.Ready(new HotReloadWarmUpContext(_projectRoot, targets)); + return HotReloadWarmUpCapture.Ready(new HotReloadWarmUpContext(_projectRoot, targets, compilationListKept)); } - private List CollectTargets(IReadOnlyList names) + private List CollectTargets(IReadOnlyList names, bool compilationListKept) { CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot); List targets = new List(); @@ -83,7 +90,12 @@ private List CollectTargets(IReadOnlyList names) continue; } - targets.Add(new HotReloadWarmUpTarget(name, dllPath, pdbPath, _collectReferencingDllPaths(name))); + // Why not collected without a kept list: the referencing assemblies come from the + // list, and asking Unity for it here would stop the main thread. + IReadOnlyList referencingDllPaths = compilationListKept + ? _collectReferencingDllPaths(name) + : Array.Empty(); + targets.Add(new HotReloadWarmUpTarget(name, dllPath, pdbPath, referencingDllPaths)); } return targets; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs index a16f7e6be5..2aaae5085f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWarmUpItemOutcome.cs @@ -24,7 +24,9 @@ private HotReloadWarmUpItemOutcome(string name, long ms, HotReloadWarmUpItemOutc internal HotReloadWarmUpItemOutcomeKind Kind { get; } - /// The exception's type and message for a failed item; empty otherwise. + /// + /// The exception's type and message for a failed item, the reason for a skipped one; empty otherwise. + /// internal string Detail { get; } internal static HotReloadWarmUpItemOutcome Done(string name, long ms) @@ -38,6 +40,13 @@ internal static HotReloadWarmUpItemOutcome Cancelled(string name, long ms) return new HotReloadWarmUpItemOutcome(name, ms, HotReloadWarmUpItemOutcomeKind.Cancelled, string.Empty); } + /// An item that was not run because what it needs is unavailable. + internal static HotReloadWarmUpItemOutcome Skipped(string name, string reason) + { + Debug.Assert(!string.IsNullOrEmpty(reason), "reason must not be null or empty."); + return new HotReloadWarmUpItemOutcome(name, 0, HotReloadWarmUpItemOutcomeKind.Skipped, reason); + } + internal static HotReloadWarmUpItemOutcome Failed(string name, long ms, Exception exception) { Debug.Assert(exception != null, "exception must not be null."); @@ -53,6 +62,7 @@ internal enum HotReloadWarmUpItemOutcomeKind { Done, Cancelled, - Failed + Failed, + Skipped } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs index 15a23f9996..12e403cb83 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IHotReloadWarmUpItem.cs @@ -12,6 +12,12 @@ internal interface IHotReloadWarmUpItem /// Name as it appears in the hot_reload_warm_up_complete entry (snake_case). string Name { get; } + /// + /// True for an item that looks assemblies up in the compilation assembly list. Such an item + /// is skipped when no list was kept, since the lookup would ask Unity on the main thread. + /// + bool NeedsCompilationList { get; } + /// /// Entered on the main thread. Throws OperationCanceledException before the next unit of work /// (one dll, one process start) once ct is cancelled, so an item that stopped early is never diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index f447ebfdca..cb03b5b660 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -86,6 +86,15 @@ internal static Task RefillInBackground() return Refill.RunAsync(); } + /// + /// Whether a list is kept now, whether it was rebuilt at startup or asked of Unity by an + /// earlier caller, so a caller that must not wait for Unity can skip instead. Main thread only. + /// + internal static bool IsKept() + { + return Cache.IsFilled; + } + // Main thread: the cache asks for the list from Current(). private static UnityCompilationAssembly[] FetchAndSaveIfClean() { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index ac7ea3594b..b940299b6d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -716,6 +716,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string VibeLogSourceSnapshotSkipped = "hot_reload_source_snapshot_skipped"; public const string SourceSnapshotSkipReasonToolsDisabled = "tools_disabled"; public const string WarmUpSkipReasonToolDisabled = "tool_disabled"; + public const string WarmUpItemSkipReasonCompilationListUnavailable = "compilation_list_unavailable"; // Saving the compilation assembly list for the next domain: why a domain did not save it, // a save that threw, and a save that finished (its size and time). From 26e39424cf3f6a62763f7ba835f064d1f008e697 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 12 Oct 2026 09:30:38 +0900 Subject: [PATCH 13/15] fix: Hot reload no longer asks for a compile that cannot help when an edited file has no method body (#3287) --- .../Tests/Editor/HotReload/CecilPdbRewrite.cs | 67 ++++++ .../Editor/HotReload/CecilPdbRewrite.cs.meta | 11 + ...dCompilationAssemblyReconstructionTests.cs | 31 +-- .../HotReloadPdbDocumentIndexTests.cs | 67 ++++++ .../HotReload/HotReloadSourceSnapshotTests.cs | 194 +++++++++++++++++- .../HotReload/HotReloadGroupProcessor.cs | 5 +- .../HotReloadWorkerNoticeAppender.cs | 6 +- .../HotReload/Shared/HotReloadConstants.cs | 9 +- .../Shared/HotReloadPdbCompilationInfo.cs | 28 ++- .../Shared/HotReloadPdbDocumentIndex.cs | 73 +++++-- .../Shared/HotReloadSnapshotMissReason.cs | 6 +- .../Shared/HotReloadSnapshotSourceCheck.cs | 4 +- .../Shared/HotReloadSourceBaseline.cs | 55 ++++- 13 files changed, 485 insertions(+), 71 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs create mode 100644 Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs.meta diff --git a/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs new file mode 100644 index 0000000000..71ae47ea02 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; + +using Mono.Cecil; +using Mono.Cecil.Cil; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Writes a copy of a compiled assembly of this project the way an IL post-processor does, for + /// tests that need a PDB which lost the documents no sequence point refers to. + /// + internal static class CecilPdbRewrite + { + // Writes the rewritten copy into a new temporary directory and returns the dll's path; the + // caller deletes the directory. Fails the test unless the copy kept the compiler's source + // count and lost a document, which every case that uses it relies on. + internal static string RewriteIntoNewDirectoryLosingDocuments(string projectRoot, string assemblyName) + { + string directory = Path.Combine(Path.GetTempPath(), "uloop-cecil-pdb-rewrite-" + Guid.NewGuid().ToString("N")); + string dllPath = Path.Combine(directory, HotReloadConstants.ScriptAssembliesRelativeDirectory, assemblyName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(dllPath)); + Rewrite(projectRoot, assemblyName, directory, dllPath); + HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb")); + Assert.That(rewritten.SourceFileCount, Is.Not.Null, "Precondition: the rewritten PDB must keep the compiler's source count."); + Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount.Value), "Precondition: the rewrite must drop some document."); + return dllPath; + } + + // The temporary directory RewriteIntoNewDirectoryLosingDocuments created for the dll. + internal static string RootOf(string dllPath) + { + return Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(dllPath))); + } + + // Reads the assembly with its symbols and writes both again, as an IL post-processor does. + // Cecil keeps only the documents a method refers to. workDirectory receives the copy that is + // read, so the compiled files are never opened for writing. + internal static void Rewrite(string projectRoot, string assemblyName, string workDirectory, string outputDllPath) + { + CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(projectRoot); + string sourceDll = Path.Combine(workDirectory, "Source", assemblyName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(sourceDll)); + File.Copy(real.DllPath(assemblyName), sourceDll); + File.Copy(real.PdbPath(assemblyName), Path.ChangeExtension(sourceDll, ".pdb")); + using (DefaultAssemblyResolver resolver = new DefaultAssemblyResolver()) + { + resolver.AddSearchDirectory(real.CompiledAssembliesDirectory); + resolver.AddSearchDirectory(Path.GetDirectoryName(typeof(object).Assembly.Location)); + using (ModuleDefinition module = ModuleDefinition.ReadModule( + sourceDll, + new ReaderParameters { ReadSymbols = true, AssemblyResolver = resolver })) + { + module.Write(outputDllPath, new WriterParameters + { + WriteSymbols = true, + SymbolWriterProvider = new PortablePdbWriterProvider(), + }); + } + } + } + } +} diff --git a/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs.meta b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs.meta new file mode 100644 index 0000000000..0eae96ec21 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/CecilPdbRewrite.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c9825d04ecc5417392d4c02a5494bf8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs index 164ca82f7d..6a24f3f512 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs @@ -4,9 +4,6 @@ using System.Threading; using System.Threading.Tasks; -using Mono.Cecil; -using Mono.Cecil.Cil; - using Newtonsoft.Json; using NUnit.Framework; @@ -196,7 +193,7 @@ public void TryFromPdb_PdbThatLostSourcesInARewrite_IsRefused() Directory.CreateDirectory(scriptAssemblies); string dllPath = Path.Combine(scriptAssemblies, TargetAssemblyName + ".dll"); string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); - RewriteWithCecil(dllPath); + CecilPdbRewrite.Rewrite(_projectRoot, TargetAssemblyName, _directory, dllPath); HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(pdbPath); Assert.That(rewritten.SourceFileCount, Is.Not.Null, "Precondition: the rewritten PDB must keep the compiler's source count."); Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount.Value), "Precondition: the rewrite must drop some document."); @@ -461,32 +458,6 @@ private static int IndexOfReferenceNotIn(HotReloadSavedCompilationList saved, Ho return found; } - // Reads the target assembly with its symbols and writes both again, as an IL post-processor - // does. Cecil keeps only the documents a method refers to. - private void RewriteWithCecil(string outputDllPath) - { - CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(_projectRoot); - string sourceDll = Path.Combine(_directory, "Source", TargetAssemblyName + ".dll"); - Directory.CreateDirectory(Path.GetDirectoryName(sourceDll)); - File.Copy(real.DllPath(TargetAssemblyName), sourceDll); - File.Copy(real.PdbPath(TargetAssemblyName), Path.ChangeExtension(sourceDll, ".pdb")); - using (DefaultAssemblyResolver resolver = new DefaultAssemblyResolver()) - { - resolver.AddSearchDirectory(real.CompiledAssembliesDirectory); - resolver.AddSearchDirectory(Path.GetDirectoryName(typeof(object).Assembly.Location)); - using (ModuleDefinition module = ModuleDefinition.ReadModule( - sourceDll, - new ReaderParameters { ReadSymbols = true, AssemblyResolver = resolver })) - { - module.Write(outputDllPath, new WriterParameters - { - WriteSymbols = true, - SymbolWriterProvider = new PortablePdbWriterProvider(), - }); - } - } - } - private static int IndexOf(string assemblyName) { return Array.FindIndex(CompilationPipeline.GetAssemblies(), assembly => assembly.name == assemblyName); diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index 9c66b97cbe..a15d5a9d69 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -548,6 +548,68 @@ private void AssertRewrittenListIsNotUsed(Func rewrite) Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); } + /// + /// What: the compiler's own PDB, which has a document for every source it emitted, is not + /// reported as having dropped documents. + /// + [Test] + public void DropsDocuments_CompilerPdb_IsFalse() + { + string dllPath = DllPath(TestAssemblyName); + HotReloadPdbCompilationInfo compiled = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb")); + Assert.That(compiled.SourceFileCount, Is.Not.Null, "Precondition: the compiler's PDB must record its source count."); + Assert.That(compiled.DocumentCount, Is.GreaterThanOrEqualTo(compiled.SourceFileCount.Value), "Precondition: the compiler's PDB must drop nothing."); + + bool drops = DropsDocuments(_index, dllPath); + + Assert.That(drops, Is.False); + } + + /// + /// What: a PDB rewritten the way an IL post-processor does, which lost the documents no + /// sequence point refers to, is reported as having dropped documents. + /// + [Test] + public void DropsDocuments_PdbThatLostDocumentsInARewrite_IsTrue() + { + string dllPath = CecilPdbRewrite.RewriteIntoNewDirectoryLosingDocuments(ProjectRoot(), TestAssemblyName); + try + { + bool drops = DropsDocuments(_index, dllPath); + + Assert.That(drops, Is.True); + } + finally + { + Directory.Delete(CecilPdbRewrite.RootOf(dllPath), recursive: true); + } + } + + /// + /// What: a new index on the same directory answers that a rewritten PDB dropped documents + /// from the persisted list, without reading the dll and the PDB. + /// + [Test] + public void DropsDocuments_NewIndexOnTheSameDirectory_AnswersFromThePersistedList() + { + string dllPath = CecilPdbRewrite.RewriteIntoNewDirectoryLosingDocuments(ProjectRoot(), TestAssemblyName); + try + { + DropsDocuments(_index, dllPath); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool drops = DropsDocuments(second, dllPath); + + Assert.That(drops, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(0)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(1)); + } + finally + { + Directory.Delete(CecilPdbRewrite.RootOf(dllPath), recursive: true); + } + } + /// /// What: the list read from the PDB holds the same documents, with the same url, hash algorithm /// and hash, as a Cecil walk over the dll's types, methods and sequence points, and as many. @@ -699,6 +761,11 @@ private static bool Find( return index.TryFindDocument(dllPath, pdbPath, moduleVersionId, projectRelativePath, out document); } + private static bool DropsDocuments(HotReloadPdbDocumentIndex index, string dllPath) + { + return index.DropsDocuments(dllPath, Path.ChangeExtension(dllPath, ".pdb"), HotReloadAssemblyMvid.Read(dllPath)); + } + // Copies the assembly's dll and PDB into a new temp directory and returns the dll's path. // The caller deletes the directory. private static string CopyAssemblyToTemp(string assemblyName) diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 1e4cb6554b..d63553ec42 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -189,7 +189,7 @@ public void LoadVerifiedSnapshotSource_PackageSourceGivenByItsAssetPath_ReturnsT PausePointsRuntimeAssemblyName + HotReloadConstants.CompiledAssemblyExtension); string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSource(PackageSourceAssetPath, dllPath); - HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMiss(PackageSourceAssetPath, dllPath); + HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMiss(PackageSourceAssetPath, dllPath, null); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.None)); Assert.That(loaded, Is.EqualTo(File.ReadAllText(Path.Combine(projectRoot, PackageSourcePhysicalPath)))); @@ -263,7 +263,8 @@ public void LoadVerifiedSnapshotSourceAt_VirtualPlayerRootAndPackageSource_Finds playerRoot, PackageSourceAssetPath, dllPath, - documentIndex); + documentIndex, + null); string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( playerRoot, PackageSourceAssetPath, @@ -359,7 +360,8 @@ public void DescribeSnapshotMiss_ForAFileWithoutMethodBodies_ReportsNoDocumentIn projectRoot, BodylessFixtureProjectRelativePath, TestAssemblyDllPath(projectRoot), - HotReloadPdbDocumentIndex.Shared); + HotReloadPdbDocumentIndex.Shared, + null); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); } @@ -377,11 +379,158 @@ public void DescribeSnapshotMiss_WithoutASnapshotFile_ReportsNoSnapshotFile() emptyRoot, FixtureProjectRelativePath, TestAssemblyDllPath(projectRoot), - HotReloadPdbDocumentIndex.Shared); + HotReloadPdbDocumentIndex.Shared, + null); + + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile)); + } + + /// + /// What: a listed file without a snapshot in an assembly whose PDB dropped nothing reports + /// the missing snapshot a compile will write, even when no sequence point refers to it: such + /// a file is one the last compile did not build yet, as after a rename the compile has not + /// caught up with. + /// + [Test] + public void DescribeSnapshotMiss_ListedFileWithoutASnapshotInAPdbThatDroppedNothing_ReportsNoSnapshotFile() + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string emptyRoot = Path.Combine(Path.GetTempPath(), "uloop-hot-reload-snapshot-empty-" + Guid.NewGuid().ToString("N")); + string dllPath = TestAssemblyDllPath(projectRoot); + HotReloadPdbCompilationInfo compiled = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb")); + Assert.That(compiled.SourceFileCount, Is.Not.Null, "Precondition: the compiler's PDB must record its source count."); + Assert.That(compiled.DocumentCount, Is.GreaterThanOrEqualTo(compiled.SourceFileCount.Value), "Precondition: the compiler's PDB must drop nothing."); + + try + { + // Why a new index: the shared one answers from a list persisted by an earlier domain, + // which would hide a change to how the drop is worked out. + HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( + emptyRoot, + BodylessFixtureProjectRelativePath, + dllPath, + new HotReloadPdbDocumentIndex(Path.Combine(emptyRoot, "PdbDocuments")), + null); + + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile)); + } + finally + { + if (Directory.Exists(emptyRoot)) + { + Directory.Delete(emptyRoot, recursive: true); + } + } + } + + /// + /// What: in an assembly whose PDB an IL post-processor rewrote and dropped documents from, a + /// listed file without a snapshot that no sequence point refers to, which the capture could + /// not name from that PDB, reports that no compile gives it a baseline. + /// + [Test] + public void DescribeSnapshotMiss_ListedBodylessFileWithoutASnapshotInAPdbThatDroppedDocuments_ReportsNoDocumentInPdb() + { + HotReloadSnapshotMissReason reason = DescribeMissInARewrittenTestAssembly( + BodylessFixtureProjectRelativePath, + withNewSourceEvidence: false); + + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); + } + + /// + /// What: in an assembly whose PDB dropped documents, a listed file with method bodies and no + /// snapshot still reports the missing snapshot a compile will write. + /// + [Test] + public void DescribeSnapshotMiss_ListedFileWithBodiesWithoutASnapshotInAPdbThatDroppedDocuments_ReportsNoSnapshotFile() + { + HotReloadSnapshotMissReason reason = DescribeMissInARewrittenTestAssembly( + FixtureProjectRelativePath, + withNewSourceEvidence: false); + + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile)); + } + + /// + /// What: a file the compilation assemblies do not list yet, placed in the assembly by new + /// source evidence, reports the missing snapshot a compile will write even in an assembly + /// whose PDB dropped documents, whether or not it has method bodies. + /// + [TestCase(BodylessFixtureProjectRelativePath)] + [TestCase("Assets/Tests/Editor/HotReload/HotReloadAddedSinceTheCompile.cs")] + public void DescribeSnapshotMiss_FileWithNewSourceEvidenceInAPdbThatDroppedDocuments_ReportsNoSnapshotFile( + string projectRelativePath) + { + HotReloadSnapshotMissReason reason = DescribeMissInARewrittenTestAssembly( + projectRelativePath, + withNewSourceEvidence: true); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoSnapshotFile)); } + // Asks why a file has no snapshot in a copy of this test assembly whose PDB a Cecil round + // trip rewrote and dropped documents from, under a root with no snapshot tree. + private static HotReloadSnapshotMissReason DescribeMissInARewrittenTestAssembly( + string projectRelativePath, + bool withNewSourceEvidence) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string dllPath = CecilPdbRewrite.RewriteIntoNewDirectoryLosingDocuments(projectRoot, TestAssemblyName); + string rewriteRoot = CecilPdbRewrite.RootOf(dllPath); + try + { + return HotReloadSourceBaseline.DescribeSnapshotMissAt( + rewriteRoot, + projectRelativePath, + dllPath, + new HotReloadPdbDocumentIndex(Path.Combine(rewriteRoot, "PdbDocuments")), + withNewSourceEvidence ? NewSourceEvidence(projectRelativePath, dllPath) : null); + } + finally + { + Directory.Delete(rewriteRoot, recursive: true); + } + } + + /// + /// What: a PDB of another build beside the dll is rejected the same way for a listed file + /// without a snapshot as for one with a snapshot. + /// + [TestCase(true)] + [TestCase(false)] + public void DescribeSnapshotMiss_PdbOfAnotherBuild_ThrowsWithOrWithoutASnapshot(bool withSnapshot) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string realDllPath = TestAssemblyDllPath(projectRoot); + string fakeRoot = Path.Combine(Path.GetTempPath(), "uloop-hot-reload-snapshot-other-build-" + Guid.NewGuid().ToString("N")); + string dllPath = TestAssemblyDllPath(fakeRoot); + Directory.CreateDirectory(Path.GetDirectoryName(dllPath)); + File.Copy(realDllPath, dllPath); + File.Copy( + Path.ChangeExtension(typeof(HotReloadSourceBaseline).Assembly.Location, ".pdb"), + Path.ChangeExtension(dllPath, ".pdb")); + if (withSnapshot) + { + CopyRealSnapshot(projectRoot, fakeRoot, realDllPath, FixtureProjectRelativePath); + } + + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(Path.Combine(fakeRoot, "PdbDocuments")); + try + { + Assert.Throws(() => HotReloadSourceBaseline.DescribeSnapshotMissAt( + fakeRoot, + FixtureProjectRelativePath, + dllPath, + index, + null)); + } + finally + { + Directory.Delete(fakeRoot, recursive: true); + } + } + /// /// What: snapshot bytes that fail the PDB checksum report a hash mismatch. /// @@ -398,7 +547,8 @@ public void DescribeSnapshotMiss_WhenSnapshotBytesTampered_ReportsHashMismatch() fakeRoot, FixtureProjectRelativePath, dllPath, - HotReloadPdbDocumentIndex.Shared); + HotReloadPdbDocumentIndex.Shared, + null); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.HashMismatch)); } finally @@ -419,7 +569,8 @@ public void DescribeSnapshotMiss_ForAVerifiedSnapshot_ReportsNone() projectRoot, FixtureProjectRelativePath, TestAssemblyDllPath(projectRoot), - HotReloadPdbDocumentIndex.Shared); + HotReloadPdbDocumentIndex.Shared, + null); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.None)); } @@ -479,7 +630,8 @@ public void DescribeSnapshotMissAt_AfterALoadThatFoundNoDocument_DoesNotReadTheP projectRoot, BodylessFixtureProjectRelativePath, TestAssemblyDllPath(projectRoot), - index); + index, + null); Assert.That(loaded, Is.Null); Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); @@ -502,6 +654,18 @@ private static string TestAssemblyDllPath(string projectRoot) TestAssemblyName + HotReloadConstants.CompiledAssemblyExtension); } + // Evidence of the kind a run carries for a file Unity's compilation assemblies do not list. + private static HotReloadNewSourceMembershipEvidence NewSourceEvidence(string projectRelativePath, string dllPath) + { + return new HotReloadNewSourceMembershipEvidence( + projectRelativePath, + TestAssemblyName, + dllPath, + HotReloadAssemblyMvid.Read(dllPath), + "Assets/Tests/Editor/HotReload/UnityCLILoop.Tests.Editor.HotReload.asmdef", + Array.Empty()); + } + // No source is written at or after the end of time, so the capture never reads a compiled assembly. private static HotReloadSnapshotSourceCheck SuspectsNothing(string root) { @@ -515,6 +679,22 @@ private static HotReloadSnapshotSourceCheck SuspectsNothing(string root) StringComparison.Ordinal); } + // Copies the real snapshot of the file, unchanged, under another root for the same assembly build. + private static void CopyRealSnapshot(string projectRoot, string targetRoot, string dllPath, string projectRelativePath) + { + string mvid = HotReloadAssemblyMvid.Read(dllPath); + string realSnapshotPath = HotReloadSourceSnapshotLayout.SourcePath( + HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, TestAssemblyName, mvid), + projectRelativePath); + Assert.That(File.Exists(realSnapshotPath), Is.True, "Precondition: the real snapshot must exist."); + + string targetPath = HotReloadSourceSnapshotLayout.SourcePath( + HotReloadSourceSnapshotLayout.AssemblyDirectory(targetRoot, TestAssemblyName, mvid), + projectRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)); + File.Copy(realSnapshotPath, targetPath); + } + // Copies the fixture's real snapshot under a temporary root with its first byte flipped. private static string WriteTamperedSnapshotTree(string projectRoot, string dllPath) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs index c2ba888cbb..9bd7eb7e65 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupProcessor.cs @@ -635,7 +635,10 @@ private void SnapshotGroupState(IReadOnlyList files) file.ProjectRelativePath, file.TargetDllPath); file.SnapshotMissReason = file.SnapshotSource == null - ? HotReloadSourceBaseline.DescribeSnapshotMiss(file.ProjectRelativePath, file.TargetDllPath) + ? HotReloadSourceBaseline.DescribeSnapshotMiss( + file.ProjectRelativePath, + file.TargetDllPath, + file.NewSourceMembershipEvidence) : HotReloadSnapshotMissReason.None; } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs index c78b0b6255..78f937f514 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadWorkerNoticeAppender.cs @@ -122,8 +122,8 @@ private static void AppendBaselineNotices( /// /// Why this file has no verified snapshot. A file declaring a type hot reload introduced /// gets its own kind: for it a missing baseline is the normal state rather than something - /// a compile has yet to establish. So does a file with no compiled method body, which the - /// PDB never lists and so no compile can give a baseline. + /// a compile has yet to establish. So does a file with no compiled method body, which no + /// sequence point of the PDB refers to and so no compile can give a baseline. /// private static HotReloadMissingBaselineKind ChooseMissingBaselineKind( HotReloadSnapshotMissReason snapshotMissReason, @@ -170,7 +170,7 @@ private static string ChooseNoVerifiedSourceSnapshotWarningFormat( } // Why no other reason comes here: a file with a verified snapshot gets no warning, and - // one the PDB lists no document for gets the no-method-body kind. + // one no sequence point of the PDB refers to gets the no-method-body kind. Debug.Assert( snapshotMissReason == HotReloadSnapshotMissReason.NoSnapshotFile, "Only NoSnapshotFile, HashMismatch, or NoCompiledAssembly may reach the no-verified-snapshot wording."); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index b940299b6d..e68dc694cc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -482,10 +482,11 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE "No verified source snapshot for {0} (assembly {1}): the compiled assembly or its PDB is " + "missing; patching all methods. Run 'uloop compile'."; - // Format: file name, assembly name. Used instead of the warnings above when the compiled - // PDB lists no document for the file: a file whose code compiles to no method body (an - // enum, an interface, fields only) is never recorded there, so no compile can produce the - // baseline the other wording asks the reader to establish. + // Format: file name, assembly name. Used instead of the warnings above when no sequence + // point of the compiled PDB refers to the file: a file whose code compiles to no method + // body (an enum, an interface, fields only) has none, so no compile can produce the + // baseline the other wording asks the reader to establish. The compiler still records such + // a file as a document, but an IL post-processor that rewrites the PDB drops it. public const string NoCompiledMethodBodyBaselineWarningFormat = "{0} (assembly {1}) has no compiled method body, so there is no baseline for " + "edited-method detection; patching all methods. This is expected for files that only " diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs index 392582efa5..361e0d8f13 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs @@ -95,7 +95,32 @@ internal static HotReloadPdbCompilationInfo Read(string pdbPath) reader.Documents.Count); } + /// + /// Reads the compiler's source count from an open PDB, or null when the PDB has no + /// compilation options record or the record does not hold the count. Throws + /// InvalidOperationException when the record is malformed. + /// + internal static int? ReadSourceFileCount(MetadataReader reader, string pdbPath) + { + Debug.Assert(reader != null, "reader must not be null."); + + byte[] options = TryReadModuleRecord(reader, CompilationOptionsKind); + return options == null ? null : ToSourceFileCount(ParseOptions(options, pdbPath)); + } + private static byte[] ReadModuleRecord(MetadataReader reader, Guid kind, string pdbPath) + { + byte[] record = TryReadModuleRecord(reader, kind); + if (record == null) + { + throw new InvalidOperationException( + "The PDB " + Path.GetFileName(pdbPath) + " has no compilation record of kind " + kind + "."); + } + + return record; + } + + private static byte[] TryReadModuleRecord(MetadataReader reader, Guid kind) { // Why only the module's rows: both records describe the whole compilation, so Roslyn // attaches them to the module. @@ -110,8 +135,7 @@ private static byte[] ReadModuleRecord(MetadataReader reader, Guid kind, string return reader.GetBlobBytes(information.Value); } - throw new InvalidOperationException( - "The PDB " + Path.GetFileName(pdbPath) + " has no compilation record of kind " + kind + "."); + return null; } // The options blob is a sequence of null-terminated UTF-8 key and value pairs. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index e1499f0d8e..b8d3866c96 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -38,6 +38,7 @@ public HotReloadPdbDocument(string url, DocumentHashAlgorithm hashAlgorithm, byt /// list of urls and checksums, and there is at most one entry per assembly of the project. /// Each list is also written to a file under the persistence directory with the same five /// values, so a new index in the next domain reads it from there instead of walking the PDB. + /// With the list it keeps whether a tool that rewrote the PDB dropped documents from it. /// internal sealed class HotReloadPdbDocumentIndex { @@ -86,8 +87,8 @@ public override int GetHashCode() } } - private const string PersistedFormatHeader = "uloop-pdb-documents 1"; - private const int PersistedStampFieldCount = 6; + private const string PersistedFormatHeader = "uloop-pdb-documents 2"; + private const int PersistedStampFieldCount = 7; private const int PersistedDocumentFieldCount = 3; // Hash algorithm GUIDs a portable PDB records for its documents. @@ -99,11 +100,13 @@ private sealed class Entry { public readonly FileStamp Stamp; public readonly List Documents; + public readonly bool DropsDocuments; - public Entry(FileStamp stamp, List documents) + public Entry(FileStamp stamp, List documents, bool dropsDocuments) { Stamp = stamp; Documents = documents; + DropsDocuments = dropsDocuments; } } @@ -182,6 +185,28 @@ internal bool TryFindDocument( } } + /// + /// Whether the PDB's document table has fewer rows than the sources the compiler recorded + /// it emitted. The compiler writes one row per source, so fewer rows means a tool that + /// rewrote the PDB after the compile, as an IL post-processor does, dropped the documents + /// no sequence point refers to. More rows is not a drop: #line directives add documents. + /// False when the PDB does not record the count. Takes the same paths and MVID as + /// TryFindDocument, and reads the files only when TryFindDocument would. + /// + internal bool DropsDocuments(string dllPath, string pdbPath, string moduleVersionId) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(moduleVersionId), "moduleVersionId must not be null or empty."); + + string fullDllPath = Path.GetFullPath(dllPath); + FileStamp stamp = ReadStamp(fullDllPath, pdbPath, moduleVersionId); + lock (_gate) + { + return EnsureEntryLocked(fullDllPath, pdbPath, stamp).DropsDocuments; + } + } + /// /// Reads the dll's document list now so that a later TryFindDocument in this domain reads /// nothing. Takes the same paths and MVID as TryFindDocument. @@ -208,7 +233,7 @@ private Entry EnsureEntryLocked(string fullDllPath, string pdbPath, FileStamp st } string persistedPath = PersistedListPath(fullDllPath); - List documents = TryReadPersistedList(persistedPath, stamp); + List documents = TryReadPersistedList(persistedPath, stamp, out bool dropsDocuments); if (documents != null) { _persistedLoadCount++; @@ -218,15 +243,15 @@ private Entry EnsureEntryLocked(string fullDllPath, string pdbPath, FileStamp st // Why the walk before any write: a walk that throws leaves no entry and no // file, so the next lookup reads the files again instead of answering from // a list of other files. - documents = ReadDocuments(fullDllPath, pdbPath); + documents = ReadDocumentsNotingDrops(fullDllPath, pdbPath, out dropsDocuments); _loadCount++; - WritePersistedList(persistedPath, stamp, documents); + WritePersistedList(persistedPath, stamp, documents, dropsDocuments); } // Why stored under the stamp read before the walk: if a file is replaced while // it is being read, the next lookup sees another stamp and reads again, so a // list is never served for files it was not read from. - entry = new Entry(stamp, documents); + entry = new Entry(stamp, documents, dropsDocuments); _entries[fullDllPath] = entry; return entry; } @@ -239,13 +264,15 @@ private string PersistedListPath(string fullDllPath) // File format: UTF-8 without a BOM, every line ends with "\n", fields are TAB-separated and // numbers are invariant. // line 1: the format header - // line 2: dll length, dll write time ticks, MVID, PDB length, PDB write time ticks, count + // line 2: dll length, dll write time ticks, MVID, PDB length, PDB write time ticks, + // 1 when the PDB dropped documents and 0 when not, count // then count lines: (int)HashAlgorithm, the hash in hex ("" for none), url // Null for a missing file, another stamp, or any malformed part: a file cut short or edited // by hand must not answer with part of a list. Why the url last: it is the only field that // can hold anything, so splitting into three keeps a TAB in a url intact. - private static List TryReadPersistedList(string path, FileStamp stamp) + private static List TryReadPersistedList(string path, FileStamp stamp, out bool dropsDocuments) { + dropsDocuments = false; if (!File.Exists(path)) { return null; @@ -262,7 +289,7 @@ private static List TryReadPersistedList(string path, File return null; } - if (!TryParseStampLine(lines[1], out FileStamp persistedStamp, out int count) + if (!TryParseStampLine(lines[1], out FileStamp persistedStamp, out bool persistedDrops, out int count) || !persistedStamp.Equals(stamp)) { return null; @@ -285,12 +312,14 @@ private static List TryReadPersistedList(string path, File documents.Add(document); } + dropsDocuments = persistedDrops; return documents; } - private static bool TryParseStampLine(string line, out FileStamp stamp, out int count) + private static bool TryParseStampLine(string line, out FileStamp stamp, out bool dropsDocuments, out int count) { stamp = default(FileStamp); + dropsDocuments = false; count = 0; string[] fields = line.Split('\t'); if (fields.Length != PersistedStampFieldCount @@ -299,7 +328,8 @@ private static bool TryParseStampLine(string line, out FileStamp stamp, out int || fields[2].Length == 0 || !TryParseLong(fields[3], out long pdbLength) || !TryParseLong(fields[4], out long pdbTicks) - || !int.TryParse(fields[5], NumberStyles.None, CultureInfo.InvariantCulture, out count)) + || !TryParseFlag(fields[5], out dropsDocuments) + || !int.TryParse(fields[6], NumberStyles.None, CultureInfo.InvariantCulture, out count)) { return false; } @@ -308,6 +338,12 @@ private static bool TryParseStampLine(string line, out FileStamp stamp, out int return true; } + private static bool TryParseFlag(string text, out bool value) + { + value = string.Equals(text, "1", StringComparison.Ordinal); + return value || string.Equals(text, "0", StringComparison.Ordinal); + } + private static bool TryParseDocumentLine(string line, out HotReloadPdbDocument document) { document = default(HotReloadPdbDocument); @@ -369,7 +405,7 @@ private static bool TryParseHex(string text, out byte[] bytes) return true; } - private void WritePersistedList(string path, FileStamp stamp, List documents) + private void WritePersistedList(string path, FileStamp stamp, List documents, bool dropsDocuments) { Directory.CreateDirectory(_persistenceDirectory); StringBuilder text = new StringBuilder(); @@ -379,6 +415,7 @@ private void WritePersistedList(string path, FileStamp stamp, List ReadDocuments(string dllPath, string pdbPath) + { + return ReadDocumentsNotingDrops(dllPath, pdbPath, out bool _); + } + + // ReadDocuments, also answering whether the PDB dropped documents; see DropsDocuments. + // Why from the reader the walk already opened: the source count is one more record of + // the same PDB, so the answer costs no second read of the file. + private static List ReadDocumentsNotingDrops(string dllPath, string pdbPath, out bool dropsDocuments) { Guid codeViewGuid = ReadCodeViewGuid(dllPath); using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); @@ -475,6 +520,8 @@ internal static List ReadDocuments(string dllPath, string reader.GetBlobBytes(document.Hash))); } + int? sourceFileCount = HotReloadPdbCompilationInfo.ReadSourceFileCount(reader, pdbPath); + dropsDocuments = sourceFileCount.HasValue && reader.Documents.Count < sourceFileCount.Value; return documents; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs index 8237b8167f..d79c0bde60 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotMissReason.cs @@ -16,8 +16,10 @@ internal enum HotReloadSnapshotMissReason NoSnapshotFile, /// - /// The PDB has no document for the file because none of its code compiled to a method - /// body, so no compile can produce a baseline for it. + /// No sequence point of the PDB refers to the file because none of its code compiled to a + /// method body, so no compile can produce a baseline for it. Reported when a snapshot of the + /// file was written, and, without one, for a file the compilation assemblies list in an + /// assembly whose PDB a tool rewrote and dropped such documents from. /// NoDocumentInPdb, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs index f2f9b96607..b37bbadc0f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotSourceCheck.cs @@ -64,8 +64,8 @@ internal bool IsCompiledSource(string projectRoot, string slashNormalizedRelativ slashNormalizedRelativePath, _packageRoots, _pathComparison); - // Why NoDocumentInPdb counts as not compiled: a file without method bodies has no document, - // and a copy that cannot be confirmed is safer treated as edited than trusted. + // Why NoDocumentInPdb counts as not compiled: no sequence point refers to a file without + // method bodies, and a copy that cannot be confirmed is safer treated as edited than trusted. return HotReloadSourceBaseline.CompareWithCompiledDocumentAtLookupPath( pdbLookupPath, _dllPath, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs index 340cea85a8..54d87d8859 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceBaseline.cs @@ -59,30 +59,66 @@ internal static string LoadVerifiedSnapshotSourceAt( /// second lookup reuses the document list the first one kept, so it does not read the PDB /// again. /// + /// + /// The evidence that placed a file Unity's compilation assemblies do not list in the + /// assembly, or null for a file they list. Null does not mean the last compile built the + /// file: the list also changes on an import that compiles nothing yet, so a file moved, + /// renamed or split off into a partial since the compile is listed too. + /// internal static HotReloadSnapshotMissReason DescribeSnapshotMiss( string projectRelativeSourcePath, - string targetDllPath) + string targetDllPath, + HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); return DescribeSnapshotMissAt( projectRoot, projectRelativeSourcePath, targetDllPath, - HotReloadPdbDocumentIndex.Shared); + HotReloadPdbDocumentIndex.Shared, + newSourceMembershipEvidence); } internal static HotReloadSnapshotMissReason DescribeSnapshotMissAt( string projectRoot, string projectRelativeSourcePath, string targetDllPath, - HotReloadPdbDocumentIndex documentIndex) + HotReloadPdbDocumentIndex documentIndex, + HotReloadNewSourceMembershipEvidence newSourceMembershipEvidence) { - return TryLoadVerifiedSnapshotSource( + HotReloadSnapshotMissReason reason = TryLoadVerifiedSnapshotSource( projectRoot, projectRelativeSourcePath, targetDllPath, documentIndex, out string _); + // Why a listed file without a snapshot still asks the PDB: an IL post-processor that + // rewrites the PDB drops the documents no sequence point refers to, so the capture, which + // names the sources from the PDB, writes no snapshot for a file without a method body. + // Such a file must read as one no compile gives a baseline, as it does where the snapshot + // was written. Why only when the PDB dropped documents: elsewhere a listed file without a + // snapshot is one the last compile did not build yet, and the next compile does give it + // a baseline. A file with evidence is not in the list at all, so it is such a file too. + if (reason != HotReloadSnapshotMissReason.NoSnapshotFile || newSourceMembershipEvidence != null) + { + return reason; + } + + string pdbPath = Path.ChangeExtension(targetDllPath, ".pdb"); + string mvid = HotReloadAssemblyMvid.Read(targetDllPath); + if (!documentIndex.DropsDocuments(targetDllPath, pdbPath, mvid)) + { + return reason; + } + + return documentIndex.TryFindDocument( + targetDllPath, + pdbPath, + mvid, + ToCompiledLookupPath(projectRoot, projectRelativeSourcePath.Replace('\\', '/')), + out HotReloadPdbDocument _) + ? HotReloadSnapshotMissReason.NoSnapshotFile + : HotReloadSnapshotMissReason.NoDocumentInPdb; } private static HotReloadSnapshotMissReason TryLoadVerifiedSnapshotSource( @@ -266,10 +302,8 @@ internal static HotReloadSnapshotMissReason CompareWithCompiledDocument( // Library, so a package folder of the main project lies outside the player's root, and // relativizing against the player's root would hand the lookup an absolute path that no // document ends with. For an ordinary project the two roots are the same. - string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; - string pdbLookupPath = ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, slashNormalizedRelativePath); return CompareWithCompiledDocumentAtLookupPath( - pdbLookupPath, + ToCompiledLookupPath(projectRoot, slashNormalizedRelativePath), targetDllPath, pdbPath, moduleVersionId, @@ -277,6 +311,13 @@ internal static HotReloadSnapshotMissReason CompareWithCompiledDocument( documentIndex); } + // The path the compiled assembly's PDB records for the source; see CompareWithCompiledDocument. + private static string ToCompiledLookupPath(string projectRoot, string slashNormalizedRelativePath) + { + string compiledProjectRoot = CompiledAssemblyLayout.Resolve(projectRoot).MainProjectRoot; + return ScriptPackageRoots.ToPhysicalPath(compiledProjectRoot, slashNormalizedRelativePath); + } + /// /// Answers the same as for a source already named /// the way the PDB records it. Asks nothing of the Package Manager, so it can run off the From 94dd31ae724865c6a35995efcc1d92b36d59d5c9 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 12 Oct 2026 10:54:26 +0900 Subject: [PATCH 14/15] fix: Hot reload notices edits to files without a method body in assemblies whose debug symbols a post-processor rewrote (#3291) --- ...HotReloadCaptureAssemblyEnumeratorTests.cs | 130 +++++++++++- ...onAssemblyReconstructionCrossCheckTests.cs | 86 +++++--- ...dCompilationAssemblyReconstructionTests.cs | 37 ++++ .../HotReloadCompiledSourceCrossCheckTests.cs | 5 +- .../HotReloadCompilerOutputDocumentsTests.cs | 193 ++++++++++++++++++ ...ReloadCompilerOutputDocumentsTests.cs.meta | 11 + ...otReloadIncrementalSnapshotCaptureTests.cs | 2 +- .../HotReloadPdbDocumentTableTests.cs | 53 ++++- ...tReloadSnapshotAssemblyEnumerationTests.cs | 8 +- .../HotReload/HotReloadSourceSnapshotTests.cs | 4 +- .../Shared/HotReloadCaptureAssembly.cs | 19 +- .../HotReloadCaptureAssemblyEnumerator.cs | 37 +++- .../HotReloadCompilationAssemblyRebuilder.cs | 24 ++- .../HotReloadCompilerOutputDocuments.cs | 89 ++++++++ .../HotReloadCompilerOutputDocuments.cs.meta | 11 + .../Shared/HotReloadPdbDocumentTable.cs | 29 ++- .../Shared/HotReloadSnapshotCaptureStats.cs | 4 +- .../Shared/HotReloadSourceSnapshotter.cs | 8 +- docs/vibe-logs.md | 4 +- 19 files changed, 684 insertions(+), 70 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs index 1b0cca35ee..c40bc6de81 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs @@ -21,6 +21,7 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload public sealed class HotReloadCaptureAssemblyEnumeratorTests { private const int MaxListedMismatches = 5; + private const string RewrittenAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; private string _projectRoot; @@ -76,7 +77,9 @@ public void ListCompiled_WithoutTheCompiledAssembliesDirectory_ListsNothing() /// /// Verifies, on this project, that the listed assemblies are the compilation assemblies Unity /// lists with a PDB beside their dll, that each read-only classification is the Package - /// Manager's, and that for each editable one the sources read from the PDB are Unity's source files. + /// Manager's, and that for every one, read-only or not, the sources read are Unity's source + /// files, including where a rewrite left the PDB beside the dll without some documents and + /// the compiler's own PDB is read instead. /// [Test] public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAssemblies() @@ -97,7 +100,9 @@ public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAss IReadOnlyList candidates = HotReloadCaptureAssemblyEnumerator.ListCompiled(projectRoot); List mismatches = new List(); int immutableAssemblies = 0; - int editableSources = 0; + int comparedSources = 0; + int lostDocuments = 0; + int fromCompilerPdb = 0; foreach (HotReloadCaptureAssemblyCandidate candidate in candidates) { if (!fromUnity.TryGetValue(candidate.Name, out UnityCompilationAssembly unityAssembly)) @@ -106,7 +111,10 @@ public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAss continue; } + HotReloadPdbCompilationInfo beside = HotReloadPdbCompilationInfo.Read(candidate.PdbPath); + lostDocuments += beside.DocumentCount < beside.SourceFileCount ? 1 : 0; HotReloadCaptureAssembly assembly = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, inputs); + fromCompilerPdb += assembly.SourcesFromCompilerOutput ? 1 : 0; string[] sourceFiles = unityAssembly.sourceFiles; bool expectedImmutable = sourceFiles.Length > 0 && HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); @@ -118,14 +126,7 @@ public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAss continue; } - // Why the sources of a read-only package's assembly are not compared: the capture - // copies none of them, and some such packages list files their PDB does not. - if (expectedImmutable) - { - continue; - } - - editableSources += assembly.SourceAssetPaths.Count; + comparedSources += assembly.SourceAssetPaths.Count; HashSet fromPdb = new HashSet(assembly.SourceAssetPaths, comparer); if (!fromPdb.SetEquals(sourceFiles.Select(HotReloadSourcePathNormalizer.ToForwardSlashes))) { @@ -142,8 +143,11 @@ public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAss } string summary = "Listed " + candidates.Count + " assemblies (Unity: " + fromUnity.Count + "), " - + immutableAssemblies + " read-only, " + editableSources + " sources of editable ones; " + mismatches.Count + " mismatches"; + + immutableAssemblies + " read-only, " + comparedSources + " sources, " + fromCompilerPdb + " from the compiler's PDB of " + + lostDocuments + " whose PDB lost documents; " + mismatches.Count + " mismatches"; TestContext.WriteLine(summary + "."); + Debug.Log("[cross-check] " + summary); + Assert.That(lostDocuments, Is.GreaterThan(0), "Precondition: some assembly's PDB must have lost sources, so the compiler's PDB is read."); Assert.That(candidates.Count, Is.GreaterThan(0), "No compiled assembly with a PDB was listed."); Assert.That(immutableAssemblies, Is.GreaterThan(0), "No read-only package assembly was found to compare."); Assert.That( @@ -152,6 +156,110 @@ public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAss summary + ":\n" + string.Join("\n", mismatches.Take(MaxListedMismatches))); } + /// + /// What: a PDB that lists every source is used as it is, even when the compiler's output of the + /// same build sits under Library/Bee and would be used if it were read. + /// + [Test] + public void ReadSources_CompletePdb_DoesNotReadTheCompilerOutput() + { + CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(RealProjectRoot()); + string dllPath = ScriptAssembliesDllPathInTemporaryProject(); + File.Copy(real.DllPath(RewrittenAssemblyName), dllPath); + File.Copy(real.PdbPath(RewrittenAssemblyName), Path.ChangeExtension(dllPath, ".pdb")); + PutTheRealCompilerOutputInBee(); + + HotReloadCaptureAssembly assembly = ReadSourcesOf(dllPath); + + Assert.That(assembly.HasEverySource, Is.True); + Assert.That(assembly.SourcesFromCompilerOutput, Is.False); + } + + /// + /// What: when the PDB beside the dll lost documents in a rewrite, the sources are read from the + /// compiler's PDB of the same build under Library/Bee, and are the sources the complete PDB lists. + /// + [Test] + public void ReadSources_PdbThatLostDocuments_ListsEverySourceFromTheCompilersPdb() + { + string dllPath = RewriteIntoTemporaryProject(); + PutTheRealCompilerOutputInBee(); + + HotReloadCaptureAssembly assembly = ReadSourcesOf(dllPath); + + Assert.That(assembly.HasEverySource, Is.True); + Assert.That(assembly.SourcesFromCompilerOutput, Is.True); + Assert.That(assembly.SourceAssetPaths, Is.EquivalentTo(ReadRealSources().SourceAssetPaths)); + } + + /// + /// What: when the PDB beside the dll lost documents and no compiler output stands in, the sources + /// are what that PDB lists, fewer than the complete PDB's, and are marked as not every source. + /// + [Test] + public void ReadSources_PdbThatLostDocumentsWithoutTheCompilerOutput_ListsWhatThePdbHas() + { + string dllPath = RewriteIntoTemporaryProject(); + + HotReloadCaptureAssembly assembly = ReadSourcesOf(dllPath); + + Assert.That(assembly.HasEverySource, Is.False); + Assert.That(assembly.SourcesFromCompilerOutput, Is.False); + Assert.That(assembly.SourceAssetPaths.Count, Is.LessThan(ReadRealSources().SourceAssetPaths.Count)); + } + + private static string RealProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private string ScriptAssembliesDllPathInTemporaryProject() + { + string directory = CompiledAssemblyLayout.Resolve(_projectRoot).CompiledAssembliesDirectory; + Directory.CreateDirectory(directory); + return Path.Combine(directory, RewrittenAssemblyName + ".dll"); + } + + // Writes, beside the temporary project's Library, a copy of the assembly the way an IL + // post-processor does, and checks that it lost documents while keeping the MVID. + private string RewriteIntoTemporaryProject() + { + string projectRoot = RealProjectRoot(); + string dllPath = ScriptAssembliesDllPathInTemporaryProject(); + CecilPdbRewrite.Rewrite(projectRoot, RewrittenAssemblyName, Path.Combine(_projectRoot, "Work"), dllPath); + Assert.That( + HotReloadAssemblyMetadataMvid.Read(dllPath), + Is.EqualTo(HotReloadAssemblyMetadataMvid.Read(CompiledAssemblyLayout.Resolve(projectRoot).DllPath(RewrittenAssemblyName))), + "Precondition: the rewrite must keep the MVID."); + HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(dllPath, ".pdb")); + Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount), "Precondition: the rewrite must drop some document."); + return dllPath; + } + + private void PutTheRealCompilerOutputInBee() + { + CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(RealProjectRoot()); + string dag = Path.Combine(_projectRoot, "Library", "Bee", "artifacts", "test.dag"); + Directory.CreateDirectory(dag); + File.Copy(real.DllPath(RewrittenAssemblyName), Path.Combine(dag, RewrittenAssemblyName + ".dll")); + File.Copy(real.PdbPath(RewrittenAssemblyName), Path.Combine(dag, RewrittenAssemblyName + ".pdb")); + } + + private static HotReloadCaptureAssembly ReadSourcesOf(string dllPath) + { + return HotReloadCaptureAssemblyEnumerator.ReadSources( + new HotReloadCaptureAssemblyCandidate(RewrittenAssemblyName, dllPath, Path.ChangeExtension(dllPath, ".pdb")), + HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + } + + private static HotReloadCaptureAssembly ReadRealSources() + { + CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(RealProjectRoot()); + HotReloadCaptureAssembly assembly = ReadSourcesOf(real.DllPath(RewrittenAssemblyName)); + Assert.That(assembly.HasEverySource, Is.True, "Precondition: the compiled test assembly's PDB must list every source."); + return assembly; + } + private static void WriteEmptyFiles(string directory, params string[] fileNames) { foreach (string fileName in fileNames) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs index 35dc0bd860..530417021e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionCrossCheckTests.cs @@ -20,7 +20,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// Cross-checks the rebuilt compilation assembly list against Unity's on every assembly of this /// project, once from the saved entries and once with every assembly rebuilt from its PDB. /// Some packages' PDBs are rewritten after the compile and lose the documents no sequence point - /// refers to; those must be refused, not rebuilt with fewer sources. + /// refers to; those must be rebuilt from the compiler's own PDB under Library/Bee, not with + /// fewer sources. /// public sealed class HotReloadCompilationAssemblyReconstructionCrossCheckTests { @@ -64,11 +65,11 @@ public void ReconstructedList_MatchesGetAssembliesForEveryField() /// /// What: rebuilding every compiled assembly from its PDB gives Unity's fields, in order, for - /// every assembly whose PDB lists as many sources as the compiler was given, and - /// pdb_documents_incomplete for every assembly whose PDB lost some of Unity's sources. + /// every assembly, including those whose PDB a rewrite left without some documents, which + /// are rebuilt from the compiler's own PDB; none is refused. /// [Test] - public void EveryAssemblyRebuiltFromItsPdb_MatchesGetAssemblies_OrIsRefusedWhenThePdbLostSources() + public void EveryAssemblyRebuiltFromItsPdb_MatchesGetAssemblies() { UnityCompilationAssembly[] expected = CompilationPipeline.GetAssemblies(); HotReloadSavedCompilationList saved = SaveClean(expected).Load().List; @@ -82,7 +83,7 @@ public void EveryAssemblyRebuiltFromItsPdb_MatchesGetAssemblies_OrIsRefusedWhenT List mismatches = new List(); List refused = new List(); - List lostSources = new List(); + int lostDocuments = 0; int rebuilt = 0; foreach (HotReloadCaptureAssemblyCandidate candidate in HotReloadCaptureAssemblyEnumerator.ListCompiled(inputs.ProjectRoot)) { @@ -91,30 +92,25 @@ public void EveryAssemblyRebuiltFromItsPdb_MatchesGetAssemblies_OrIsRefusedWhenT continue; } + HotReloadPdbCompilationInfo beside = HotReloadPdbCompilationInfo.Read(candidate.PdbPath); + lostDocuments += beside.DocumentCount < beside.SourceFileCount ? 1 : 0; HotReloadSavedCompilationAssembly savedAssembly = saved.assemblies.Find(assembly => assembly.name == candidate.Name); string reason = rebuilder.TryFromPdb(savedAssembly, candidate, out HotReloadRebuiltAssemblyEntry entry); - if (LostSources(want, candidate, inputs)) + if (reason != null) { - lostSources.Add(candidate.Name); - } - - if (reason == HotReloadConstants.CompilationListReasonPdbDocumentsIncomplete) - { - refused.Add(candidate.Name); + refused.Add(candidate.Name + ": " + reason + " (" + DescribeCompilerOutput(candidate) + ")"); continue; } - Assert.That(reason, Is.Null, candidate.Name + " failure"); rebuilt++; AddEntryMismatches(mismatches, want, entry); } - Debug.Log("[cross-check] rebuilt " + rebuilt + ", refused " + refused.Count + ", lost sources " + lostSources.Count); + Debug.Log("[cross-check] rebuilt " + rebuilt + ", from compiler pdb " + lostDocuments + ", refused " + refused.Count); + Assert.That(lostDocuments, Is.GreaterThan(0), "Precondition: some assembly's PDB must have lost sources, so the compiler's PDB is read."); + Assert.That(refused, Is.Empty, "refused assemblies:\n" + string.Join("\n", refused)); Assert.That(rebuilt, Is.GreaterThan(0), "Precondition: some assembly must be rebuilt."); Assert.That(mismatches, Is.Empty, "rebuilt entries"); - // Why only a superset: a PDB may also be refused for another shortfall against the - // compiler's count, which the sources Unity lists need not show. - Assert.That(refused, Is.SupersetOf(lostSources), "refused assemblies"); } private HotReloadCompilationAssemblyListStore SaveClean(UnityCompilationAssembly[] fetched) @@ -125,14 +121,56 @@ private HotReloadCompilationAssemblyListStore SaveClean(UnityCompilationAssembly return store; } - // True when some source Unity lists for the assembly is not among the PDB's documents. - private static bool LostSources( - UnityCompilationAssembly want, - HotReloadCaptureAssemblyCandidate candidate, - HotReloadSnapshotCaptureInputs inputs) + // Says, per dag, why the compiler's own output under Library/Bee did not stand in for this + // assembly's PDB, so a refusal on CI can be told apart from the log alone: no dag of the same + // build, or one whose PDB lacks documents too. The last case gives the counts either way. + private static string DescribeCompilerOutput(HotReloadCaptureAssemblyCandidate candidate) + { + string library = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(candidate.DllPath))); + string artifacts = Path.Combine(library, "Bee", "artifacts"); + if (!Directory.Exists(artifacts)) + { + return "no Bee artifacts"; + } + + Guid mvid = HotReloadAssemblyMetadataMvid.Read(candidate.DllPath); + string[] dags = Directory.GetDirectories(artifacts, "*.dag"); + Array.Sort(dags, StringComparer.Ordinal); + List seen = new List(); + foreach (string dag in dags) + { + seen.Add(Path.GetFileName(dag) + ": " + DescribeDag(dag, Path.GetFileName(candidate.DllPath), mvid)); + } + + return seen.Count == 0 ? "no dag" : string.Join("; ", seen); + } + + private static string DescribeDag(string dag, string fileName, Guid mvid) { - HashSet inPdb = new HashSet(HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, inputs).SourceAssetPaths); - return !inPdb.IsSupersetOf(want.sourceFiles); + string dll = Path.Combine(dag, fileName); + string pdb = Path.ChangeExtension(dll, ".pdb"); + if (!File.Exists(dll)) + { + return "no compiler dll"; + } + + if (HotReloadAssemblyMetadataMvid.Read(dll) != mvid) + { + return "compiler dll of another build"; + } + + if (!File.Exists(pdb)) + { + return "no compiler PDB"; + } + + List documents = HotReloadPdbDocumentTable.TryRead(dll, pdb, out int? count); + if (documents == null) + { + return "compiler PDB of another dll"; + } + + return "compiler PDB documents " + documents.Count + "/" + (count.HasValue ? count.Value.ToString() : "no count"); } private static void AddEntryMismatches(List mismatches, UnityCompilationAssembly want, HotReloadRebuiltAssemblyEntry got) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs index 6a24f3f512..3b258a9de6 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs @@ -209,6 +209,43 @@ public void TryFromPdb_PdbThatLostSourcesInARewrite_IsRefused() Assert.That(entry, Is.Null, "entry"); } + /// + /// What: a recompiled assembly whose PDB lost documents in a rewrite is rebuilt from the + /// compiler's own PDB of the same build under Library/Bee, with Unity's source files in + /// Unity's order. + /// + [Test] + public void TryFromPdb_PdbThatLostSourcesInARewrite_IsRebuiltFromTheCompilersPdb() + { + HotReloadSavedCompilationList saved = SaveCleanThenEdit(edit => { }).Load().List; + string scriptAssemblies = Path.Combine(_directory, "Project", HotReloadConstants.ScriptAssembliesRelativeDirectory); + Directory.CreateDirectory(scriptAssemblies); + string dllPath = Path.Combine(scriptAssemblies, TargetAssemblyName + ".dll"); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + CecilPdbRewrite.Rewrite(_projectRoot, TargetAssemblyName, _directory, dllPath); + CompiledAssemblyLayout real = CompiledAssemblyLayout.Resolve(_projectRoot); + Assert.That( + HotReloadAssemblyMetadataMvid.Read(dllPath), + Is.EqualTo(HotReloadAssemblyMetadataMvid.Read(real.DllPath(TargetAssemblyName))), + "Precondition: the rewrite must keep the MVID."); + HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(pdbPath); + Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount), "Precondition: the rewrite must drop some document."); + string dag = Path.Combine(_directory, "Project", "Library", "Bee", "artifacts", "test.dag"); + Directory.CreateDirectory(dag); + File.Copy(real.DllPath(TargetAssemblyName), Path.Combine(dag, TargetAssemblyName + ".dll")); + File.Copy(real.PdbPath(TargetAssemblyName), Path.Combine(dag, TargetAssemblyName + ".pdb")); + HotReloadCompilationAssemblyRebuilder rebuilder = + new HotReloadCompilationAssemblyRebuilder(saved, HotReloadSnapshotCaptureInputs.ReadOnMainThread()); + + string reason = rebuilder.TryFromPdb( + Target(saved), + new HotReloadCaptureAssemblyCandidate(TargetAssemblyName, dllPath, pdbPath), + out HotReloadRebuiltAssemblyEntry entry); + + Assert.That(reason, Is.Null); + Assert.That(entry.SourceFiles, Is.EqualTo(CompilationPipeline.GetAssemblies()[IndexOf(TargetAssemblyName)].sourceFiles)); + } + /// /// What: a recompiled assembly whose PDB belongs to another build of the dll gives no list. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs index 46fd9aa9c1..5d694f9eaa 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs @@ -50,7 +50,7 @@ public void FromDocuments_MatchesTheSourceFilesUnityListsForEveryCompiledAssembl } readTime.Start(); - List documents = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + List documents = HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _); readTime.Stop(); HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( documents, @@ -100,7 +100,8 @@ public void FromDocuments_IncludesTheBodylessFixtures() CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); List documents = HotReloadPdbDocumentTable.Read( layout.DllPath(TestAssemblyName), - layout.PdbPath(TestAssemblyName)); + layout.PdbPath(TestAssemblyName), + out int? _); HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( documents, diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs new file mode 100644 index 0000000000..1a2650befd --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of reading a rewritten PDB's documents from the compiler's own output under + /// Library/Bee/artifacts: only a compiler dll with the same MVID whose own PDB lists every source + /// stands in, whichever dag holds it, and anything else gives null. Each case lays out a + /// temporary Library with copies of this project's compiled files. + /// + public sealed class HotReloadCompilerOutputDocumentsTests + { + private const string TargetAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + + private string _projectRoot; + private string _directory; + private string _targetDllPath; + private string _targetPdbPath; + private string _otherDllPath; + private string _otherPdbPath; + private string _scriptAssembliesDllPath; + private string _artifactsDirectory; + + [SetUp] + public void SetUp() + { + _projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_projectRoot); + _targetDllPath = layout.DllPath(TargetAssemblyName); + _targetPdbPath = layout.PdbPath(TargetAssemblyName); + string otherAssemblyName = typeof(HotReloadSourceBaseline).Assembly.GetName().Name; + _otherDllPath = layout.DllPath(otherAssemblyName); + _otherPdbPath = layout.PdbPath(otherAssemblyName); + + HotReloadPdbCompilationInfo target = HotReloadPdbCompilationInfo.Read(_targetPdbPath); + Assert.That(target.DocumentCount, Is.EqualTo(target.SourceFileCount), "Precondition: the compiled test assembly's PDB must list every source."); + HotReloadPdbCompilationInfo other = HotReloadPdbCompilationInfo.Read(_otherPdbPath); + Assert.That(other.DocumentCount, Is.EqualTo(other.SourceFileCount), "Precondition: the other assembly's PDB must list every source, so a guard that let its files through would return documents."); + + _directory = Path.Combine(Path.GetTempPath(), "uloop-compiler-output-documents-" + Guid.NewGuid().ToString("N")); + _scriptAssembliesDllPath = Path.Combine( + _directory, HotReloadConstants.ScriptAssembliesRelativeDirectory, TargetAssemblyName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(_scriptAssembliesDllPath)); + File.Copy(_targetDllPath, _scriptAssembliesDllPath); + _artifactsDirectory = Path.Combine(_directory, "Library", "Bee", "artifacts"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + /// + /// What: without a Library/Bee/artifacts folder beside the dll's Library there is nothing to read. + /// + [Test] + public void TryReadEveryDocument_NoBeeFolder_ReturnsNull() + { + Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null); + } + + /// + /// What: a dag that holds no dll of the assembly's name gives null. + /// + [Test] + public void TryReadEveryDocument_NoCompilerDllOfThatName_ReturnsNull() + { + Dag("test.dag"); + + Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null); + } + + /// + /// What: a compiler dll of the same name but another MVID, with a complete PDB of its own, is not used. + /// + [Test] + public void TryReadEveryDocument_CompilerDllOfAnotherBuild_ReturnsNull() + { + PutOtherBuild(Dag("test.dag")); + + Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null); + } + + /// + /// What: a compiler dll with the same MVID but no PDB beside it gives null. + /// + [Test] + public void TryReadEveryDocument_NoCompilerPdb_ReturnsNull() + { + File.Copy(_targetDllPath, Path.Combine(Dag("test.dag"), TargetAssemblyName + ".dll")); + + Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null); + } + + /// + /// What: a compiler dll with the same MVID whose PDB belongs to another dll gives null instead of throwing. + /// + [Test] + public void TryReadEveryDocument_CompilerPdbOfAnotherDll_ReturnsNull() + { + string dag = Dag("test.dag"); + File.Copy(_targetDllPath, Path.Combine(dag, TargetAssemblyName + ".dll")); + File.Copy(_otherPdbPath, Path.Combine(dag, TargetAssemblyName + ".pdb")); + + Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null); + } + + /// + /// What: a compiler dll with the same MVID whose own PDB also lost documents gives null. + /// + [Test] + public void TryReadEveryDocument_CompilerPdbAlsoLostDocuments_ReturnsNull() + { + string compilerDllPath = Path.Combine(Dag("test.dag"), TargetAssemblyName + ".dll"); + CecilPdbRewrite.Rewrite(_projectRoot, TargetAssemblyName, Path.Combine(_directory, "Work"), compilerDllPath); + Assert.That( + HotReloadAssemblyMetadataMvid.Read(compilerDllPath), + Is.EqualTo(HotReloadAssemblyMetadataMvid.Read(_targetDllPath)), + "Precondition: the rewrite must keep the MVID."); + HotReloadPdbCompilationInfo rewritten = HotReloadPdbCompilationInfo.Read(Path.ChangeExtension(compilerDllPath, ".pdb")); + Assert.That(rewritten.DocumentCount, Is.LessThan(rewritten.SourceFileCount), "Precondition: the rewrite must drop some document."); + + Assert.That(HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath), Is.Null); + } + + /// + /// What: a compiler dll with the same MVID and a complete PDB of its own gives that PDB's documents in table order. + /// + [Test] + public void TryReadEveryDocument_CompilerOutputOfTheSameBuild_ReturnsEveryDocument() + { + PutSameBuild(Dag("test.dag")); + + List documents = HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath); + + Assert.That(documents, Is.Not.Null); + Assert.That(documents.Select(document => document.Url), Is.EqualTo(ExpectedUrls())); + } + + /// + /// What: when the first dag holds another build and the second the same one, the second dag's documents are returned. + /// + [Test] + public void TryReadEveryDocument_SecondDagHoldsTheSameBuild_ReturnsItsDocuments() + { + PutOtherBuild(Dag("a.dag")); + PutSameBuild(Dag("b.dag")); + + List documents = HotReloadCompilerOutputDocuments.TryReadEveryDocument(_scriptAssembliesDllPath); + + Assert.That(documents, Is.Not.Null); + Assert.That(documents.Select(document => document.Url), Is.EqualTo(ExpectedUrls())); + } + + private string Dag(string name) + { + string dag = Path.Combine(_artifactsDirectory, name); + Directory.CreateDirectory(dag); + return dag; + } + + private void PutSameBuild(string dag) + { + File.Copy(_targetDllPath, Path.Combine(dag, TargetAssemblyName + ".dll")); + File.Copy(_targetPdbPath, Path.Combine(dag, TargetAssemblyName + ".pdb")); + } + + private void PutOtherBuild(string dag) + { + File.Copy(_otherDllPath, Path.Combine(dag, TargetAssemblyName + ".dll")); + File.Copy(_otherPdbPath, Path.Combine(dag, TargetAssemblyName + ".pdb")); + } + + private IEnumerable ExpectedUrls() + { + return HotReloadPdbDocumentTable.Read(_targetDllPath, _targetPdbPath, out int? _) + .Select(document => document.Url); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs.meta new file mode 100644 index 0000000000..fe5ea2b076 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilerOutputDocumentsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 518dc09b1ed4d4dc282c1c9d3bc19bfc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs index aac139d38f..dedae6b7e9 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs @@ -534,7 +534,7 @@ private HotReloadSnapshotCaptureStats CaptureAssemblies(params string[] sources) return HotReloadSourceSnapshotter.CaptureAssemblies( inputs, new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")), - (candidate, capturedInputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false), + (candidate, capturedInputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false, hasEverySource: true, sourcesFromCompilerOutput: false), CancellationToken.None); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs index a1442d3e9a..0e17f50c82 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs @@ -39,7 +39,7 @@ public void Read_ListsSourcesWithoutAMethodBody() string dllPath = DllPath(TestAssemblyName); string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); - List table = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + List table = HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _); List withSequencePoints = HotReloadPdbDocumentIndex.ReadDocuments(dllPath, pdbPath); foreach (string assetPath in BodylessFixtureAssetPaths) @@ -72,7 +72,7 @@ public void Read_WhenThePdbBelongsToAnotherBuild_Throws() File.Copy(DllPath(TestAssemblyName), dllPath); File.Copy(Path.ChangeExtension(DllPath(OtherAssemblyName), ".pdb"), pdbPath); - Assert.Throws(() => HotReloadPdbDocumentTable.Read(dllPath, pdbPath)); + Assert.Throws(() => HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _)); } finally { @@ -80,6 +80,55 @@ public void Read_WhenThePdbBelongsToAnotherBuild_Throws() } } + /// + /// What: the non-throwing read returns null, rather than documents, for a PDB of another + /// assembly than the dll. + /// + [Test] + public void TryRead_PdbOfAnotherBuild_ReturnsNull() + { + string directory = Path.Combine( + Path.GetTempPath(), + "uloop-pdb-document-table-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string dllPath = Path.Combine(directory, "X.dll"); + string pdbPath = Path.Combine(directory, "X.pdb"); + File.Copy(DllPath(TestAssemblyName), dllPath); + File.Copy(Path.ChangeExtension(DllPath(OtherAssemblyName), ".pdb"), pdbPath); + + List documents = HotReloadPdbDocumentTable.TryRead(dllPath, pdbPath, out int? sourceFileCount); + + Assert.That(documents, Is.Null); + Assert.That(sourceFileCount, Is.Null); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// What: for a PDB the compiler wrote, the non-throwing read returns the same documents as + /// Read, and the source count it records equals the number of documents. + /// + [Test] + public void TryRead_CompilerPdb_ReturnsEveryDocumentAndTheSourceCount() + { + string dllPath = DllPath(TestAssemblyName); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + + List documents = HotReloadPdbDocumentTable.TryRead(dllPath, pdbPath, out int? sourceFileCount); + List read = HotReloadPdbDocumentTable.Read(dllPath, pdbPath, out int? _); + + Assert.That(documents, Is.Not.Null); + Assert.That(sourceFileCount, Is.EqualTo(documents.Count)); + Assert.That( + documents.Select(document => document.Url), + Is.EqualTo(read.Select(document => document.Url))); + } + private static bool EndsWithAssetPath(HotReloadPdbDocument document, string assetPath) { return document.Url.Replace('\\', '/').EndsWith("/" + assetPath, StringComparison.Ordinal); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index 0c3b551d85..0ba2eaf348 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs @@ -305,8 +305,8 @@ public void CaptureAssemblies_PdbOfAnotherBuild_SkipsThatAssemblyAndCapturesTheR HotReloadCompileStart.Unknown, (candidate, inputs) => { - HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath); - return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, false); + HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath, out int? _); + return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, false, hasEverySource: true, sourcesFromCompilerOutput: false); }); Assert.That(stats.AssembliesSkippedPdbMismatch, Is.EqualTo(1), "skipped for another build's PDB"); @@ -603,7 +603,7 @@ private HotReloadSnapshotCaptureStats Capture( private static Func ReadsSources( params string[] sources) { - return (candidate, inputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false); + return (candidate, inputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false, hasEverySource: true, sourcesFromCompilerOutput: false); } // A PDB source reader that counts its calls and names the fixture source, with the read-only @@ -615,7 +615,7 @@ private static Func { onRead(); - return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, isImmutablePackageAssembly); + return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, isImmutablePackageAssembly, hasEverySource: true, sourcesFromCompilerOutput: false); }; } diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index d63553ec42..2dc7bee992 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -209,9 +209,9 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase() JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown); string[] fields = { - "getAssembliesMs", "pdbSourcesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs", + "listingMs", "pdbSourcesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs", "pdbCheckMs", "publishMs", "cleanupMs", "totalMs", "assemblies", "assembliesImmutable", - "assembliesUnchanged", "assembliesCaptured", "assembliesSkippedPdbMismatch", "filesCopied", "bytesCopied", "filesChecked" + "assembliesUnchanged", "assembliesCaptured", "assembliesSkippedPdbMismatch", "assembliesFromCompilerPdb", "filesCopied", "bytesCopied", "filesChecked" }; foreach (string field in fields) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs index 8aa1f0701f..ec31afb477 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssembly.cs @@ -13,7 +13,9 @@ internal sealed class HotReloadCaptureAssembly internal HotReloadCaptureAssembly( string name, IReadOnlyList sourceAssetPaths, - bool isImmutablePackageAssembly) + bool isImmutablePackageAssembly, + bool hasEverySource, + bool sourcesFromCompilerOutput) { Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty."); Debug.Assert(sourceAssetPaths != null, "sourceAssetPaths must not be null."); @@ -21,6 +23,8 @@ internal HotReloadCaptureAssembly( Name = name; SourceAssetPaths = sourceAssetPaths; IsImmutablePackageAssembly = isImmutablePackageAssembly; + HasEverySource = hasEverySource; + SourcesFromCompilerOutput = sourcesFromCompilerOutput; } internal string Name { get; } @@ -28,5 +32,18 @@ internal HotReloadCaptureAssembly( internal IReadOnlyList SourceAssetPaths { get; } internal bool IsImmutablePackageAssembly { get; } + + /// + /// True when the sources are every source the compiler was given, one each: the PDB they were + /// read from has as many documents as the source count the compiler recorded. False when the + /// PDB records no count. + /// + internal bool HasEverySource { get; } + + /// + /// True when the sources were read from the compiler's own PDB under Library/Bee instead of + /// the PDB beside the dll. + /// + internal bool SourcesFromCompilerOutput { get; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs index b6f371a5b0..8796c2255d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs @@ -74,8 +74,13 @@ internal static IReadOnlyList ListCompiled(st /// /// Reads the sources of one compiled assembly from its PDB's document table, named by their - /// asset paths, and whether it is a read-only package's. Throws InvalidOperationException when - /// the PDB belongs to another build of the dll, as while a compile replaces them. + /// asset paths, and whether it is a read-only package's. When the PDB beside the dll has + /// fewer documents than the sources the compiler recorded, as after a tool rewrote it, the + /// documents are read from the compiler's own PDB under Library/Bee if one of the same build + /// lists every source. The result tells whether it has every source and whether it came from + /// the compiler's PDB. Throws InvalidOperationException when the PDB beside the dll belongs to + /// another build of it, as while a compile replaces them, or its compilation options record + /// cannot be read. /// internal static HotReloadCaptureAssembly ReadSources( HotReloadCaptureAssemblyCandidate candidate, @@ -84,7 +89,26 @@ internal static HotReloadCaptureAssembly ReadSources( Debug.Assert(candidate != null, "candidate must not be null."); Debug.Assert(inputs != null, "inputs must not be null."); - List documents = HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath); + List documents = HotReloadPdbDocumentTable.Read( + candidate.DllPath, candidate.PdbPath, out int? sourceFileCount); + bool hasEverySource = sourceFileCount == documents.Count; + bool fromCompilerOutput = false; + // Why the compiler's own output: a tool that rewrites the PDB drops the documents no + // sequence point refers to but keeps the count, and the readers that compare a source + // with its snapshot byte for byte take a file without a snapshot as unchanged, so a + // file without a method body needs a snapshot too. A PDB without the count, or with more + // documents than the count (#line), does not come here and keeps what it lists. + if (sourceFileCount.HasValue && documents.Count < sourceFileCount.Value) + { + List compiled = HotReloadCompilerOutputDocuments.TryReadEveryDocument(candidate.DllPath); + if (compiled != null) + { + documents = compiled; + hasEverySource = true; + fromCompilerOutput = true; + } + } + // Why the main project's root: a Virtual Player reads the main project's assemblies, // whose PDB names sources from the main project's compile. HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( @@ -93,7 +117,12 @@ internal static HotReloadCaptureAssembly ReadSources( inputs.ProjectRoot, inputs.PackageRoots, inputs.PathComparison); - return new HotReloadCaptureAssembly(candidate.Name, sources.AssetPaths, sources.IsImmutablePackageAssembly); + return new HotReloadCaptureAssembly( + candidate.Name, + sources.AssetPaths, + sources.IsImmutablePackageAssembly, + hasEverySource, + fromCompilerOutput); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs index 166eafc309..6aef025756 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyRebuilder.cs @@ -96,9 +96,10 @@ internal HotReloadRebuiltAssemblyEntry FromSaved(HotReloadSavedCompilationAssemb /// /// Makes the entry of a compiled-again assembly from its PDB. Returns null and the entry, or - /// the reason no entry can be trusted: a PDB of another build, a reference that does not - /// resolve to a file of the recorded MVID, or a PDB whose documents are not one per syntax - /// tree the compiler emitted. + /// the reason no entry can be trusted: a PDB of another build, sources that cannot be shown + /// to be every source the compiler was given, or a reference that does not resolve to a file + /// of the recorded MVID. A PDB that a rewrite left without some documents still gives an + /// entry when the compiler's own PDB of the same build under Library/Bee lists every source. /// internal string TryFromPdb( HotReloadSavedCompilationAssembly assembly, @@ -117,11 +118,14 @@ internal string TryFromPdb( return HotReloadConstants.CompilationListReasonPdbMismatch; } - // Why the compiler's count: the compiler writes one document per syntax tree, while a - // PDB rewritten after the compile (an IL post-processor does this) can drop the - // documents no sequence point refers to and keeps the count. A PDB without the count - // cannot show it is whole, and extra #line documents are refused as well. - if (info.SourceFileCount != info.DocumentCount) + // Why the capture's reader decides: it alone judges whether the sources are every + // source the compiler was given, reading the compiler's own PDB when a rewrite after the + // compile (an IL post-processor does this) dropped the documents no sequence point refers + // to. A PDB without the compiler's count cannot show it is whole, and one with extra + // #line documents is refused as well. Read before the references so this reason still + // comes before theirs. + HotReloadCaptureAssembly compiled = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, _inputs); + if (!compiled.HasEverySource) { return HotReloadConstants.CompilationListReasonPdbDocumentsIncomplete; } @@ -146,12 +150,10 @@ internal string TryFromPdb( compiledPathByFileName[reference.FileName] = path; } - IReadOnlyList sources = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, _inputs).SourceAssetPaths; - entry = new HotReloadRebuiltAssemblyEntry( assembly.name, assembly.outputPath, - InSavedOrder(assembly.sourceFiles, sources, _pathComparer).ToArray(), + InSavedOrder(assembly.sourceFiles, compiled.SourceAssetPaths, _pathComparer).ToArray(), ToArray(info.Defines), InSavedOrder(assembly.assemblyReferences, projectReferences, StringComparer.Ordinal), CompiledReferencesInSavedOrder(assembly, compiledPathByFileName)); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs new file mode 100644 index 0000000000..216a2ca07d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reads the documents of every source an assembly was compiled from out of the compiler's own + /// dll and PDB, which Unity's build leaves under Library/Bee/artifacts/<dag>/, for when a tool + /// that rewrote the PDB beside the dll dropped documents. Does not call Unity, so it runs on a + /// pool thread. + /// + internal static class HotReloadCompilerOutputDocuments + { + private const string BeeDirectoryName = "Bee"; + private const string ArtifactsDirectoryName = "artifacts"; + private const string DagSearchPattern = "*.dag"; + private const string PdbExtension = ".pdb"; + + /// + /// Returns, in table order, the documents of the compiler's PDB for the dll under + /// Library/ScriptAssemblies, when a compiler dll with the same MVID exists, its PDB belongs to + /// it, and that PDB has one document per source the compiler recorded. Returns null otherwise. + /// IO failures are thrown as they are. + /// + internal static List TryReadEveryDocument(string dllPath) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + + // Why beside the dll and not under the project root: a Virtual Player reads the main + // project's Library/ScriptAssemblies, whose build left Bee next to it, and a dll copied + // elsewhere has no compiler output of its own to stand in for its PDB. + string libraryDirectory = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(dllPath))); + string artifactsDirectory = Path.Combine(libraryDirectory, BeeDirectoryName, ArtifactsDirectoryName); + if (!Directory.Exists(artifactsDirectory)) + { + return null; + } + + string fileName = Path.GetFileName(dllPath); + Guid mvid = HotReloadAssemblyMetadataMvid.Read(dllPath); + // Why every dag, matched by MVID: the dag's name changes with the Unity version and the + // code optimization, and a player build leaves dags of its own beside the Editor's. + // Sorted so the order the dags are tried in does not depend on the file system. + string[] dagDirectories = Directory.GetDirectories(artifactsDirectory, DagSearchPattern); + Array.Sort(dagDirectories, StringComparer.Ordinal); + foreach (string dagDirectory in dagDirectories) + { + List documents = TryReadFromDag(dagDirectory, fileName, mvid); + if (documents != null) + { + return documents; + } + } + + return null; + } + + private static List TryReadFromDag(string dagDirectory, string fileName, Guid mvid) + { + string compilerDllPath = Path.Combine(dagDirectory, fileName); + string compilerPdbPath = Path.ChangeExtension(compilerDllPath, PdbExtension); + if (!File.Exists(compilerDllPath) || !File.Exists(compilerPdbPath)) + { + return null; + } + + // Why the MVID: an IL post-processor keeps it while it changes the PDB id, and Unity + // compiles deterministically, so the same MVID means the same compiler inputs. + if (HotReloadAssemblyMetadataMvid.Read(compilerDllPath) != mvid) + { + return null; + } + + List documents = HotReloadPdbDocumentTable.TryRead( + compilerDllPath, compilerPdbPath, out int? sourceFileCount); + if (documents == null) + { + return null; + } + + // Why the count again: a compiler PDB that also lacks documents, or records no count, + // cannot vouch for every source. + return sourceFileCount == documents.Count ? documents : null; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs.meta new file mode 100644 index 0000000000..4c3ad8e95f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilerOutputDocuments.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74d9d6fda54dc4a8b9dbf2e15a082a0d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs index c0eed27fab..65be35832a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs @@ -15,14 +15,32 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class HotReloadPdbDocumentTable { /// - /// Returns the documents of the PDB in table order, after checking that the PDB belongs to - /// the build of the dll. Throws InvalidOperationException for a PDB of another build. + /// Returns the documents of the PDB in table order and, in sourceFileCount, the number of + /// sources the compiler recorded (null when it did not), after checking that the PDB belongs + /// to the build of the dll. Throws InvalidOperationException for a PDB of another build. /// - internal static List Read(string dllPath, string pdbPath) + internal static List Read(string dllPath, string pdbPath, out int? sourceFileCount) + { + List documents = TryRead(dllPath, pdbPath, out sourceFileCount); + if (documents == null) + { + throw new InvalidOperationException( + "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + } + + return documents; + } + + /// + /// Same as Read, but returns null instead of throwing when the PDB belongs to another build + /// of the dll. + /// + internal static List TryRead(string dllPath, string pdbPath, out int? sourceFileCount) { Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + sourceFileCount = null; Guid codeViewGuid = HotReloadPdbDocumentIndex.ReadCodeViewGuid(dllPath); using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); @@ -32,8 +50,7 @@ internal static List Read(string dllPath, string pdbPath) BlobContentId pdbId = new BlobContentId(reader.DebugMetadataHeader.Id); if (pdbId.Guid != codeViewGuid) { - throw new InvalidOperationException( - "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + return null; } List documents = new List(reader.Documents.Count); @@ -46,6 +63,8 @@ internal static List Read(string dllPath, string pdbPath) reader.GetBlobBytes(document.Hash))); } + // Why from the same reader: the PDB is then opened once for both. + sourceFileCount = HotReloadPdbCompilationInfo.ReadSourceFileCount(reader, pdbPath); return documents; } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs index 997e07e28f..60b05df804 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs @@ -25,6 +25,7 @@ internal sealed class HotReloadSnapshotCaptureStats internal int AssembliesUnchanged; internal int AssembliesCaptured; internal int AssembliesSkippedPdbMismatch; + internal int AssembliesFromCompilerPdb; internal int FilesReused; internal int FilesCopied; internal long BytesCopied; @@ -34,7 +35,7 @@ internal object ToVibeLogContext(long totalMs) { return new { - getAssembliesMs = ToMilliseconds(ListingTicks), + listingMs = ToMilliseconds(ListingTicks), pdbSourcesMs = ToMilliseconds(PdbSourcesTicks), packageLookupMs = ToMilliseconds(PackageLookupTicks), stampMs = ToMilliseconds(StampTicks), @@ -50,6 +51,7 @@ internal object ToVibeLogContext(long totalMs) assembliesUnchanged = AssembliesUnchanged, assembliesCaptured = AssembliesCaptured, assembliesSkippedPdbMismatch = AssembliesSkippedPdbMismatch, + assembliesFromCompilerPdb = AssembliesFromCompilerPdb, filesReused = FilesReused, filesCopied = FilesCopied, bytesCopied = BytesCopied, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 39172fb5a1..d231c9397a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -180,7 +180,13 @@ private static HotReloadCaptureAssembly ReadSourcesOrNull( System.Diagnostics.Stopwatch readWatch = System.Diagnostics.Stopwatch.StartNew(); try { - return readSources(candidate, inputs); + HotReloadCaptureAssembly assembly = readSources(candidate, inputs); + if (assembly.SourcesFromCompilerOutput) + { + stats.AssembliesFromCompilerPdb++; + } + + return assembly; } catch (InvalidOperationException) { diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 8536dec40a..9e1bdb3849 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -120,7 +120,7 @@ command ran against: instead, and it does not appear when the capture threw or was cancelled. The capture lists the compiled assemblies from the dlls and PDBs in the compiled assemblies folder and reads an assembly's sources from its PDB, not from Unity's compilation assembly list. It says where the capture spent its time: - - `getAssembliesMs`: listing the compiled dlls that have a PDB beside them. + - `listingMs`: listing the compiled dlls that have a PDB beside them. - `pdbSourcesMs`: reading the sources of each assembly whose dll changed from its PDB. - `packageLookupMs`: reading whether each assembly's sources are immutable, which the PDB's sources and the package roots read once from Package Manager decide, so this is close to 0; @@ -147,6 +147,8 @@ command ran against: - `assembliesCaptured`: those whose sources were copied into a new snapshot directory. - `assembliesSkippedPdbMismatch`: those skipped because their PDB belonged to another build of the dll, as while a compile replaces them; no stamp is written, so the next domain captures them. + - `assembliesFromCompilerPdb`: assemblies whose sources were read from the compiler's own PDB + under `Library/Bee` because the PDB beside the dll had lost documents. - `filesReused`: the sources whose previous copy was kept without reading them. - `filesCopied` and `bytesCopied`: the sources copied and their total size. - `filesChecked`: the sources checked against the PDB. From 935ead91d0efc2496b5f6bd33b32f265cacd3d90 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Mon, 12 Oct 2026 11:44:20 +0900 Subject: [PATCH 15/15] chore: Hot reload's startup log separates rebuilding the compilation list from waiting for the main thread (#3292) --- ...dCompilationAssemblyReconstructionTests.cs | 127 ++++++++++++++++++ .../Shared/HotReloadCompilationAssemblies.cs | 8 +- .../Shared/HotReloadCompilationListRefill.cs | 27 ++-- docs/vibe-logs.md | 48 ++++++- 4 files changed, 196 insertions(+), 14 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs index 3b258a9de6..11a442efdb 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyReconstructionTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -402,6 +403,132 @@ public void ReconstructAndFill_WhileCompilationFailed_KeepsAndSavesNothing() Is.EqualTo(HotReloadConstants.CompilationListReasonCompilationFailed)); } + /// + /// What: a rebuild that fills the cache logs both the background time and the main thread + /// wait, and together they fit within the whole time. + /// + [Test] + public void ReconstructAndFill_WhenFilled_LogsTheBackgroundAndMainThreadWaitTimes() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty); + VibeLogger.ClearMemoryLogs(); + + Task refill = CreateRefill(store, cache).RunAsync(); + + Assert.That(refill.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed); + Assert.That((bool)context["filled"], Is.True, "filled"); + Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Integer), "backgroundMs"); + Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Integer), "mainThreadWaitMs"); + long backgroundMs = (long)context["backgroundMs"]; + long mainThreadWaitMs = (long)context["mainThreadWaitMs"]; + Assert.That(backgroundMs, Is.GreaterThanOrEqualTo(0), "backgroundMs"); + Assert.That(mainThreadWaitMs, Is.GreaterThanOrEqualTo(0), "mainThreadWaitMs"); + Assert.That(backgroundMs + mainThreadWaitMs, Is.LessThanOrEqualTo((long)context["reconstructMs"]), "sum"); + } + + /// + /// What: a rebuild cancelled after the background work finished logs the background time and + /// leaves the main thread wait null, because the switch to the main thread never completed. + /// + [Test] + public async Task ReconstructAndFill_CancelledBeforeTheMainThread_LogsOnlyTheBackgroundTime() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty); + TaskCompletionSource held = + new TaskCompletionSource(); + Func rebuild = null; + HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill( + store, + cache, + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + () => _isCompilationFailed, + work => + { + rebuild = work; + return held.Task; + }); + VibeLogger.ClearMemoryLogs(); + + Task run = refill.RunAsync(); + HotReloadCompilationListReconstruction rebuilt = rebuild(); + refill.Cancel(); + held.SetResult(rebuilt); + // Why awaited: the rest of the run resumes off this thread after the held rebuild completes. + await run; + + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed); + Assert.That((string)context["reason"], Is.EqualTo(HotReloadConstants.CompilationListReasonCancelled), "reason"); + Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Integer), "backgroundMs"); + Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Null), "mainThreadWaitMs"); + } + + /// + /// What: a rebuild cancelled while the background work runs logs neither time as a number, + /// because the work threw before its time was taken. + /// + [Test] + public async Task ReconstructAndFill_CancelledDuringTheRebuild_LogsNeitherTime() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty); + TaskCompletionSource held = + new TaskCompletionSource(); + Func rebuild = null; + HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill( + store, + cache, + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + () => _isCompilationFailed, + work => + { + rebuild = work; + return held.Task; + }); + VibeLogger.ClearMemoryLogs(); + + Task run = refill.RunAsync(); + refill.Cancel(); + OperationCanceledException thrown = Assert.Throws(() => rebuild()); + held.SetException(thrown); + // Why awaited: the rest of the run resumes off this thread after the held rebuild completes. + await run; + + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed); + Assert.That((string)context["reason"], Is.EqualTo(HotReloadConstants.CompilationListReasonCancelled), "reason"); + Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Null), "backgroundMs"); + Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Null), "mainThreadWaitMs"); + } + + /// + /// What: a rebuild whose background work does not finish logs neither time as a number, so a + /// step that never ran is not reported as taking 0 ms. + /// + [Test] + public void ReconstructAndFill_WhenTheRebuildFails_LogsNeitherTime() + { + HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => { }); + HotReloadCompilationAssemblyCache cache = new HotReloadCompilationAssemblyCache(Array.Empty); + HotReloadCompilationListRefill refill = new HotReloadCompilationListRefill( + store, + cache, + HotReloadSnapshotCaptureInputs.ReadOnMainThread, + () => _isCompilationFailed, + work => Task.FromException(new InvalidOperationException("test"))); + VibeLogger.ClearMemoryLogs(); + + Task run = refill.RunAsync(); + + Assert.That(run.Status, Is.EqualTo(TaskStatus.RanToCompletion), "refill status"); + Assert.That(run.Result, Is.False, "kept"); + JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogCompilationListReconstructed); + Assert.That((string)context["reason"], Is.EqualTo(nameof(InvalidOperationException)), "reason"); + Assert.That(context["backgroundMs"].Type, Is.EqualTo(JTokenType.Null), "backgroundMs"); + Assert.That(context["mainThreadWaitMs"].Type, Is.EqualTo(JTokenType.Null), "mainThreadWaitMs"); + } + private void AssertRecompiledTargetGives(string reason, Action editTargetReference) { HotReloadCompilationAssemblyListStore store = SaveCleanThenEdit(saved => diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index cb03b5b660..b429246cae 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -12,12 +12,14 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// The compilation assembly list for hot reload, asked of Unity once per domain. + /// The compilation assembly list for hot reload, kept for the rest of the domain. /// Why: on a project with several hundred assemblies one CompilationPipeline.GetAssemblies() call /// takes 340-620 ms, while the list only changes through a compile, or through an import that /// changes the script set without compiling (see HotReloadCompilationAssemblyListPostprocessor). - /// Whichever caller first needs the list after a domain reload asks Unity for it; later callers - /// in the domain find it kept. + /// After a domain reload the list is normally rebuilt on a pool thread from the list a previous + /// domain saved (RefillInBackground). Unity is asked only when that rebuild kept nothing or the + /// list was dropped in the domain, by the first caller that then needs it; later callers in the + /// domain find it kept. /// A list rebuilt at startup from the saved list holds only the fields hot reload reads: name, /// outputPath, sourceFiles, defines, assemblyReferences and compiledAssemblyReferences. /// compilerOptions, flags and rootNamespace keep their defaults, so code that needs them reads diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs index c792b330ca..33e657c47b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationListRefill.cs @@ -69,21 +69,31 @@ internal void Cancel() private async Task RunUntilCancelledAsync(CancellationToken ct) { Stopwatch watch = Stopwatch.StartNew(); + long? backgroundMs = null; + long? mainThreadWaitMs = null; try { int generation = _cache.Generation; HotReloadSnapshotCaptureInputs inputs = _readInputs(); - HotReloadCompilationListReconstruction reconstruction = await _runInBackground( - () => HotReloadCompilationAssemblyReconstruction.Reconstruct(_store, inputs, ct)).ConfigureAwait(false); + HotReloadCompilationListReconstruction reconstruction = await _runInBackground(() => + { + // Why measured inside the work: the time it waited for a pool thread is not rebuilding. + Stopwatch backgroundWatch = Stopwatch.StartNew(); + HotReloadCompilationListReconstruction rebuilt = HotReloadCompilationAssemblyReconstruction.Reconstruct(_store, inputs, ct); + backgroundMs = backgroundWatch.ElapsedMilliseconds; + return rebuilt; + }).ConfigureAwait(false); // Why the main thread: the cache has no lock and is kept on the main thread only. + Stopwatch waitWatch = Stopwatch.StartNew(); await MainThreadSwitcher.SwitchToMainThread(ct); + mainThreadWaitMs = waitWatch.ElapsedMilliseconds; // Why not kept after a failed compile: the dlls are then older than the sources, so // every stamp still matches the saved list while Unity's list already names the // added and removed files. if (reconstruction.Assemblies != null && _isCompilationFailed()) { - LogReconstructed(false, HotReloadConstants.CompilationListReasonCompilationFailed, watch.ElapsedMilliseconds); + LogReconstructed(false, HotReloadConstants.CompilationListReasonCompilationFailed, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); return false; } @@ -98,12 +108,12 @@ private async Task RunUntilCancelledAsync(CancellationToken ct) string reason = filled ? null : reconstruction.FailureReason ?? HotReloadConstants.CompilationListReasonSuperseded; - LogReconstructed(filled, reason, watch.ElapsedMilliseconds); + LogReconstructed(filled, reason, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); return filled; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - LogReconstructed(false, HotReloadConstants.CompilationListReasonCancelled, watch.ElapsedMilliseconds); + LogReconstructed(false, HotReloadConstants.CompilationListReasonCancelled, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); return false; } catch (Exception ex) @@ -111,17 +121,18 @@ private async Task RunUntilCancelledAsync(CancellationToken ct) // Approved deviation: this is the boundary of work that started at Editor startup, // and a list that cannot be rebuilt only leaves the cache to ask Unity as before. string reason = ex.GetType().Name; - LogReconstructed(false, reason, watch.ElapsedMilliseconds); + LogReconstructed(false, reason, watch.ElapsedMilliseconds, backgroundMs, mainThreadWaitMs); return false; } } - private static void LogReconstructed(bool filled, string reason, long reconstructMs) + // backgroundMs and mainThreadWaitMs stay null when the run did not get through that step. + private static void LogReconstructed(bool filled, string reason, long reconstructMs, long? backgroundMs, long? mainThreadWaitMs) { VibeLogger.LogInfo( HotReloadConstants.VibeLogCompilationListReconstructed, "Hot reload rebuilt the compilation assembly list at startup.", - new { filled, reason, reconstructMs }); + new { filled, reason, reconstructMs, backgroundMs, mainThreadWaitMs }); } } } diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 9e1bdb3849..25e7caf469 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -155,9 +155,51 @@ command ran against: The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry also includes reading the compile start and the package roots, and writing this breakdown entry. `hot_reload_compilation_assemblies_fetched` appears each time hot reload asks Unity for the - compilation assembly list, with `fetchMs` and `assemblyCount`. The list is kept for the rest of - the domain once it is not empty, so this normally appears once per domain, from whichever of the - warm-up or the first hot reload run needs the list first. + compilation assembly list, with `fetchMs` and `assemblyCount`. After a domain reload the list is + normally rebuilt off the main thread from the list a previous domain saved + (`hot_reload_compilation_list_reconstructed` below), so this appears only when that rebuild kept + no list or the cache was invalidated in the domain, from the first hot reload run that needs the + list. The list is then kept for the rest of the domain once it is not empty. + `hot_reload_compilation_list_reconstructed` appears once per domain reload. `filled` says whether + the rebuilt list filled the cache, and `reason` says why it did not: + - `no_saved_list`: no list has been saved yet. + - `saved_list_unreadable`: the saved list is torn, of another format version, or missing a + required field. + - `assembly_set_changed`: an assembly definition was added or removed after the list was saved. + - `pdb_mismatch`: the PDB beside a compiled-again dll belongs to another build, as while a + compile replaces the files. + - `pdb_documents_incomplete`: neither the PDB of a compiled-again assembly nor the compiler's + own PDB of the same build shows that it names every source the compile was given. + - `unresolved_reference`: a reference has a file name the saved list does not know, or a project + reference is not in the list. + - `reference_missing`: every path saved for a reference's file name is gone, as when a package + update renames its folder. + - `reference_changed`: a saved path exists, but not with the dll of the recorded MVID. + - `compilation_failed`: the last compile failed, so the dlls are older than the sources. + - `superseded`: the cache was filled or invalidated meanwhile. + - `cancelled`: a compile started or the domain is ending. + - `tool_disabled`: the hot-reload tool is disabled in the tool settings. + - Any other value is the type name of the exception that stopped the rebuild. + `reconstructMs` is the time from the start of the rebuild to the entry, including the wait for + the main thread. `backgroundMs` is the time spent rebuilding on a pool thread, without the wait + for that thread, and `mainThreadWaitMs` the time it then waited for the main thread to run again. + Either is null when the rebuild did not get through that step. A `tool_disabled` entry has only + `filled` and `reason`, because no rebuild started. + When the cache was not filled, the first hot reload run that needs the list asks Unity for it + (`hot_reload_compilation_assemblies_fetched`) and saves it when the cache was not invalidated + in the domain and the last compile did not fail, so a reason that comes from an old saved list + (`no_saved_list`, `saved_list_unreadable`, `assembly_set_changed`, `unresolved_reference`, + `reference_missing`, `reference_changed`) does not repeat after that. Without a list, the + warm-up skips `publicized_targets`, `referenced_method_sets` and `transform_worker` with + `compilation_list_unavailable`. + `hot_reload_compilation_list_saved` appears when a list was written for the next domain, with + its size in `bytes`, `saveMs`, and the counts of `assemblies` and `references`. It is written on + a pool thread after a list is asked of Unity and after a rebuilt list fills the cache, only when + the cache was not invalidated in the domain and the last compile did not fail. + `hot_reload_compilation_list_save_skipped` names why a list was not saved in `reason`: + `generation` (the cache was invalidated in the domain), `compilation_failed`, or `superseded` + (the cache was invalidated while it was written, as when a compile started). + `hot_reload_compilation_list_save_failed` is a warning with the exception type name in `error`. After a domain reload, the warm-up writes one `hot_reload_warm_up_complete` with the target assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy` (`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one