diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs
new file mode 100644
index 000000000..1b0cca35e
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+using NUnit.Framework;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Covers listing the compiled assemblies from the dlls and PDBs on disk, and, on this project's
+ /// real assemblies, that the listing and the sources read from each PDB agree with what Unity's
+ /// compilation pipeline and the Package Manager report.
+ ///
+ public sealed class HotReloadCaptureAssemblyEnumeratorTests
+ {
+ private const int MaxListedMismatches = 5;
+
+ private string _projectRoot;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path.Combine(_projectRoot, "Assets"));
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_projectRoot))
+ {
+ Directory.Delete(_projectRoot, true);
+ }
+ }
+
+ ///
+ /// Verifies a dll without a PDB beside it is not listed, and the dlls that have one are listed
+ /// in ordinal order with their paths.
+ ///
+ [Test]
+ public void ListCompiled_DllWithoutPdb_IsNotListed()
+ {
+ // Why not Path.Combine with the "Library/ScriptAssemblies" constant: on Windows that keeps
+ // the "/" inside the constant, while the listing returns paths with "\" throughout.
+ string directory = CompiledAssemblyLayout.Resolve(_projectRoot).CompiledAssembliesDirectory;
+ Directory.CreateDirectory(directory);
+ WriteEmptyFiles(directory, "b.dll", "b.pdb", "A.dll", "A.pdb", "NoPdb.dll", "notes.txt");
+
+ IReadOnlyList candidates =
+ HotReloadCaptureAssemblyEnumerator.ListCompiled(_projectRoot);
+
+ Assert.That(candidates.Select(candidate => candidate.Name), Is.EqualTo(new[] { "A", "b" }));
+ Assert.That(candidates[0].DllPath, Is.EqualTo(Path.Combine(directory, "A.dll")));
+ Assert.That(candidates[0].PdbPath, Is.EqualTo(Path.Combine(directory, "A.pdb")));
+ }
+
+ ///
+ /// Verifies a project whose compiled assemblies directory does not exist lists nothing.
+ ///
+ [Test]
+ public void ListCompiled_WithoutTheCompiledAssembliesDirectory_ListsNothing()
+ {
+ IReadOnlyList candidates =
+ HotReloadCaptureAssemblyEnumerator.ListCompiled(_projectRoot);
+
+ Assert.That(candidates, Is.Empty);
+ }
+
+ ///
+ /// Verifies, on this project, that the listed assemblies are the compilation assemblies Unity
+ /// lists with a PDB beside their dll, that each read-only classification is the Package
+ /// Manager's, and that for each editable one the sources read from the PDB are Unity's source files.
+ ///
+ [Test]
+ public void CompiledAssemblyEnumeration_ListsTheSameAssembliesAndSourcesAsGetAssemblies()
+ {
+ string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot);
+ HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread();
+ StringComparer comparer = HotReloadSourcePathNormalizer.ProjectRelativePathComparer();
+ Dictionary fromUnity = new Dictionary(StringComparer.Ordinal);
+ foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current())
+ {
+ if (File.Exists(layout.DllPath(assembly.name)) && File.Exists(layout.PdbPath(assembly.name)))
+ {
+ fromUnity[assembly.name] = assembly;
+ }
+ }
+
+ IReadOnlyList candidates = HotReloadCaptureAssemblyEnumerator.ListCompiled(projectRoot);
+ List mismatches = new List();
+ int immutableAssemblies = 0;
+ int editableSources = 0;
+ foreach (HotReloadCaptureAssemblyCandidate candidate in candidates)
+ {
+ if (!fromUnity.TryGetValue(candidate.Name, out UnityCompilationAssembly unityAssembly))
+ {
+ mismatches.Add(candidate.Name + ": listed from disk but not by Unity.");
+ continue;
+ }
+
+ HotReloadCaptureAssembly assembly = HotReloadCaptureAssemblyEnumerator.ReadSources(candidate, inputs);
+ string[] sourceFiles = unityAssembly.sourceFiles;
+ bool expectedImmutable = sourceFiles.Length > 0
+ && HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles);
+ immutableAssemblies += expectedImmutable ? 1 : 0;
+ if (assembly.IsImmutablePackageAssembly != expectedImmutable)
+ {
+ mismatches.Add(candidate.Name + ": read-only is " + assembly.IsImmutablePackageAssembly
+ + " but the Package Manager says " + expectedImmutable + ".");
+ continue;
+ }
+
+ // Why the sources of a read-only package's assembly are not compared: the capture
+ // copies none of them, and some such packages list files their PDB does not.
+ if (expectedImmutable)
+ {
+ continue;
+ }
+
+ editableSources += assembly.SourceAssetPaths.Count;
+ HashSet fromPdb = new HashSet(assembly.SourceAssetPaths, comparer);
+ if (!fromPdb.SetEquals(sourceFiles.Select(HotReloadSourcePathNormalizer.ToForwardSlashes)))
+ {
+ mismatches.Add(candidate.Name + ": the sources differ from Unity's.");
+ }
+ }
+
+ foreach (string name in fromUnity.Keys)
+ {
+ if (!candidates.Any(candidate => candidate.Name == name))
+ {
+ mismatches.Add(name + ": listed by Unity but not from disk.");
+ }
+ }
+
+ string summary = "Listed " + candidates.Count + " assemblies (Unity: " + fromUnity.Count + "), "
+ + immutableAssemblies + " read-only, " + editableSources + " sources of editable ones; " + mismatches.Count + " mismatches";
+ TestContext.WriteLine(summary + ".");
+ Assert.That(candidates.Count, Is.GreaterThan(0), "No compiled assembly with a PDB was listed.");
+ Assert.That(immutableAssemblies, Is.GreaterThan(0), "No read-only package assembly was found to compare.");
+ Assert.That(
+ mismatches,
+ Is.Empty,
+ summary + ":\n" + string.Join("\n", mismatches.Take(MaxListedMismatches)));
+ }
+
+ private static void WriteEmptyFiles(string directory, params string[] fileNames)
+ {
+ foreach (string fileName in fileNames)
+ {
+ File.WriteAllBytes(Path.Combine(directory, fileName), Array.Empty());
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta
new file mode 100644
index 000000000..68ad3f3e4
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadCaptureAssemblyEnumeratorTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 97fe28077814042f3851e56cd050e57f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
index 13135c996..aac139d38 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs
@@ -4,18 +4,15 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
+using System.Threading;
using NUnit.Framework;
-using UnityEditor.Compilation;
-
using UnityEngine;
using UnityEngine.TestTools;
using io.github.hatayama.UnityCliLoop.FirstPartyTools;
-using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
-
namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
///
@@ -525,32 +522,20 @@ private HotReloadSnapshotCaptureStats CaptureIncrementally(HotReloadSnapshotSour
return stats;
}
+ // Why a reader that names the sources: the planted PDB is this test assembly's, whose sources
+ // live under the real project, so none of them maps to an asset path under the temporary root.
private HotReloadSnapshotCaptureStats CaptureAssemblies(params string[] sources)
{
- UnityCompilationAssembly assembly = new UnityCompilationAssembly(
- AssemblyName,
- HotReloadConstants.ScriptAssembliesRelativeDirectory + "/" + AssemblyName + HotReloadConstants.CompiledAssemblyExtension,
- sources,
- Array.Empty(),
- Array.Empty(),
- Array.Empty(),
- AssemblyFlags.EditorAssembly);
HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs(
_root,
HotReloadCompileStart.Unknown,
Array.Empty(),
- StringComparison.Ordinal,
- new[]
- {
- HotReloadSnapshotCaptureInputs.ToCaptureAssembly(
- assembly,
- Array.Empty(),
- StringComparison.Ordinal)
- },
- 0);
+ StringComparison.Ordinal);
return HotReloadSourceSnapshotter.CaptureAssemblies(
inputs,
- new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")));
+ new HotReloadPdbDocumentIndex(Path.Combine(_root, "PdbDocuments")),
+ (candidate, capturedInputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false),
+ CancellationToken.None);
}
// A check whose suspect window starts at the planted compile start, against the given build.
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs
index 116440f3a..0c3b551d8 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
+using System.Threading;
using NUnit.Framework;
@@ -65,9 +66,7 @@ public void CaptureAssemblies_WithoutStamp_CapturesSourcesRemovesStaleSnapshotAn
string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(staleDirectory);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
Assert.That(Directory.Exists(staleDirectory), Is.False);
@@ -85,9 +84,7 @@ public void CaptureAssemblies_WhenStampMatches_SkipsCapture()
string stamp = ExpectedStamp(dllPath, mvid);
File.WriteAllText(StampPath("Fixture"), stamp);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(Path.Combine(_snapshotRoot, "Fixture-" + mvid)), Is.False);
Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(stamp));
@@ -107,9 +104,7 @@ public void CaptureAssemblies_WhenMvidDirectoryExists_KeepsSnapshotsAndWritesSta
string staleDirectory = Path.Combine(_snapshotRoot, "Fixture-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(staleDirectory);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.GetFiles(currentDirectory), Is.Empty);
Assert.That(Directory.Exists(staleDirectory), Is.True);
@@ -147,9 +142,7 @@ public void CaptureAssemblies_WhenStampIsMalformedOrDiffers_CapturesAgain(string
.Replace("{length}", length.ToString(CultureInfo.InvariantCulture));
File.WriteAllText(StampPath("Fixture"), stamp);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.True);
Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(ExpectedStamp(dllPath, mvid)));
@@ -175,9 +168,7 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories()
Directory.CreateDirectory(sibling);
Directory.CreateDirectory(nonMvid);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(orphanTemporary), Is.False);
Assert.That(Directory.Exists(stale), Is.False);
@@ -187,7 +178,9 @@ public void CaptureAssemblies_RemovesOnlyThisAssemblysStaleMvidDirectories()
}
///
- /// Verifies an assembly without sources is skipped before anything is written: no snapshot directory and no stamp.
+ /// Verifies an assembly whose PDB names no source that maps to an asset path is skipped before
+ /// anything is written: no snapshot directory, no stamp and no marker. The planted PDB is this
+ /// test assembly's, whose sources live under the real project and so map to nothing here.
///
[Test]
public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing()
@@ -195,35 +188,132 @@ public void CaptureAssemblies_WhenAssemblyHasNoSources_WritesNothing()
string dllPath = PlantCompiledAssembly("NoSources");
string mvid = HotReloadAssemblyMvid.Read(dllPath);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssemblyWithSources("NoSources", Array.Empty()) }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, HotReloadCaptureAssemblyEnumerator.ReadSources);
Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "NoSources", mvid)), Is.False);
Assert.That(File.Exists(StampPath("NoSources")), Is.False);
+ Assert.That(File.Exists(MarkerPath("NoSources")), Is.False);
}
///
- /// Verifies an assembly the inputs classified as an immutable package's is counted and skipped
- /// before anything is written, though its DLL and PDB are there: no snapshot directory and no stamp.
+ /// Verifies a read-only package's marker that matches the compiled dll skips the assembly
+ /// without reading its PDB, and counts it as read-only.
///
[Test]
- public void CaptureAssemblies_ImmutablePackageAssembly_CountsItAndWritesNothing()
+ public void CaptureAssemblies_ImmutableMarkerMatchingTheDll_SkipsWithoutReadingThePdb()
{
- PlantCompiledAssembly("Fixture");
- HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs(
+ string dllPath = PlantCompiledAssembly("Fixture");
+ File.WriteAllText(MarkerPath("Fixture"), DllStampText("immutable", dllPath, 0));
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats stats = Capture(
_projectRoot,
HotReloadCompileStart.Unknown,
- Array.Empty(),
- StringComparison.Ordinal,
- new[] { new HotReloadCaptureAssembly("Fixture", new[] { SourceRelativePath }, true) },
- 0);
-
- HotReloadSnapshotCaptureStats stats = HotReloadSourceSnapshotter.CaptureAssemblies(inputs, CreateDocumentIndex());
+ CountingReads(() => reads++, false));
+ Assert.That(reads, Is.EqualTo(0), "PDB reads");
Assert.That(stats.AssembliesImmutable, Is.EqualTo(1), "immutable");
+ Assert.That(File.Exists(StampPath("Fixture")), Is.False);
+ }
+
+ ///
+ /// Verifies a stamp that matches the compiled dll skips the assembly without reading its PDB.
+ ///
+ [Test]
+ public void CaptureAssemblies_UnchangedStamp_SkipsWithoutReadingThePdb()
+ {
+ string dllPath = PlantCompiledAssembly("Fixture");
+ File.WriteAllText(StampPath("Fixture"), ExpectedStamp(dllPath, HotReloadAssemblyMvid.Read(dllPath)));
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats stats = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, false));
+
+ Assert.That(reads, Is.EqualTo(0), "PDB reads");
+ Assert.That(stats.AssembliesUnchanged, Is.EqualTo(1), "unchanged");
+ }
+
+ ///
+ /// Verifies an assembly whose PDB says it is a read-only package's gets a marker recording its
+ /// dll and nothing else: no snapshot directory and no stamp. The next capture of the same dll
+ /// does not read the PDB again.
+ ///
+ [Test]
+ public void CaptureAssemblies_ImmutablePackageAssembly_WritesTheMarkerAndCopiesNothing()
+ {
+ string dllPath = PlantCompiledAssembly("Fixture");
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats first = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, true));
+ HotReloadSnapshotCaptureStats second = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, true));
+
+ Assert.That(File.ReadAllText(MarkerPath("Fixture")), Is.EqualTo(DllStampText("immutable", dllPath, 0)));
Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty);
Assert.That(File.Exists(StampPath("Fixture")), Is.False);
+ Assert.That(first.AssembliesImmutable, Is.EqualTo(1), "first: immutable");
+ Assert.That(second.AssembliesImmutable, Is.EqualTo(1), "second: immutable");
+ Assert.That(reads, Is.EqualTo(1), "PDB reads over both captures");
+ }
+
+ ///
+ /// Verifies a marker left from an earlier dll, as when a package was made editable and its
+ /// assembly rebuilt, does not skip the assembly: its PDB is read and its sources are captured.
+ ///
+ [Test]
+ public void CaptureAssemblies_StaleImmutableMarker_IsIgnoredWhenTheDllChanged()
+ {
+ string dllPath = PlantCompiledAssembly("Fixture");
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+ File.WriteAllText(MarkerPath("Fixture"), DllStampText("immutable", dllPath, -1));
+ int reads = 0;
+
+ HotReloadSnapshotCaptureStats stats = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ CountingReads(() => reads++, false));
+
+ Assert.That(reads, Is.EqualTo(1), "PDB reads");
+ Assert.That(stats.AssembliesCaptured, Is.EqualTo(1), "captured");
+ Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
+ }
+
+ ///
+ /// Verifies an assembly whose PDB belongs to another build of its dll, as while a compile
+ /// replaces them, is skipped and counted without a stamp, while the assembly after it is captured.
+ ///
+ [Test]
+ public void CaptureAssemblies_PdbOfAnotherBuild_SkipsThatAssemblyAndCapturesTheRest()
+ {
+ string otherBuildDll = PlantCompiledAssembly("Another");
+ string otherAssemblyPdb = Path.ChangeExtension(typeof(HotReloadSourceSnapshotter).Assembly.Location, ".pdb");
+ File.Copy(otherAssemblyPdb, Path.ChangeExtension(otherBuildDll, ".pdb"), true);
+ string dllPath = PlantCompiledAssembly("Fixture");
+ string mvid = HotReloadAssemblyMvid.Read(dllPath);
+
+ // Why the real document table is read before the sources are named: it is what checks
+ // that the PDB belongs to the dll.
+ HotReloadSnapshotCaptureStats stats = Capture(
+ _projectRoot,
+ HotReloadCompileStart.Unknown,
+ (candidate, inputs) =>
+ {
+ HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath);
+ return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, false);
+ });
+
+ Assert.That(stats.AssembliesSkippedPdbMismatch, Is.EqualTo(1), "skipped for another build's PDB");
+ Assert.That(File.Exists(StampPath("Another")), Is.False);
+ Assert.That(Directory.GetDirectories(_snapshotRoot, "Another-*"), Is.Empty);
+ Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
+ Assert.That(File.Exists(StampPath("Fixture")), Is.True);
}
///
@@ -236,9 +326,7 @@ public void CaptureAssemblies_WhenCompiledOutputIsMissing_WritesNothingAndDoesNo
string dllPath = PlantCompiledAssembly("Fixture");
File.Delete(Path.ChangeExtension(dllPath, missingExtension));
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.GetDirectories(_snapshotRoot, "Fixture-*"), Is.Empty);
Assert.That(File.Exists(StampPath("Fixture")), Is.False);
@@ -260,9 +348,7 @@ public void CaptureAssemblies_WhenStampMatches_DoesNotOpenTheCompiledAssembly()
string stamp = ExpectedStamp(dllPath, mvid);
File.WriteAllText(StampPath("Fixture"), stamp);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(Directory.Exists(HotReloadSourceSnapshotLayout.AssemblyDirectory(_projectRoot, "Fixture", mvid)), Is.False);
Assert.That(File.ReadAllText(StampPath("Fixture")), Is.EqualTo(stamp));
@@ -286,9 +372,7 @@ public void CaptureAssemblies_WhenOneAssemblyCannotBeRead_WarnsAndCapturesTheRes
string mvid = HotReloadAssemblyMvid.Read(dllPath);
LogAssert.Expect(LogType.Warning, new Regex("Snapshot capture failed for assembly Broken"));
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Broken"), CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(_projectRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
Assert.That(File.Exists(StampPath("Broken")), Is.False);
Assert.That(File.Exists(SnapshotSourcePath("Fixture", mvid)), Is.True);
@@ -347,9 +431,7 @@ public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSna
File.SetLastWriteTimeUtc(playerFixturePath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
string mvid = HotReloadAssemblyMvid.Read(dllPath);
- HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(playerRoot, new[] { CreateAssembly("Fixture") }, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ Capture(playerRoot, HotReloadCompileStart.Unknown, ReadsSources(SourceRelativePath));
string playerSnapshotSource = HotReloadSourceSnapshotLayout.SourcePath(
HotReloadSourceSnapshotLayout.AssemblyDirectory(playerRoot, "Fixture", mvid),
@@ -414,17 +496,11 @@ public void CaptureAssemblies_ReturnsWhatItCopiedAndWhatItSkipped()
PlantCompiledAssembly("Fixture");
WriteSourceWrittenBeforeTheCompile("Assets/Three.cs", "abc");
WriteSourceWrittenBeforeTheCompile("Assets/Five.cs", "abcde");
- UnityCompilationAssembly[] assemblies =
- {
- CreateAssemblyWithSources("Fixture", new[] { "Assets/Three.cs", "Assets/Five.cs", "Assets/Missing.cs" })
- };
+ Func assemblies =
+ ReadsSources("Assets/Three.cs", "Assets/Five.cs", "Assets/Missing.cs");
- HotReloadSnapshotCaptureStats first = HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, assemblies, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
- HotReloadSnapshotCaptureStats second = HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, assemblies, HotReloadCompileStart.Unknown),
- CreateDocumentIndex());
+ HotReloadSnapshotCaptureStats first = Capture(_projectRoot, HotReloadCompileStart.Unknown, assemblies);
+ HotReloadSnapshotCaptureStats second = Capture(_projectRoot, HotReloadCompileStart.Unknown, assemblies);
Assert.That(first.Assemblies, Is.EqualTo(1), "first: assemblies");
Assert.That(first.AssembliesCaptured, Is.EqualTo(1), "first: captured");
@@ -450,9 +526,7 @@ private string CaptureWithSourceSavedBetweenStartAndDll(
new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
string mvid = HotReloadAssemblyMvid.Read(dllPath);
- stats = HotReloadSourceSnapshotter.CaptureAssemblies(
- CreateInputs(_projectRoot, new[] { CreateAssembly("Fixture") }, compileStart),
- CreateDocumentIndex());
+ stats = Capture(_projectRoot, compileStart, ReadsSources(SourceRelativePath));
return Path.Combine(_snapshotRoot, "Fixture-" + mvid);
}
@@ -508,29 +582,53 @@ private static UnityCompilationAssembly CreateAssemblyWithSources(string assembl
AssemblyFlags.EditorAssembly);
}
- // The capture's inputs for a temporary root: no package is registered there, so every
- // assembly reads as editable.
- private static HotReloadSnapshotCaptureInputs CreateInputs(
+ // Captures the compiled assemblies planted under the root, with a PDB source reader the test
+ // chooses. No package is registered under a temporary root, so every path reads as editable.
+ private HotReloadSnapshotCaptureStats Capture(
string projectRoot,
- IEnumerable assemblies,
- HotReloadCompileStart compileStart)
+ HotReloadCompileStart compileStart,
+ Func readSources)
{
- List captureAssemblies = new List();
- foreach (UnityCompilationAssembly assembly in assemblies)
- {
- captureAssemblies.Add(HotReloadSnapshotCaptureInputs.ToCaptureAssembly(
- assembly,
- Array.Empty(),
- StringComparison.Ordinal));
- }
-
- return new HotReloadSnapshotCaptureInputs(
+ HotReloadSnapshotCaptureInputs inputs = new HotReloadSnapshotCaptureInputs(
projectRoot,
compileStart,
Array.Empty(),
- StringComparison.Ordinal,
- captureAssemblies,
- 0);
+ StringComparison.Ordinal);
+ return HotReloadSourceSnapshotter.CaptureAssemblies(inputs, CreateDocumentIndex(), readSources, CancellationToken.None);
+ }
+
+ // A PDB source reader that names the given sources for every assembly. Why not the real one:
+ // the planted PDB is this test assembly's, whose sources live under the real project, so
+ // none of them maps to an asset path under a temporary root.
+ private static Func ReadsSources(
+ params string[] sources)
+ {
+ return (candidate, inputs) => new HotReloadCaptureAssembly(candidate.Name, sources, false);
+ }
+
+ // A PDB source reader that counts its calls and names the fixture source, with the read-only
+ // classification the test chooses.
+ private static Func CountingReads(
+ Action onRead,
+ bool isImmutablePackageAssembly)
+ {
+ return (candidate, inputs) =>
+ {
+ onRead();
+ return new HotReloadCaptureAssembly(candidate.Name, new[] { SourceRelativePath }, isImmutablePackageAssembly);
+ };
+ }
+
+ private string MarkerPath(string assemblyName)
+ {
+ return HotReloadSourceSnapshotLayout.ImmutableMarkerPath(_projectRoot, assemblyName);
+ }
+
+ // The stamp form for the dll: the tag, then its write time shifted by the given ticks, then its length.
+ private static string DllStampText(string tag, string dllPath, long mtimeOffsetTicks)
+ {
+ FileInfo dllInfo = new FileInfo(dllPath);
+ return tag + "," + (dllInfo.LastWriteTimeUtc.Ticks + mtimeOffsetTicks) + "," + dllInfo.Length;
}
private string SnapshotSourcePath(string assemblyName, string mvid)
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs
index 41f7ac0e8..804fe5b8c 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotCaptureInputsTests.cs
@@ -1,158 +1,29 @@
-using System;
-using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
-using UnityEditor.Compilation;
-
using UnityEngine;
using io.github.hatayama.UnityCliLoop.FirstPartyTools;
-using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
-
namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
///
- /// Covers the values the snapshot capture reads from Unity before it starts: how one of Unity's
- /// compilation assemblies is classified from the package roots, and, on this project's real
- /// assemblies, that the classification agrees with the Package Manager's answer.
+ /// Covers the values the snapshot capture reads from Unity on the main thread before it starts.
///
public sealed class HotReloadSnapshotCaptureInputsTests
{
- private const int MaxListedMismatches = 5;
-
- private static readonly HotReloadSnapshotPackageRoot[] Packages =
- {
- new HotReloadSnapshotPackageRoot("/proj/Packages/src", "Packages/com.example.embedded", false),
- new HotReloadSnapshotPackageRoot("/elsewhere/LocalPkg", "Packages/com.example.local", false),
- new HotReloadSnapshotPackageRoot("/proj/Library/PackageCache/com.example.reg@1.0.0", "Packages/com.example.reg", true),
- };
-
- ///
- /// Verifies an assembly whose first source lies in an immutable package is classified as
- /// immutable, and keeps its name and sources.
- ///
- [Test]
- public void ToCaptureAssembly_FirstSourceInAnImmutablePackage_IsImmutable()
- {
- HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs");
-
- Assert.That(assembly.IsImmutablePackageAssembly, Is.True);
- Assert.That(assembly.Name, Is.EqualTo("Fixture"));
- Assert.That(assembly.SourceAssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Runtime/A.cs", "Assets/B.cs" }));
- }
-
- ///
- /// Verifies sources of an embedded package, a local package and Assets are editable.
- ///
- [TestCase("Packages/com.example.embedded/Editor/A.cs")]
- [TestCase("Packages/com.example.local/A.cs")]
- [TestCase("Assets/A.cs")]
- public void ToCaptureAssembly_FirstSourceEditable_IsNotImmutable(string firstSource)
- {
- HotReloadCaptureAssembly assembly = Classify(firstSource);
-
- Assert.That(assembly.IsImmutablePackageAssembly, Is.False);
- }
-
- ///
- /// Verifies a package whose name only starts with an immutable package's name is not taken for it.
- ///
- [Test]
- public void ToCaptureAssembly_PackageNameExtendingAnImmutableOne_IsNotImmutable()
- {
- HotReloadCaptureAssembly assembly = Classify("Packages/com.example.reg2/A.cs");
-
- Assert.That(assembly.IsImmutablePackageAssembly, Is.False);
- }
-
- ///
- /// Verifies an assembly without sources is classified as editable with an empty source list.
- ///
- [Test]
- public void ToCaptureAssembly_WithoutSources_IsNotImmutableAndHasNoSources()
- {
- HotReloadCaptureAssembly assembly = Classify();
-
- Assert.That(assembly.IsImmutablePackageAssembly, Is.False);
- Assert.That(assembly.SourceAssetPaths, Is.Empty);
- }
-
- ///
- /// Verifies, for every compilation assembly of this project that has sources, that the
- /// classification from the package roots equals the Package Manager's answer the capture
- /// asked before.
- ///
- [Test]
- public void ToCaptureAssembly_FromPackageRoots_MatchesThePackageManagerAnswerForEveryAssembly()
- {
- IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent();
- StringComparison comparison = HotReloadSnapshotCaptureInputs.CurrentPathComparison();
- List mismatches = new List();
- int checkedAssemblies = 0;
- int immutableAssemblies = 0;
-
- foreach (UnityCompilationAssembly compilationAssembly in HotReloadCompilationAssemblies.Current())
- {
- string[] sourceFiles = compilationAssembly.sourceFiles;
- if (sourceFiles == null || sourceFiles.Length == 0)
- {
- continue;
- }
-
- checkedAssemblies++;
- bool expected = HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles);
- immutableAssemblies += expected ? 1 : 0;
- bool actual = HotReloadSnapshotCaptureInputs
- .ToCaptureAssembly(compilationAssembly, packageRoots, comparison)
- .IsImmutablePackageAssembly;
- if (actual != expected)
- {
- mismatches.Add(compilationAssembly.name + " (" + sourceFiles[0] + "): package roots say "
- + actual + ", the Package Manager says " + expected + ".");
- }
- }
-
- TestContext.WriteLine(
- "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; "
- + mismatches.Count + " mismatches.");
- Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compilation assembly with sources was found.");
- Assert.That(immutableAssemblies, Is.GreaterThan(0), "No immutable package assembly was found to compare.");
- Assert.That(
- mismatches,
- Is.Empty,
- "Checked " + checkedAssemblies + " assemblies, " + immutableAssemblies + " immutable; "
- + mismatches.Count + " mismatches:\n"
- + string.Join("\n", mismatches.GetRange(0, Math.Min(MaxListedMismatches, mismatches.Count))));
- }
-
///
- /// Verifies the inputs read on the main thread name this project's root and carry one entry
- /// for each compilation assembly Unity lists.
+ /// Verifies the inputs read on the main thread name this project's root and carry the
+ /// registered packages.
///
[Test]
- public void ReadOnMainThread_CarriesTheProjectRootAndEveryCompilationAssembly()
+ public void ReadOnMainThread_CarriesTheProjectRootAndThePackageRoots()
{
HotReloadSnapshotCaptureInputs inputs = HotReloadSnapshotCaptureInputs.ReadOnMainThread();
Assert.That(inputs.ProjectRoot, Is.EqualTo(Path.GetFullPath(Path.Combine(Application.dataPath, ".."))));
- Assert.That(inputs.Assemblies.Count, Is.EqualTo(HotReloadCompilationAssemblies.Current().Count));
Assert.That(inputs.PackageRoots, Is.Not.Empty);
}
-
- private static HotReloadCaptureAssembly Classify(params string[] sourceFiles)
- {
- UnityCompilationAssembly compilationAssembly = new UnityCompilationAssembly(
- "Fixture",
- "Library/ScriptAssemblies/Fixture.dll",
- sourceFiles,
- Array.Empty(),
- Array.Empty(),
- Array.Empty(),
- AssemblyFlags.EditorAssembly);
- return HotReloadSnapshotCaptureInputs.ToCaptureAssembly(compilationAssembly, Packages, StringComparison.Ordinal);
- }
}
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs
index 52fd7ec15..8c4067726 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs
@@ -208,9 +208,9 @@ public void CaptureAfterDomainReload_LogsOneBreakdownWithEveryPhase()
JObject context = HotReloadWarmUpTestDoubles.ReadSingleVibeContext(HotReloadConstants.VibeLogSourceSnapshotBreakdown);
string[] fields =
{
- "getAssembliesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs",
+ "getAssembliesMs", "pdbSourcesMs", "packageLookupMs", "stampMs", "mvidMs", "statMs", "readWriteMs",
"pdbCheckMs", "publishMs", "cleanupMs", "totalMs", "assemblies", "assembliesImmutable",
- "assembliesUnchanged", "assembliesCaptured", "filesCopied", "bytesCopied", "filesChecked"
+ "assembliesUnchanged", "assembliesCaptured", "assembliesSkippedPdbMismatch", "filesCopied", "bytesCopied", "filesChecked"
};
foreach (string field in fields)
{
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
index b3e05786d..3382ae4d5 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
@@ -16,6 +16,11 @@ public static void Initialize(Func isToolEnabled)
{
Debug.Assert(isToolEnabled != null, "isToolEnabled must not be null.");
+ // Why first: the snapshot capture no longer touches the compilation assembly list, so
+ // the list's static constructor, which subscribes to compilationStarted, would otherwise
+ // run for whichever caller touches the list first, which need not be on the main thread.
+ HotReloadCompilationAssemblies.EnsureInitialized();
+
// Why not EditorApplication.delayCall: a cold-start session that hits Unity's native
// "Scripts have compiler errors" dialog never flushes delayCall again for the rest of
// that process's lifetime, even for later registrations — while
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs
new file mode 100644
index 000000000..b6f371a5b
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+using UnityEngine;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// A compiled assembly found on disk with a PDB beside it, before its sources are read.
+ ///
+ internal sealed class HotReloadCaptureAssemblyCandidate
+ {
+ internal HotReloadCaptureAssemblyCandidate(string name, string dllPath, string pdbPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty.");
+ Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty.");
+ Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty.");
+
+ Name = name;
+ DllPath = dllPath;
+ PdbPath = pdbPath;
+ }
+
+ internal string Name { get; }
+
+ internal string DllPath { get; }
+
+ internal string PdbPath { get; }
+ }
+
+ ///
+ /// Lists the project's compiled assemblies from the dlls and PDBs on disk, and reads an
+ /// assembly's sources from its PDB, instead of asking Unity's compilation pipeline, which only
+ /// answers on the main thread. Asks Unity nothing, so it can run on any thread.
+ ///
+ internal static class HotReloadCaptureAssemblyEnumerator
+ {
+ private const string DllSearchPattern = "*.dll";
+
+ ///
+ /// Returns every compiled dll that has a PDB beside it, in ordinal order of the file name.
+ /// A dll without a PDB is left out, because no snapshot can be checked against it. Returns
+ /// an empty list when the compiled assemblies directory does not exist.
+ ///
+ internal static IReadOnlyList ListCompiled(string projectRoot)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty.");
+
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot);
+ string directory = layout.CompiledAssembliesDirectory;
+ if (!Directory.Exists(directory))
+ {
+ return Array.Empty();
+ }
+
+ string[] dllPaths = Directory.GetFiles(directory, DllSearchPattern);
+ Array.Sort(dllPaths, StringComparer.Ordinal);
+ List candidates = new List(dllPaths.Length);
+ foreach (string dllPath in dllPaths)
+ {
+ string name = Path.GetFileNameWithoutExtension(dllPath);
+ string pdbPath = layout.PdbPath(name);
+ if (!File.Exists(pdbPath))
+ {
+ continue;
+ }
+
+ candidates.Add(new HotReloadCaptureAssemblyCandidate(name, dllPath, pdbPath));
+ }
+
+ return candidates;
+ }
+
+ ///
+ /// Reads the sources of one compiled assembly from its PDB's document table, named by their
+ /// asset paths, and whether it is a read-only package's. Throws InvalidOperationException when
+ /// the PDB belongs to another build of the dll, as while a compile replaces them.
+ ///
+ internal static HotReloadCaptureAssembly ReadSources(
+ HotReloadCaptureAssemblyCandidate candidate,
+ HotReloadSnapshotCaptureInputs inputs)
+ {
+ Debug.Assert(candidate != null, "candidate must not be null.");
+ Debug.Assert(inputs != null, "inputs must not be null.");
+
+ List documents = HotReloadPdbDocumentTable.Read(candidate.DllPath, candidate.PdbPath);
+ // Why the main project's root: a Virtual Player reads the main project's assemblies,
+ // whose PDB names sources from the main project's compile.
+ HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments(
+ documents,
+ CompiledAssemblyLayout.Resolve(inputs.ProjectRoot).MainProjectRoot,
+ inputs.ProjectRoot,
+ inputs.PackageRoots,
+ inputs.PathComparison);
+ return new HotReloadCaptureAssembly(candidate.Name, sources.AssetPaths, sources.IsImmutablePackageAssembly);
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta
new file mode 100644
index 000000000..fa815d3e7
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCaptureAssemblyEnumerator.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 863665f156a0043eaba0b28d6335329f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs
index 8f38993d1..d502dd2bb 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs
@@ -16,8 +16,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
/// Why: on a project with several hundred assemblies one CompilationPipeline.GetAssemblies() call
/// takes 340-620 ms, while the list only changes through a compile, or through an import that
/// changes the script set without compiling (see HotReloadCompilationAssemblyListPostprocessor).
- /// The startup snapshot capture is the first caller after a domain reload, so runs find the list
- /// already kept.
+ /// Whichever caller first needs the list after a domain reload asks Unity for it; later callers
+ /// in the domain find it kept.
/// A list rebuilt at startup from the saved list holds only the fields hot reload reads: name,
/// outputPath, sourceFiles, defines, assemblyReferences and compiledAssemblyReferences.
/// compilerOptions, flags and rootNamespace keep their defaults, so code that needs them reads
@@ -42,6 +42,15 @@ static HotReloadCompilationAssemblies()
CompilationPipeline.compilationStarted += OnCompilationStarted;
}
+ ///
+ /// Does nothing but make sure the static constructor has run, so the subscription to
+ /// compilationStarted is made on the main thread when the domain loads rather than by
+ /// whichever caller first asks for the list.
+ ///
+ internal static void EnsureInitialized()
+ {
+ }
+
///
/// Returns the compilation assemblies. The list is shared; callers only read it.
///
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs
index b025a41a3..57d7bd99b 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs
@@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.Threading;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
using UnityEngine;
using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
@@ -43,8 +45,14 @@ internal IReadOnlyList Current()
return _assemblies;
}
+ System.Diagnostics.Stopwatch fetchWatch = System.Diagnostics.Stopwatch.StartNew();
UnityCompilationAssembly[] fetched = _fetch();
+ fetchWatch.Stop();
Debug.Assert(fetched != null, "fetch must not return null.");
+ VibeLogger.LogInfo(
+ HotReloadConstants.VibeLogCompilationAssembliesFetched,
+ "Hot reload asked Unity for the compilation assemblies.",
+ new { fetchMs = fetchWatch.ElapsedMilliseconds, assemblyCount = fetched.Length });
// Why not memoize an empty result: GetAssemblies() answers nothing while a compile is in
// flight, and keeping that would hide every assembly for the rest of the domain's life.
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
index 8d0f5547f..f38b407e5 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
@@ -681,6 +681,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE
public const string VibeLogPublicizedCopyWritten = "hot_reload_publicized_copy_written";
public const string VibeLogSourceSnapshotChecked = "hot_reload_source_snapshot_checked";
public const string VibeLogSourceSnapshotBreakdown = "hot_reload_source_snapshot_breakdown";
+ public const string VibeLogCompilationAssembliesFetched = "hot_reload_compilation_assemblies_fetched";
public const string VibeLogShimCompilerTiming = "hot_reload_shim_compiler_timing";
public const string VibeLogShimCompileFailed = "hot_reload_shim_compile_failed";
public const string VibeLogIsolationRetry = "hot_reload_isolation_retry";
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs
index 1ed87b328..5cd37293e 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPackageFolderMatch.cs
@@ -1,14 +1,12 @@
using System;
-using System.Collections.Generic;
using UnityEngine;
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
///
- /// Tells which package folder a slash-separated path lies in, and whether hot reload treats that
- /// package's sources as read-only. Does no IO and asks nothing of the Package Manager, so it can
- /// run on any thread.
+ /// Tells whether a slash-separated path lies in a folder, respecting folder boundaries. Does no
+ /// IO, so it can run on any thread.
///
internal static class HotReloadPackageFolderMatch
{
@@ -34,35 +32,6 @@ internal static int PrefixLength(string path, string folder, StringComparison co
return folder.Length;
}
- ///
- /// Returns whether the asset path lies in a package hot reload treats as read-only: the
- /// of the package whose asset path
- /// contains it, or false when no package does, as for Assets. The same rule the PDB source
- /// map applies to the first source of an assembly.
- ///
- internal static bool IsInImmutablePackage(
- string slashNormalizedAssetPath,
- IReadOnlyList packageRoots,
- StringComparison comparison)
- {
- Debug.Assert(!string.IsNullOrEmpty(slashNormalizedAssetPath), "slashNormalizedAssetPath must not be null or empty.");
- Debug.Assert(packageRoots != null, "packageRoots must not be null.");
-
- int bestLength = -1;
- bool bestIsImmutable = false;
- foreach (HotReloadSnapshotPackageRoot root in packageRoots)
- {
- int length = PrefixLength(slashNormalizedAssetPath, NormalizeFolder(root.AssetPath), comparison);
- if (length > bestLength)
- {
- bestLength = length;
- bestIsImmutable = root.IsImmutable;
- }
- }
-
- return bestIsImmutable;
- }
-
///
/// Returns the folder with forward slashes and without a trailing slash, the form
/// expects.
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs
index 9fdc288ca..3dec67974 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureInputs.cs
@@ -4,8 +4,6 @@
using UnityEngine;
-using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
-
namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
///
@@ -18,23 +16,18 @@ internal HotReloadSnapshotCaptureInputs(
string projectRoot,
HotReloadCompileStart compileStart,
IReadOnlyList packageRoots,
- StringComparison pathComparison,
- IReadOnlyList assemblies,
- long assemblyListingMs)
+ StringComparison pathComparison)
{
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty.");
Debug.Assert(packageRoots != null, "packageRoots must not be null.");
Debug.Assert(
pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase,
"pathComparison must be Ordinal or OrdinalIgnoreCase.");
- Debug.Assert(assemblies != null, "assemblies must not be null.");
ProjectRoot = projectRoot;
CompileStart = compileStart;
PackageRoots = packageRoots;
PathComparison = pathComparison;
- Assemblies = assemblies;
- AssemblyListingMs = assemblyListingMs;
}
/// The root of this Editor's project, which owns the snapshots.
@@ -49,39 +42,18 @@ internal HotReloadSnapshotCaptureInputs(
/// How paths compare on this platform: case-insensitive on Windows only.
internal StringComparison PathComparison { get; }
- /// The compiled assemblies to capture.
- internal IReadOnlyList Assemblies { get; }
-
- /// How long listing the assemblies took, in milliseconds.
- internal long AssemblyListingMs { get; }
-
///
- /// Reads the inputs from Unity. Main thread only: the compile start lives in SessionState, the
- /// package roots come from the Package Manager, and the assemblies from
- /// CompilationPipeline.GetAssemblies, all of which Unity allows on the main thread alone.
+ /// Reads the inputs from Unity. Main thread only: the compile start lives in SessionState and
+ /// the package roots come from the Package Manager, both of which Unity allows on the main
+ /// thread alone.
///
internal static HotReloadSnapshotCaptureInputs ReadOnMainThread()
{
- string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
- HotReloadCompileStart compileStart = HotReloadCompileStartRecord.Read();
- IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent();
- StringComparison pathComparison = CurrentPathComparison();
- System.Diagnostics.Stopwatch listingWatch = System.Diagnostics.Stopwatch.StartNew();
- IReadOnlyList compilationAssemblies = HotReloadCompilationAssemblies.Current();
- listingWatch.Stop();
- List assemblies = new List(compilationAssemblies.Count);
- foreach (UnityCompilationAssembly compilationAssembly in compilationAssemblies)
- {
- assemblies.Add(ToCaptureAssembly(compilationAssembly, packageRoots, pathComparison));
- }
-
return new HotReloadSnapshotCaptureInputs(
- projectRoot,
- compileStart,
- packageRoots,
- pathComparison,
- assemblies,
- listingWatch.ElapsedMilliseconds);
+ Path.GetFullPath(Path.Combine(Application.dataPath, "..")),
+ HotReloadCompileStartRecord.Read(),
+ HotReloadSnapshotPackageRoots.ReadCurrent(),
+ CurrentPathComparison());
}
///
@@ -92,24 +64,5 @@ internal static StringComparison CurrentPathComparison()
{
return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
}
-
- ///
- /// Turns one of Unity's compilation assemblies into what the capture reads, classifying it by
- /// its first source because an asmdef keeps one assembly inside one package.
- ///
- internal static HotReloadCaptureAssembly ToCaptureAssembly(
- UnityCompilationAssembly compilationAssembly,
- IReadOnlyList packageRoots,
- StringComparison pathComparison)
- {
- Debug.Assert(compilationAssembly != null, "compilationAssembly must not be null.");
-
- string[] sourceFiles = compilationAssembly.sourceFiles ?? Array.Empty();
- bool isImmutable = sourceFiles.Length > 0 && HotReloadPackageFolderMatch.IsInImmutablePackage(
- sourceFiles[0].Replace('\\', '/'),
- packageRoots,
- pathComparison);
- return new HotReloadCaptureAssembly(compilationAssembly.name, sourceFiles, isImmutable);
- }
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
index 3f939b7bf..997e07e28 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs
@@ -9,6 +9,8 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
internal sealed class HotReloadSnapshotCaptureStats
{
// Times are TimeSpan ticks, summed over every assembly and source of the capture.
+ internal long ListingTicks;
+ internal long PdbSourcesTicks;
internal long PackageLookupTicks;
internal long StampTicks;
internal long MvidTicks;
@@ -22,16 +24,18 @@ internal sealed class HotReloadSnapshotCaptureStats
internal int AssembliesImmutable;
internal int AssembliesUnchanged;
internal int AssembliesCaptured;
+ internal int AssembliesSkippedPdbMismatch;
internal int FilesReused;
internal int FilesCopied;
internal long BytesCopied;
internal int FilesChecked;
- internal object ToVibeLogContext(long getAssembliesMs, long totalMs)
+ internal object ToVibeLogContext(long totalMs)
{
return new
{
- getAssembliesMs,
+ getAssembliesMs = ToMilliseconds(ListingTicks),
+ pdbSourcesMs = ToMilliseconds(PdbSourcesTicks),
packageLookupMs = ToMilliseconds(PackageLookupTicks),
stampMs = ToMilliseconds(StampTicks),
mvidMs = ToMilliseconds(MvidTicks),
@@ -45,6 +49,7 @@ internal object ToVibeLogContext(long getAssembliesMs, long totalMs)
assembliesImmutable = AssembliesImmutable,
assembliesUnchanged = AssembliesUnchanged,
assembliesCaptured = AssembliesCaptured,
+ assembliesSkippedPdbMismatch = AssembliesSkippedPdbMismatch,
filesReused = FilesReused,
filesCopied = FilesCopied,
bytesCopied = BytesCopied,
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs
index 41bb66dfb..80a5ae76c 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotLayout.cs
@@ -16,11 +16,23 @@ internal static class HotReloadSourceSnapshotLayout
// The suffix of an assembly directory that is still being written; only a Move makes it complete.
internal const string IncompleteDirectorySuffix = ".tmp";
+ private const string ImmutableMarkerExtension = ".immutable";
+
internal static string Root(string projectRoot)
{
return Path.Combine(projectRoot, HotReloadConstants.SourceSnapshotRelativeDirectory);
}
+ ///
+ /// The file that records the compiled dll of a read-only package's assembly, in the stamp's
+ /// form, so a later capture skips that dll without reading its PDB. A file rather than a
+ /// directory, so the cleanup of earlier builds' directories leaves it alone.
+ ///
+ internal static string ImmutableMarkerPath(string projectRoot, string assemblyName)
+ {
+ return Path.Combine(Root(projectRoot), assemblyName + ImmutableMarkerExtension);
+ }
+
internal static string AssemblyDirectoryName(string assemblyName, string mvid)
{
return assemblyName + "-" + mvid;
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs
index 41f96e320..2360eda6d 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.Stamp.cs
@@ -9,6 +9,10 @@ internal static partial class HotReloadSourceSnapshotter
{
private const string StampFileExtension = ".stamp";
+ // Why a tag where a stamp has the MVID: the marker of a read-only package's assembly names
+ // no snapshot directory, and reading the MVID would open a dll the capture otherwise skips.
+ private const string ImmutableMarkerTag = "immutable";
+
private static string StampPath(string snapshotRoot, string assemblyName)
{
return Path.Combine(snapshotRoot, assemblyName + StampFileExtension);
@@ -95,4 +99,20 @@ private static void WriteStamp(string stampPath, string mvid, long dllMtimeTicks
File.WriteAllText(stampPath, mvid + "," + dllMtimeTicks + "," + dllByteLength);
}
}
+
+ ///
+ /// The write time and length of a compiled dll, which the stamp and the marker beside its
+ /// snapshots record.
+ ///
+ internal readonly struct HotReloadCompiledDllStamp
+ {
+ internal readonly long MtimeTicks;
+ internal readonly long ByteLength;
+
+ internal HotReloadCompiledDllStamp(long mtimeTicks, long byteLength)
+ {
+ MtimeTicks = mtimeTicks;
+ ByteLength = byteLength;
+ }
+ }
}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs
index bdae7bfdc..413df68c9 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Threading;
using io.github.hatayama.UnityCliLoop.ToolContracts;
@@ -20,53 +21,63 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
internal static partial class HotReloadSourceSnapshotter
{
///
- /// Captures snapshots for project assemblies that have adjacent portable PDBs.
+ /// Captures snapshots for the project's compiled assemblies that have adjacent portable PDBs,
+ /// listed from the dlls on disk and each read from its PDB.
/// A copy of a source written since the compile started is checked against the PDB checksum
/// when it is captured, and marked in the manifest when it does not match, so the default file
/// selection, the sibling scan and the skip check treat that file as changed. The method-diff
/// baseline checks every copy against the PDB when it uses one.
- /// Logs how long each phase took, even when the list is empty.
- /// Returns false when the inputs list no compilation assembly (as while Unity compiles).
+ /// Logs how long each phase took, even when nothing is listed.
+ /// Returns false when no compiled dll with a PDB was listed.
/// Asks Unity nothing beyond what carries.
///
internal static bool CaptureAfterDomainReload(HotReloadSnapshotCaptureInputs inputs)
{
Debug.Assert(inputs != null, "inputs must not be null.");
- System.Diagnostics.Stopwatch captureWatch = System.Diagnostics.Stopwatch.StartNew();
- HotReloadSnapshotCaptureStats stats = CaptureAssemblies(inputs, HotReloadPdbDocumentIndex.Shared);
- captureWatch.Stop();
- // Why the listing time is added: the assemblies are now listed while the inputs are read,
- // before this method runs, and totalMs has always included that listing.
+ System.Diagnostics.Stopwatch totalWatch = System.Diagnostics.Stopwatch.StartNew();
+ HotReloadSnapshotCaptureStats stats = CaptureAssemblies(
+ inputs,
+ HotReloadPdbDocumentIndex.Shared,
+ HotReloadCaptureAssemblyEnumerator.ReadSources,
+ CancellationToken.None);
+ totalWatch.Stop();
VibeLogger.LogInfo(
HotReloadConstants.VibeLogSourceSnapshotBreakdown,
"Hot reload source snapshot capture breakdown.",
- stats.ToVibeLogContext(
- inputs.AssemblyListingMs,
- inputs.AssemblyListingMs + captureWatch.ElapsedMilliseconds));
- return inputs.Assemblies.Count > 0;
+ stats.ToVibeLogContext(totalWatch.ElapsedMilliseconds));
+ return stats.Assemblies > 0;
}
- // Why separate from CaptureAfterDomainReload: taking the document index as an argument lets
- // tests drive the per-assembly loop in a temporary root without writing the shared index.
+ // Why separate from CaptureAfterDomainReload: taking the document index and the PDB source
+ // reader as arguments lets tests drive the per-assembly loop in a temporary root, without
+ // writing the shared index, and see whether a PDB was read.
// Returns how long each phase took and how much the capture did.
internal static HotReloadSnapshotCaptureStats CaptureAssemblies(
HotReloadSnapshotCaptureInputs inputs,
- HotReloadPdbDocumentIndex documentIndex)
+ HotReloadPdbDocumentIndex documentIndex,
+ Func readSources,
+ CancellationToken cancellationToken)
{
Debug.Assert(inputs != null, "inputs must not be null.");
Debug.Assert(documentIndex != null, "documentIndex must not be null.");
+ Debug.Assert(readSources != null, "readSources must not be null.");
string snapshotRoot = HotReloadSourceSnapshotLayout.Root(inputs.ProjectRoot);
Directory.CreateDirectory(snapshotRoot);
HotReloadSnapshotCaptureStats stats = new HotReloadSnapshotCaptureStats();
- foreach (HotReloadCaptureAssembly assembly in inputs.Assemblies)
+ System.Diagnostics.Stopwatch listingWatch = System.Diagnostics.Stopwatch.StartNew();
+ IReadOnlyList candidates =
+ HotReloadCaptureAssemblyEnumerator.ListCompiled(inputs.ProjectRoot);
+ stats.ListingTicks += listingWatch.Elapsed.Ticks;
+ foreach (HotReloadCaptureAssemblyCandidate candidate in candidates)
{
+ cancellationToken.ThrowIfCancellationRequested();
stats.Assemblies++;
try
{
- CaptureAssemblyIfNeeded(inputs, snapshotRoot, assembly, documentIndex, stats);
+ CaptureCandidateIfNeeded(inputs, snapshotRoot, candidate, documentIndex, readSources, stats);
}
catch (Exception ex) when (IsSkippableAssemblyCaptureException(ex))
{
@@ -75,7 +86,7 @@ internal static HotReloadSnapshotCaptureStats CaptureAssemblies(
// Continuing preserves the fail-closed invariant because the method-diff baseline
// rejects missing snapshots by PDB checksum rather than using them for an incorrect diff.
UnityEngine.Debug.LogWarning(
- $"[UnityCliLoop] Snapshot capture failed for assembly {assembly.Name}: {ex.Message}");
+ $"[UnityCliLoop] Snapshot capture failed for assembly {candidate.Name}: {ex.Message}");
}
}
@@ -91,68 +102,114 @@ ex is UnauthorizedAccessException ||
ex is BadImageFormatException;
}
- private static void CaptureAssemblyIfNeeded(
+ // Why the stamps are checked before the PDB is read: after an ordinary edit only one dll has
+ // changed, and after files are added or removed a few dozen, so only those PDBs are read.
+ private static void CaptureCandidateIfNeeded(
HotReloadSnapshotCaptureInputs inputs,
string snapshotRoot,
- HotReloadCaptureAssembly assembly,
+ HotReloadCaptureAssemblyCandidate candidate,
HotReloadPdbDocumentIndex documentIndex,
+ Func readSources,
HotReloadSnapshotCaptureStats stats)
{
- IReadOnlyList sourceFiles = assembly.SourceAssetPaths;
- if (sourceFiles.Count == 0)
+ FileInfo dllInfo = new FileInfo(candidate.DllPath);
+ HotReloadCompiledDllStamp dll = new HotReloadCompiledDllStamp(dllInfo.LastWriteTimeUtc.Ticks, dllInfo.Length);
+ string markerPath = HotReloadSourceSnapshotLayout.ImmutableMarkerPath(inputs.ProjectRoot, candidate.Name);
+ string stampPath = StampPath(snapshotRoot, candidate.Name);
+
+ // Why stamp short-circuits Cecil: a false stamp (identical mtime+length with different
+ // bytes) is vanishingly rare, and even then LoadVerifiedSnapshotSource rejects via PDB
+ // checksum — so stamp lies degrade to fallback, never to a wrong method diff. The marker of
+ // a read-only package's assembly works the same way: a dll rebuilt after the package was
+ // made editable no longer matches it.
+ System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew();
+ bool markerMatches = HasMatchingStamp(markerPath, dll.MtimeTicks, dll.ByteLength);
+ bool stampMatches = !markerMatches && HasMatchingStamp(stampPath, dll.MtimeTicks, dll.ByteLength);
+ stats.StampTicks += stampWatch.Elapsed.Ticks;
+ if (markerMatches)
{
+ stats.AssembliesImmutable++;
+ return;
+ }
+
+ if (stampMatches)
+ {
+ stats.AssembliesUnchanged++;
+ return;
+ }
+
+ HotReloadCaptureAssembly assembly = ReadSourcesOrNull(candidate, inputs, readSources, stats);
+ if (assembly == null || assembly.SourceAssetPaths.Count == 0)
+ {
+ // No source maps to an asset path: nothing is written, as for an assembly Unity listed
+ // without sources, so the next capture reads the PDB again.
return;
}
- string projectRoot = inputs.ProjectRoot;
- // Why only a read: the inputs classified the assembly from the package roots, so the
- // capture no longer asks the Package Manager here.
System.Diagnostics.Stopwatch packageLookupWatch = System.Diagnostics.Stopwatch.StartNew();
bool immutable = assembly.IsImmutablePackageAssembly;
stats.PackageLookupTicks += packageLookupWatch.Elapsed.Ticks;
if (immutable)
{
+ System.Diagnostics.Stopwatch writeMarkerWatch = System.Diagnostics.Stopwatch.StartNew();
+ WriteStamp(markerPath, ImmutableMarkerTag, dll.MtimeTicks, dll.ByteLength);
+ stats.StampTicks += writeMarkerWatch.Elapsed.Ticks;
stats.AssembliesImmutable++;
return;
}
- // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main
- // project for a Virtual Player; its snapshots stay under its own root.
- CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot);
- string dllPath = layout.DllPath(assembly.Name);
- string pdbPath = layout.PdbPath(assembly.Name);
- if (!File.Exists(dllPath) || !File.Exists(pdbPath))
+ CaptureChangedAssembly(inputs, snapshotRoot, candidate, assembly, dll, stampPath, documentIndex, stats);
+ }
+
+ // The assembly's sources from its PDB, or null when the PDB belongs to another build of the
+ // dll. Why that build is skipped rather than failing the capture: it happens when the capture
+ // reads while a compile replaces the dll and the PDB; no stamp is written, so the next domain
+ // captures that assembly.
+ private static HotReloadCaptureAssembly ReadSourcesOrNull(
+ HotReloadCaptureAssemblyCandidate candidate,
+ HotReloadSnapshotCaptureInputs inputs,
+ Func readSources,
+ HotReloadSnapshotCaptureStats stats)
+ {
+ System.Diagnostics.Stopwatch readWatch = System.Diagnostics.Stopwatch.StartNew();
+ try
{
- return;
+ return readSources(candidate, inputs);
}
-
- FileInfo dllInfo = new FileInfo(dllPath);
- long dllMtimeTicks = dllInfo.LastWriteTimeUtc.Ticks;
- long dllByteLength = dllInfo.Length;
- string stampPath = StampPath(snapshotRoot, assembly.Name);
-
- // Why stamp short-circuits Cecil: a false stamp (identical mtime+length with different
- // bytes) is vanishingly rare, and even then LoadVerifiedSnapshotSource rejects via PDB
- // checksum — so stamp lies degrade to fallback, never to a wrong method diff.
- System.Diagnostics.Stopwatch stampWatch = System.Diagnostics.Stopwatch.StartNew();
- bool stampMatches = HasMatchingStamp(stampPath, dllMtimeTicks, dllByteLength);
- stats.StampTicks += stampWatch.Elapsed.Ticks;
- if (stampMatches)
+ catch (InvalidOperationException)
{
- stats.AssembliesUnchanged++;
- return;
+ stats.AssembliesSkippedPdbMismatch++;
+ return null;
}
+ finally
+ {
+ stats.PdbSourcesTicks += readWatch.Elapsed.Ticks;
+ }
+ }
+ private static void CaptureChangedAssembly(
+ HotReloadSnapshotCaptureInputs inputs,
+ string snapshotRoot,
+ HotReloadCaptureAssemblyCandidate candidate,
+ HotReloadCaptureAssembly assembly,
+ HotReloadCompiledDllStamp dll,
+ string stampPath,
+ HotReloadPdbDocumentIndex documentIndex,
+ HotReloadSnapshotCaptureStats stats)
+ {
+ // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main
+ // project for a Virtual Player; its snapshots stay under its own root.
+ string projectRoot = inputs.ProjectRoot;
System.Diagnostics.Stopwatch mvidWatch = System.Diagnostics.Stopwatch.StartNew();
- string mvid = HotReloadAssemblyMvid.Read(dllPath);
+ string mvid = HotReloadAssemblyMvid.Read(candidate.DllPath);
stats.MvidTicks += mvidWatch.Elapsed.Ticks;
string assemblySnapshotDirectory = HotReloadSourceSnapshotLayout.AssemblyDirectory(projectRoot, assembly.Name, mvid);
if (!Directory.Exists(assemblySnapshotDirectory))
{
HotReloadSnapshotSourceCheck check = new HotReloadSnapshotSourceCheck(
- inputs.CompileStart.SuspectWritesFrom(dllMtimeTicks),
- dllPath,
- pdbPath,
+ inputs.CompileStart.SuspectWritesFrom(dll.MtimeTicks),
+ candidate.DllPath,
+ candidate.PdbPath,
mvid,
documentIndex,
inputs.PackageRoots,
@@ -169,7 +226,7 @@ private static void CaptureAssemblyIfNeeded(
HotReloadSourceSnapshotCopier.CaptureAtomically(
projectRoot,
assemblySnapshotDirectory,
- sourceFiles,
+ assembly.SourceAssetPaths,
assembly.Name,
check,
stats);
@@ -180,7 +237,7 @@ private static void CaptureAssemblyIfNeeded(
projectRoot,
assemblySnapshotDirectory,
previousSnapshotDirectory,
- sourceFiles,
+ assembly.SourceAssetPaths,
assembly.Name,
check,
stats);
@@ -197,7 +254,7 @@ private static void CaptureAssemblyIfNeeded(
// because the method-diff baseline requires a PDB checksum match, and a changed DLL
// invalidates this stamp through its mtime or length before the next capture.
System.Diagnostics.Stopwatch writeStampWatch = System.Diagnostics.Stopwatch.StartNew();
- WriteStamp(stampPath, mvid, dllMtimeTicks, dllByteLength);
+ WriteStamp(stampPath, mvid, dll.MtimeTicks, dll.ByteLength);
stats.StampTicks += writeStampWatch.Elapsed.Ticks;
}
diff --git a/docs/hot-reload.md b/docs/hot-reload.md
index 86b0fc72e..0c4093728 100644
--- a/docs/hot-reload.md
+++ b/docs/hot-reload.md
@@ -84,7 +84,10 @@ written since the compile started; it deletes the copies of sources no longer in
When both the hot-reload and pause-point tools are disabled in the tool settings, the capture
after a domain reload is skipped; the first hot reload run or pause point after either tool is
enabled captures it, and any source written since the compile started is then checked against
-the PDB),
+the PDB. The capture lists the assemblies from the compiled dlls that have a PDB beside them and
+reads an assembly's sources from its PDB only when its dll changed; a read-only package's
+assembly gets a `.immutable` marker beside the snapshots instead, which skips its
+PDB until the dll changes),
`Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence
points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the
first run after a domain reload does not walk the PDB again while they still match), and
diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md
index 3ca5cb287..d94951c8c 100644
--- a/docs/vibe-logs.md
+++ b/docs/vibe-logs.md
@@ -92,7 +92,7 @@ command ran against:
and `exceptionMessage`, logged as a warning).
`hot_reload_source_snapshot_captured` appears once per domain when the source snapshot
capture ran to completion, with its `trigger` (`domain_load` normally; `first_update_tick` or
- `apply` only when the capture at domain load threw or Unity listed no compilation assembly
+ `apply` only when the capture at domain load threw or found no compiled dll with a PDB
yet; `apply` or `pause_point` when the captures at startup were skipped and a hot reload run
or a pause point is the first to read the snapshot) and `captureMs`. When both the hot-reload
and pause-point tools are disabled in the tool settings, the captures at `domain_load` and
@@ -100,16 +100,18 @@ command ran against:
`reason` `tools_disabled` instead, and the first hot reload run or pause point after either
tool is enabled captures it.
`hot_reload_source_snapshot_breakdown` appears once each time the capture runs to the end,
- just before `hot_reload_source_snapshot_captured`. It also appears when Unity listed no
- compilation assembly, in which case no captured entry follows, and it does not appear when the
- capture threw. It says where the capture spent its time:
- - `getAssembliesMs`: listing the compilation assemblies from Unity, which happens while the
- capture's inputs are read on the main thread, before any copying starts.
- - `packageLookupMs`: reading whether each assembly's sources are immutable. The inputs decide
- that from the package roots they read from Package Manager once, so this is close to 0; it
- no longer asks Package Manager for each assembly.
- - `stampMs`: reading and writing the stamps beside the snapshots, and reading the previous
- build's manifest.
+ just before `hot_reload_source_snapshot_captured`. It also appears when no compiled dll with a
+ PDB beside it was found, in which case no captured entry follows, and it does not appear when
+ the capture threw. The capture lists the compiled assemblies from the dlls and PDBs in the
+ compiled assemblies folder and reads an assembly's sources from its PDB, not from Unity's
+ compilation assembly list. It says where the capture spent its time:
+ - `getAssembliesMs`: listing the compiled dlls that have a PDB beside them.
+ - `pdbSourcesMs`: reading the sources of each assembly whose dll changed from its PDB.
+ - `packageLookupMs`: reading whether each assembly's sources are immutable, which the PDB's
+ sources and the package roots read once from Package Manager decide, so this is close to 0;
+ it no longer asks Package Manager for each assembly.
+ - `stampMs`: reading and writing the stamps and the read-only markers beside the snapshots, and
+ reading the previous build's manifest.
- `mvidMs`: reading the MVID of each compiled dll whose stamp did not match.
- `statMs`: reading each source's length and write time before and after it is copied, and
checking whether a source and its previous copy are unchanged.
@@ -120,17 +122,25 @@ command ran against:
previous build's directory there, writing the manifest, and moving the directory into place.
- `cleanupMs`: deleting the snapshot directories of earlier builds, and the copies of sources
the assembly no longer lists.
- - `totalMs`: listing the assemblies plus the whole per-assembly loop. Reading the compile start
- and the package roots for the inputs is not counted here.
- - `assemblies`: the compilation assemblies listed.
- - `assembliesImmutable`: those skipped because their sources belong to an immutable package.
+ - `totalMs`: the whole capture. Reading the compile start and the package roots before it is
+ not counted here.
+ - `assemblies`: the compiled assemblies listed.
+ - `assembliesImmutable`: those skipped because their sources belong to an immutable package:
+ either their read-only marker matched the compiled dll, or their PDB showed it and the marker
+ was written.
- `assembliesUnchanged`: those skipped because their stamp matched the compiled dll.
- `assembliesCaptured`: those whose sources were copied into a new snapshot directory.
+ - `assembliesSkippedPdbMismatch`: those skipped because their PDB belonged to another build of
+ the dll, as while a compile replaces them; no stamp is written, so the next domain captures them.
- `filesReused`: the sources whose previous copy was kept without reading them.
- `filesCopied` and `bytesCopied`: the sources copied and their total size.
- `filesChecked`: the sources checked against the PDB.
The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry
also includes reading the compile start and the package roots, and writing this breakdown entry.
+ `hot_reload_compilation_assemblies_fetched` appears each time hot reload asks Unity for the
+ compilation assembly list, with `fetchMs` and `assemblyCount`. The list is kept for the rest of
+ the domain once it is not empty, so this normally appears once per domain, from whichever of the
+ warm-up or the first hot reload run needs the list first.
After a domain reload, the warm-up writes one `hot_reload_warm_up_complete` with the target
assemblies, each item's `outcome` (`done`, `cancelled` or `failed`) and time, and `cancelledBy`
(`run`, `beforeAssemblyReload`, `compilationStarted`, or null), or one