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..38d56ec0b 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,12 @@ 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`. 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 25e9f5d6b..38d56ec0b 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,12 @@ 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`. 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/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadChangedSiblingSourceDetectorTests.cs index b475459c7..d26838329 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 while its write time stays the same, so the length alone triggers a new comparison. + /// + [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", FirstWriteTimeUtc); + + 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( 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( 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/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) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs new file mode 100644 index 000000000..cd1cb8164 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadSiblingVerdictCache.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; + +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 + { + // 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>(); + + // 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. + /// + /// + /// 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, + 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."); + + Entry entry; + lock (_gate) + { + if (!_entries.TryGetValue((snapshotPath, sourcePath), out entry)) + { + matchesSnapshot = false; + return false; + } + } + + if (entry.Length != length || entry.LastWriteTimeUtcTicks != lastWriteTimeUtcTicks) + { + matchesSnapshot = false; + return false; + } + + matchesSnapshot = entry.MatchesSnapshot; + return true; + } + + /// + /// 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."); + + lock (_gate) + { + _entries[(snapshotPath, sourcePath)] = new Entry(length, lastWriteTimeUtcTicks, matchesSnapshot); + } + } + + internal void Clear() + { + lock (_gate) + { + _entries.Clear(); + } + } + + internal int Count + { + get + { + lock (_gate) + { + return _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; + } + } + } +} 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: 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..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 @@ -348,6 +348,12 @@ 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`. 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