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: