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/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}"); + } + } + } } } 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); 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