diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactSweeperTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactSweeperTests.cs
new file mode 100644
index 000000000..6cf83f89c
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactSweeperTests.cs
@@ -0,0 +1,298 @@
+using System;
+using System.IO;
+
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Covers which introduced-type artifacts of earlier domains the sweep deletes and which files it
+ /// keeps, laid out under a temporary project root.
+ ///
+ public class HotReloadIntroducedTypeArtifactSweeperTests
+ {
+ private string _projectRoot;
+ private string _currentSessionId;
+
+ private string ArtifactsRoot =>
+ Path.Combine(_projectRoot, HotReloadConstants.IntroducedTypeArtifactsRelativeDirectory);
+
+ private string PublicizedRoot =>
+ Path.Combine(_projectRoot, HotReloadConstants.PublicizedRefsRelativeDirectory);
+
+ private string ExposedRoot =>
+ Path.Combine(_projectRoot, HotReloadConstants.InternalsExposedRefsRelativeDirectory);
+
+ [SetUp]
+ public void SetUp()
+ {
+ _projectRoot = Path.Combine(Path.GetTempPath(), "uloop-artifact-sweep-" + NewId());
+ Directory.CreateDirectory(_projectRoot);
+ _currentSessionId = NewId();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_projectRoot))
+ {
+ Directory.Delete(_projectRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// What: session directories that earlier domains left are deleted, while the current domain's
+ /// session directory and its artifact stay.
+ ///
+ [Test]
+ public void Sweep_DeletesEarlierSessionDirectoriesAndKeepsTheCurrentOne()
+ {
+ string earlierSession = Path.GetDirectoryName(CreateArtifactDirectory(NewId(), NewId()));
+ string currentArtifact = CreateArtifactDirectory(_currentSessionId, NewId());
+
+ CreateSweeper().Sweep();
+
+ Assert.That(Directory.Exists(earlierSession), Is.False);
+ Assert.That(Directory.Exists(currentArtifact), Is.True);
+ }
+
+ ///
+ /// What: a session directory whose name differs from the current session id only in case is
+ /// kept, since a case-insensitive file system resolves the current session to it.
+ ///
+ [Test]
+ public void Sweep_KeepsTheCurrentSessionDirectoryWhenItsNameDiffersOnlyInCase()
+ {
+ string currentArtifact = CreateArtifactDirectory(_currentSessionId.ToUpperInvariant(), NewId());
+
+ CreateSweeper().Sweep();
+
+ Assert.That(Directory.Exists(currentArtifact), Is.True);
+ }
+
+ ///
+ /// What: entries under the artifact directory that are not session directories of an earlier
+ /// domain stay: directories that tests name freely, a GUID written in another format, and a
+ /// plain file.
+ ///
+ [Test]
+ public void Sweep_KeepsEntriesThatAreNotSessionDirectories()
+ {
+ string testSession = CreateArtifactDirectory("compiler-tests", NewId());
+ string hyphenatedGuidSession = CreateArtifactDirectory(Guid.NewGuid().ToString("D"), NewId());
+ string plainFile = Path.Combine(ArtifactsRoot, NewId());
+ File.WriteAllText(plainFile, "file");
+
+ CreateSweeper().Sweep();
+
+ Assert.That(Directory.Exists(testSession), Is.True);
+ Assert.That(Directory.Exists(hyphenatedGuidSession), Is.True);
+ Assert.That(File.Exists(plainFile), Is.True);
+ }
+
+ ///
+ /// What: copies made from artifacts of earlier domains are deleted from both reference caches,
+ /// including a copy whose artifact directory is already gone and a temporary file a copy was
+ /// being written through.
+ ///
+ [Test]
+ public void Sweep_DeletesArtifactCopiesOfEarlierSessionsFromBothCaches()
+ {
+ string earlierArtifactId = NewId();
+ CreateArtifactDirectory(NewId(), earlierArtifactId);
+ string publicizedCopy = CreateCacheFile(PublicizedRoot, ArtifactCopyName(earlierArtifactId));
+ string exposedCopy = CreateCacheFile(ExposedRoot, ArtifactCopyName(earlierArtifactId));
+ string temporaryCopy = CreateCacheFile(
+ PublicizedRoot,
+ ArtifactCopyName(earlierArtifactId) + ".tmp-" + NewId());
+ string orphanCopy = CreateCacheFile(ExposedRoot, ArtifactCopyName(NewId()));
+
+ CreateSweeper().Sweep();
+
+ Assert.That(File.Exists(publicizedCopy), Is.False);
+ Assert.That(File.Exists(exposedCopy), Is.False);
+ Assert.That(File.Exists(temporaryCopy), Is.False);
+ Assert.That(File.Exists(orphanCopy), Is.False);
+ }
+
+ ///
+ /// What: the sweep keeps copies made from artifacts of the current domain, copies of other
+ /// assemblies, and files whose names only resemble an artifact copy.
+ ///
+ [Test]
+ public void Sweep_KeepsCopiesOfCurrentArtifactsAndOfOtherAssemblies()
+ {
+ string currentArtifactId = NewId();
+ CreateArtifactDirectory(_currentSessionId, currentArtifactId);
+ string currentCopy = CreateCacheFile(PublicizedRoot, ArtifactCopyName(currentArtifactId));
+ string currentExposedCopy = CreateCacheFile(ExposedRoot, ArtifactCopyName(currentArtifactId));
+ string otherAssemblyCopy = CreateCacheFile(PublicizedRoot, "Assembly-CSharp-" + NewId() + ".dll");
+ string nonHexIdentifier = CreateCacheFile(
+ PublicizedRoot,
+ HotReloadConstants.IntroducedTypeArtifactAssemblyNamePrefix + new string('z', 32) + "-" + NewId() + ".dll");
+ string missingSeparator = CreateCacheFile(
+ ExposedRoot,
+ HotReloadConstants.IntroducedTypeArtifactAssemblyNamePrefix + NewId() + ".dll");
+
+ CreateSweeper().Sweep();
+
+ Assert.That(File.Exists(currentCopy), Is.True);
+ Assert.That(File.Exists(currentExposedCopy), Is.True);
+ Assert.That(File.Exists(otherAssemblyCopy), Is.True);
+ Assert.That(File.Exists(nonHexIdentifier), Is.True);
+ Assert.That(File.Exists(missingSeparator), Is.True);
+ }
+
+ ///
+ /// What: a session directory that cannot be deleted because it is still in use is skipped,
+ /// and the sweep still deletes the other leftovers.
+ ///
+ [Test]
+ public void Sweep_WhenDeletingADirectoryThrowsIOException_ContinuesWithTheRest()
+ {
+ string lockedSessionId = NewId();
+ string lockedSession = Path.GetDirectoryName(CreateArtifactDirectory(lockedSessionId, NewId()));
+ string otherSession = Path.GetDirectoryName(CreateArtifactDirectory(NewId(), NewId()));
+ string orphanCopy = CreateCacheFile(PublicizedRoot, ArtifactCopyName(NewId()));
+ HotReloadIntroducedTypeArtifactSweeper sweeper = new HotReloadIntroducedTypeArtifactSweeper(
+ _projectRoot,
+ _currentSessionId,
+ deleteDirectory: path =>
+ {
+ if (Path.GetFileName(path) == lockedSessionId)
+ {
+ throw new IOException("The directory is in use.");
+ }
+
+ Directory.Delete(path, recursive: true);
+ });
+
+ sweeper.Sweep();
+
+ Assert.That(Directory.Exists(lockedSession), Is.True);
+ Assert.That(Directory.Exists(otherSession), Is.False);
+ Assert.That(File.Exists(orphanCopy), Is.False);
+ }
+
+ ///
+ /// What: a copy the operating system refuses to delete is skipped, and the sweep still deletes
+ /// the other copies.
+ ///
+ [Test]
+ public void Sweep_WhenDeletingACopyIsDenied_ContinuesWithTheRest()
+ {
+ string deniedCopyName = ArtifactCopyName(NewId());
+ string deniedCopy = CreateCacheFile(PublicizedRoot, deniedCopyName);
+ string otherCopy = CreateCacheFile(ExposedRoot, ArtifactCopyName(NewId()));
+ HotReloadIntroducedTypeArtifactSweeper sweeper = new HotReloadIntroducedTypeArtifactSweeper(
+ _projectRoot,
+ _currentSessionId,
+ deleteFile: path =>
+ {
+ if (Path.GetFileName(path) == deniedCopyName)
+ {
+ throw new UnauthorizedAccessException("Access to the path is denied.");
+ }
+
+ File.Delete(path);
+ });
+
+ sweeper.Sweep();
+
+ Assert.That(File.Exists(deniedCopy), Is.True);
+ Assert.That(File.Exists(otherCopy), Is.False);
+ }
+
+ ///
+ /// What: with no artifact directory at all, as before a domain prepares its first batch, the
+ /// sweep still deletes every artifact copy in both caches and keeps other assemblies' copies.
+ ///
+ [Test]
+ public void Sweep_WhenNoArtifactDirectoryExists_StillDeletesTheArtifactCopies()
+ {
+ string publicizedCopy = CreateCacheFile(PublicizedRoot, ArtifactCopyName(NewId()));
+ string exposedCopy = CreateCacheFile(ExposedRoot, ArtifactCopyName(NewId()));
+ string otherAssemblyCopy = CreateCacheFile(PublicizedRoot, "Assembly-CSharp-" + NewId() + ".dll");
+
+ CreateSweeper().Sweep();
+
+ Assert.That(File.Exists(publicizedCopy), Is.False);
+ Assert.That(File.Exists(exposedCopy), Is.False);
+ Assert.That(File.Exists(otherAssemblyCopy), Is.True);
+ }
+
+ ///
+ /// What: a project where none of the swept directories exists is left as it is, without the
+ /// sweep creating any of them.
+ ///
+ [Test]
+ public void Sweep_WhenNoSweptDirectoryExists_CreatesNothing()
+ {
+ CreateSweeper().Sweep();
+
+ Assert.That(Directory.Exists(Path.Combine(_projectRoot, "Library")), Is.False);
+ }
+
+ ///
+ /// What: a current session id that is not a GUID is rejected, since the sweep would otherwise
+ /// take the current domain's session directory for an earlier one.
+ ///
+ [Test]
+ public void Constructor_RejectsASessionIdThatIsNotAGuid()
+ {
+ Assert.Throws(
+ () => new HotReloadIntroducedTypeArtifactSweeper(_projectRoot, string.Empty));
+ Assert.Throws(
+ () => new HotReloadIntroducedTypeArtifactSweeper(_projectRoot, "compiler-tests"));
+ }
+
+ ///
+ /// What: a project root that is not an absolute path is rejected, so the sweep never resolves
+ /// the directories against whatever the working directory happens to be.
+ ///
+ [Test]
+ public void Constructor_RejectsAProjectRootThatIsNotAbsolute()
+ {
+ Assert.Throws(
+ () => new HotReloadIntroducedTypeArtifactSweeper("project", _currentSessionId));
+ Assert.Throws(
+ () => new HotReloadIntroducedTypeArtifactSweeper(string.Empty, _currentSessionId));
+ }
+
+ private HotReloadIntroducedTypeArtifactSweeper CreateSweeper()
+ {
+ return new HotReloadIntroducedTypeArtifactSweeper(_projectRoot, _currentSessionId);
+ }
+
+ // Lays an artifact out the way preparation writes one, so the sweep meets a real batch.
+ private string CreateArtifactDirectory(string sessionId, string artifactId)
+ {
+ string directory = Path.Combine(ArtifactsRoot, sessionId, artifactId);
+ Directory.CreateDirectory(directory);
+ string assemblyName = HotReloadConstants.IntroducedTypeArtifactAssemblyNamePrefix + artifactId;
+ File.WriteAllText(Path.Combine(directory, assemblyName + ".dll"), "dll");
+ File.WriteAllText(Path.Combine(directory, assemblyName + ".pdb"), "pdb");
+ return directory;
+ }
+
+ private string CreateCacheFile(string cacheRoot, string fileName)
+ {
+ Directory.CreateDirectory(cacheRoot);
+ string path = Path.Combine(cacheRoot, fileName);
+ File.WriteAllText(path, "copy");
+ return path;
+ }
+
+ private string ArtifactCopyName(string artifactId)
+ {
+ return HotReloadConstants.IntroducedTypeArtifactAssemblyNamePrefix + artifactId + "-" + NewId() + ".dll";
+ }
+
+ private string NewId()
+ {
+ return Guid.NewGuid().ToString("N");
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactSweeperTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactSweeperTests.cs.meta
new file mode 100644
index 000000000..acdf34b95
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactSweeperTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a4f89fef21cba4a23950bfdd4c76b96d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
index ac11d7de2..075f9dcf3 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadEditorStartup.cs
@@ -1,4 +1,7 @@
+using System.IO;
+
using UnityEditor;
+using UnityEngine;
using io.github.hatayama.UnityCliLoop.ToolContracts;
@@ -21,6 +24,16 @@ void CaptureOnFirstUpdateTick()
HotReloadSourceSnapshotter.CaptureAfterDomainReload();
}
+ // Why a callback of its own rather than a line in the capture above: an exception in
+ // one of the two must not keep the other from running. Why unsubscribe first: a sweep
+ // that throws is then not retried on every later tick.
+ void SweepArtifactsOnFirstUpdateTick()
+ {
+ EditorApplication.update -= SweepArtifactsOnFirstUpdateTick;
+ HotReloadIntroducedTypePreparation.SweepArtifactsOfEarlierDomains(
+ Path.GetFullPath(Path.Combine(Application.dataPath, "..")));
+ }
+
// The services are rebuilt here rather than on first use because the introduced type
// resolver subscribes to AppDomain.AssemblyResolve when it is built, and that
// subscription is lost on every domain reload.
@@ -43,6 +56,7 @@ void CaptureOnFirstUpdateTick()
HotReloadWiredValueEditorHooks.GetPersistence =
() => HotReloadCompositionRoot.Services.WiredValuePersistence;
EditorApplication.update += CaptureOnFirstUpdateTick;
+ EditorApplication.update += SweepArtifactsOnFirstUpdateTick;
HotReloadPlayModeEntryDropRecorder.Initialize();
HotReloadAutoRefreshHold.Initialize();
HotReloadUnityMessageForwardingEditorHooks.Initialize();
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs
index 71060ef2a..926215ba1 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs
@@ -19,6 +19,16 @@ internal static class HotReloadIntroducedTypePreparation
// the assembly files a loaded artifact of this domain still maps.
private static readonly string SessionId = Guid.NewGuid().ToString("N");
+ ///
+ /// Deletes the artifacts that earlier domains left and the reference-cache copies made from
+ /// them. Nothing in a later domain loads them, since the registry that knew them died with
+ /// their domain; this domain's session stays whatever it already holds.
+ ///
+ public static void SweepArtifactsOfEarlierDomains(string projectRoot)
+ {
+ new HotReloadIntroducedTypeArtifactSweeper(projectRoot, SessionId).Sweep();
+ }
+
public static async Task PrepareAsync(
HotReloadGroupStageCollaborators collaborators,
IReadOnlyList files,
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeArtifactPathFactory.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeArtifactPathFactory.cs
index f7079dd23..8d5ddeba4 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeArtifactPathFactory.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeArtifactPathFactory.cs
@@ -27,7 +27,7 @@ public HotReloadIntroducedTypeArtifactPaths Create()
HotReloadConstants.IntroducedTypeArtifactsRelativeDirectory,
sessionId,
artifactId);
- string assemblyName = "UloopIntroducedTypes_" + artifactId;
+ string assemblyName = HotReloadConstants.IntroducedTypeArtifactAssemblyNamePrefix + artifactId;
string dllPath = Path.Combine(directory, assemblyName + ".dll");
return new HotReloadIntroducedTypeArtifactPaths(
Path.Combine(directory, assemblyName + ".cs"),
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
index df2a5de73..4c98d8c82 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs
@@ -15,6 +15,10 @@ internal static class HotReloadConstants
// session and artifact below it. Shared with the publicizer, which accepts an image from
// here as well as one from ScriptAssemblies.
public const string IntroducedTypeArtifactsRelativeDirectory = "Library/UloopHotReload/IntroducedTypes";
+
+ // An artifact assembly is named this prefix followed by its artifact id, which is also the
+ // name of its directory. The reference caches key their copies by the same name.
+ public const string IntroducedTypeArtifactAssemblyNamePrefix = "UloopIntroducedTypes_";
public const string CompiledAssemblyExtension = ".dll";
// Publicized reference copies are keyed by assembly name + Mvid so a recompiled assembly
@@ -572,6 +576,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE
public const string VibeLogWorkerHostLifecycleClosed = "hot_reload_worker_lifecycle_closed";
public const string VibeLogWorkerHostBrokenConversation = "hot_reload_worker_broken_conversation";
public const string VibeLogWorkerHostTempCleanupFailed = "hot_reload_worker_temp_cleanup_failed";
+ public const string VibeLogIntroducedTypeArtifactSweepFailed = "hot_reload_introduced_type_artifact_sweep_failed";
public const string VibeLogWorkerHostFallbackOneShot = "hot_reload_worker_fallback_one_shot";
public const string VibeLogFileStart = "hot_reload_file_start";
public const string VibeLogWorkerResult = "hot_reload_worker_result";
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadIntroducedTypeArtifactSweeper.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadIntroducedTypeArtifactSweeper.cs
new file mode 100644
index 000000000..da537c780
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadIntroducedTypeArtifactSweeper.cs
@@ -0,0 +1,195 @@
+using System;
+using System.IO;
+
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Deletes the introduced-type artifacts that earlier domains left under the project's Library,
+ /// together with the reference-cache copies made from them.
+ ///
+ internal sealed class HotReloadIntroducedTypeArtifactSweeper
+ {
+ private const string GuidFormat = "N";
+ private const int ArtifactIdLength = 32;
+ private const char ArtifactCopyNameSeparator = '-';
+
+ private readonly string _projectRoot;
+ private readonly string _currentSessionId;
+ private readonly Guid _currentSessionGuid;
+ private readonly Action _deleteDirectory;
+ private readonly Action _deleteFile;
+
+ public HotReloadIntroducedTypeArtifactSweeper(
+ string projectRoot,
+ string currentSessionId,
+ Action deleteDirectory = null,
+ Action deleteFile = null)
+ {
+ // Why absolute only: a relative root resolves against whatever the working directory
+ // happens to be, and the sweep deletes directories recursively below it.
+ if (string.IsNullOrEmpty(projectRoot) || !Path.IsPathRooted(projectRoot))
+ {
+ throw new ArgumentException("The project root must be an absolute path.", nameof(projectRoot));
+ }
+
+ // Why a GUID only: session directories are told apart by parsing their names as GUIDs,
+ // so an id of any other shape would make the current session look like an earlier one.
+ if (!Guid.TryParseExact(currentSessionId, GuidFormat, out Guid currentSessionGuid))
+ {
+ throw new ArgumentException(
+ "The current session id must be a GUID in \"N\" format.",
+ nameof(currentSessionId));
+ }
+
+ _projectRoot = projectRoot;
+ _currentSessionId = currentSessionId;
+ _currentSessionGuid = currentSessionGuid;
+ _deleteDirectory = deleteDirectory ?? DeleteDirectoryRecursively;
+ _deleteFile = deleteFile ?? File.Delete;
+ }
+
+ ///
+ /// Deletes every session directory named by a GUID other than the current session's, then
+ /// every reference-cache copy of an artifact the current session does not hold.
+ ///
+ public void Sweep()
+ {
+ string artifactsRoot = Path.Combine(
+ _projectRoot,
+ HotReloadConstants.IntroducedTypeArtifactsRelativeDirectory);
+ DeleteEarlierSessionDirectories(artifactsRoot);
+
+ string currentSessionDirectory = Path.Combine(artifactsRoot, _currentSessionId);
+ DeleteCopiesOfEarlierArtifacts(
+ Path.Combine(_projectRoot, HotReloadConstants.PublicizedRefsRelativeDirectory),
+ currentSessionDirectory);
+ DeleteCopiesOfEarlierArtifacts(
+ Path.Combine(_projectRoot, HotReloadConstants.InternalsExposedRefsRelativeDirectory),
+ currentSessionDirectory);
+ }
+
+ private void DeleteEarlierSessionDirectories(string artifactsRoot)
+ {
+ foreach (string sessionDirectory in ListEntries(artifactsRoot, Directory.GetDirectories))
+ {
+ if (!IsEarlierSessionDirectory(Path.GetFileName(sessionDirectory)))
+ {
+ continue;
+ }
+
+ TryDelete(sessionDirectory, _deleteDirectory);
+ }
+ }
+
+ // Why compare parsed GUIDs: a name that differs from the current session id only in case
+ // is the current session's directory on a case-insensitive file system.
+ private bool IsEarlierSessionDirectory(string directoryName)
+ {
+ return Guid.TryParseExact(directoryName, GuidFormat, out Guid sessionGuid)
+ && sessionGuid != _currentSessionGuid;
+ }
+
+ private void DeleteCopiesOfEarlierArtifacts(string cacheDirectory, string currentSessionDirectory)
+ {
+ foreach (string cachedFile in ListEntries(cacheDirectory, Directory.GetFiles))
+ {
+ if (!TryReadArtifactId(Path.GetFileName(cachedFile), out string artifactId))
+ {
+ continue;
+ }
+
+ // Why only the current session decides: once the earlier sessions are gone it holds
+ // every artifact this domain can still load, and a copy deleted too eagerly is
+ // rebuilt on its next request, while an artifact directory cannot be.
+ if (Directory.Exists(Path.Combine(currentSessionDirectory, artifactId)))
+ {
+ continue;
+ }
+
+ TryDelete(cachedFile, _deleteFile);
+ }
+ }
+
+ // Why read the id at a fixed offset: a copy is named -.dll, a temporary
+ // file it is written through appends .tmp-, and the assembly name is the prefix
+ // followed by the 32-digit artifact id.
+ private static bool TryReadArtifactId(string fileName, out string artifactId)
+ {
+ artifactId = null;
+ string prefix = HotReloadConstants.IntroducedTypeArtifactAssemblyNamePrefix;
+ int separatorIndex = prefix.Length + ArtifactIdLength;
+ if (fileName.Length <= separatorIndex
+ || !fileName.StartsWith(prefix, StringComparison.Ordinal)
+ || fileName[separatorIndex] != ArtifactCopyNameSeparator)
+ {
+ return false;
+ }
+
+ string candidate = fileName.Substring(prefix.Length, ArtifactIdLength);
+ if (!Guid.TryParseExact(candidate, GuidFormat, out Guid _))
+ {
+ return false;
+ }
+
+ artifactId = candidate;
+ return true;
+ }
+
+ // Why swallow only these two, here and when deleting: the sweep only reclaims disk space,
+ // so a directory it cannot read or an entry the OS keeps is left for the next domain to
+ // retry rather than failing the Editor's first update.
+ private string[] ListEntries(string directory, Func list)
+ {
+ if (!Directory.Exists(directory))
+ {
+ return Array.Empty();
+ }
+
+ try
+ {
+ return list(directory);
+ }
+ catch (IOException ex)
+ {
+ LogFailure(directory, ex.Message);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ LogFailure(directory, ex.Message);
+ }
+
+ return Array.Empty();
+ }
+
+ private void TryDelete(string path, Action delete)
+ {
+ try
+ {
+ delete(path);
+ }
+ catch (IOException ex)
+ {
+ LogFailure(path, ex.Message);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ LogFailure(path, ex.Message);
+ }
+ }
+
+ private static void LogFailure(string path, string reason)
+ {
+ VibeLogger.LogWarning(
+ HotReloadConstants.VibeLogIntroducedTypeArtifactSweepFailed,
+ "An introduced-type artifact of an earlier domain could not be swept.",
+ new { path, reason });
+ }
+
+ private static void DeleteDirectoryRecursively(string path)
+ {
+ Directory.Delete(path, recursive: true);
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadIntroducedTypeArtifactSweeper.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadIntroducedTypeArtifactSweeper.cs.meta
new file mode 100644
index 000000000..873963419
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadIntroducedTypeArtifactSweeper.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c6266d328acff4cd5a2dc6985dc46aa6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: