From d63bb8d7f5d840eb55c3de5242881ec141021036 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:10:05 +0900 Subject: [PATCH 1/9] Add failing tests for the sibling verdict cache The cache will let hot reload skip re-reading a sibling source whose length and write time are unchanged. The stub never remembers a verdict, so the tests that expect a recorded verdict back fail. --- .../HotReloadSiblingVerdictCacheTests.cs | 137 ++++++++++++++++++ .../HotReloadSiblingVerdictCacheTests.cs.meta | 11 ++ .../HotReload/HotReloadSiblingVerdictCache.cs | 48 ++++++ .../HotReloadSiblingVerdictCache.cs.meta | 11 ++ 4 files changed, 207 insertions(+) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs new file mode 100644 index 000000000..512195bef --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs @@ -0,0 +1,137 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Verifies when the sibling verdict cache answers from a recorded verdict and when it does not. + /// + public sealed class HotReloadSiblingVerdictCacheTests + { + private const string SnapshotPath = "/proj/Library/UloopHotReload/SourceSnapshot/Asm-mvid1/a.cs"; + private const string OtherSnapshotPath = "/proj/Library/UloopHotReload/SourceSnapshot/Asm-mvid2/a.cs"; + private const string SourcePath = "/proj/Assets/A.cs"; + private const string OtherSourcePath = "/proj/Temp/Copy/A.cs"; + + /// + /// What: an empty cache has no verdict. + /// + [Test] + public void TryGetVerdict_WhenNothingRecorded_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: a recorded verdict is returned for the same pair and the same stamp. + /// + [Test] + public void TryGetVerdict_SamePairAndStamp_ReturnsRecordedVerdict() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool matches); + + Assert.That(found, Is.True); + Assert.That(matches, Is.True); + } + + /// + /// What: a different length makes the recorded verdict unusable. + /// + [Test] + public void TryGetVerdict_DifferentLength_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 11, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: a different last write time makes the recorded verdict unusable. + /// + [Test] + public void TryGetVerdict_DifferentWriteTime_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 101, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: the same source compared with another snapshot has no verdict, because the + /// snapshot path is part of the key. + /// + [Test] + public void TryGetVerdict_OtherSnapshotPath_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(OtherSnapshotPath, SourcePath, 10, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: another source compared with the same snapshot has no verdict, because the + /// source path is part of the key. + /// + [Test] + public void TryGetVerdict_OtherSourcePath_ReturnsFalse() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + + bool found = cache.TryGetVerdict(SnapshotPath, OtherSourcePath, 10, 100, out bool _); + + Assert.That(found, Is.False); + } + + /// + /// What: recording the pair again replaces the earlier verdict. + /// + [Test] + public void Record_SamePairAgain_ReplacesTheVerdict() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + cache.Record(SnapshotPath, SourcePath, 10, 100, false); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool matches); + + Assert.That(found, Is.True); + Assert.That(matches, Is.False); + Assert.That(cache.Count, Is.EqualTo(1)); + } + + /// + /// What: Clear drops every recorded verdict. + /// + [Test] + public void Clear_DropsEveryVerdict() + { + HotReloadSiblingVerdictCache cache = new HotReloadSiblingVerdictCache(); + cache.Record(SnapshotPath, SourcePath, 10, 100, true); + cache.Record(OtherSnapshotPath, SourcePath, 10, 100, false); + + cache.Clear(); + + bool found = cache.TryGetVerdict(SnapshotPath, SourcePath, 10, 100, out bool _); + Assert.That(found, Is.False); + Assert.That(cache.Count, Is.EqualTo(0)); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta new file mode 100644 index 000000000..6b2fb1811 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadSiblingVerdictCacheTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e765a53c312f6487fa098ff2e8991402 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs new file mode 100644 index 000000000..849a3559d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs @@ -0,0 +1,48 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Remembers whether a source file matched its snapshot, together with the file's length and + /// last write time, so an unchanged file need not be read again. + /// + internal sealed class HotReloadSiblingVerdictCache + { + /// + /// Returns true with the remembered verdict when the pair was recorded with the same stamp. + /// + internal bool TryGetVerdict( + string snapshotPath, + string sourcePath, + long length, + long lastWriteTimeUtcTicks, + out bool matchesSnapshot) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); + + matchesSnapshot = false; + return false; + } + + /// + /// Records the verdict for the pair, replacing any earlier one. + /// + internal void Record( + string snapshotPath, + string sourcePath, + long length, + long lastWriteTimeUtcTicks, + bool matchesSnapshot) + { + Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); + } + + internal void Clear() + { + } + + internal int Count => 0; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta new file mode 100644 index 000000000..5e1455b13 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fe33db03b44e461f9e50087fcf28ff1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 96001727a12f04e8e288b41c5b98ccc45ef4507a Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:10:45 +0900 Subject: [PATCH 2/9] Remember each sibling verdict with the file's length and write time The verdict is keyed by the snapshot path and the source path together, and a changed stamp reads as no verdict, so the caller always re-reads and records again instead of trusting a stale answer. --- .../HotReload/HotReloadSiblingVerdictCache.cs | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs index 849a3559d..d4d12631c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -8,9 +10,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal sealed class HotReloadSiblingVerdictCache { + // Why both paths in the key: the same file compared with another assembly generation's + // snapshot is a different verdict, and the same snapshot can be compared with another file. + private readonly Dictionary<(string snapshotPath, string sourcePath), Entry> _entries = + new Dictionary<(string snapshotPath, string sourcePath), Entry>(); + /// /// Returns true with the remembered verdict when the pair was recorded with the same stamp. /// + /// + /// Why a changed stamp reads as no verdict: the caller then has a single path that reads, + /// compares, and records again, so a stale verdict is never returned. + /// internal bool TryGetVerdict( string snapshotPath, string sourcePath, @@ -21,8 +32,20 @@ internal bool TryGetVerdict( Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); - matchesSnapshot = false; - return false; + if (!_entries.TryGetValue((snapshotPath, sourcePath), out Entry entry)) + { + matchesSnapshot = false; + return false; + } + + if (entry.Length != length || entry.LastWriteTimeUtcTicks != lastWriteTimeUtcTicks) + { + matchesSnapshot = false; + return false; + } + + matchesSnapshot = entry.MatchesSnapshot; + return true; } /// @@ -37,12 +60,29 @@ internal void Record( { Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); + + _entries[(snapshotPath, sourcePath)] = new Entry(length, lastWriteTimeUtcTicks, matchesSnapshot); } internal void Clear() { + _entries.Clear(); } - internal int Count => 0; + internal int Count => _entries.Count; + + private readonly struct Entry + { + internal readonly long Length; + internal readonly long LastWriteTimeUtcTicks; + internal readonly bool MatchesSnapshot; + + internal Entry(long length, long lastWriteTimeUtcTicks, bool matchesSnapshot) + { + Length = length; + LastWriteTimeUtcTicks = lastWriteTimeUtcTicks; + MatchesSnapshot = matchesSnapshot; + } + } } } From f2492ca58ee8d6de66f11bda7defc2f029b4c73a Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:12:41 +0900 Subject: [PATCH 3/9] Add sibling detector tests for reusing a verdict across scans Two of them fail today because every scan reads the file again: a file rewritten while its length and write time stay the same should keep the earlier verdict, in both directions. The other three guard that a new length, a new write time, or another snapshot directory still compares again. --- ...ReloadChangedSiblingSourceDetectorTests.cs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index b475459c7..c616cf3f8 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs @@ -17,6 +17,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// public class HotReloadChangedSiblingSourceDetectorTests { + // Why fixed write times: the detector reuses a verdict while a file's length and write time + // stay the same, so each test sets the stamp it means instead of relying on the clock. + private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1); + /// /// What: a sibling whose on-disk bytes differ from its snapshot is returned, and the /// edited file itself is excluded even when it also differs. @@ -504,6 +509,142 @@ public void SourceMatchesSnapshot_NoPdb_IsFalse() } } + /// + /// What: a sibling that matched its snapshot is reported once it is rewritten with another + /// length and a later write time. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewLength_ReturnsSibling() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA-longer", SecondWriteTimeUtc); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a sibling that matched its snapshot is reported once it is rewritten with the same + /// length and a later write time, so the write time alone triggers a new comparison. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_ReturnsSibling() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", SecondWriteTimeUtc); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a sibling rewritten with the same length while its write time is put back keeps + /// the earlier "matches" verdict. This is a known limit: a change that keeps both length and + /// write time (a copy that preserves timestamps) is not noticed until the next compile. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheMatchVerdict() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc); + + Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a verdict against one assembly generation's snapshot is not reused against + /// another generation's snapshot of the same file. + /// + [Test] + public void DetectFromSnapshotDirectory_SameSiblingAgainstAnotherSnapshotDirectory_ComparesAgain() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid1", siblingRelative, "sibling-AAAA"); + WriteSnapshot(projectRoot, "Asm-mvid2", siblingRelative, "sibling-BBBB"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + Assert.That(ScanSibling(projectRoot, "Asm-mvid1", siblingRelative), Is.Empty); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid2", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: a sibling put back to its snapshot bytes while its length and write time are + /// kept keeps the earlier "differs" verdict. This is the same known limit in the other + /// direction: it stays reported until the next compile. + /// + [Test] + public void DetectFromSnapshotDirectory_SecondScanWithSameStamp_KeepsTheDiffersVerdict() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string siblingRelative = "Assets/Sibling.cs"; + WriteSnapshot(projectRoot, "Asm-mvid", siblingRelative, "sibling-AAAA"); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-BBBB", FirstWriteTimeUtc); + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); + + Assert.That( + ScanSibling(projectRoot, "Asm-mvid", siblingRelative), + Is.EqualTo(new[] { AbsoluteProjectPath(projectRoot, siblingRelative) })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + /// /// What: sibling-derived warnings are ordinal-deduped among themselves and skipped /// when the own-file list already contains the exact string, without collapsing @@ -524,6 +665,38 @@ public void AppendSiblingDerivedWarnings_DedupesAmongSiblingsAndSkipsOwnFileMatc Is.EqualTo(new[] { "own-a", "own-a", "shared", "sibling-b" })); } + // Why an edited file: a run always has one, and the detector requires it. + private const string EditedRelative = "Assets/Edited.cs"; + + private static string[] ScanSibling( + string projectRoot, + string assemblySnapshotDirectoryName, + string siblingRelative) + { + return HotReloadChangedSiblingSourceDetector.DetectFromSnapshotDirectory( + projectRoot, + assemblySnapshotDirectoryName, + new[] { EditedRelative, siblingRelative }, + new[] { EditedRelative }).ChangedSiblingAbsolutePaths; + } + + // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while + // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly. + private static void WriteProjectFileAt( + string projectRoot, + string projectRelativePath, + string contents, + DateTime lastWriteTimeUtc) + { + WriteProjectFile(projectRoot, projectRelativePath, contents); + string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath); + File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc); + Assert.That( + new FileInfo(absolutePath).LastWriteTimeUtc, + Is.EqualTo(lastWriteTimeUtc), + "The write time must be settable exactly, or the stamp the detector reads is not the one the test chose."); + } + private static string CreateTempProjectRoot() { string projectRoot = Path.Combine( From 685ff27b0ca3af4f56e26d4dd2746e1228ffe33f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:13:42 +0900 Subject: [PATCH 4/9] Reuse a sibling's snapshot verdict while its length and write time are unchanged Every hot reload run read each source of the edited assembly and its snapshot to find drifted siblings, which takes hundreds of milliseconds on a large assembly. A file compared once in this domain is now read again only when its stamp changes; the memo is cleared when a compile starts. --- .../HotReloadChangedSiblingSourceDetector.cs | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs index ae717ee7f..6e008adee 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedSiblingSourceDetector.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.IO; +using UnityEditor.Compilation; + using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -13,6 +15,17 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class HotReloadChangedSiblingSourceDetector { + // Why per domain: a verdict compares a file with a snapshot that never changes for its MVID, so it can only + // go stale when the file itself changes, and a save changes the file's length or write time. + private static readonly HotReloadSiblingVerdictCache _verdicts = new HotReloadSiblingVerdictCache(); + + static HotReloadChangedSiblingSourceDetector() + { + // Why: a compile that fails keeps the domain alive, so without this the memo would keep + // verdicts for an assembly generation that is no longer current. + CompilationPipeline.compilationStarted += _ => _verdicts.Clear(); + } + /// /// Returns changed sibling absolute paths for , excluding /// . Missing snapshots or DLLs yield an empty @@ -159,7 +172,13 @@ internal static bool SourceMatchesSnapshotDirectory( "assemblySnapshotDirectoryName must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(projectRelativePath), "projectRelativePath must not be null or empty."); - if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + if (string.IsNullOrEmpty(sourcePath)) + { + return false; + } + + FileInfo source = new FileInfo(sourcePath); + if (!source.Exists) { return false; } @@ -174,7 +193,7 @@ internal static bool SourceMatchesSnapshotDirectory( return false; } - return BytesEqual(File.ReadAllBytes(sourcePath), File.ReadAllBytes(snapshotPath)); + return MatchesSnapshot(source, snapshotPath); } private static HotReloadChangedSourceScanResult DetectChangedFromSnapshotDirectory( @@ -232,7 +251,8 @@ private static string TryResolveChangedProjectRelativePath( } string absoluteSourcePath = ToAbsoluteProjectPath(projectRoot, normalizedRelativePath); - if (!File.Exists(absoluteSourcePath)) + FileInfo source = new FileInfo(absoluteSourcePath); + if (!source.Exists) { return null; } @@ -245,9 +265,7 @@ private static string TryResolveChangedProjectRelativePath( return null; } - byte[] diskBytes = File.ReadAllBytes(absoluteSourcePath); - byte[] snapshotBytes = File.ReadAllBytes(snapshotPath); - if (BytesEqual(diskBytes, snapshotBytes)) + if (MatchesSnapshot(source, snapshotPath)) { return null; } @@ -256,6 +274,28 @@ private static string TryResolveChangedProjectRelativePath( return normalizedRelativePath; } + // Compares an existing source with an existing snapshot file, reusing the last verdict while + // the source keeps its length and write time. + private static bool MatchesSnapshot(FileInfo source, string snapshotPath) + { + long lastWriteTimeUtcTicks = source.LastWriteTimeUtc.Ticks; + if (_verdicts.TryGetVerdict( + snapshotPath, + source.FullName, + source.Length, + lastWriteTimeUtcTicks, + out bool matches)) + { + return matches; + } + + bool equal = BytesEqual(File.ReadAllBytes(source.FullName), File.ReadAllBytes(snapshotPath)); + // Why the stamp taken before reading: a write during the read moves the stamp on, so the + // next scan reads the file again instead of trusting this verdict. + _verdicts.Record(snapshotPath, source.FullName, source.Length, lastWriteTimeUtcTicks, equal); + return equal; + } + private static HotReloadChangedSourceScanResult LimitChangedSources( List changedProjectRelativePaths) { From 01a2eaf0c4f247628e481e45c2e101212fb818d4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:18:01 +0900 Subject: [PATCH 5/9] Document that a baseline comparison is reused while a file's stamp holds A rewrite that keeps both the length and the write time is now noticed only after the next domain reload, so the limit is stated beside the baseline description. --- .../skills/uloop-hot-reload/references/scope-and-limits.md | 4 ++++ .../skills/uloop-hot-reload/references/scope-and-limits.md | 4 ++++ .../HotReload/Skill/references/scope-and-limits.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 25e9f5d6b..8859df4b0 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -348,6 +348,10 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. +Within one Editor session, a source compared with its baseline once is compared again +only when its length or last write time changes; a rewrite that keeps both (for example +a copy that preserves timestamps) is noticed only after the next domain reload, such as +`uloop compile`. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 25e9f5d6b..8859df4b0 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -348,6 +348,10 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. +Within one Editor session, a source compared with its baseline once is compared again +only when its length or last write time changes; a rewrite that keeps both (for example +a copy that preserves timestamps) is noticed only after the next domain reload, such as +`uloop compile`. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 25e9f5d6b..8859df4b0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -348,6 +348,10 @@ line each. A file with a declaration that hot reload refused to introduce (its `Warnings` line says the type requires a compile) gets no such line, because that compile also establishes its baseline. This also holds for an existing file that gains such a declaration, for example a nested type or a delegate. +Within one Editor session, a source compared with its baseline once is compared again +only when its length or last write time changes; a rewrite that keeps both (for example +a copy that preserves timestamps) is noticed only after the next domain reload, such as +`uloop compile`. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor From 75815dff763aa00da646206457df932e5252339e Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:52:03 +0900 Subject: [PATCH 6/9] Guard the sibling verdict cache with a lock The rebind planner reaches the cache from a thread-pool continuation while compilationStarted clears it on the main thread. --- .../HotReload/HotReloadSiblingVerdictCache.cs | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs index d4d12631c..cd1cb8164 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs @@ -15,6 +15,10 @@ internal sealed class HotReloadSiblingVerdictCache private readonly Dictionary<(string snapshotPath, string sourcePath), Entry> _entries = new Dictionary<(string snapshotPath, string sourcePath), Entry>(); + // Why a lock: the rebind planner compares on a thread-pool continuation while + // compilationStarted clears the memo on the main thread. + private readonly object _gate = new object(); + /// /// Returns true with the remembered verdict when the pair was recorded with the same stamp. /// @@ -32,10 +36,14 @@ internal bool TryGetVerdict( Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); - if (!_entries.TryGetValue((snapshotPath, sourcePath), out Entry entry)) + Entry entry; + lock (_gate) { - matchesSnapshot = false; - return false; + if (!_entries.TryGetValue((snapshotPath, sourcePath), out entry)) + { + matchesSnapshot = false; + return false; + } } if (entry.Length != length || entry.LastWriteTimeUtcTicks != lastWriteTimeUtcTicks) @@ -61,15 +69,30 @@ internal void Record( Debug.Assert(!string.IsNullOrEmpty(snapshotPath), "snapshotPath must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(sourcePath), "sourcePath must not be null or empty."); - _entries[(snapshotPath, sourcePath)] = new Entry(length, lastWriteTimeUtcTicks, matchesSnapshot); + lock (_gate) + { + _entries[(snapshotPath, sourcePath)] = new Entry(length, lastWriteTimeUtcTicks, matchesSnapshot); + } } internal void Clear() { - _entries.Clear(); + lock (_gate) + { + _entries.Clear(); + } } - internal int Count => _entries.Count; + internal int Count + { + get + { + lock (_gate) + { + return _entries.Count; + } + } + } private readonly struct Entry { From 82dda7708d0c7366b0016f7a2a69f8e42ead5013 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:52:04 +0900 Subject: [PATCH 7/9] Make the new-length sibling test differ only by length With a later write time as well, a comparison that ignored the length still passed the test. --- .../HotReload/HotReloadChangedSiblingSourceDetectorTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index c616cf3f8..d26838329 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs @@ -511,7 +511,7 @@ public void SourceMatchesSnapshot_NoPdb_IsFalse() /// /// What: a sibling that matched its snapshot is reported once it is rewritten with another - /// length and a later write time. + /// length while its write time stays the same, so the length alone triggers a new comparison. /// [Test] public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewLength_ReturnsSibling() @@ -524,7 +524,7 @@ public void DetectFromSnapshotDirectory_SecondScanAfterRewriteWithNewLength_Retu WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA", FirstWriteTimeUtc); Assert.That(ScanSibling(projectRoot, "Asm-mvid", siblingRelative), Is.Empty); - WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA-longer", SecondWriteTimeUtc); + WriteProjectFileAt(projectRoot, siblingRelative, "sibling-AAAA-longer", FirstWriteTimeUtc); Assert.That( ScanSibling(projectRoot, "Asm-mvid", siblingRelative), From 0aa57244412c0f0cf12f7a0ebe8153e5b8a04bc9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:54:31 +0900 Subject: [PATCH 8/9] Pin the verdict reuse in the default file selection Selecting files when --files is omitted goes through the same comparison, so a same-stamp rewrite is not selected until the next compile while a later write time still selects it. --- .../HotReloadChangedSourceDetectorTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs index 5fdab2ea2..f80afa95f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadChangedSourceDetectorTests.cs @@ -14,6 +14,11 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload /// public class HotReloadChangedSourceDetectorTests { + // Why fixed write times: the detector reuses a verdict while a file's length and write time + // stay the same, so each test sets the stamp it means instead of relying on the clock. + private static readonly DateTime FirstWriteTimeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime SecondWriteTimeUtc = FirstWriteTimeUtc.AddDays(1); + /// /// What: a missing snapshot directory reports no baseline instead of conflating it with no changes. /// @@ -178,6 +183,82 @@ public void DetectAllChangedFromSnapshotDirectory_WhenMoreThanLimitChanged_Trunc } } + /// + /// What: the default file selection does not pick a source rewritten while it keeps its + /// length and write time, because the earlier "matches" verdict is reused. This is a known + /// limit until the next compile. + /// + [Test] + public void DetectAllChangedFromSnapshotDirectory_SecondScanWithSameStamp_DoesNotSelectTheRewrite() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string sourcePath = "Assets/Source.cs"; + WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA"); + WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc); + Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty); + + WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", FirstWriteTimeUtc); + + Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + /// + /// What: the default file selection picks a source rewritten with a later write time after + /// an earlier scan found it unchanged. + /// + [Test] + public void DetectAllChangedFromSnapshotDirectory_SecondScanAfterRewriteWithNewWriteTime_SelectsTheRewrite() + { + string projectRoot = CreateTempProjectRoot(); + try + { + string sourcePath = "Assets/Source.cs"; + WriteSnapshot(projectRoot, "Assembly-mvid", sourcePath, "source-AAAA"); + WriteProjectFileAt(projectRoot, sourcePath, "source-AAAA", FirstWriteTimeUtc); + Assert.That(ScanAll(projectRoot, sourcePath), Is.Empty); + + WriteProjectFileAt(projectRoot, sourcePath, "source-BBBB", SecondWriteTimeUtc); + + Assert.That(ScanAll(projectRoot, sourcePath), Is.EqualTo(new[] { sourcePath })); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + private static System.Collections.Generic.List ScanAll(string projectRoot, string sourcePath) + { + return HotReloadChangedSiblingSourceDetector.DetectAllChangedFromSnapshotDirectory( + projectRoot, + "Assembly-mvid", + new[] { sourcePath }).ChangedProjectRelativePaths; + } + + // Why a write time the test chooses: SetLastWriteTimeUtc stores microseconds while + // LastWriteTimeUtc reports 100 ns ticks, so only a chosen value can be put back exactly. + private static void WriteProjectFileAt( + string projectRoot, + string projectRelativePath, + string contents, + DateTime lastWriteTimeUtc) + { + WriteProjectFile(projectRoot, projectRelativePath, contents); + string absolutePath = AbsoluteProjectPath(projectRoot, projectRelativePath); + File.SetLastWriteTimeUtc(absolutePath, lastWriteTimeUtc); + Assert.That( + new FileInfo(absolutePath).LastWriteTimeUtc, + Is.EqualTo(lastWriteTimeUtc), + "The write time must be settable exactly, or the stamp the detector reads is not the one the test chose."); + } + private static string CreateTempProjectRoot() { string projectRoot = Path.Combine( From b166d5996af557930c4eacf457c778e9611ada22 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:54:31 +0900 Subject: [PATCH 9/9] Name every path the baseline verdict reuse applies to The reuse affects sibling drift, re-applying live patches after a skip, and the default file selection, not only the sibling scan. --- .../skills/uloop-hot-reload/references/scope-and-limits.md | 4 +++- .../skills/uloop-hot-reload/references/scope-and-limits.md | 4 +++- .../HotReload/Skill/references/scope-and-limits.md | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 8859df4b0..38d56ec0b 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -351,7 +351,9 @@ gains such a declaration, for example a nested type or a delegate. Within one Editor session, a source compared with its baseline once is compared again only when its length or last write time changes; a rewrite that keeps both (for example a copy that preserves timestamps) is noticed only after the next domain reload, such as -`uloop compile`. +`uloop compile`. This applies wherever a source is compared with its baseline: finding +drifted siblings, deciding which live patches to re-apply after a skip, and selecting +files when `--files` is omitted. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 8859df4b0..38d56ec0b 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -351,7 +351,9 @@ gains such a declaration, for example a nested type or a delegate. Within one Editor session, a source compared with its baseline once is compared again only when its length or last write time changes; a rewrite that keeps both (for example a copy that preserves timestamps) is noticed only after the next domain reload, such as -`uloop compile`. +`uloop compile`. This applies wherever a source is compared with its baseline: finding +drifted siblings, deciding which live patches to re-apply after a skip, and selecting +files when `--files` is omitted. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 8859df4b0..38d56ec0b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -351,7 +351,9 @@ gains such a declaration, for example a nested type or a delegate. Within one Editor session, a source compared with its baseline once is compared again only when its length or last write time changes; a rewrite that keeps both (for example a copy that preserves timestamps) is noticed only after the next domain reload, such as -`uloop compile`. +`uloop compile`. This applies wherever a source is compared with its baseline: finding +drifted siblings, deciding which live patches to re-apply after a skip, and selecting +files when `--files` is omitted. Property getters with a body (including expression-bodied properties) are patched like ordinary methods. Editing a compiled property's setter, init, or indexer accessor