From af1b8be390966de7ea4cd2f7a4ca03f775c5f1e5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:42:11 +0900 Subject: [PATCH 1/6] Add the directory constant for persisted PDB document lists The PDB document index will keep each assembly's document list on disk so the first hot reload run after a domain reload can skip walking every sequence point. This adds the fmt1 directory under Library/UloopHotReload for those files. --- .../FirstPartyTools/HotReload/Shared/HotReloadConstants.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 3fa77955d..9400ebc9b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -49,6 +49,12 @@ internal static class HotReloadConstants // affect the on-disk layout. Adoption is decided at use time by PDB document checksum. public const string SourceSnapshotRelativeDirectory = "Library/UloopHotReload/SourceSnapshot"; + // Per-assembly lists of the documents a PDB's sequence points refer to, keyed by the dll's + // file name and stamped with the dll's and the PDB's length, write time and MVID. Lets the + // first hot reload run after a domain reload skip the walk over every sequence point. + // "fmt1" = generation of the file format, bumped the same way as PublicizedRefs. + public const string PdbDocumentsRelativeDirectory = "Library/UloopHotReload/PdbDocuments/fmt1"; + // Package-relative directory of the out-of-process transform worker sources (tilde dir = Unity-ignored). public const string WorkerSourcePackageRelativePath = "Editor/FirstPartyTools/HotReload/TransformWorker~"; From a7f15975d2c5334af11610058f3b4ea1f17379b6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:44:11 +0900 Subject: [PATCH 2/6] Add failing tests for persisting PDB document lists across indexes The index now takes the directory its lists will be persisted to, and the tests point it at a per-test temp directory so a list left by an earlier test or domain cannot satisfy a LoadCount expectation. The new tests pin that a new index on the same directory answers from the persisted list, that a changed dll, an empty file, a truncated file and a file with another stamp make it walk the PDB again, and that a walk that throws leaves the persisted list untouched. --- .../HotReloadPdbDocumentIndexTests.cs | 153 +++++++++++++++++- .../HotReload/HotReloadSourceSnapshotTests.cs | 112 ++++++++----- .../Shared/HotReloadPdbDocumentIndex.cs | 27 +++- 3 files changed, 249 insertions(+), 43 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index 52e670161..f51260afa 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -36,12 +37,25 @@ public class HotReloadPdbDocumentIndexTests private const string PackageSourceAssetPath = "Packages/io.github.hatayama.uloopmcp/Runtime/PausePoints/UloopPausePoint.cs"; + private string _persistenceDirectory; private HotReloadPdbDocumentIndex _index; [SetUp] public void SetUp() { - _index = new HotReloadPdbDocumentIndex(); + // Why a directory per test: a list persisted by an earlier test or domain would let a + // new index answer without reading the PDB and break every LoadCount expectation. + _persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + _index = new HotReloadPdbDocumentIndex(_persistenceDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_persistenceDirectory)) + { + Directory.Delete(_persistenceDirectory, recursive: true); + } } /// @@ -242,13 +256,21 @@ public void TryFindDocument_PdbUnreadableAfterAListWasKept_ThrowsAndKeepsNoStale bool foundBefore = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); // Why a shorter file: the length alone makes the files differ from the ones the list // was read from, even if the new write time lands on the same tick as the old one. + string persistedBefore = File.ReadAllText(PersistedListPath(dllPath)); File.WriteAllBytes(pdbPath, new byte[] { 0x6E, 0x6F, 0x74, 0x20, 0x61, 0x20, 0x70, 0x64, 0x62 }); TestDelegate findAgain = () => Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + TestDelegate findWithNewIndex = () => Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); Assert.That(foundBefore, Is.True); Assert.That(findAgain, Throws.Exception, "The list read before the PDB changed must not answer."); Assert.That(findAgain, Throws.Exception, "No list may be stored for a PDB that could not be read."); Assert.That(_index.LoadCount, Is.EqualTo(1)); + Assert.That( + File.ReadAllText(PersistedListPath(dllPath)), + Is.EqualTo(persistedBefore), + "A walk that threw must not rewrite the persisted list."); + Assert.That(findWithNewIndex, Throws.Exception, "The persisted list of the earlier files must not answer."); } finally { @@ -256,6 +278,135 @@ public void TryFindDocument_PdbUnreadableAfterAListWasKept_ThrowsAndKeepsNoStale } } + /// + /// What: a new index on the same directory answers from the list the first index persisted, + /// without reading the dll and the PDB, and the document it returns is the same. + /// + [Test] + public void TryFindDocument_NewIndexOnTheSameDirectory_AnswersFromThePersistedListWithoutReadingThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument firstDocument); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument secondDocument); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(0)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(1)); + Assert.That(secondDocument.Url, Is.EqualTo(firstDocument.Url)); + Assert.That(secondDocument.HashAlgorithm, Is.EqualTo(firstDocument.HashAlgorithm)); + Assert.That(secondDocument.Hash.SequenceEqual(firstDocument.Hash), Is.True); + } + + /// + /// What: a dll written after its list was persisted makes a new index read the PDB again and + /// persist the new list, which the next new index then reads. + /// + [Test] + public void TryFindDocument_DllWriteTimeChangedAfterTheListWasPersisted_ReadsThePdbAgainAndPersistsTheNewList() + { + string dllPath = CopyAssemblyToTemp(TestAssemblyName); + try + { + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + File.SetLastWriteTimeUtc(dllPath, File.GetLastWriteTimeUtc(dllPath).AddSeconds(2)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + HotReloadPdbDocumentIndex third = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundThird = Find(third, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(foundThird, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); + Assert.That(third.LoadCount, Is.EqualTo(0)); + Assert.That(third.PersistedLoadCount, Is.EqualTo(1)); + } + finally + { + Directory.Delete(Path.GetDirectoryName(dllPath), recursive: true); + } + } + + /// + /// What: an empty persisted file is treated as missing: the PDB is read and the list is + /// written again, so the next new index reads it from the file. + /// + [Test] + public void TryFindDocument_PersistedFileEmpty_ReadsThePdbAndWritesTheListAgain() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + File.WriteAllBytes(PersistedListPath(dllPath), Array.Empty()); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + long rewrittenLength = new FileInfo(PersistedListPath(dllPath)).Length; + HotReloadPdbDocumentIndex third = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundThird = Find(third, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(rewrittenLength, Is.GreaterThan(0)); + Assert.That(foundThird, Is.True); + Assert.That(third.LoadCount, Is.EqualTo(0)); + } + + /// + /// What: a persisted file that lost its last document line, while still claiming the + /// original count, is treated as missing rather than answered from the remaining lines. + /// + [Test] + public void TryFindDocument_PersistedFileMissingItsLastDocumentLine_ReadsThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + string persisted = File.ReadAllText(PersistedListPath(dllPath)); + string withoutTrailingNewline = persisted.Substring(0, persisted.Length - 1); + File.WriteAllText( + PersistedListPath(dllPath), + withoutTrailingNewline.Substring(0, withoutTrailingNewline.LastIndexOf('\n') + 1)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(persisted, Does.EndWith("\n")); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + } + + /// + /// What: a persisted file whose stamp names another dll length is not used, because it was + /// written for other files. + /// + [Test] + public void TryFindDocument_PersistedFileWithAnotherStamp_ReadsThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + string[] lines = File.ReadAllText(PersistedListPath(dllPath)).Split('\n'); + string[] stampFields = lines[1].Split('\t'); + stampFields[0] = (long.Parse(stampFields[0], CultureInfo.InvariantCulture) + 1).ToString(CultureInfo.InvariantCulture); + lines[1] = string.Join("\t", stampFields); + File.WriteAllText(PersistedListPath(dllPath), string.Join("\n", lines)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + } + + private string PersistedListPath(string dllPath) + { + return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(dllPath) + ".txt"); + } + private static string ProjectRoot() { return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); diff --git a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs index 74914225f..243b6f343 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSourceSnapshotTests.cs @@ -82,17 +82,27 @@ public void PortablePdb_DocumentChecksum_MatchesSourceFileBytes() public void TryFindDocument_ReturnsTheSameDocumentAsTheWalk() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(); - - AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), FixtureProjectRelativePath); - AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), CoreFixtureProjectRelativePath); - AssertIndexFindsTheWalkedDocument( - index, - Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - PredefinedEditorAssemblyName + HotReloadConstants.CompiledAssemblyExtension), - PredefinedEditorFixtureProjectRelativePath); + string persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(persistenceDirectory); + try + { + AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), FixtureProjectRelativePath); + AssertIndexFindsTheWalkedDocument(index, TestAssemblyDllPath(projectRoot), CoreFixtureProjectRelativePath); + AssertIndexFindsTheWalkedDocument( + index, + Path.Combine( + projectRoot, + HotReloadConstants.ScriptAssembliesRelativeDirectory, + PredefinedEditorAssemblyName + HotReloadConstants.CompiledAssemblyExtension), + PredefinedEditorFixtureProjectRelativePath); + } + finally + { + if (Directory.Exists(persistenceDirectory)) + { + Directory.Delete(persistenceDirectory, recursive: true); + } + } } private static void AssertIndexFindsTheWalkedDocument( @@ -331,22 +341,32 @@ public void DescribeSnapshotMiss_ForAVerifiedSnapshot_ReportsNone() public void LoadVerifiedSnapshotSourceAt_TwoFilesOfOneAssembly_ReadsThePdbOnce() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(); - - string first = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( - projectRoot, - FixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); - string second = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( - projectRoot, - CoreFixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); + string persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(persistenceDirectory); + try + { + string first = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + projectRoot, + FixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); + string second = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + projectRoot, + CoreFixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); - Assert.That(first, Is.Not.Null); - Assert.That(second, Is.Not.Null); - Assert.That(index.LoadCount, Is.EqualTo(1)); + Assert.That(first, Is.Not.Null); + Assert.That(second, Is.Not.Null); + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + finally + { + if (Directory.Exists(persistenceDirectory)) + { + Directory.Delete(persistenceDirectory, recursive: true); + } + } } /// @@ -356,22 +376,32 @@ public void LoadVerifiedSnapshotSourceAt_TwoFilesOfOneAssembly_ReadsThePdbOnce() public void DescribeSnapshotMissAt_AfterALoadThatFoundNoDocument_DoesNotReadThePdbAgain() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(); - - string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( - projectRoot, - BodylessFixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); - HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( - projectRoot, - BodylessFixtureProjectRelativePath, - TestAssemblyDllPath(projectRoot), - index); + string persistenceDirectory = Path.Combine(Path.GetTempPath(), "uloop-pdb-documents-" + Guid.NewGuid().ToString("N")); + HotReloadPdbDocumentIndex index = new HotReloadPdbDocumentIndex(persistenceDirectory); + try + { + string loaded = HotReloadSourceBaseline.LoadVerifiedSnapshotSourceAt( + projectRoot, + BodylessFixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); + HotReloadSnapshotMissReason reason = HotReloadSourceBaseline.DescribeSnapshotMissAt( + projectRoot, + BodylessFixtureProjectRelativePath, + TestAssemblyDllPath(projectRoot), + index); - Assert.That(loaded, Is.Null); - Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); - Assert.That(index.LoadCount, Is.EqualTo(1)); + Assert.That(loaded, Is.Null); + Assert.That(reason, Is.EqualTo(HotReloadSnapshotMissReason.NoDocumentInPdb)); + Assert.That(index.LoadCount, Is.EqualTo(1)); + } + finally + { + if (Directory.Exists(persistenceDirectory)) + { + Directory.Delete(persistenceDirectory, recursive: true); + } + } } private static string TestAssemblyDllPath(string projectRoot) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index ae4296a6f..c54b35e6c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -96,11 +96,22 @@ public Entry(FileStamp stamp, List documents) // Why a shared instance: the snapshot loader is static and has static callers in // several assemblies, like the compiled call-site cache this mirrors. - public static HotReloadPdbDocumentIndex Shared { get; } = new HotReloadPdbDocumentIndex(); + public static HotReloadPdbDocumentIndex Shared { get; } = new HotReloadPdbDocumentIndex( + Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.PdbDocumentsRelativeDirectory)); private readonly object _gate = new object(); private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); + private readonly string _persistenceDirectory; private int _loadCount; + private int _persistedLoadCount; + + internal HotReloadPdbDocumentIndex(string persistenceDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(persistenceDirectory), "persistenceDirectory must not be null or empty."); + _persistenceDirectory = persistenceDirectory; + } /// /// Number of times a dll and its PDB were read and walked. @@ -116,6 +127,20 @@ internal int LoadCount } } + /// + /// Number of times a list was read from its persisted file instead of walking the PDB. + /// + internal int PersistedLoadCount + { + get + { + lock (_gate) + { + return _persistedLoadCount; + } + } + } + /// /// Finds the document whose url names among the /// documents a sequence point refers to. False when no sequence point refers to such a From 8212c765f14f9c247a5ccb16d055cd88eaddf9f5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:50:17 +0900 Subject: [PATCH 3/6] Persist each assembly's PDB document list across domain reloads A domain reload dropped the in-memory lists, so the first hot reload run after a compile or a Play Mode reload walked every sequence point of the edited assembly again. Each list is now also written under Library/UloopHotReload/PdbDocuments with the dll's and the PDB's length, write time and MVID, and a new index reads it back when all five match. The walk happens before any write, so a PDB that cannot be read leaves no entry and no file; a missing, truncated or differently stamped file is treated as absent and rewritten after a fresh walk. --- .../Shared/HotReloadPdbDocumentIndex.cs | 205 +++++++++++++++++- 1 file changed, 203 insertions(+), 2 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index c54b35e6c..54acfe57a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Text; using Mono.Cecil; using Mono.Cecil.Cil; @@ -34,6 +36,8 @@ public HotReloadPdbDocument(string url, DocumentHashAlgorithm hashAlgorithm, byt /// again when the dll's length, write time or MVID, or the PDB's length or write time, /// differs from the files it was read from. There is no capacity limit: an entry is a short /// list of urls and checksums, and there is at most one entry per assembly of the project. + /// Each list is also written to a file under the persistence directory with the same five + /// values, so a new index in the next domain reads it from there instead of walking the PDB. /// internal sealed class HotReloadPdbDocumentIndex { @@ -82,6 +86,10 @@ public override int GetHashCode() } } + private const string PersistedFormatHeader = "uloop-pdb-documents 1"; + private const int PersistedStampFieldCount = 6; + private const int PersistedDocumentFieldCount = 3; + private sealed class Entry { public readonly FileStamp Stamp; @@ -166,11 +174,26 @@ internal bool TryFindDocument( { if (!_entries.TryGetValue(fullDllPath, out Entry entry) || !entry.Stamp.Equals(stamp)) { + string persistedPath = PersistedListPath(fullDllPath); + List documents = TryReadPersistedList(persistedPath, stamp); + if (documents != null) + { + _persistedLoadCount++; + } + else + { + // Why the walk before any write: a walk that throws leaves no entry and no + // file, so the next lookup reads the files again instead of answering from + // a list of other files. + documents = ReadDocuments(fullDllPath, pdbPath); + _loadCount++; + WritePersistedList(persistedPath, stamp, documents); + } + // Why stored under the stamp read before the walk: if a file is replaced while // it is being read, the next lookup sees another stamp and reads again, so a // list is never served for files it was not read from. - entry = new Entry(stamp, ReadDocuments(fullDllPath, pdbPath)); - _loadCount++; + entry = new Entry(stamp, documents); _entries[fullDllPath] = entry; } @@ -178,6 +201,184 @@ internal bool TryFindDocument( } } + private string PersistedListPath(string fullDllPath) + { + return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(fullDllPath) + ".txt"); + } + + // File format: UTF-8 without a BOM, every line ends with "\n", fields are TAB-separated and + // numbers are invariant. + // line 1: the format header + // line 2: dll length, dll write time ticks, MVID, PDB length, PDB write time ticks, count + // then count lines: (int)HashAlgorithm, the hash in hex ("" for none), url + // Null for a missing file, another stamp, or any malformed part: a file cut short or edited + // by hand must not answer with part of a list. Why the url last: it is the only field that + // can hold anything, so splitting into three keeps a TAB in a url intact. + private static List TryReadPersistedList(string path, FileStamp stamp) + { + if (!File.Exists(path)) + { + return null; + } + + string[] lines = File.ReadAllText(path, Encoding.UTF8).Split('\n'); + for (int index = 0; index < lines.Length; index++) + { + lines[index] = lines[index].TrimEnd('\r'); + } + + if (lines.Length < 3 || !string.Equals(lines[0], PersistedFormatHeader, StringComparison.Ordinal)) + { + return null; + } + + if (!TryParseStampLine(lines[1], out FileStamp persistedStamp, out int count) + || !persistedStamp.Equals(stamp)) + { + return null; + } + + // The header, the stamp, count documents and the empty string after the last "\n". + if (lines.Length != 3 + count || lines[lines.Length - 1].Length != 0) + { + return null; + } + + List documents = new List(count); + for (int index = 0; index < count; index++) + { + if (!TryParseDocumentLine(lines[2 + index], out HotReloadPdbDocument document)) + { + return null; + } + + documents.Add(document); + } + + return documents; + } + + private static bool TryParseStampLine(string line, out FileStamp stamp, out int count) + { + stamp = default(FileStamp); + count = 0; + string[] fields = line.Split('\t'); + if (fields.Length != PersistedStampFieldCount + || !TryParseLong(fields[0], out long dllLength) + || !TryParseLong(fields[1], out long dllTicks) + || fields[2].Length == 0 + || !TryParseLong(fields[3], out long pdbLength) + || !TryParseLong(fields[4], out long pdbTicks) + || !int.TryParse(fields[5], NumberStyles.None, CultureInfo.InvariantCulture, out count)) + { + return false; + } + + stamp = new FileStamp(dllLength, dllTicks, fields[2], pdbLength, pdbTicks); + return true; + } + + private static bool TryParseDocumentLine(string line, out HotReloadPdbDocument document) + { + document = default(HotReloadPdbDocument); + string[] fields = line.Split(new[] { '\t' }, PersistedDocumentFieldCount); + if (fields.Length != PersistedDocumentFieldCount || fields[2].Length == 0) + { + return false; + } + + if (!int.TryParse(fields[0], NumberStyles.None, CultureInfo.InvariantCulture, out int algorithmValue) + || !Enum.IsDefined(typeof(DocumentHashAlgorithm), algorithmValue)) + { + return false; + } + + if (!TryParseHex(fields[1], out byte[] hash)) + { + return false; + } + + document = new HotReloadPdbDocument(fields[2], (DocumentHashAlgorithm)algorithmValue, hash); + return true; + } + + private static bool TryParseLong(string text, out long value) + { + return long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } + + // "" is the empty hash; an odd length or a character that is not a hex digit is malformed. + private static bool TryParseHex(string text, out byte[] bytes) + { + bytes = null; + if (text.Length % 2 != 0) + { + return false; + } + + if (text.Length == 0) + { + bytes = Array.Empty(); + return true; + } + + byte[] parsed = new byte[text.Length / 2]; + for (int index = 0; index < parsed.Length; index++) + { + if (!byte.TryParse( + text.Substring(index * 2, 2), + NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, + out parsed[index])) + { + return false; + } + } + + bytes = parsed; + return true; + } + + private void WritePersistedList(string path, FileStamp stamp, List documents) + { + Directory.CreateDirectory(_persistenceDirectory); + StringBuilder text = new StringBuilder(); + text.Append(PersistedFormatHeader).Append('\n'); + text.Append(stamp.DllLength.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(stamp.DllLastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(stamp.ModuleVersionId).Append('\t') + .Append(stamp.PdbLength.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(stamp.PdbLastWriteTimeUtcTicks.ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(documents.Count.ToString(CultureInfo.InvariantCulture)).Append('\n'); + foreach (HotReloadPdbDocument document in documents) + { + text.Append(((int)document.HashAlgorithm).ToString(CultureInfo.InvariantCulture)).Append('\t') + .Append(document.Hash == null ? string.Empty : BitConverter.ToString(document.Hash).Replace("-", string.Empty)) + .Append('\t') + .Append(document.Url).Append('\n'); + } + + // Why a temp file and a move: a reader in another domain never sees a half-written list. + string tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + File.WriteAllText(tempPath, text.ToString(), new UTF8Encoding(false)); + if (File.Exists(path)) + { + File.Delete(path); + } + + File.Move(tempPath, path); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + private static FileStamp ReadStamp(string fullDllPath, string pdbPath, string moduleVersionId) { FileInfo dll = new FileInfo(fullDllPath); From 4b62cb0f77da3ef1282e371888a5e35e5b4050fe Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:50:29 +0900 Subject: [PATCH 4/6] Document the persisted PDB document lists among the hot reload caches --- docs/hot-reload.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 4bf0a678a..6feca66a6 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -72,8 +72,11 @@ added on one side alone fails a test rather than silently serializing to nothing Harmony ID: `io.github.hatayama.uloop.hot-reload` (distinct from the pause point's ID). Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`, `Library/UloopHotReload/PublicizedExternalRefs/fmt1/-.dll`, -`Library/UloopHotReload/Worker//`, and -`Library/UloopHotReload/SourceSnapshot/-/`. +`Library/UloopHotReload/Worker//`, +`Library/UloopHotReload/SourceSnapshot/-/`, and +`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). The shim compile references a fully publicized copy of the edited assembly, and of every other project assembly that grants the edited one its internals through `InternalsVisibleTo`. Any other project assembly is referenced through a `PublicizedExternalRefs` copy that keeps its top-level From 038417b44a51df8a846a9bbd005caf99e61351d7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:57:00 +0900 Subject: [PATCH 5/6] Pin that a persisted list with a malformed document line is not used The truncated-file test stops at the line-count check, so it never reaches the per-line field checks. This test keeps the header, stamp, count and line count right and breaks only one hash, so an implementation that skipped the bad line and answered from the rest would fail. --- .../HotReloadPdbDocumentIndexTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index f51260afa..301947f76 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -379,6 +379,37 @@ public void TryFindDocument_PersistedFileMissingItsLastDocumentLine_ReadsThePdb( Assert.That(second.LoadCount, Is.EqualTo(1)); } + /// + /// What: a persisted file whose header, stamp, count and line count are right but whose one + /// document line has a hash that is not hex is treated as missing: the PDB is read, the + /// same document is found, and the list is written again so the next new index reads it. + /// + [Test] + public void TryFindDocument_PersistedFileWithMalformedDocumentLine_ReadsThePdb() + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument firstDocument); + string[] lines = File.ReadAllText(PersistedListPath(dllPath)).Split('\n'); + string[] documentFields = lines[2].Split(new[] { '\t' }, 3); + documentFields[1] = "zz"; + lines[2] = string.Join("\t", documentFields); + File.WriteAllText(PersistedListPath(dllPath), string.Join("\n", lines)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument secondDocument); + HotReloadPdbDocumentIndex third = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundThird = Find(third, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); + Assert.That(secondDocument.Url, Is.EqualTo(firstDocument.Url)); + Assert.That(secondDocument.Hash.SequenceEqual(firstDocument.Hash), Is.True); + Assert.That(foundThird, Is.True); + Assert.That(third.LoadCount, Is.EqualTo(0)); + Assert.That(third.PersistedLoadCount, Is.EqualTo(1)); + } + /// /// What: a persisted file whose stamp names another dll length is not used, because it was /// written for other files. From 1b7948291bdb0ce9c621ede9dbaf21092bf1af4b Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 13:13:24 +0900 Subject: [PATCH 6/6] Pin the line-count, trailer, field and temp-file checks of persisted lists Removing the line-count check, the empty-trailer check, the hash algorithm check, the odd-length hash check or the temp-file cleanup left every test passing. Each new test breaks the file in a way only one of those checks catches, and the blocked-path test pins that a failed move leaves no temp file. --- .../HotReloadPdbDocumentIndexTests.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index 301947f76..9f739e6ad 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs @@ -433,6 +433,107 @@ public void TryFindDocument_PersistedFileWithAnotherStamp_ReadsThePdb() Assert.That(second.LoadCount, Is.EqualTo(1)); } + /// + /// What: a persisted file with one document line more than its count, still ending with a + /// newline, is treated as missing rather than answered from its first count lines. + /// + [Test] + public void TryFindDocument_PersistedFileWithAnExtraDocumentLine_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => + { + string withoutTrailingNewline = persisted.Substring(0, persisted.Length - 1); + string lastLine = withoutTrailingNewline.Substring(withoutTrailingNewline.LastIndexOf('\n') + 1); + return persisted + lastLine + "\n"; + }); + } + + /// + /// What: a persisted file with text after its last newline is treated as missing, although + /// splitting it still yields the expected number of parts. + /// + [Test] + public void TryFindDocument_PersistedFileWithTextAfterTheLastNewline_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => persisted + "garbage"); + } + + /// + /// What: a document line whose hash algorithm is not a defined value makes the file missing. + /// + [Test] + public void TryFindDocument_PersistedDocumentLineWithAnUndefinedHashAlgorithm_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => RewriteFirstDocumentField(persisted, 0, field => "9")); + } + + /// + /// What: a document line whose hash has an odd number of hex digits makes the file missing + /// instead of answering with a hash one byte short. + /// + [Test] + public void TryFindDocument_PersistedDocumentLineWithAnOddLengthHash_ReadsThePdb() + { + AssertRewrittenListIsNotUsed(persisted => RewriteFirstDocumentField(persisted, 1, field => + { + Assert.That(field.Length, Is.GreaterThan(0), "Precondition: the first document must have a hash."); + return field.Substring(0, field.Length - 1); + })); + } + + /// + /// What: when the persisted list cannot be moved into place, the lookup throws after the walk, + /// no temp file is left behind, and a later index writes the list once the path is free. + /// + [Test] + public void TryFindDocument_PersistedListPathBlocked_ThrowsAndLeavesNoTempFile() + { + string dllPath = DllPath(TestAssemblyName); + string persistedPath = PersistedListPath(dllPath); + // Why a directory: File.Exists is false for it, so the write skips the delete and the move throws. + Directory.CreateDirectory(persistedPath); + TestDelegate find = () => Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(find, Throws.Exception); + Assert.That(Directory.GetFiles(_persistenceDirectory, "*.tmp-*"), Is.Empty); + Assert.That(_index.LoadCount, Is.EqualTo(1)); + + Directory.Delete(persistedPath); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundSecond, Is.True); + Assert.That(File.Exists(persistedPath), Is.True); + } + + // Persists the list with _index, rewrites the file, and asserts that a new index walks the + // PDB instead of answering from the rewritten file. + private void AssertRewrittenListIsNotUsed(Func rewrite) + { + string dllPath = DllPath(TestAssemblyName); + bool foundFirst = Find(_index, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + string persisted = File.ReadAllText(PersistedListPath(dllPath)); + Assert.That(persisted, Does.EndWith("\n")); + File.WriteAllText(PersistedListPath(dllPath), rewrite(persisted)); + HotReloadPdbDocumentIndex second = new HotReloadPdbDocumentIndex(_persistenceDirectory); + + bool foundSecond = Find(second, dllPath, FixtureProjectRelativePath, out HotReloadPdbDocument _); + + Assert.That(foundFirst, Is.True); + Assert.That(foundSecond, Is.True); + Assert.That(second.LoadCount, Is.EqualTo(1)); + Assert.That(second.PersistedLoadCount, Is.EqualTo(0)); + } + + private static string RewriteFirstDocumentField(string persisted, int fieldIndex, Func rewrite) + { + string[] lines = persisted.Split('\n'); + string[] fields = lines[2].Split(new[] { '\t' }, 3); + fields[fieldIndex] = rewrite(fields[fieldIndex]); + lines[2] = string.Join("\t", fields); + return string.Join("\n", lines); + } + private string PersistedListPath(string dllPath) { return Path.Combine(_persistenceDirectory, Path.GetFileNameWithoutExtension(dllPath) + ".txt");