From db70bde450d42766e094cab74ac2331155556e60 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 18:22:47 +0900 Subject: [PATCH] Save the compilation assembly list of a clean domain for the next one The next domain is to rebuild the list without calling CompilationPipeline.GetAssemblies on the main thread, which needs the last list Unity gave plus the stamps that tell whether it still matches the dlls. The first fetch of a domain is saved in the background only when no invalidation happened and the last compile did not fail; a list taken after either can name sources the dlls were not built from. The background write reads the generation again before publishing, because a compile that starts after the hand-off writes new dlls whose stamps would otherwise be paired with the old list. Assemblies name their precompiled references by index into one table, since most of them share the same paths. Loading reports no_saved_list or saved_list_unreadable so logs can tell a domain that never saved from a torn or outdated file. The cache gains a thread-safe generation counter and TryFill, the hand-off the rebuild will use. --- .../HotReloadCompilationAssemblyCacheTests.cs | 51 ++++ ...ReloadCompilationAssemblyListStoreTests.cs | 275 +++++++++++++++++ ...dCompilationAssemblyListStoreTests.cs.meta | 11 + .../Shared/HotReloadCompilationAssemblies.cs | 35 ++- .../HotReloadCompilationAssemblyCache.cs | 30 +- .../HotReloadCompilationAssemblyListStore.cs | 184 ++++++++++++ ...ReloadCompilationAssemblyListStore.cs.meta | 11 + .../HotReload/Shared/HotReloadConstants.cs | 15 + .../Shared/HotReloadSavedCompilationList.cs | 276 ++++++++++++++++++ .../HotReloadSavedCompilationList.cs.meta | 11 + 10 files changed, 897 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs index b26b95ea2..6c5f6840e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyCacheTests.cs @@ -154,6 +154,57 @@ public void FindByName_UnknownName_ReturnsNull() Assert.That(found, Is.Null); } + /// + /// Verifies that a list built off the main thread is kept, without asking Unity, when nothing + /// is kept and no invalidation happened since its generation was read. + /// + [Test] + public void TryFill_WhenEmptyAtTheSameGeneration_KeepsTheRebuiltList() + { + UnityCompilationAssembly[] rebuilt = { CreateAssembly("Rebuilt") }; + HotReloadCompilationAssemblyCache cache = CreateCache(); + + bool filled = cache.TryFill(rebuilt, cache.Generation); + + Assert.That(filled, Is.True); + Assert.That(cache.Current(), Is.SameAs(rebuilt)); + Assert.That(_fetchCount, Is.EqualTo(0)); + } + + /// + /// Verifies that a list Unity already answered wins over a rebuilt one. + /// + [Test] + public void TryFill_WhenAlreadyFilled_KeepsTheFetchedList() + { + UnityCompilationAssembly[] fetched = { CreateAssembly("Fetched") }; + _answers.Enqueue(fetched); + HotReloadCompilationAssemblyCache cache = CreateCache(); + cache.Current(); + + bool filled = cache.TryFill(new[] { CreateAssembly("Rebuilt") }, cache.Generation); + + Assert.That(filled, Is.False); + Assert.That(cache.Current(), Is.SameAs(fetched)); + } + + /// + /// Verifies that a list built before an invalidation is refused even though nothing is kept, + /// because the script set may have changed while it was built. + /// + [Test] + public void TryFill_AfterAnInvalidation_Refuses() + { + HotReloadCompilationAssemblyCache cache = CreateCache(); + int generationBeforeBuild = cache.Generation; + cache.Invalidate(); + + bool filled = cache.TryFill(new[] { CreateAssembly("Rebuilt") }, generationBeforeBuild); + + Assert.That(filled, Is.False); + Assert.That(cache.IsFilled, Is.False); + } + private HotReloadCompilationAssemblyCache CreateCache() { return new HotReloadCompilationAssemblyCache(Fetch); diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs new file mode 100644 index 000000000..08e354a40 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of when the compilation assembly list is saved for the next domain (only the first + /// fetch of a domain without an invalidation and without a failed compile, and only when no + /// invalidation happened before the file is published, because only then does the list + /// describe the dlls on disk) and of telling a missing saved list from an unusable one when it + /// is loaded. + /// + public sealed class HotReloadCompilationAssemblyListStoreTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string MissingReferencePath = "Packages/com.example.missing/Plugins/Missing.dll"; + private const string PreviousFileContent = "previous"; + + // A complete saved file: one assembly whose only reference is the one reference entry. + // Each unreadable case below changes exactly one part of it. + private const string CompleteFile = + "{\"formatVersion\":1," + + "\"assemblies\":[{\"name\":\"A\",\"outputPath\":\"Library/ScriptAssemblies/A.dll\",\"sourceFiles\":[]," + + "\"defines\":[],\"assemblyReferences\":[],\"compiledAssemblyReferenceIndexes\":[0],\"dllStamp\":null}]," + + "\"references\":[{\"path\":\"Plugins/R.dll\",\"stamp\":{\"length\":1,\"lastWriteTicks\":2}," + + "\"mvid\":\"0123456789abcdef0123456789abcdef\"}]}"; + + private string _projectRoot; + private string _filePath; + private string _referencePath; + private bool _isCompilationFailed; + private int _currentGeneration; + private int _backgroundRuns; + private Action _deferredWork; + + [SetUp] + public void SetUp() + { + _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-compilation-list-store-" + Guid.NewGuid().ToString("N")); + string scriptAssemblies = Path.Combine(_projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory); + Directory.CreateDirectory(scriptAssemblies); + File.WriteAllText(Path.Combine(scriptAssemblies, "Saved" + HotReloadConstants.CompiledAssemblyExtension), "dll"); + _filePath = Path.Combine(_projectRoot, HotReloadConstants.CompilationAssemblyListRelativePath); + _referencePath = CompiledAssemblyLayout.Resolve(RealProjectRoot()).DllPath(TestAssemblyName); + _isCompilationFailed = false; + _currentGeneration = 0; + _backgroundRuns = 0; + _deferredWork = null; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectRoot)) + { + Directory.Delete(_projectRoot, true); + } + } + + /// + /// What: a clean first fetch is saved with the read fields and the dll stamp. Precompiled + /// references are saved once in the references table, with stamp and MVID, or with neither + /// when the file is missing, and assemblies point at them by index in Unity's order. + /// + [Test] + public void Store_SavesTheFirstFetchOfACleanDomain() + { + HotReloadCompilationAssemblyListStore store = CreateStore(); + + store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot); + + HotReloadSavedCompilationListLoad load = store.Load(); + Assert.That(load.FailureReason, Is.Null); + HotReloadSavedCompilationList saved = load.List; + Assert.That(saved.assemblies.Count, Is.EqualTo(2)); + HotReloadSavedCompilationAssembly entry = saved.assemblies[1]; + Assert.That(entry.name, Is.EqualTo("Saved")); + Assert.That(entry.sourceFiles, Is.EqualTo(new[] { "Assets/Saved/A.cs", "Assets/Saved/B.cs" })); + Assert.That(entry.defines, Is.EqualTo(new[] { "SECOND", "FIRST" })); + Assert.That(entry.assemblyReferences, Is.EqualTo(new[] { "Referenced" })); + Assert.That(entry.compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 1, 0 })); + Assert.That(saved.assemblies[0].compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 0 })); + Assert.That(entry.dllStamp, Is.Not.Null); + Assert.That(saved.assemblies[0].dllStamp, Is.Null, "Referenced has no dll in the temporary project."); + Assert.That(saved.references.Count, Is.EqualTo(2)); + Assert.That(saved.references[0].path, Is.EqualTo(_referencePath)); + Assert.That(saved.references[0].mvid, Is.EqualTo(HotReloadAssemblyMvid.Read(_referencePath))); + Assert.That(saved.references[1].path, Is.EqualTo(MissingReferencePath)); + Assert.That(saved.references[1].stamp, Is.Null); + Assert.That(saved.references[1].mvid, Is.Null); + } + + /// + /// What: a fetch after an invalidation of the domain (a compile start, or a script set change + /// without a reload) is not saved. + /// + [Test] + public void Store_DoesNotSaveAFetchAfterAnInvalidation() + { + HotReloadCompilationAssemblyListStore store = CreateStore(); + + store.SaveInBackgroundIfClean(CreateFetchedList(), 1, _projectRoot); + + Assert.That(File.Exists(_filePath), Is.False); + Assert.That(_backgroundRuns, Is.EqualTo(0)); + } + + /// + /// What: a fetch while the last compile failed is not saved, since the list can name sources + /// the dlls were not built from. + /// + [Test] + public void Store_DoesNotSaveWhileCompilationFailed() + { + _isCompilationFailed = true; + HotReloadCompilationAssemblyListStore store = CreateStore(); + + store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot); + + Assert.That(File.Exists(_filePath), Is.False); + Assert.That(_backgroundRuns, Is.EqualTo(0)); + } + + /// + /// What: when an invalidation happens after the hand-off but before the background write + /// publishes, the earlier saved file is left as it was and no temporary file remains. + /// + [Test] + public void Store_WhenInvalidatedBeforeTheWriteFinishes_KeepsThePreviousFile() + { + WriteSavedFile(PreviousFileContent); + HotReloadCompilationAssemblyListStore store = CreateDeferringStore(); + store.SaveInBackgroundIfClean(CreateFetchedList(), 0, _projectRoot); + + _currentGeneration = 1; + _deferredWork(); + + Assert.That(File.ReadAllText(_filePath), Is.EqualTo(PreviousFileContent)); + Assert.That(File.Exists(_filePath + ".tmp"), Is.False); + } + + /// + /// What: without a saved file, the load names no_saved_list. + /// + [Test] + public void Load_WithoutASavedFile_ReportsNoSavedList() + { + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.List, Is.Null); + Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonNoSavedList)); + } + + /// + /// What: a saved file with every required field, in-range indexes and the current format + /// version loads. The unreadable cases below each differ from it in one way. + /// + [Test] + public void Load_CompleteFileOfTheCurrentVersion_ReturnsTheList() + { + WriteSavedFile(CompleteFile); + + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.FailureReason, Is.Null); + Assert.That(load.List.assemblies[0].compiledAssemblyReferenceIndexes, Is.EqualTo(new[] { 0 })); + } + + /// + /// What: a torn file loads as saved_list_unreadable instead of throwing. + /// + [Test] + public void Load_TornFile_ReportsUnreadable() + { + WriteSavedFile(CompleteFile.Substring(0, CompleteFile.Length / 2)); + + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.List, Is.Null); + Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonSavedListUnreadable)); + } + + /// + /// What: a file of another format version, an assembly without a name, a reference index + /// outside the table and a reference with a stamp but no MVID all load as + /// saved_list_unreadable instead of failing later in the rebuild. + /// + [TestCase("\"formatVersion\":1", "\"formatVersion\":2", TestName = "Load_OtherFormatVersion_ReportsUnreadable")] + [TestCase("\"name\":\"A\",", "", TestName = "Load_AssemblyWithoutAName_ReportsUnreadable")] + [TestCase("\"compiledAssemblyReferenceIndexes\":[0]", "\"compiledAssemblyReferenceIndexes\":[1]", TestName = "Load_ReferenceIndexOutOfRange_ReportsUnreadable")] + [TestCase(",\"mvid\":\"0123456789abcdef0123456789abcdef\"", "", TestName = "Load_ReferenceWithAStampButNoMvid_ReportsUnreadable")] + public void Load_IncompleteFile_ReportsUnreadable(string completePart, string replacement) + { + Assert.That(CompleteFile, Does.Contain(completePart), "The case must change the complete file."); + WriteSavedFile(CompleteFile.Replace(completePart, replacement)); + + HotReloadSavedCompilationListLoad load = CreateStore().Load(); + + Assert.That(load.List, Is.Null); + Assert.That(load.FailureReason, Is.EqualTo(HotReloadConstants.CompilationListReasonSavedListUnreadable)); + } + + private void WriteSavedFile(string json) + { + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)); + File.WriteAllText(_filePath, json); + } + + private HotReloadCompilationAssemblyListStore CreateStore() + { + // Why inline: EditMode tests must not start pool work, so the "background" runs here. + return new HotReloadCompilationAssemblyListStore( + _filePath, + () => _isCompilationFailed, + () => _currentGeneration, + work => + { + _backgroundRuns++; + work(); + return Task.CompletedTask; + }); + } + + private HotReloadCompilationAssemblyListStore CreateDeferringStore() + { + // Keeps the write so the test can change the generation between the hand-off and the write. + return new HotReloadCompilationAssemblyListStore( + _filePath, + () => _isCompilationFailed, + () => _currentGeneration, + work => + { + _deferredWork = work; + return Task.CompletedTask; + }); + } + + private UnityCompilationAssembly[] CreateFetchedList() + { + UnityCompilationAssembly referenced = new UnityCompilationAssembly( + "Referenced", + HotReloadConstants.ScriptAssembliesRelativeDirectory + "/Referenced" + HotReloadConstants.CompiledAssemblyExtension, + new[] { "Assets/Referenced/R.cs" }, + Array.Empty(), + Array.Empty(), + new[] { _referencePath }, + AssemblyFlags.EditorAssembly); + UnityCompilationAssembly saved = new UnityCompilationAssembly( + "Saved", + HotReloadConstants.ScriptAssembliesRelativeDirectory + "/Saved" + HotReloadConstants.CompiledAssemblyExtension, + new[] { "Assets/Saved/A.cs", "Assets/Saved/B.cs" }, + new[] { "SECOND", "FIRST" }, + new[] { referenced }, + new[] { MissingReferencePath, _referencePath }, + AssemblyFlags.EditorAssembly); + return new[] { referenced, saved }; + } + + private static string RealProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta new file mode 100644 index 000000000..2e4bec536 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompilationAssemblyListStoreTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a13938635d42e496b82fb97fdf46f182 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs index 4d0bd6bc7..8f38993d1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblies.cs @@ -1,7 +1,12 @@ using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using UnityEditor; using UnityEditor.Compilation; +using UnityEngine; + using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -13,11 +18,22 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// changes the script set without compiling (see HotReloadCompilationAssemblyListPostprocessor). /// The startup snapshot capture is the first caller after a domain reload, so runs find the list /// already kept. + /// A list rebuilt at startup from the saved list holds only the fields hot reload reads: name, + /// outputPath, sourceFiles, defines, assemblyReferences and compiledAssemblyReferences. + /// compilerOptions, flags and rootNamespace keep their defaults, so code that needs them reads + /// CompilationPipeline.GetAssemblies instead. /// internal static class HotReloadCompilationAssemblies { + private static readonly HotReloadCompilationAssemblyListStore Store = + new HotReloadCompilationAssemblyListStore( + Path.Combine(ProjectRoot(), HotReloadConstants.CompilationAssemblyListRelativePath), + () => EditorUtility.scriptCompilationFailed, + () => Cache.Generation, + work => Task.Run(work)); + private static readonly HotReloadCompilationAssemblyCache Cache = - new HotReloadCompilationAssemblyCache(CompilationPipeline.GetAssemblies); + new HotReloadCompilationAssemblyCache(FetchAndSaveIfClean); static HotReloadCompilationAssemblies() { @@ -42,6 +58,23 @@ internal static UnityCompilationAssembly FindByName(string assemblyName) return Cache.FindByName(assemblyName); } + // Main thread: the cache asks for the list from Current(). + private static UnityCompilationAssembly[] FetchAndSaveIfClean() + { + UnityCompilationAssembly[] fetched = CompilationPipeline.GetAssemblies(); + if (fetched.Length > 0) + { + Store.SaveInBackgroundIfClean(fetched, Cache.Generation, ProjectRoot()); + } + + return fetched; + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + private static void OnCompilationStarted(object context) { Cache.Invalidate(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs index 5eac8cd97..b025a41a3 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyCache.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using UnityEngine; @@ -9,12 +10,13 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// /// Keeps the last non-empty compilation assembly list so repeated lookups ask Unity only once - /// until the owner invalidates it. Main thread only, like the Unity call it wraps. + /// until the owner invalidates it. Main thread only, like the Unity call it wraps, except Generation. /// internal sealed class HotReloadCompilationAssemblyCache { private readonly Func _fetch; private UnityCompilationAssembly[] _assemblies; + private int _generation; internal HotReloadCompilationAssemblyCache(Func fetch) { @@ -24,6 +26,13 @@ internal HotReloadCompilationAssemblyCache(Func fetc internal bool IsFilled => _assemblies != null; + /// + /// Counts the invalidations of this domain, so a list built off the main thread can tell + /// whether the script set changed while it was being built. The only member readable off the + /// main thread. + /// + internal int Generation => Volatile.Read(ref _generation); + /// /// Returns the memoized list, asking Unity when nothing is kept. The list is shared; callers only read it. /// @@ -66,9 +75,28 @@ internal UnityCompilationAssembly FindByName(string assemblyName) return null; } + /// + /// Keeps a list built without asking Unity, when nothing is kept yet and no invalidation + /// happened since the builder read the generation. Returns whether the list was kept. + /// + internal bool TryFill(UnityCompilationAssembly[] assemblies, int expectedGeneration) + { + Debug.Assert(assemblies != null && assemblies.Length > 0, "assemblies must not be null or empty."); + + // Why a kept list wins: a list Unity answered is the truth the rebuilt one only approximates. + if (_assemblies != null || Generation != expectedGeneration) + { + return false; + } + + _assemblies = assemblies; + return true; + } + internal void Invalidate() { _assemblies = null; + Interlocked.Increment(ref _generation); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs new file mode 100644 index 000000000..371645512 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs @@ -0,0 +1,184 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json; + +using Debug = UnityEngine.Debug; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Saves the compilation assembly list of a clean domain so the next domain can rebuild it + /// without asking Unity on the main thread, and loads it back. + /// + internal sealed class HotReloadCompilationAssemblyListStore + { + internal const int FormatVersion = 1; + private const string TempFileExtension = ".tmp"; + + private readonly string _filePath; + private readonly Func _isCompilationFailed; + private readonly Func _currentGeneration; + private readonly Func _runInBackground; + + internal HotReloadCompilationAssemblyListStore( + string filePath, + Func isCompilationFailed, + Func currentGeneration, + Func runInBackground) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty."); + Debug.Assert(isCompilationFailed != null, "isCompilationFailed must not be null."); + Debug.Assert(currentGeneration != null, "currentGeneration must not be null."); + Debug.Assert(runInBackground != null, "runInBackground must not be null."); + _filePath = filePath; + _isCompilationFailed = isCompilationFailed; + _currentGeneration = currentGeneration; + _runInBackground = runInBackground; + } + + /// + /// Saves the list in the background when it describes the dlls on disk: no invalidation has + /// happened in this domain (generation 0) and the last compile did not fail. Otherwise the + /// list can name sources that were never compiled, and a later domain whose dlls did not + /// change would trust it. Main thread only, because it reads the compile state. + /// + internal void SaveInBackgroundIfClean(UnityCompilationAssembly[] fetched, int generation, string projectRoot) + { + Debug.Assert(fetched != null && fetched.Length > 0, "fetched must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + if (generation != 0) + { + LogSaveSkipped("generation"); + return; + } + + if (_isCompilationFailed()) + { + LogSaveSkipped("compilation_failed"); + return; + } + + // Why only logged: the saved list is an optimization, and without it the next domain + // asks Unity as it always did. + _runInBackground(() => Write(fetched, projectRoot, generation)) + .ContinueWith( + LogSaveFailure, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + /// + /// Loads the saved list. When it cannot be used, the result has no list and names why: no + /// file, or a file that is torn, of another format version, or missing a required field. + /// Touches only the file, so it can run on a pool thread. + /// + internal HotReloadSavedCompilationListLoad Load() + { + if (!File.Exists(_filePath)) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonNoSavedList); + } + + string json = File.ReadAllText(_filePath, Encoding.UTF8); + HotReloadSavedCompilationList saved; + // Why caught: the file is outside input that a crash can tear or a person can edit, and an + // unreadable file only sends the next domain back to Unity's list. Only JSON errors are + // caught; an IO error goes to the caller's boundary. + try + { + saved = JsonConvert.DeserializeObject(json); + } + catch (JsonException) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonSavedListUnreadable); + } + + // Why the fields are checked here: a missing one would otherwise surface later as a + // NullReferenceException that names no cause. + if (saved == null || saved.formatVersion != FormatVersion || !saved.IsComplete()) + { + return HotReloadSavedCompilationListLoad.Failed(HotReloadConstants.CompilationListReasonSavedListUnreadable); + } + + return HotReloadSavedCompilationListLoad.Loaded(saved); + } + + private void Write(UnityCompilationAssembly[] fetched, string projectRoot, int generation) + { + Stopwatch watch = Stopwatch.StartNew(); + HotReloadSavedCompilationList saved = HotReloadSavedCompilationList.Capture(fetched, projectRoot, FormatVersion); + string json = JsonConvert.SerializeObject(saved); + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)); + + // Why a fixed temp name: a domain has one writer, and the previous domain's writer + // stopped when that domain was unloaded. + string tempPath = _filePath + TempFileExtension; + File.WriteAllText(tempPath, json, new UTF8Encoding(false)); + + // Why the generation is read again here: a compile that started after the hand-off may + // have written new dlls whose stamps Capture read, which would pair the old list with + // them. Unity writes dlls only after compilationStarted invalidates the cache, so an + // unchanged generation means every stamp read above belongs to this domain's dlls. + if (_currentGeneration() != generation) + { + File.Delete(tempPath); + LogSaveSkipped("superseded"); + return; + } + + long bytes = new FileInfo(tempPath).Length; + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + + File.Move(tempPath, _filePath); + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListSaved, + "Hot reload saved the compilation assembly list for the next domain.", + new + { + bytes, + saveMs = watch.ElapsedMilliseconds, + assemblies = saved.assemblies.Count, + references = saved.references.Count, + }); + } + + private static void LogSaveSkipped(string reason) + { + // Why logged: domains that never save would silently keep the next startup on Unity's list. + VibeLogger.LogInfo( + HotReloadConstants.VibeLogCompilationListSaveSkipped, + "Hot reload did not save the compilation assembly list of this domain.", + new { reason }); + } + + private static void LogSaveFailure(Task write) + { + if (!write.IsFaulted) + { + return; + } + + // Why read before the call: VibeLogger.LogWarning is compiled out without ULOOP_DEBUG, + // and reading Exception is what marks the fault as observed. + Exception error = write.Exception.GetBaseException(); + VibeLogger.LogWarning( + HotReloadConstants.VibeLogCompilationListSaveFailed, + "Hot reload could not save the compilation assembly list.", + new { error = error.GetType().Name }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta new file mode 100644 index 000000000..e4b4dc5be --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompilationAssemblyListStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8092f6887a9a4282ba931bbf48543c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 7967a25f2..8d0f5547f 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -49,6 +49,10 @@ 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"; + // The compilation assembly list saved by a clean domain, from which the next domain + // rebuilds the list without asking Unity on the main thread. + public const string CompilationAssemblyListRelativePath = "Library/UloopHotReload/CompilationAssemblies.json"; + // The file inside a snapshot directory that records each copied source's length and last // write time (UTC ticks), so a later domain tells an unchanged source by its stat alone. public const string SourceStampManifestFileName = "source-stamps.txt"; @@ -705,6 +709,17 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE public const string SourceSnapshotSkipReasonToolsDisabled = "tools_disabled"; public const string WarmUpSkipReasonToolDisabled = "tool_disabled"; + // Saving the compilation assembly list for the next domain: why a domain did not save it, + // a save that threw, and a save that finished (its size and time). + public const string VibeLogCompilationListSaveSkipped = "hot_reload_compilation_list_save_skipped"; + public const string VibeLogCompilationListSaveFailed = "hot_reload_compilation_list_save_failed"; + public const string VibeLogCompilationListSaved = "hot_reload_compilation_list_saved"; + + // Why a domain found no saved compilation assembly list to rebuild from: none was saved, or + // the saved file is torn, of another format version or missing a required field. + public const string CompilationListReasonNoSavedList = "no_saved_list"; + public const string CompilationListReasonSavedListUnreadable = "saved_list_unreadable"; + // What stopped a running warm-up or caller-note backfill, as named in the cancelledBy field // of hot_reload_warm_up_complete and hot_reload_caller_note_backfill_complete. public const string WarmUpCancelledByRun = "run"; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs new file mode 100644 index 000000000..86e2256d5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using UnityEngine; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The compilation assembly list as saved for the next domain: only the fields hot reload reads, + /// plus the file stamps and MVIDs that tell whether each entry still describes the dlls on disk. + /// + internal sealed class HotReloadSavedCompilationList + { + public int formatVersion; + + // In the order CompilationPipeline.GetAssemblies returned them. + public List assemblies; + + // One per distinct compiled assembly reference path, in first-seen order. Assemblies name + // their references by index, because most of them share the same few hundred paths and + // repeating the paths made the file several times larger. + public List references; + + /// + /// True when every field a rebuild reads is present and every reference index is in range. + /// + internal bool IsComplete() + { + if (assemblies == null || references == null) + { + return false; + } + + foreach (HotReloadSavedReference reference in references) + { + if (reference == null || !reference.IsComplete()) + { + return false; + } + } + + foreach (HotReloadSavedCompilationAssembly assembly in assemblies) + { + if (assembly == null || !assembly.IsComplete(references.Count)) + { + return false; + } + } + + return true; + } + + /// + /// Reads the saved fields of every assembly, the stamp of each assembly's dll and the stamp + /// and MVID of every referenced dll. Touches only files and managed properties, so it can + /// run on a pool thread. + /// + internal static HotReloadSavedCompilationList Capture( + IReadOnlyList fetched, + string projectRoot, + int formatVersion) + { + Debug.Assert(fetched != null, "fetched must not be null."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + List assemblies = new List(fetched.Count); + List references = new List(); + Dictionary indexByPath = new Dictionary(StringComparer.Ordinal); + foreach (UnityCompilationAssembly assembly in fetched) + { + string[] referencePaths = assembly.compiledAssemblyReferences ?? Array.Empty(); + int[] referenceIndexes = new int[referencePaths.Length]; + for (int position = 0; position < referencePaths.Length; position++) + { + referenceIndexes[position] = IndexOf(referencePaths[position], projectRoot, indexByPath, references); + } + + assemblies.Add(HotReloadSavedCompilationAssembly.From(assembly, referenceIndexes, layout)); + } + + return new HotReloadSavedCompilationList + { + formatVersion = formatVersion, + assemblies = assemblies, + references = references, + }; + } + + private static int IndexOf( + string referencePath, + string projectRoot, + Dictionary indexByPath, + List references) + { + if (indexByPath.TryGetValue(referencePath, out int index)) + { + return index; + } + + index = references.Count; + indexByPath.Add(referencePath, index); + references.Add(HotReloadSavedReference.Read(referencePath, projectRoot)); + return index; + } + } + + /// + /// The outcome of loading the saved list: the list, or why there is none to use. + /// + internal readonly struct HotReloadSavedCompilationListLoad + { + public readonly HotReloadSavedCompilationList List; + public readonly string FailureReason; + + private HotReloadSavedCompilationListLoad(HotReloadSavedCompilationList list, string failureReason) + { + List = list; + FailureReason = failureReason; + } + + internal static HotReloadSavedCompilationListLoad Loaded(HotReloadSavedCompilationList list) + { + Debug.Assert(list != null, "list must not be null."); + return new HotReloadSavedCompilationListLoad(list, null); + } + + internal static HotReloadSavedCompilationListLoad Failed(string failureReason) + { + Debug.Assert(!string.IsNullOrEmpty(failureReason), "failureReason must not be null or empty."); + return new HotReloadSavedCompilationListLoad(null, failureReason); + } + } + + /// + /// One saved compilation assembly. Fields hot reload never reads (compiler options, flags, root + /// namespace) are not saved. + /// + internal sealed class HotReloadSavedCompilationAssembly + { + public string name; + public string outputPath; + public string[] sourceFiles; + public string[] defines; + + // Names of the project assemblies this one references, in Unity's order. + public string[] assemblyReferences; + + // The precompiled assemblies this one references, in Unity's order, as indexes into the + // list's references. + public int[] compiledAssemblyReferenceIndexes; + + // Null when the assembly had no dll when the list was saved. + public HotReloadSavedFileStamp dllStamp; + + internal bool IsComplete(int referenceCount) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(outputPath) || sourceFiles == null + || defines == null || assemblyReferences == null || compiledAssemblyReferenceIndexes == null) + { + return false; + } + + foreach (int index in compiledAssemblyReferenceIndexes) + { + if (index < 0 || index >= referenceCount) + { + return false; + } + } + + return true; + } + + internal static HotReloadSavedCompilationAssembly From( + UnityCompilationAssembly assembly, + int[] compiledAssemblyReferenceIndexes, + CompiledAssemblyLayout layout) + { + UnityCompilationAssembly[] referenced = assembly.assemblyReferences ?? Array.Empty(); + string[] referencedNames = new string[referenced.Length]; + for (int index = 0; index < referenced.Length; index++) + { + referencedNames[index] = referenced[index].name; + } + + return new HotReloadSavedCompilationAssembly + { + name = assembly.name, + outputPath = assembly.outputPath, + sourceFiles = assembly.sourceFiles ?? Array.Empty(), + defines = assembly.defines ?? Array.Empty(), + assemblyReferences = referencedNames, + compiledAssemblyReferenceIndexes = compiledAssemblyReferenceIndexes, + dllStamp = HotReloadSavedFileStamp.ReadOrNull(layout.DllPath(assembly.name)), + }; + } + } + + /// + /// A precompiled assembly reference as it was when the list was saved. + /// + internal sealed class HotReloadSavedReference + { + // The path exactly as Unity listed it in compiledAssemblyReferences. + public string path; + + // Both null when the file did not exist when the list was saved. + public HotReloadSavedFileStamp stamp; + public string mvid; + + internal bool IsComplete() + { + if (string.IsNullOrEmpty(path) || (stamp == null) != (mvid == null)) + { + return false; + } + + return mvid == null || Guid.TryParseExact(mvid, "N", out Guid _); + } + + internal static HotReloadSavedReference Read(string referencePath, string projectRoot) + { + string fullPath = Path.GetFullPath(Path.Combine(projectRoot, referencePath)); + HotReloadSavedFileStamp stamp = HotReloadSavedFileStamp.ReadOrNull(fullPath); + return new HotReloadSavedReference + { + path = referencePath, + stamp = stamp, + mvid = stamp == null ? null : HotReloadAssemblyMetadataMvid.Read(fullPath).ToString("N"), + }; + } + } + + /// + /// The length and last write time of a file, which together tell that a dll was not rebuilt. + /// + internal sealed class HotReloadSavedFileStamp + { + public long length; + public long lastWriteTicks; + + /// + /// Returns the stamp of the file, or null when the file does not exist. + /// + internal static HotReloadSavedFileStamp ReadOrNull(string fullPath) + { + FileInfo file = new FileInfo(fullPath); + if (!file.Exists) + { + return null; + } + + return new HotReloadSavedFileStamp { length = file.Length, lastWriteTicks = file.LastWriteTimeUtc.Ticks }; + } + } + + /// + /// Reads a dll's MVID from its metadata header without loading the assembly. Safe on a pool thread. + /// + internal static class HotReloadAssemblyMetadataMvid + { + internal static Guid Read(string dllPath) + { + using FileStream stream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using PEReader peReader = new PEReader(stream); + MetadataReader reader = peReader.GetMetadataReader(); + return reader.GetGuid(reader.GetModuleDefinition().Mvid); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta new file mode 100644 index 000000000..9af5d3b30 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSavedCompilationList.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9450b5e2fb2394510b9039e08c5628f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: