diff --git a/Assets/Tests/Editor/HotReload/Fixtures.meta b/Assets/Tests/Editor/HotReload/Fixtures.meta
new file mode 100644
index 000000000..5d26d7d21
--- /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 000000000..d2be3a989
--- /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 000000000..88249b1eb
--- /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 000000000..b3daab23a
--- /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 000000000..e69de29bb
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 000000000..ef2b11b25
--- /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 000000000..a76345970
--- /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 000000000..01cacb640
--- /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 000000000..46fd9aa9c
--- /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 000000000..b36ce1580
--- /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 000000000..c74701520
--- /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 000000000..8cca48e74
--- /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/HotReloadCompositionRootTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompositionRootTests.cs
index 73c2a6fe6..2707d8012 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 8a85bc864..d2a11ac07 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 7cb294de6..4a0288dae 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/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
new file mode 100644
index 000000000..e054e1806
--- /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 000000000..cca725a36
--- /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/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs
new file mode 100644
index 000000000..a1442d3e9
--- /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 000000000..e4632109c
--- /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/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs
index c6f162161..035bb8f09 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 290c33f9c..e0699649e 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 e33fbb089..860c5ccd7 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 365c5fd24..49203054f 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/CompositionRoot/UnityCliLoopEditorBootstrapper.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopEditorBootstrapper.cs
index e811db475..619394bea 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 7a279d88b..2bc77b757 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 6828ece28..11663b5e3 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 881fd704b..b3e05786d 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 75c000929..c6ad53482 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/HotReloadCompiledSourceMap.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs
new file mode 100644
index 000000000..19977800f
--- /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 000000000..7401d0860
--- /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/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
index 1d552fa21..7967a25f2 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";
@@ -695,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/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs
index f17ab1280..e1499f0d8 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 000000000..c0eed27fa
--- /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 000000000..233654e52
--- /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/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
new file mode 100644
index 000000000..3f939b7bf
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
@@ -0,0 +1,60 @@
+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 FilesReused;
+ 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,
+ filesReused = FilesReused,
+ 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 000000000..f0b624a61
--- /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/HotReloadSnapshotPackageRoots.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs
new file mode 100644
index 000000000..5bb8eee85
--- /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 000000000..cbd3384be
--- /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:
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 000000000..62db46410
--- /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 000000000..25245131e
--- /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 8188dd526..40886d6be 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs
@@ -13,31 +13,32 @@ 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,
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);
- int skippedSourceCount = 0;
- string firstSkippedSourcePath = null;
- int suspectSourceCount = 0;
- int editedAfterCompileCount = 0;
- long checkTicks = 0;
+ stats.PublishTicks += prepareWatch.Elapsed.Ticks;
+ SourceCopyTally tally = new SourceCopyTally();
List manifestLines = new List(sourceFiles.Length);
foreach (string projectRelativeSourcePath in sourceFiles)
{
@@ -46,48 +47,16 @@ internal static void CaptureAtomically(
temporaryDirectory,
projectRelativeSourcePath,
check,
- manifestLines);
- 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++;
- }
+ manifestLines,
+ stats);
+ tally.Count(outcome, projectRelativeSourcePath);
}
+ System.Diagnostics.Stopwatch publishWatch = System.Diagnostics.Stopwatch.StartNew();
HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines);
Directory.Move(temporaryDirectory, assemblySnapshotDirectory);
- 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}");
- }
+ stats.PublishTicks += publishWatch.Elapsed.Ticks;
+ tally.LogAndWarn(assemblyName);
}
private static SourceCopyOutcome CopySourceFileByteExact(
@@ -95,25 +64,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 +123,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 +139,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 +169,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;
@@ -254,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 9d1455132..41f96e320 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 e06eb4857..54d3b0e8d 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))
{
@@ -131,20 +158,48 @@ 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);
+ 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);
+ 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/hot-reload.md b/docs/hot-reload.md
index 26352e61c..86b0fc72e 100644
--- a/docs/hot-reload.md
+++ b/docs/hot-reload.md
@@ -78,7 +78,13 @@ 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.
+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 60d022fed..b750dc519 100644
--- a/docs/vibe-logs.md
+++ b/docs/vibe-logs.md
@@ -93,12 +93,46 @@ 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
+ capture threw. It says where the capture spent its time:
+ - `getAssembliesMs`: listing the compilation assemblies from Unity.
+ - `packageLookupMs`: asking Package Manager whether each assembly's sources are immutable.
+ - `stampMs`: reading and writing the stamps beside the snapshots, and reading the previous
+ build's manifest.
+ - `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.
+ - `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 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
+ 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
`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