From 4ad1aa55f11e5b05ed464655eba40f5c36ee7ab4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 13:15:00 +0900 Subject: [PATCH 1/3] Move the copy outcome tally out of CaptureAtomically into its own class The incremental capture that follows needs the same counting of unreadable and PDB-checked sources and the same log and warning, so the tally becomes a private nested class both paths can use. The copier becomes partial for the incremental path's file. Behavior is unchanged. --- .../Shared/HotReloadSourceSnapshotCopier.cs | 103 ++++++++++-------- 1 file changed, 60 insertions(+), 43 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs index 7b5c140d2..40886d6be 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.cs @@ -13,7 +13,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// into a temporary directory first and publishing it with one Move, and decides what the /// manifest records for each copy. /// - internal static class HotReloadSourceSnapshotCopier + internal static partial class HotReloadSourceSnapshotCopier { internal static void CaptureAtomically( string projectRoot, @@ -38,11 +38,7 @@ internal static void CaptureAtomically( Directory.CreateDirectory(temporaryDirectory); stats.PublishTicks += prepareWatch.Elapsed.Ticks; - int skippedSourceCount = 0; - string firstSkippedSourcePath = null; - int suspectSourceCount = 0; - int editedAfterCompileCount = 0; - long checkTicks = 0; + SourceCopyTally tally = new SourceCopyTally(); List manifestLines = new List(sourceFiles.Length); foreach (string projectRelativeSourcePath in sourceFiles) { @@ -53,49 +49,14 @@ internal static void CaptureAtomically( check, manifestLines, stats); - if (outcome.Kind == SourceCopyKind.Unreadable) - { - skippedSourceCount++; - firstSkippedSourcePath ??= projectRelativeSourcePath; - continue; - } - - if (outcome.Kind == SourceCopyKind.Checked || outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) - { - suspectSourceCount++; - checkTicks += outcome.CheckTicks; - } - - if (outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) - { - editedAfterCompileCount++; - } + tally.Count(outcome, projectRelativeSourcePath); } System.Diagnostics.Stopwatch publishWatch = System.Diagnostics.Stopwatch.StartNew(); HotReloadSourceStampManifest.Write(temporaryDirectory, manifestLines); Directory.Move(temporaryDirectory, assemblySnapshotDirectory); stats.PublishTicks += publishWatch.Elapsed.Ticks; - if (suspectSourceCount > 0) - { - VibeLogger.LogInfo( - HotReloadConstants.VibeLogSourceSnapshotChecked, - "Hot reload checked the sources written since the compile started against the PDB.", - new - { - assemblyName, - suspect = suspectSourceCount, - editedAfterCompile = editedAfterCompileCount, - checkMs = (long)TimeSpan.FromTicks(checkTicks).TotalMilliseconds - }); - } - - if (skippedSourceCount > 0) - { - UnityEngine.Debug.LogWarning( - $"[UnityCliLoop] Skipped {skippedSourceCount} unreadable source(s) while snapshotting " + - $"{assemblyName}: {firstSkippedSourcePath}"); - } + tally.LogAndWarn(assemblyName); } private static SourceCopyOutcome CopySourceFileByteExact( @@ -278,5 +239,61 @@ internal SourceCopyOutcome(SourceCopyKind kind, long checkTicks) CheckTicks = checkTicks; } } + + // Counts the outcomes of one assembly's copies, then logs the PDB checks and warns about the + // sources that could not be read. + private sealed class SourceCopyTally + { + private int _skippedSourceCount; + private string _firstSkippedSourcePath; + private int _suspectSourceCount; + private int _editedAfterCompileCount; + private long _checkTicks; + + internal void Count(SourceCopyOutcome outcome, string projectRelativeSourcePath) + { + if (outcome.Kind == SourceCopyKind.Unreadable) + { + _skippedSourceCount++; + _firstSkippedSourcePath ??= projectRelativeSourcePath; + return; + } + + if (outcome.Kind == SourceCopyKind.Checked || outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) + { + _suspectSourceCount++; + _checkTicks += outcome.CheckTicks; + } + + if (outcome.Kind == SourceCopyKind.CheckedEditedAfterCompile) + { + _editedAfterCompileCount++; + } + } + + internal void LogAndWarn(string assemblyName) + { + if (_suspectSourceCount > 0) + { + VibeLogger.LogInfo( + HotReloadConstants.VibeLogSourceSnapshotChecked, + "Hot reload checked the sources written since the compile started against the PDB.", + new + { + assemblyName, + suspect = _suspectSourceCount, + editedAfterCompile = _editedAfterCompileCount, + checkMs = (long)TimeSpan.FromTicks(_checkTicks).TotalMilliseconds + }); + } + + if (_skippedSourceCount > 0) + { + UnityEngine.Debug.LogWarning( + $"[UnityCliLoop] Skipped {_skippedSourceCount} unreadable source(s) while snapshotting " + + $"{assemblyName}: {_firstSkippedSourcePath}"); + } + } + } } } From 9640c91493742fea2d6c8a775e6aad7093619e81 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 13:31:31 +0900 Subject: [PATCH 2/3] Reuse the previous build's snapshot copies of sources that have not changed Every compile rewrote every source of each recompiled assembly into a new directory, although a body edit changes one file. The capture now moves the previous build's directory, named by the stamp, to the new MVID's temporary name, keeps the copies whose unmarked manifest line still matches the source's length and write time, rewrites the rest, and deletes the copies of sources no longer listed. When the previous directory cannot be moved, it copies every source as before. --- ...otReloadIncrementalSnapshotCaptureTests.cs | 726 ++++++++++++++++++ ...oadIncrementalSnapshotCaptureTests.cs.meta | 11 + .../Shared/HotReloadSnapshotCaptureStats.cs | 2 + ...tReloadSourceSnapshotCopier.Incremental.cs | 190 +++++ ...adSourceSnapshotCopier.Incremental.cs.meta | 11 + .../HotReloadSourceSnapshotter.Stamp.cs | 49 ++ .../Shared/HotReloadSourceSnapshotter.cs | 32 +- 7 files changed, 1016 insertions(+), 5 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIncrementalSnapshotCaptureTests.cs new file mode 100644 index 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/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs index 1097c0cd4..3f939b7bf 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotCaptureStats.cs @@ -22,6 +22,7 @@ internal sealed class HotReloadSnapshotCaptureStats internal int AssembliesImmutable; internal int AssembliesUnchanged; internal int AssembliesCaptured; + internal int FilesReused; internal int FilesCopied; internal long BytesCopied; internal int FilesChecked; @@ -44,6 +45,7 @@ internal object ToVibeLogContext(long getAssembliesMs, long totalMs) assembliesImmutable = AssembliesImmutable, assembliesUnchanged = AssembliesUnchanged, assembliesCaptured = AssembliesCaptured, + filesReused = FilesReused, filesCopied = FilesCopied, bytesCopied = BytesCopied, filesChecked = FilesChecked diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotCopier.Incremental.cs new file mode 100644 index 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/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 a1f874e7e..54d3b0e8d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -158,13 +158,35 @@ private static void CaptureAssemblyIfNeeded( pdbPath, mvid, documentIndex); - HotReloadSourceSnapshotCopier.CaptureAtomically( + System.Diagnostics.Stopwatch previousWatch = System.Diagnostics.Stopwatch.StartNew(); + string previousSnapshotDirectory = FindReusablePreviousSnapshotDirectory( projectRoot, - assemblySnapshotDirectory, - sourceFiles, + stampPath, assembly.name, - check, - stats); + assemblySnapshotDirectory); + stats.StampTicks += previousWatch.Elapsed.Ticks; + if (previousSnapshotDirectory == null) + { + HotReloadSourceSnapshotCopier.CaptureAtomically( + projectRoot, + assemblySnapshotDirectory, + sourceFiles, + assembly.name, + check, + stats); + } + else + { + HotReloadSourceSnapshotCopier.CaptureIncrementally( + projectRoot, + assemblySnapshotDirectory, + previousSnapshotDirectory, + sourceFiles, + assembly.name, + check, + stats); + } + stats.AssembliesCaptured++; System.Diagnostics.Stopwatch cleanupWatch = System.Diagnostics.Stopwatch.StartNew(); DeleteStaleSnapshotDirectories(snapshotRoot, assembly.name, assemblySnapshotDirectory); From 23f0d3a8edc2c5bdc7d4db88666d4d95ac0e0d70 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 13:40:10 +0900 Subject: [PATCH 3/3] Document the incremental snapshot capture and the reused-file count --- docs/hot-reload.md | 4 +++- docs/vibe-logs.md | 17 +++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 26352e61c..36606e3e3 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -78,7 +78,9 @@ records each copied source's length and write time, so a run after a domain relo unchanged sibling by a stat instead of reading it; a source written since the compile started whose bytes do not match the PDB checksum carries a mark that it was edited after the compile, and the default selection, the sibling scan and the skip check treat a marked source as -changed), +changed; a capture moves the previous build's directory to the new MVID's and rewrites only the +sources whose length or write time changed, that carry no unmarked manifest line, or that were +written since the compile started; it deletes the copies of sources no longer in the assembly), `Library/UloopHotReload/PdbDocuments/fmt1/.txt` (the documents the PDB's sequence points refer to, stamped with the dll's and the PDB's length and write time and the MVID, so the first run after a domain reload does not walk the PDB again while they still match), and diff --git a/docs/vibe-logs.md b/docs/vibe-logs.md index 9a799638e..7aa174458 100644 --- a/docs/vibe-logs.md +++ b/docs/vibe-logs.md @@ -100,19 +100,24 @@ command ran against: capture threw. It says where the capture spent its time: - `getAssembliesMs`: listing the compilation assemblies from Unity. - `packageLookupMs`: asking Package Manager whether each assembly's sources are immutable. - - `stampMs`: reading and writing the stamps beside the snapshots. + - `stampMs`: reading and writing the stamps beside the snapshots, and reading the previous + build's manifest. - `mvidMs`: reading the MVID of each compiled dll whose stamp did not match. - - `statMs`: reading each source's length and write time before and after it is copied. - - `readWriteMs`: reading each source and writing its copy. + - `statMs`: reading each source's length and write time before and after it is copied, and + checking whether a source and its previous copy are unchanged. + - `readWriteMs`: reading each source and writing its copy, including deleting the previous copy + it replaces. - `pdbCheckMs`: checking the sources written since the compile started against the PDB. - - `publishMs`: deleting a leftover temporary directory and creating a new one, writing the - manifest, and moving the directory into place. - - `cleanupMs`: deleting the snapshot directories of earlier builds. + - `publishMs`: deleting a leftover temporary directory and creating a new one or moving the + previous build's directory there, writing the manifest, and moving the directory into place. + - `cleanupMs`: deleting the snapshot directories of earlier builds, and the copies of sources + the assembly no longer lists. - `totalMs`: the whole capture. - `assemblies`: the compilation assemblies listed. - `assembliesImmutable`: those skipped because their sources belong to an immutable package. - `assembliesUnchanged`: those skipped because their stamp matched the compiled dll. - `assembliesCaptured`: those whose sources were copied into a new snapshot directory. + - `filesReused`: the sources whose previous copy was kept without reading them. - `filesCopied` and `bytesCopied`: the sources copied and their total size. - `filesChecked`: the sources checked against the PDB. The phases add up to `totalMs` except for the loop overhead; `captureMs` in the captured entry