diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentIndexTests.cs index 52e670161..9f739e6ad 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,267 @@ 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 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. + /// + [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)); + } + + /// + /// 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"); + } + 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/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~"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index ae4296a6f..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; @@ -96,11 +104,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 +135,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 @@ -141,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; } @@ -153,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); 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