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: