From 4d300aa25645df67f92520dd6a1739fcb27683fa Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 18:35:43 +0900 Subject: [PATCH 1/6] test: characterize AssemblyDefinitionConsoleErrorValidationService message formatting Pin CreateFailureMessage and AssemblyDefinitionConsoleErrorResult.Message behavior (file-prefixed listing, no-file fallback, ten-issue display cap) before extracting this pure logic into its own class. Co-authored-by: Cursor --- ...itionConsoleErrorValidationServiceTests.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs b/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs index 75d7b8ddd9..3c8a38731b 100644 --- a/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs +++ b/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs @@ -2,6 +2,7 @@ using UnityEditor; using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Tests.Editor { @@ -174,5 +175,72 @@ public void FindErrors_WhenValidRegistryPackageAsmdefPathIsMentioned_ReturnsNoIs Assert.That(result.HasErrors, Is.False); Assert.That(result.Errors, Is.Empty); } + + [Test] + public void CreateFailureMessage_WhenErrorsHaveFiles_ListsEachIssueWithItsFile() + { + // Pins the failure message shape consumed by CompileResult.Message for compile responses. + AssemblyDefinitionConsoleError[] errors = + { + new("duplicate references", "Assets/Editor/Sample.asmdef", 0), + new("invalid target", "Assets/Tests/Sample.asmref", 0) + }; + + string message = AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors); + + Assert.That(message, Does.StartWith(UnityCliLoopConstants.ERROR_MESSAGE_ASSEMBLY_DEFINITION_IMPORT_ERROR)); + Assert.That(message, Does.Contain("- Assets/Editor/Sample.asmdef: duplicate references")); + Assert.That(message, Does.Contain("- Assets/Tests/Sample.asmref: invalid target")); + } + + [Test] + public void CreateFailureMessage_WhenErrorHasNoFile_OmitsFilePrefix() + { + // Pins fallback formatting for issues without a resolvable asset path. + AssemblyDefinitionConsoleError[] errors = { new("generic import failure", "", 0) }; + + string message = AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors); + + Assert.That(message, Does.Contain("- generic import failure")); + Assert.That(message, Does.Not.Contain(": generic import failure")); + } + + [Test] + public void CreateFailureMessage_WhenMoreThanTenErrorsExist_ListsOnlyFirstTen() + { + // Pins the display cap so console failure messages stay readable with many issues. + AssemblyDefinitionConsoleError[] errors = new AssemblyDefinitionConsoleError[12]; + for (int i = 0; i < errors.Length; i++) + { + errors[i] = new AssemblyDefinitionConsoleError($"issue-{i}", $"Assets/Sample{i}.asmdef", 0); + } + + string message = AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors); + + Assert.That(message, Does.Contain("issue-9")); + Assert.That(message, Does.Not.Contain("issue-10")); + Assert.That(message, Does.Not.Contain("issue-11")); + } + + [Test] + public void AssemblyDefinitionConsoleErrorResult_WhenErrorsExist_ExposesFormattedMessage() + { + // Pins that the result DTO's Message mirrors CreateFailureMessage for compile failure reporting. + AssemblyDefinitionConsoleError[] errors = { new("duplicate references", "Assets/Editor/Sample.asmdef", 0) }; + + AssemblyDefinitionConsoleErrorResult result = new(errors); + + Assert.That(result.Message, Is.EqualTo(AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors))); + } + + [Test] + public void AssemblyDefinitionConsoleErrorResult_WhenNoErrorsExist_HasNullMessage() + { + // Pins that an empty result never triggers Message-based failure handling. + AssemblyDefinitionConsoleErrorResult result = new(System.Array.Empty()); + + Assert.That(result.HasErrors, Is.False); + Assert.That(result.Message, Is.Null); + } } } From 81ae6cb54a03d6213863d672aeddb787f4f330a3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 18:39:56 +0900 Subject: [PATCH 2/6] refactor: split AssemblyDefinitionConsoleErrorValidationService into 500-line-clean files Extract Class: move the AssemblyDefinitionConsoleError/Result DTOs into their own files and move the pure CreateFailureMessage formatting into AssemblyDefinitionConsoleErrorMessageFormatter, separating message formatting from Console error detection. No behavior change; the characterization tests added in the previous commit stay green. Co-authored-by: Cursor --- ...itionConsoleErrorValidationServiceTests.cs | 8 +-- .../Compile/AssemblyDefinitionConsoleError.cs | 19 +++++++ .../AssemblyDefinitionConsoleError.cs.meta | 11 ++++ ...yDefinitionConsoleErrorMessageFormatter.cs | 34 +++++++++++ ...nitionConsoleErrorMessageFormatter.cs.meta | 11 ++++ .../AssemblyDefinitionConsoleErrorResult.cs | 22 ++++++++ ...semblyDefinitionConsoleErrorResult.cs.meta | 11 ++++ ...DefinitionConsoleErrorValidationService.cs | 56 ------------------- 8 files changed, 112 insertions(+), 60 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs.meta diff --git a/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs b/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs index 3c8a38731b..72c4c531cd 100644 --- a/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs +++ b/Assets/Tests/Editor/AssemblyDefinitionConsoleErrorValidationServiceTests.cs @@ -186,7 +186,7 @@ public void CreateFailureMessage_WhenErrorsHaveFiles_ListsEachIssueWithItsFile() new("invalid target", "Assets/Tests/Sample.asmref", 0) }; - string message = AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors); + string message = AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors); Assert.That(message, Does.StartWith(UnityCliLoopConstants.ERROR_MESSAGE_ASSEMBLY_DEFINITION_IMPORT_ERROR)); Assert.That(message, Does.Contain("- Assets/Editor/Sample.asmdef: duplicate references")); @@ -199,7 +199,7 @@ public void CreateFailureMessage_WhenErrorHasNoFile_OmitsFilePrefix() // Pins fallback formatting for issues without a resolvable asset path. AssemblyDefinitionConsoleError[] errors = { new("generic import failure", "", 0) }; - string message = AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors); + string message = AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors); Assert.That(message, Does.Contain("- generic import failure")); Assert.That(message, Does.Not.Contain(": generic import failure")); @@ -215,7 +215,7 @@ public void CreateFailureMessage_WhenMoreThanTenErrorsExist_ListsOnlyFirstTen() errors[i] = new AssemblyDefinitionConsoleError($"issue-{i}", $"Assets/Sample{i}.asmdef", 0); } - string message = AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors); + string message = AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors); Assert.That(message, Does.Contain("issue-9")); Assert.That(message, Does.Not.Contain("issue-10")); @@ -230,7 +230,7 @@ public void AssemblyDefinitionConsoleErrorResult_WhenErrorsExist_ExposesFormatte AssemblyDefinitionConsoleErrorResult result = new(errors); - Assert.That(result.Message, Is.EqualTo(AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(errors))); + Assert.That(result.Message, Is.EqualTo(AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(errors))); } [Test] diff --git a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs new file mode 100644 index 0000000000..b47c9254e7 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs @@ -0,0 +1,19 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Immutable Console error snapshot for one Assembly Definition or Assembly Reference issue. + /// + public sealed class AssemblyDefinitionConsoleError + { + public string Message { get; } + public string File { get; } + public int Line { get; } + + public AssemblyDefinitionConsoleError(string message, string file, int line) + { + Message = message ?? string.Empty; + File = file ?? string.Empty; + Line = line; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs.meta new file mode 100644 index 0000000000..011c29b280 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleError.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a9effbf84b0c47808273b67f44ee60f4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs new file mode 100644 index 0000000000..495ff3dab1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using System.Linq; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Formats Assembly Definition and Assembly Reference Console errors into a compile failure message. + /// + internal static class AssemblyDefinitionConsoleErrorMessageFormatter + { + private const int MaxDisplayedIssueCount = 10; + + /// + /// Creates the compile failure message shown when Assembly Definition or Assembly Reference errors are present. + /// + internal static string CreateFailureMessage(AssemblyDefinitionConsoleError[] errors) + { + Debug.Assert(errors != null, "errors must not be null"); + + string details = string.Join( + "\n", + errors + .Take(MaxDisplayedIssueCount) + .Select(error => string.IsNullOrWhiteSpace(error.File) + ? $"- {error.Message}" + : $"- {error.File}: {error.Message}") + ); + + return $"{UnityCliLoopConstants.ERROR_MESSAGE_ASSEMBLY_DEFINITION_IMPORT_ERROR}\n{details}"; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs.meta new file mode 100644 index 0000000000..0370c43f7c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorMessageFormatter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 68e3d743817348eeb998dcf9327f9df7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs new file mode 100644 index 0000000000..780975051b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs @@ -0,0 +1,22 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Immutable result for Assembly Definition and Assembly Reference Console error detection. + /// + public sealed class AssemblyDefinitionConsoleErrorResult + { + public AssemblyDefinitionConsoleError[] Errors { get; } + public bool HasErrors => Errors.Length > 0; + public string Message { get; } + + public AssemblyDefinitionConsoleErrorResult(AssemblyDefinitionConsoleError[] errors) + { + Errors = errors ?? Array.Empty(); + Message = HasErrors + ? AssemblyDefinitionConsoleErrorMessageFormatter.CreateFailureMessage(Errors) + : null; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs.meta new file mode 100644 index 0000000000..6481d4f3d6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5242f82585684c609ab4fc7c2ee1c78f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorValidationService.cs b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorValidationService.cs index 8e503fa0ea..3f08c12bf2 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorValidationService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/AssemblyDefinitionConsoleErrorValidationService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using UnityEditor; using UnityEditor.AssetImporters; @@ -16,7 +15,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public sealed class AssemblyDefinitionConsoleErrorValidationService { - private const int MaxDisplayedIssueCount = 10; private const string GuidReferencePrefix = "GUID:"; private readonly Func _isCurrentImportError; @@ -443,59 +441,5 @@ private static bool ContainsText(string value, string marker) { return value.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0; } - - /// - /// Creates the compile failure message shown when Assembly Definition or Assembly Reference errors are present. - /// - internal static string CreateFailureMessage(AssemblyDefinitionConsoleError[] errors) - { - Debug.Assert(errors != null, "errors must not be null"); - - string details = string.Join( - "\n", - errors - .Take(MaxDisplayedIssueCount) - .Select(error => string.IsNullOrWhiteSpace(error.File) - ? $"- {error.Message}" - : $"- {error.File}: {error.Message}") - ); - - return $"{UnityCliLoopConstants.ERROR_MESSAGE_ASSEMBLY_DEFINITION_IMPORT_ERROR}\n{details}"; - } - } - - /// - /// Immutable Console error snapshot for one Assembly Definition or Assembly Reference issue. - /// - public sealed class AssemblyDefinitionConsoleError - { - public string Message { get; } - public string File { get; } - public int Line { get; } - - public AssemblyDefinitionConsoleError(string message, string file, int line) - { - Message = message ?? string.Empty; - File = file ?? string.Empty; - Line = line; - } - } - - /// - /// Immutable result for Assembly Definition and Assembly Reference Console error detection. - /// - public sealed class AssemblyDefinitionConsoleErrorResult - { - public AssemblyDefinitionConsoleError[] Errors { get; } - public bool HasErrors => Errors.Length > 0; - public string Message { get; } - - public AssemblyDefinitionConsoleErrorResult(AssemblyDefinitionConsoleError[] errors) - { - Errors = errors ?? Array.Empty(); - Message = HasErrors - ? AssemblyDefinitionConsoleErrorValidationService.CreateFailureMessage(Errors) - : null; - } } } From 0f25a67747022d1e4834d51184d59df038b8f946 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 18:43:31 +0900 Subject: [PATCH 3/6] test: characterize ExternalSceneChangeTracker fingerprint diff reporting Parameterize BuildFingerprintDiffContexts over an injected snapshot dictionary and fingerprint reader instead of closing over static tracker state, then pin its unchanged/changed/no-snapshot behavior with tests. This prepares the pure logic for extraction into its own class without altering ResolveForFocusReturn's observed behavior. Co-authored-by: Cursor --- .../ExternalSceneChangeResolverTests.cs | 54 +++++++++++++++++++ .../Compile/ExternalSceneChangeTracker.cs | 21 +++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index a480fb7d65..0a11c9a650 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -542,6 +542,60 @@ public void CreatePrefabStageReopenContext_WhenInstanceIsInvalid_FallsBackToIsol } } + [Test] + public void BuildFingerprintDiffContexts_WhenNoSnapshotExists_ReportsChangedWithoutSnapshot() + { + // Pins first-observation behavior used by focus-return start/end observability logging. + Dictionary snapshots = + new Dictionary(StringComparer.Ordinal); + (string AssetPath, bool IsDirty)[] scenes = { (ScenePath, false) }; + + object[] diffs = ExternalSceneChangeTracker.BuildFingerprintDiffContexts( + scenes, snapshots, _ => (true, ChangedTime, 20)); + + Assert.That(diffs, Has.Length.EqualTo(1)); + dynamic diff = diffs[0]; + Assert.That((string)diff.assetPath, Is.EqualTo(ScenePath)); + Assert.That((bool)diff.changed, Is.True); + Assert.That((bool)diff.hasSnapshot, Is.False); + Assert.That((bool)diff.snapshotExists, Is.False); + Assert.That((bool)diff.currentExists, Is.True); + Assert.That((long)diff.currentLength, Is.EqualTo(20)); + } + + [Test] + public void BuildFingerprintDiffContexts_WhenFingerprintMatchesSnapshot_ReportsUnchanged() + { + // Pins that identical fingerprints are reported as unchanged for observability logging. + Dictionary snapshots = CreateSnapshots(); + (string AssetPath, bool IsDirty)[] scenes = { (ScenePath, true) }; + + object[] diffs = ExternalSceneChangeTracker.BuildFingerprintDiffContexts( + scenes, snapshots, _ => (true, SavedTime, 10)); + + dynamic diff = diffs[0]; + Assert.That((bool)diff.hasSnapshot, Is.True); + Assert.That((bool)diff.changed, Is.False); + Assert.That((bool)diff.isDirty, Is.True); + } + + [Test] + public void BuildFingerprintDiffContexts_WhenFingerprintDiffersFromSnapshot_ReportsChanged() + { + // Pins that a diverged fingerprint is reported as changed for observability logging. + Dictionary snapshots = CreateSnapshots(); + (string AssetPath, bool IsDirty)[] scenes = { (ScenePath, false) }; + + object[] diffs = ExternalSceneChangeTracker.BuildFingerprintDiffContexts( + scenes, snapshots, _ => (true, ChangedTime, 20)); + + dynamic diff = diffs[0]; + Assert.That((bool)diff.hasSnapshot, Is.True); + Assert.That((bool)diff.changed, Is.True); + Assert.That((long)diff.snapshotLength, Is.EqualTo(10)); + Assert.That((long)diff.currentLength, Is.EqualTo(20)); + } + [Test] public void CreatePrefabStageReopenContext_WhenInstanceIsMissing_UsesIsolation() { diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs index db7a18d240..6737afbd91 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -171,7 +171,8 @@ private static void ResolveForFocusReturn() // Focus return treats Unity's in-memory editor state as authoritative because source-control // operations can replace files while Unity is unfocused and would otherwise trigger reload dialogs. (string AssetPath, bool IsDirty)[] openScenesBefore = GetOpenSceneStates(); - object[] fingerprintDiffsBefore = BuildFingerprintDiffContexts(openScenesBefore); + object[] fingerprintDiffsBefore = + BuildFingerprintDiffContexts(openScenesBefore, SceneSnapshots, ReadAssetFileFingerprint); VibeLogger.LogInfo( "external_scene_resolve_focus_return", "ResolveForFocusReturn started", @@ -230,7 +231,7 @@ private static void ResolveForFocusReturn() { phase = "end", scenes = openScenesAfter, - fingerprintDiffs = BuildFingerprintDiffContexts(openScenesAfter), + fingerprintDiffs = BuildFingerprintDiffContexts(openScenesAfter, SceneSnapshots, ReadAssetFileFingerprint), dirtySceneSaveFailures, missingSceneSaveFailures, held = IsAutoRefreshHeld() @@ -238,17 +239,25 @@ private static void ResolveForFocusReturn() includeStackTrace: false); } - private static object[] BuildFingerprintDiffContexts((string AssetPath, bool IsDirty)[] scenes) + /// + /// Builds a fingerprint diff snapshot for observability logging, given explicit snapshot state + /// and a fingerprint reader so the comparison logic itself can be characterized without Unity APIs. + /// + internal static object[] BuildFingerprintDiffContexts( + (string AssetPath, bool IsDirty)[] scenes, + Dictionary snapshots, + Func readFingerprint) { Debug.Assert(scenes != null, "scenes must not be null"); + Debug.Assert(snapshots != null, "snapshots must not be null"); + Debug.Assert(readFingerprint != null, "readFingerprint must not be null"); List diffs = new List(scenes.Length); for (int i = 0; i < scenes.Length; i++) { string assetPath = scenes[i].AssetPath; - (bool Exists, DateTime LastWriteTimeUtc, long Length) current = - ReadAssetFileFingerprint(assetPath); - bool hasSnapshot = SceneSnapshots.TryGetValue( + (bool Exists, DateTime LastWriteTimeUtc, long Length) current = readFingerprint(assetPath); + bool hasSnapshot = snapshots.TryGetValue( assetPath, out (bool Exists, DateTime LastWriteTimeUtc, long Length) snapshot); bool changed = !hasSnapshot || From cb657cf81f7edbe5f5928e74085572356d1b7832 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 18:51:01 +0900 Subject: [PATCH 4/6] refactor: extract ExternalPrefabStageChangeTracker from ExternalSceneChangeTracker Extract Class: move all Prefab Stage snapshot tracking (event registration, save/reload, focus-return resolution, reopen-context decision) into its own tracker, mirroring the existing Scene tracking responsibility split. ExternalSceneChangeTracker keeps Scene tracking and delegates Prefab Stage concerns to the new class; shared fingerprint/path/logging helpers are now internal so both classes can share them without behavior change. Co-authored-by: Cursor --- .../ExternalSceneChangeResolverTests.cs | 6 +- .../ExternalPrefabStageChangeTracker.cs | 250 ++++++++++++++++++ .../ExternalPrefabStageChangeTracker.cs.meta | 11 + .../Compile/ExternalSceneChangeTracker.cs | 242 +---------------- 4 files changed, 275 insertions(+), 234 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs.meta diff --git a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs index 0a11c9a650..9dd333f0fb 100644 --- a/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs +++ b/Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs @@ -506,7 +506,7 @@ public void CreatePrefabStageReopenContext_WhenInstanceIsValid_PreservesContext( try { (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = - ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + ExternalPrefabStageChangeTracker.CreatePrefabStageReopenContext( openedFromInstanceObject, PrefabStage.Mode.InContext, _ => true); @@ -528,7 +528,7 @@ public void CreatePrefabStageReopenContext_WhenInstanceIsInvalid_FallsBackToIsol try { (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = - ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + ExternalPrefabStageChangeTracker.CreatePrefabStageReopenContext( openedFromInstanceObject, PrefabStage.Mode.InContext, _ => false); @@ -601,7 +601,7 @@ public void CreatePrefabStageReopenContext_WhenInstanceIsMissing_UsesIsolation() { // Verifies missing Prefab Stage context reopens without invalid InContext arguments. (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) context = - ExternalSceneChangeTracker.CreatePrefabStageReopenContext( + ExternalPrefabStageChangeTracker.CreatePrefabStageReopenContext( null, PrefabStage.Mode.InContext, _ => true); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs new file mode 100644 index 0000000000..bc72887af3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Tracks the current Prefab Stage asset snapshot so compile and focus-return can resolve + /// external disk changes to the open Prefab, mirroring ExternalSceneChangeTracker's Scene tracking. + /// + internal static class ExternalPrefabStageChangeTracker + { + private const string PrefabStageSnapshotsSessionStateKey = + "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.PrefabStageSnapshots"; + private static readonly Dictionary PrefabStageSnapshots = + new Dictionary(StringComparer.Ordinal); + + internal static void RegisterEventHandlers() + { + PrefabStage.prefabStageOpened -= HandlePrefabStageOpened; + PrefabStage.prefabStageOpened += HandlePrefabStageOpened; + PrefabStage.prefabStageClosing -= HandlePrefabStageClosing; + PrefabStage.prefabStageClosing += HandlePrefabStageClosing; + PrefabStage.prefabSaved -= HandlePrefabSaved; + PrefabStage.prefabSaved += HandlePrefabSaved; + } + + internal static void RestoreFromSessionState() + { + ExternalAssetSnapshotSessionStore.RestoreSnapshots( + PrefabStageSnapshots, + SessionState.GetString(PrefabStageSnapshotsSessionStateKey, "")); + } + + internal static void RecordCurrent() + { + Record(PrefabStageUtility.GetCurrentPrefabStage()); + } + + private static void HandlePrefabStageOpened(PrefabStage prefabStage) + { + Record(prefabStage); + } + + private static void HandlePrefabStageClosing(PrefabStage prefabStage) + { + if (!IsTrackable(prefabStage)) + { + return; + } + + PrefabStageSnapshots.Remove(ExternalSceneChangeTracker.NormalizeAssetPath(prefabStage.assetPath)); + SaveToSessionState(); + } + + private static void HandlePrefabSaved(GameObject prefabRoot) + { + RecordCurrent(); + } + + private static void Record(PrefabStage prefabStage) + { + if (!IsTrackable(prefabStage)) + { + return; + } + + string assetPath = ExternalSceneChangeTracker.NormalizeAssetPath(prefabStage.assetPath); + PrefabStageSnapshots[assetPath] = ExternalSceneChangeTracker.ReadAssetFileFingerprint(assetPath); + SaveToSessionState(); + } + + private static bool IsTrackable(PrefabStage prefabStage) + { + return prefabStage != null && + prefabStage.scene.IsValid() && + !string.IsNullOrEmpty(prefabStage.assetPath) && + prefabStage.assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Reopens the current Prefab Stage after Unity reimports its externally changed asset. + /// + internal static void ResolveExternalChangeForFocusReturn() + { + PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (!IsTrackable(prefabStage)) + { + return; + } + + string assetPath = ExternalSceneChangeTracker.NormalizeAssetPath(prefabStage.assetPath); + (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint = + ExternalSceneChangeTracker.ReadAssetFileFingerprint(assetPath); + if (!PrefabStageSnapshots.ContainsKey(assetPath)) + { + PrefabStageSnapshots[assetPath] = currentFingerprint; + SaveToSessionState(); + return; + } + + if (ExternalAssetFileStateComparer.HasSameFileState( + PrefabStageSnapshots[assetPath], currentFingerprint)) + { + return; + } + + if (!currentFingerprint.Exists) + { + string[] saveFailures = SaveMissingAsset(); + ExternalSceneChangeTracker.LogFocusReturnFailures( + "restore the missing Prefab asset from the Unity state", saveFailures); + return; + } + + AssetDatabase.ImportAsset(assetPath); + UnityEngine.Object prefabAsset = AssetDatabase.LoadMainAssetAtPath(assetPath); + if (prefabAsset == null) + { + Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + + "Prefab Stage: " + assetPath); + return; + } + + (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) reopenContext = + CreatePrefabStageReopenContext( + prefabStage.openedFromInstanceObject, + prefabStage.mode, + PrefabUtility.IsPartOfPrefabInstance); + PrefabStage reopenedStage = + PrefabStageUtility.OpenPrefab(assetPath, reopenContext.OpenedFromInstanceObject, reopenContext.Mode); + if (reopenedStage == null) + { + Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + + "Prefab Stage: " + assetPath); + return; + } + + Record(reopenedStage); + } + + internal static (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) CreatePrefabStageReopenContext( + GameObject openedFromInstanceObject, + PrefabStage.Mode prefabStageMode, + Func isPartOfPrefabInstance) + { + Debug.Assert(isPartOfPrefabInstance != null, "isPartOfPrefabInstance must not be null"); + + if (openedFromInstanceObject == null) + { + return (null, PrefabStage.Mode.InIsolation); + } + + if (isPartOfPrefabInstance(openedFromInstanceObject)) + { + return (openedFromInstanceObject, prefabStageMode); + } + + return (null, PrefabStage.Mode.InIsolation); + } + + internal static string[] SaveDirty() + { + PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (!IsTrackable(prefabStage) || !prefabStage.scene.isDirty) + { + return Array.Empty(); + } + + if (TrySave(prefabStage)) + { + return Array.Empty(); + } + + return new[] { GetDisplayPath(prefabStage) }; + } + + internal static string[] SaveMissingAsset() + { + PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (!IsTrackable(prefabStage)) + { + return Array.Empty(); + } + + string assetPath = ExternalSceneChangeTracker.NormalizeAssetPath(prefabStage.assetPath); + (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint = + ExternalSceneChangeTracker.ReadAssetFileFingerprint(assetPath); + if (currentFingerprint.Exists) + { + return Array.Empty(); + } + + if (TrySave(prefabStage)) + { + return Array.Empty(); + } + + return new[] { GetDisplayPath(prefabStage) }; + } + + internal static bool IsCurrentDirty() + { + PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + return IsTrackable(prefabStage) && prefabStage.scene.isDirty; + } + + private static bool TrySave(PrefabStage prefabStage) + { + Debug.Assert(prefabStage != null, "prefabStage must not be null"); + + if (string.IsNullOrEmpty(prefabStage.assetPath)) + { + return false; + } + + bool success; + PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabStage.assetPath, out success); + if (!success) + { + return false; + } + + prefabStage.ClearDirtiness(); + Record(prefabStage); + return true; + } + + private static void SaveToSessionState() + { + SessionState.SetString( + PrefabStageSnapshotsSessionStateKey, + ExternalAssetSnapshotSessionStore.SerializeSnapshots(PrefabStageSnapshots)); + } + + private static string GetDisplayPath(PrefabStage prefabStage) + { + Debug.Assert(prefabStage != null, "prefabStage must not be null"); + + if (!string.IsNullOrEmpty(prefabStage.assetPath)) + { + return ExternalSceneChangeTracker.NormalizeAssetPath(prefabStage.assetPath); + } + + return ExternalSceneChangeTracker.GetSceneDisplayPath(prefabStage.scene); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs.meta new file mode 100644 index 0000000000..eff7847e31 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalPrefabStageChangeTracker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 40a74b393611486384404ee0c20e00d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs index 6737afbd91..9a133659fc 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/ExternalSceneChangeTracker.cs @@ -19,12 +19,8 @@ internal static class ExternalSceneChangeTracker "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.AutoRefreshHeld"; private const string SceneSnapshotsSessionStateKey = "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.SceneSnapshots"; - private const string PrefabStageSnapshotsSessionStateKey = - "io.github.hatayama.UnityCliLoop.ExternalSceneChangeTracker.PrefabStageSnapshots"; private static readonly Dictionary SceneSnapshots = new Dictionary(StringComparer.Ordinal); - private static readonly Dictionary PrefabStageSnapshots = - new Dictionary(StringComparer.Ordinal); private static readonly ExternalAssetFocusReturnService FocusReturnService = new ExternalAssetFocusReturnService( IsAutoRefreshHeld, @@ -69,12 +65,7 @@ public static void Initialize() EditorSceneManager.sceneSaved += HandleSceneSaved; EditorSceneManager.sceneClosed -= HandleSceneClosed; EditorSceneManager.sceneClosed += HandleSceneClosed; - PrefabStage.prefabStageOpened -= HandlePrefabStageOpened; - PrefabStage.prefabStageOpened += HandlePrefabStageOpened; - PrefabStage.prefabStageClosing -= HandlePrefabStageClosing; - PrefabStage.prefabStageClosing += HandlePrefabStageClosing; - PrefabStage.prefabSaved -= HandlePrefabSaved; - PrefabStage.prefabSaved += HandlePrefabSaved; + ExternalPrefabStageChangeTracker.RegisterEventHandlers(); EditorApplication.focusChanged -= HandleFocusChanged; EditorApplication.focusChanged += HandleFocusChanged; EditorApplication.update -= ReconcileAutoRefreshHoldOnUpdate; @@ -83,7 +74,7 @@ public static void Initialize() if (!restoredHeldAutoRefresh && !IsAutoRefreshHeld()) { RecordOpenSceneSnapshots(); - RecordCurrentPrefabStageSnapshot(); + ExternalPrefabStageChangeTracker.RecordCurrent(); } // Why immediate Hold: background launch never fires focusChanged(false), so Auto Refresh @@ -145,27 +136,6 @@ private static void HandleSceneClosed(Scene scene) SaveSceneSnapshotsToSessionState(); } - private static void HandlePrefabStageOpened(PrefabStage prefabStage) - { - RecordPrefabStageSnapshot(prefabStage); - } - - private static void HandlePrefabStageClosing(PrefabStage prefabStage) - { - if (!IsTrackablePrefabStage(prefabStage)) - { - return; - } - - PrefabStageSnapshots.Remove(NormalizeAssetPath(prefabStage.assetPath)); - SavePrefabStageSnapshotsToSessionState(); - } - - private static void HandlePrefabSaved(GameObject prefabRoot) - { - RecordCurrentPrefabStageSnapshot(); - } - private static void ResolveForFocusReturn() { // Focus return treats Unity's in-memory editor state as authoritative because source-control @@ -191,16 +161,16 @@ private static void ResolveForFocusReturn() string[] missingSceneSaveFailures = SaveMissingOpenScenesFromUnity(); LogFocusReturnFailures("restore missing Scene files from the Unity state", missingSceneSaveFailures); - string[] dirtyPrefabSaveFailures = SaveDirtyCurrentPrefabStage(); + string[] dirtyPrefabSaveFailures = ExternalPrefabStageChangeTracker.SaveDirty(); LogFocusReturnFailures("save the dirty Prefab Stage", dirtyPrefabSaveFailures); - string[] missingPrefabSaveFailures = SaveMissingCurrentPrefabStageAsset(); + string[] missingPrefabSaveFailures = ExternalPrefabStageChangeTracker.SaveMissingAsset(); LogFocusReturnFailures("restore the missing Prefab asset from the Unity state", missingPrefabSaveFailures); ResolveSceneExternalChangesForFocusReturn(); if (dirtyPrefabSaveFailures.Length > 0 || missingPrefabSaveFailures.Length > 0 || - IsCurrentPrefabStageDirty()) + ExternalPrefabStageChangeTracker.IsCurrentDirty()) { Debug.LogWarning( "Unity CLI Loop skipped Prefab Stage external-change reload because the current Prefab Stage is still dirty or could not be saved."); @@ -221,7 +191,7 @@ private static void ResolveForFocusReturn() return; } - ResolveCurrentPrefabStageExternalChangeForFocusReturn(); + ExternalPrefabStageChangeTracker.ResolveExternalChangeForFocusReturn(); (string AssetPath, bool IsDirty)[] openScenesAfter = GetOpenSceneStates(); VibeLogger.LogInfo( @@ -310,23 +280,6 @@ private static bool RecordSceneSnapshotIfTrackable(Scene scene) return true; } - private static void RecordCurrentPrefabStageSnapshot() - { - RecordPrefabStageSnapshot(PrefabStageUtility.GetCurrentPrefabStage()); - } - - private static void RecordPrefabStageSnapshot(PrefabStage prefabStage) - { - if (!IsTrackablePrefabStage(prefabStage)) - { - return; - } - - string assetPath = NormalizeAssetPath(prefabStage.assetPath); - PrefabStageSnapshots[assetPath] = ReadAssetFileFingerprint(assetPath); - SavePrefabStageSnapshotsToSessionState(); - } - private static (string AssetPath, bool IsDirty)[] GetOpenSceneStates() { List<(string AssetPath, bool IsDirty)> scenes = new List<(string AssetPath, bool IsDirty)>(); @@ -352,15 +305,7 @@ private static bool IsTrackableScene(Scene scene) scene.path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); } - private static bool IsTrackablePrefabStage(PrefabStage prefabStage) - { - return prefabStage != null && - prefabStage.scene.IsValid() && - !string.IsNullOrEmpty(prefabStage.assetPath) && - prefabStage.assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase); - } - - private static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadAssetFileFingerprint( + internal static (bool Exists, DateTime LastWriteTimeUtc, long Length) ReadAssetFileFingerprint( string assetPath) { Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); @@ -391,83 +336,6 @@ private static void ResolveSceneExternalChangesForFocusReturn() result.Message); } - private static void ResolveCurrentPrefabStageExternalChangeForFocusReturn() - { - PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); - if (!IsTrackablePrefabStage(prefabStage)) - { - return; - } - - string assetPath = NormalizeAssetPath(prefabStage.assetPath); - (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint = - ReadAssetFileFingerprint(assetPath); - if (!PrefabStageSnapshots.ContainsKey(assetPath)) - { - PrefabStageSnapshots[assetPath] = currentFingerprint; - SavePrefabStageSnapshotsToSessionState(); - return; - } - - if (ExternalAssetFileStateComparer.HasSameFileState( - PrefabStageSnapshots[assetPath], currentFingerprint)) - { - return; - } - - if (!currentFingerprint.Exists) - { - string[] saveFailures = SaveMissingCurrentPrefabStageAsset(); - LogFocusReturnFailures("restore the missing Prefab asset from the Unity state", saveFailures); - return; - } - - AssetDatabase.ImportAsset(assetPath); - UnityEngine.Object prefabAsset = AssetDatabase.LoadMainAssetAtPath(assetPath); - if (prefabAsset == null) - { - Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + - "Prefab Stage: " + assetPath); - return; - } - - (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) reopenContext = - CreatePrefabStageReopenContext( - prefabStage.openedFromInstanceObject, - prefabStage.mode, - PrefabUtility.IsPartOfPrefabInstance); - PrefabStage reopenedStage = - PrefabStageUtility.OpenPrefab(assetPath, reopenContext.OpenedFromInstanceObject, reopenContext.Mode); - if (reopenedStage == null) - { - Debug.LogWarning("Unity CLI Loop could not reopen externally changed Prefab asset on focus return. " + - "Prefab Stage: " + assetPath); - return; - } - - RecordPrefabStageSnapshot(reopenedStage); - } - - internal static (GameObject OpenedFromInstanceObject, PrefabStage.Mode Mode) CreatePrefabStageReopenContext( - GameObject openedFromInstanceObject, - PrefabStage.Mode prefabStageMode, - Func isPartOfPrefabInstance) - { - Debug.Assert(isPartOfPrefabInstance != null, "isPartOfPrefabInstance must not be null"); - - if (openedFromInstanceObject == null) - { - return (null, PrefabStage.Mode.InIsolation); - } - - if (isPartOfPrefabInstance(openedFromInstanceObject)) - { - return (openedFromInstanceObject, prefabStageMode); - } - - return (null, PrefabStage.Mode.InIsolation); - } - private static string[] SaveDirtyOpenScenesBeforeReload() { List failedScenePaths = new List(); @@ -534,73 +402,6 @@ private static string[] SaveMissingOpenScenesFromUnity() return failedScenePaths.ToArray(); } - private static string[] SaveDirtyCurrentPrefabStage() - { - PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); - if (!IsTrackablePrefabStage(prefabStage) || !prefabStage.scene.isDirty) - { - return Array.Empty(); - } - - if (TrySavePrefabStage(prefabStage)) - { - return Array.Empty(); - } - - return new[] { GetPrefabStageDisplayPath(prefabStage) }; - } - - private static string[] SaveMissingCurrentPrefabStageAsset() - { - PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); - if (!IsTrackablePrefabStage(prefabStage)) - { - return Array.Empty(); - } - - string assetPath = NormalizeAssetPath(prefabStage.assetPath); - (bool Exists, DateTime LastWriteTimeUtc, long Length) currentFingerprint = - ReadAssetFileFingerprint(assetPath); - if (currentFingerprint.Exists) - { - return Array.Empty(); - } - - if (TrySavePrefabStage(prefabStage)) - { - return Array.Empty(); - } - - return new[] { GetPrefabStageDisplayPath(prefabStage) }; - } - - private static bool IsCurrentPrefabStageDirty() - { - PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); - return IsTrackablePrefabStage(prefabStage) && prefabStage.scene.isDirty; - } - - private static bool TrySavePrefabStage(PrefabStage prefabStage) - { - Debug.Assert(prefabStage != null, "prefabStage must not be null"); - - if (string.IsNullOrEmpty(prefabStage.assetPath)) - { - return false; - } - - bool success; - PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabStage.assetPath, out success); - if (!success) - { - return false; - } - - prefabStage.ClearDirtiness(); - RecordPrefabStageSnapshot(prefabStage); - return true; - } - private static bool ReloadOpenSceneSetup() { SceneSetup[] sceneSetup = EditorSceneManager.GetSceneManagerSetup(); @@ -639,9 +440,7 @@ private static void RestoreSnapshotsFromSessionState() ExternalAssetSnapshotSessionStore.RestoreSnapshots( SceneSnapshots, SessionState.GetString(SceneSnapshotsSessionStateKey, "")); - ExternalAssetSnapshotSessionStore.RestoreSnapshots( - PrefabStageSnapshots, - SessionState.GetString(PrefabStageSnapshotsSessionStateKey, "")); + ExternalPrefabStageChangeTracker.RestoreFromSessionState(); } private static void SaveSceneSnapshotsToSessionState() @@ -651,20 +450,13 @@ private static void SaveSceneSnapshotsToSessionState() ExternalAssetSnapshotSessionStore.SerializeSnapshots(SceneSnapshots)); } - private static void SavePrefabStageSnapshotsToSessionState() - { - SessionState.SetString( - PrefabStageSnapshotsSessionStateKey, - ExternalAssetSnapshotSessionStore.SerializeSnapshots(PrefabStageSnapshots)); - } - - private static string NormalizeAssetPath(string assetPath) + internal static string NormalizeAssetPath(string assetPath) { Debug.Assert(!string.IsNullOrEmpty(assetPath), "assetPath must not be empty"); return assetPath.Replace('\\', '/'); } - private static string GetSceneDisplayPath(Scene scene) + internal static string GetSceneDisplayPath(Scene scene) { if (!string.IsNullOrEmpty(scene.path)) { @@ -679,19 +471,7 @@ private static string GetSceneDisplayPath(Scene scene) return "Untitled scene"; } - private static string GetPrefabStageDisplayPath(PrefabStage prefabStage) - { - Debug.Assert(prefabStage != null, "prefabStage must not be null"); - - if (!string.IsNullOrEmpty(prefabStage.assetPath)) - { - return NormalizeAssetPath(prefabStage.assetPath); - } - - return GetSceneDisplayPath(prefabStage.scene); - } - - private static void LogFocusReturnFailures(string action, string[] failedAssetPaths) + internal static void LogFocusReturnFailures(string action, string[] failedAssetPaths) { Debug.Assert(!string.IsNullOrEmpty(action), "action must not be empty"); Debug.Assert(failedAssetPaths != null, "failedAssetPaths must not be null"); From ba725f91bdcabf331745e0b0efa92a24f3222590 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 19:01:17 +0900 Subject: [PATCH 5/6] test: characterize CompileController compile lifecycle recovery decisions Introduce CompileLifecycleRecoveryCoordinator (delegate-injected, mirroring the CompileLifecycleWatchdog pattern) so start-timeout and missed-callback recovery decisions are pure-C# testable without running real Unity compilation. Pin the existing IsCurrentCompileRequest/CreateStoppedWithoutFinishResult behavior plus new characterization tests for HandleCompileStartTimeout and HandleCompileStoppedWithoutFinishEvent branching (assembly definition errors, duplicate asmdef names, generic fallback) before extracting this class into its own file. Co-authored-by: Cursor --- ...ompileLifecycleRecoveryCoordinatorTests.cs | 164 ++++++++ ...eLifecycleRecoveryCoordinatorTests.cs.meta | 11 + .../Editor/CompileLifecycleWatchdogTests.cs | 5 +- .../Compile/CompileController.cs | 376 +++++++++++------- 4 files changed, 404 insertions(+), 152 deletions(-) create mode 100644 Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs create mode 100644 Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs.meta diff --git a/Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs b/Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs new file mode 100644 index 0000000000..1713510c3a --- /dev/null +++ b/Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Pins the recovery decisions made by CompileLifecycleRecoveryCoordinator when the watchdog + /// reports a start timeout or a missed finish callback, without running real Unity compilation. + /// + [TestFixture] + public sealed class CompileLifecycleRecoveryCoordinatorTests + { + [Test] + public void HandleCompileStartTimeout_WhenAssemblyDefinitionErrorsExist_AbortsWithAssemblyDefinitionResult() + { + // Verifies assembly definition errors take priority over the generic start-timeout abort message. + const string asmdefPath = "Assets/Tests/EditMode/Sample.EditMode.Tests.asmdef"; + AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = new( + new AssemblyDefinitionConsoleError[] + { + new("Assembly has duplicate references", asmdefPath, 0) + }); + CompileResult abortedWithResult = null; + string abortedWithMessage = null; + CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator( + findAssemblyDefinitionErrors: () => assemblyDefinitionErrors, + validateNoDuplicateAsmdefNames: ValidationResult.Success, + abortWithResult: result => abortedWithResult = result, + abort: message => abortedWithMessage = message); + + coordinator.HandleCompileStartTimeout(1234); + + Assert.That(abortedWithResult, Is.Not.Null); + Assert.That(abortedWithResult.Success, Is.False); + Assert.That(abortedWithResult.ErrorCount, Is.EqualTo(1)); + Assert.That(abortedWithResult.Errors[0].file, Is.EqualTo(asmdefPath)); + Assert.That(abortedWithMessage, Is.Null); + } + + [Test] + public void HandleCompileStartTimeout_WhenDuplicateAsmdefNamesExist_AbortsWithValidationMessage() + { + // Verifies duplicate asmdef name validation is checked once assembly definition errors are ruled out. + const string duplicateAsmdefMessage = "Duplicate assembly definition name: Sample"; + CompileResult abortedWithResult = null; + string abortedWithMessage = null; + CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator( + findAssemblyDefinitionErrors: () => new AssemblyDefinitionConsoleErrorResult( + new AssemblyDefinitionConsoleError[0]), + validateNoDuplicateAsmdefNames: () => ValidationResult.Failure(duplicateAsmdefMessage), + abortWithResult: result => abortedWithResult = result, + abort: message => abortedWithMessage = message); + + coordinator.HandleCompileStartTimeout(1234); + + Assert.That(abortedWithResult, Is.Null); + Assert.That(abortedWithMessage, Is.EqualTo(duplicateAsmdefMessage)); + } + + [Test] + public void HandleCompileStartTimeout_WhenNoKnownCauseExists_AbortsWithGenericStartTimeoutMessage() + { + // Verifies the fallback message is unchanged when neither known recovery cause applies. + CompileResult abortedWithResult = null; + string abortedWithMessage = null; + CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator( + findAssemblyDefinitionErrors: () => new AssemblyDefinitionConsoleErrorResult( + new AssemblyDefinitionConsoleError[0]), + validateNoDuplicateAsmdefNames: ValidationResult.Success, + abortWithResult: result => abortedWithResult = result, + abort: message => abortedWithMessage = message); + + coordinator.HandleCompileStartTimeout(1234); + + Assert.That(abortedWithResult, Is.Null); + Assert.That( + abortedWithMessage, + Is.EqualTo( + "Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes.")); + } + + [Test] + public void HandleCompileStoppedWithoutFinishEvent_WhenAssemblyDefinitionErrorsExist_AbortsWithAssemblyDefinitionResult() + { + // Verifies missed-callback recovery reports actionable asmdef errors instead of an indeterminate result. + const string asmdefPath = "Assets/Tests/EditMode/Sample.EditMode.Tests.asmdef"; + AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = new( + new AssemblyDefinitionConsoleError[] + { + new("Assembly has duplicate references", asmdefPath, 0) + }); + CompileResult abortedWithResult = null; + CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator( + findAssemblyDefinitionErrors: () => assemblyDefinitionErrors, + getCompileMessages: () => new CompilerMessage[0], + getIsForceCompile: () => false, + abortWithResult: result => abortedWithResult = result); + + coordinator.HandleCompileStoppedWithoutFinishEvent(500); + + Assert.That(abortedWithResult, Is.Not.Null); + Assert.That(abortedWithResult.Success, Is.False); + Assert.That(abortedWithResult.IsIndeterminate, Is.False); + Assert.That(abortedWithResult.Errors[0].file, Is.EqualTo(asmdefPath)); + } + + [Test] + public void HandleCompileStoppedWithoutFinishEvent_WhenNoAssemblyDefinitionErrorsExist_AbortsWithIndeterminateResult() + { + // Verifies missed-callback recovery keeps indeterminate status when nothing known explains the gap. + CompilerMessage[] compileMessages = + { + new() + { + type = CompilerMessageType.Error, + message = "CS0000: sample compile error", + file = "Assets/Scripts/Sample.cs", + line = 7 + } + }; + CompileResult abortedWithResult = null; + CompileLifecycleRecoveryCoordinator coordinator = CreateCoordinator( + findAssemblyDefinitionErrors: () => new AssemblyDefinitionConsoleErrorResult( + new AssemblyDefinitionConsoleError[0]), + getCompileMessages: () => compileMessages, + getIsForceCompile: () => false, + abortWithResult: result => abortedWithResult = result); + + coordinator.HandleCompileStoppedWithoutFinishEvent(500); + + Assert.That(abortedWithResult, Is.Not.Null); + Assert.That(abortedWithResult.Success, Is.Null); + Assert.That(abortedWithResult.IsIndeterminate, Is.True); + Assert.That(abortedWithResult.Errors[0].message, Is.EqualTo("CS0000: sample compile error")); + } + + private static CompileLifecycleRecoveryCoordinator CreateCoordinator( + Func findAssemblyDefinitionErrors = null, + Func validateNoDuplicateAsmdefNames = null, + Func getCompileMessages = null, + Func getIsForceCompile = null, + Action abortWithResult = null, + Action abort = null) + { + return new CompileLifecycleRecoveryCoordinator( + isEditorCompiling: () => false, + isRequestCompleted: () => false, + getCurrentCompileTask: () => null, + findAssemblyDefinitionErrors: findAssemblyDefinitionErrors ?? + (() => new AssemblyDefinitionConsoleErrorResult(new AssemblyDefinitionConsoleError[0])), + validateNoDuplicateAsmdefNames: validateNoDuplicateAsmdefNames ?? ValidationResult.Success, + getIsForceCompile: getIsForceCompile ?? (() => false), + getCompileMessages: getCompileMessages ?? (() => new CompilerMessage[0]), + buildStateContext: context => context, + abortWithResult: abortWithResult ?? (_ => { }), + abort: abort ?? (_ => { })); + } + } +} diff --git a/Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs.meta b/Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs.meta new file mode 100644 index 0000000000..ef96004560 --- /dev/null +++ b/Assets/Tests/Editor/CompileLifecycleRecoveryCoordinatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2d1abe83598749bda50fda076e910988 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs b/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs index 54ea31d070..bbb75f3754 100644 --- a/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs +++ b/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs @@ -146,7 +146,8 @@ public void IsCurrentCompileRequest_WhenTaskMatches_ReturnsTrue() // Verifies watchdog fault recovery accepts the compile request it was created for. TaskCompletionSource compileTask = new(); - bool isCurrentCompileRequest = CompileController.IsCurrentCompileRequest(compileTask, compileTask); + bool isCurrentCompileRequest = + CompileLifecycleRecoveryCoordinator.IsCurrentCompileRequest(compileTask, compileTask); Assert.That(isCurrentCompileRequest, Is.True); } @@ -158,7 +159,7 @@ public void IsCurrentCompileRequest_WhenTaskDiffers_ReturnsFalse() TaskCompletionSource currentCompileTask = new(); TaskCompletionSource staleCompileTask = new(); - bool isCurrentCompileRequest = CompileController.IsCurrentCompileRequest( + bool isCurrentCompileRequest = CompileLifecycleRecoveryCoordinator.IsCurrentCompileRequest( currentCompileTask, staleCompileTask); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs index d9a9f3421f..e87c95ebcf 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs @@ -25,6 +25,7 @@ public class CompileController : IDisposable private bool _reloadExternalSceneChanges = true; private CompileResultRecordingContext _resultRecordingContext = CompileResultRecordingContext.Disabled(); private DateTime _compileStartedAtUtc = DateTime.MinValue; + private readonly CompileLifecycleRecoveryCoordinator _recoveryCoordinator; public CompileController( ICompileResultSessionRepository compileResultSessionRepository, @@ -37,6 +38,17 @@ public CompileController( throw new ArgumentNullException(nameof(compileResultSessionRepository)); _pendingCompileSessionRepository = pendingCompileSessionRepository ?? throw new ArgumentNullException(nameof(pendingCompileSessionRepository)); + _recoveryCoordinator = new CompileLifecycleRecoveryCoordinator( + () => EditorApplication.isCompiling, + IsCompileRequestCompleted, + () => _currentCompileTask, + () => new AssemblyDefinitionConsoleErrorValidationService().FindCurrentErrors(), + () => new AssemblyDefinitionDuplicationValidationService().ValidateNoDuplicateAsmdefNames(), + () => _isForceCompile, + () => _compileMessages.ToArray(), + BuildCompileControllerStateContext, + AbortCompileWithResult, + AbortCompile); } /// @@ -178,7 +190,7 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati CompilationPipeline.RequestScriptCompilation(); } - StartCompileLifecycleWatchdog(compileTask, ct); + _recoveryCoordinator.StartWatchdog(compileTask, ct); compileTaskTransferred = true; return await compileTask.Task.ConfigureAwait(false); } @@ -202,84 +214,11 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati } } - private void StartCompileLifecycleWatchdog(TaskCompletionSource compileTask, CancellationToken ct) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - - Task watchdogTask = WatchCompileLifecycleAsync(ct); - _ = watchdogTask.ContinueWith( - faultedTask => HandleCompileLifecycleWatchdogFault(compileTask, faultedTask), - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); - } - - private Task WatchCompileLifecycleAsync(CancellationToken ct) - { - CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog( - () => EditorApplication.isCompiling, - IsCompileRequestCompleted, - WaitForCompileWatchdogPollAsync, - _ => { }, - HandleCompileStartTimeout, - HandleCompileStoppedWithoutFinishEvent, - AbortCompile); - return watchdog.WatchAsync(ct); - } - private bool IsCompileRequestCompleted() { return _currentCompileTask == null || _currentCompileTask.Task.IsCompleted; } - private static Task WaitForCompileWatchdogPollAsync() - { - return TimerDelay.Wait(UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS); - } - - private void HandleCompileLifecycleWatchdogFault( - TaskCompletionSource compileTask, - Task faultedTask) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - UnityEngine.Debug.Assert(faultedTask != null, "faultedTask must not be null"); - UnityEngine.Debug.Assert(faultedTask.IsFaulted, "faultedTask must be faulted"); - - if (!IsCurrentCompileRequest(_currentCompileTask, compileTask)) - { - return; - } - - Exception exception = faultedTask.Exception; - UnityEngine.Debug.Assert(exception != null, "faultedTask exception must not be null"); - if (exception != null) - { - UnityEngine.Debug.LogException(exception); - } - - EditorApplication.delayCall += () => AbortCompileAfterWatchdogFault(compileTask); - } - - private void AbortCompileAfterWatchdogFault(TaskCompletionSource compileTask) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - - if (!IsCurrentCompileRequest(_currentCompileTask, compileTask)) - { - return; - } - - AbortCompile("Compilation watchdog failed unexpectedly."); - } - - internal static bool IsCurrentCompileRequest( - TaskCompletionSource currentCompileTask, - TaskCompletionSource compileTask) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - return currentCompileTask != null && ReferenceEquals(currentCompileTask, compileTask); - } - private Dictionary BuildCompileControllerStateContext( Dictionary extraContext) { @@ -309,82 +248,6 @@ private Dictionary BuildCompileControllerStateContext( return context; } - private void HandleCompileStartTimeout(int waitedMs) - { - AssemblyDefinitionConsoleErrorValidationService assemblyDefinitionValidationService = new(); - AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = - assemblyDefinitionValidationService.FindCurrentErrors(); - if (assemblyDefinitionErrors.HasErrors) - { - VibeLogger.LogWarning( - "compile_start_timeout_assembly_definition_error", - assemblyDefinitionErrors.Message, - BuildCompileControllerStateContext(new Dictionary - { - ["waited_ms"] = waitedMs - })); - AbortCompileWithResult( - CompileResultFactory.CreateAssemblyDefinitionFailureResult(assemblyDefinitionErrors)); - return; - } - - AssemblyDefinitionDuplicationValidationService asmdefValidationService = new(); - ValidationResult asmdefValidation = asmdefValidationService.ValidateNoDuplicateAsmdefNames(); - if (!asmdefValidation.IsValid) - { - VibeLogger.LogWarning( - "compile_start_timeout_duplicate_asmdef", - asmdefValidation.ErrorMessage, - BuildCompileControllerStateContext(new Dictionary - { - ["waited_ms"] = waitedMs - })); - AbortCompile(asmdefValidation.ErrorMessage); - return; - } - - VibeLogger.LogWarning( - "compile_start_timeout", - "Compilation did not start before the start timeout.", - BuildCompileControllerStateContext(new Dictionary - { - ["waited_ms"] = waitedMs - })); - AbortCompile( - "Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes." - ); - } - - private void HandleCompileStoppedWithoutFinishEvent(int stoppedMs) - { - string message = - "Unity stopped compiling before Unity CLI Loop received the compilationFinished callback. " + - "The compile result is indeterminate; use get-logs to inspect the compiler output."; - AssemblyDefinitionConsoleErrorValidationService assemblyDefinitionValidationService = new(); - AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = - assemblyDefinitionValidationService.FindCurrentErrors(); - CompileResult result = CompileResultFactory.CreateStoppedWithoutFinishResult( - assemblyDefinitionErrors, - _compileMessages.ToArray(), - _isForceCompile, - message); - VibeLogger.LogWarning( - "compile_finish_callback_missing", - result.Message ?? message, - new - { - force_recompile = _isForceCompile, - stopped_ms = stoppedMs, - message_count = _compileMessages.Count, - assembly_definition_error_count = assemblyDefinitionErrors.Errors.Length, - editor_compiling = EditorApplication.isCompiling, - editor_updating = EditorApplication.isUpdating, - editor_playing = EditorApplication.isPlaying, - editor_paused = EditorApplication.isPaused - }); - AbortCompileWithResult(result); - } - /// /// Completes an active compile request with a prepared failure result before Unity reports compilationFinished. /// @@ -616,4 +479,217 @@ public void Dispose() OnAssemblyCompiled = null; } } + + /// + /// Decides and triggers recovery actions when CompileLifecycleWatchdog observes a stalled or + /// faulted compile request. Assembly Definition validation, message building, and abort actions + /// are injected so these recovery decisions can be pinned with tests without running Unity compilation. + /// + internal sealed class CompileLifecycleRecoveryCoordinator + { + private readonly Func _isEditorCompiling; + private readonly Func _isRequestCompleted; + private readonly Func> _getCurrentCompileTask; + private readonly Func _findAssemblyDefinitionErrors; + private readonly Func _validateNoDuplicateAsmdefNames; + private readonly Func _getIsForceCompile; + private readonly Func _getCompileMessages; + private readonly Func, Dictionary> _buildStateContext; + private readonly Action _abortWithResult; + private readonly Action _abort; + + internal CompileLifecycleRecoveryCoordinator( + Func isEditorCompiling, + Func isRequestCompleted, + Func> getCurrentCompileTask, + Func findAssemblyDefinitionErrors, + Func validateNoDuplicateAsmdefNames, + Func getIsForceCompile, + Func getCompileMessages, + Func, Dictionary> buildStateContext, + Action abortWithResult, + Action abort) + { + UnityEngine.Debug.Assert(isEditorCompiling != null, "isEditorCompiling must not be null"); + UnityEngine.Debug.Assert(isRequestCompleted != null, "isRequestCompleted must not be null"); + UnityEngine.Debug.Assert(getCurrentCompileTask != null, "getCurrentCompileTask must not be null"); + UnityEngine.Debug.Assert(findAssemblyDefinitionErrors != null, "findAssemblyDefinitionErrors must not be null"); + UnityEngine.Debug.Assert(validateNoDuplicateAsmdefNames != null, "validateNoDuplicateAsmdefNames must not be null"); + UnityEngine.Debug.Assert(getIsForceCompile != null, "getIsForceCompile must not be null"); + UnityEngine.Debug.Assert(getCompileMessages != null, "getCompileMessages must not be null"); + UnityEngine.Debug.Assert(buildStateContext != null, "buildStateContext must not be null"); + UnityEngine.Debug.Assert(abortWithResult != null, "abortWithResult must not be null"); + UnityEngine.Debug.Assert(abort != null, "abort must not be null"); + + _isEditorCompiling = isEditorCompiling ?? throw new ArgumentNullException(nameof(isEditorCompiling)); + _isRequestCompleted = isRequestCompleted ?? throw new ArgumentNullException(nameof(isRequestCompleted)); + _getCurrentCompileTask = getCurrentCompileTask ?? throw new ArgumentNullException(nameof(getCurrentCompileTask)); + _findAssemblyDefinitionErrors = findAssemblyDefinitionErrors ?? + throw new ArgumentNullException(nameof(findAssemblyDefinitionErrors)); + _validateNoDuplicateAsmdefNames = validateNoDuplicateAsmdefNames ?? + throw new ArgumentNullException(nameof(validateNoDuplicateAsmdefNames)); + _getIsForceCompile = getIsForceCompile ?? throw new ArgumentNullException(nameof(getIsForceCompile)); + _getCompileMessages = getCompileMessages ?? throw new ArgumentNullException(nameof(getCompileMessages)); + _buildStateContext = buildStateContext ?? throw new ArgumentNullException(nameof(buildStateContext)); + _abortWithResult = abortWithResult ?? throw new ArgumentNullException(nameof(abortWithResult)); + _abort = abort ?? throw new ArgumentNullException(nameof(abort)); + } + + /// + /// Starts watching one compile request and wires fault recovery for it. + /// + internal void StartWatchdog(TaskCompletionSource compileTask, CancellationToken ct) + { + UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); + + Task watchdogTask = WatchCompileLifecycleAsync(ct); + _ = watchdogTask.ContinueWith( + faultedTask => HandleCompileLifecycleWatchdogFault(compileTask, faultedTask), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + private Task WatchCompileLifecycleAsync(CancellationToken ct) + { + CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog( + _isEditorCompiling, + _isRequestCompleted, + WaitForCompileWatchdogPollAsync, + _ => { }, + HandleCompileStartTimeout, + HandleCompileStoppedWithoutFinishEvent, + _abort); + return watchdog.WatchAsync(ct); + } + + private static Task WaitForCompileWatchdogPollAsync() + { + return TimerDelay.Wait(UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS); + } + + private void HandleCompileLifecycleWatchdogFault( + TaskCompletionSource compileTask, + Task faultedTask) + { + UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); + UnityEngine.Debug.Assert(faultedTask != null, "faultedTask must not be null"); + UnityEngine.Debug.Assert(faultedTask.IsFaulted, "faultedTask must be faulted"); + + if (!IsCurrentCompileRequest(_getCurrentCompileTask(), compileTask)) + { + return; + } + + Exception exception = faultedTask.Exception; + UnityEngine.Debug.Assert(exception != null, "faultedTask exception must not be null"); + if (exception != null) + { + UnityEngine.Debug.LogException(exception); + } + + EditorApplication.delayCall += () => AbortCompileAfterWatchdogFault(compileTask); + } + + /// + /// Aborts a compile request after its watchdog faulted, unless a newer request has replaced it. + /// + internal void AbortCompileAfterWatchdogFault(TaskCompletionSource compileTask) + { + UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); + + if (!IsCurrentCompileRequest(_getCurrentCompileTask(), compileTask)) + { + return; + } + + _abort("Compilation watchdog failed unexpectedly."); + } + + internal static bool IsCurrentCompileRequest( + TaskCompletionSource currentCompileTask, + TaskCompletionSource compileTask) + { + UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); + return currentCompileTask != null && ReferenceEquals(currentCompileTask, compileTask); + } + + /// + /// Recovers from Unity never starting compilation before the watchdog's start timeout. + /// + internal void HandleCompileStartTimeout(int waitedMs) + { + AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = _findAssemblyDefinitionErrors(); + if (assemblyDefinitionErrors.HasErrors) + { + VibeLogger.LogWarning( + "compile_start_timeout_assembly_definition_error", + assemblyDefinitionErrors.Message, + _buildStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); + _abortWithResult(CompileResultFactory.CreateAssemblyDefinitionFailureResult(assemblyDefinitionErrors)); + return; + } + + ValidationResult asmdefValidation = _validateNoDuplicateAsmdefNames(); + if (!asmdefValidation.IsValid) + { + VibeLogger.LogWarning( + "compile_start_timeout_duplicate_asmdef", + asmdefValidation.ErrorMessage, + _buildStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); + _abort(asmdefValidation.ErrorMessage); + return; + } + + VibeLogger.LogWarning( + "compile_start_timeout", + "Compilation did not start before the start timeout.", + _buildStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); + _abort( + "Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes." + ); + } + + /// + /// Recovers from Unity stopping compilation without firing the compilationFinished callback. + /// + internal void HandleCompileStoppedWithoutFinishEvent(int stoppedMs) + { + string message = + "Unity stopped compiling before Unity CLI Loop received the compilationFinished callback. " + + "The compile result is indeterminate; use get-logs to inspect the compiler output."; + AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = _findAssemblyDefinitionErrors(); + CompilerMessage[] compileMessages = _getCompileMessages(); + bool isForceCompile = _getIsForceCompile(); + CompileResult result = CompileResultFactory.CreateStoppedWithoutFinishResult( + assemblyDefinitionErrors, + compileMessages, + isForceCompile, + message); + VibeLogger.LogWarning( + "compile_finish_callback_missing", + result.Message ?? message, + new + { + force_recompile = isForceCompile, + stopped_ms = stoppedMs, + message_count = compileMessages.Length, + assembly_definition_error_count = assemblyDefinitionErrors.Errors.Length, + editor_compiling = EditorApplication.isCompiling, + editor_updating = EditorApplication.isUpdating, + editor_playing = EditorApplication.isPlaying, + editor_paused = EditorApplication.isPaused + }); + _abortWithResult(result); + } + } } From f1a87efbb9c5f0610ffe1b046d9b296e1660b1b6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 14 Jul 2026 19:04:32 +0900 Subject: [PATCH 6/6] refactor: extract CompileLifecycleRecoveryCoordinator from CompileController Move the delegate-injected recovery coordinator (start-timeout handling, missed-callback handling, watchdog fault recovery) into its own file so CompileController.cs drops from 619 to 482 lines. No behavior change; the watchdog lifecycle, TaskCompletionSource semantics, and MainThreadSwitcher/ ConfigureAwait(false) call sites in TryCompileAsync are untouched, and the characterization tests added in the previous commit stay green. Co-authored-by: Cursor --- .../Compile/CompileController.cs | 213 ----------------- .../CompileLifecycleRecoveryCoordinator.cs | 225 ++++++++++++++++++ ...ompileLifecycleRecoveryCoordinator.cs.meta | 11 + 3 files changed, 236 insertions(+), 213 deletions(-) create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs.meta diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs index e87c95ebcf..15b06c64e9 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs @@ -479,217 +479,4 @@ public void Dispose() OnAssemblyCompiled = null; } } - - /// - /// Decides and triggers recovery actions when CompileLifecycleWatchdog observes a stalled or - /// faulted compile request. Assembly Definition validation, message building, and abort actions - /// are injected so these recovery decisions can be pinned with tests without running Unity compilation. - /// - internal sealed class CompileLifecycleRecoveryCoordinator - { - private readonly Func _isEditorCompiling; - private readonly Func _isRequestCompleted; - private readonly Func> _getCurrentCompileTask; - private readonly Func _findAssemblyDefinitionErrors; - private readonly Func _validateNoDuplicateAsmdefNames; - private readonly Func _getIsForceCompile; - private readonly Func _getCompileMessages; - private readonly Func, Dictionary> _buildStateContext; - private readonly Action _abortWithResult; - private readonly Action _abort; - - internal CompileLifecycleRecoveryCoordinator( - Func isEditorCompiling, - Func isRequestCompleted, - Func> getCurrentCompileTask, - Func findAssemblyDefinitionErrors, - Func validateNoDuplicateAsmdefNames, - Func getIsForceCompile, - Func getCompileMessages, - Func, Dictionary> buildStateContext, - Action abortWithResult, - Action abort) - { - UnityEngine.Debug.Assert(isEditorCompiling != null, "isEditorCompiling must not be null"); - UnityEngine.Debug.Assert(isRequestCompleted != null, "isRequestCompleted must not be null"); - UnityEngine.Debug.Assert(getCurrentCompileTask != null, "getCurrentCompileTask must not be null"); - UnityEngine.Debug.Assert(findAssemblyDefinitionErrors != null, "findAssemblyDefinitionErrors must not be null"); - UnityEngine.Debug.Assert(validateNoDuplicateAsmdefNames != null, "validateNoDuplicateAsmdefNames must not be null"); - UnityEngine.Debug.Assert(getIsForceCompile != null, "getIsForceCompile must not be null"); - UnityEngine.Debug.Assert(getCompileMessages != null, "getCompileMessages must not be null"); - UnityEngine.Debug.Assert(buildStateContext != null, "buildStateContext must not be null"); - UnityEngine.Debug.Assert(abortWithResult != null, "abortWithResult must not be null"); - UnityEngine.Debug.Assert(abort != null, "abort must not be null"); - - _isEditorCompiling = isEditorCompiling ?? throw new ArgumentNullException(nameof(isEditorCompiling)); - _isRequestCompleted = isRequestCompleted ?? throw new ArgumentNullException(nameof(isRequestCompleted)); - _getCurrentCompileTask = getCurrentCompileTask ?? throw new ArgumentNullException(nameof(getCurrentCompileTask)); - _findAssemblyDefinitionErrors = findAssemblyDefinitionErrors ?? - throw new ArgumentNullException(nameof(findAssemblyDefinitionErrors)); - _validateNoDuplicateAsmdefNames = validateNoDuplicateAsmdefNames ?? - throw new ArgumentNullException(nameof(validateNoDuplicateAsmdefNames)); - _getIsForceCompile = getIsForceCompile ?? throw new ArgumentNullException(nameof(getIsForceCompile)); - _getCompileMessages = getCompileMessages ?? throw new ArgumentNullException(nameof(getCompileMessages)); - _buildStateContext = buildStateContext ?? throw new ArgumentNullException(nameof(buildStateContext)); - _abortWithResult = abortWithResult ?? throw new ArgumentNullException(nameof(abortWithResult)); - _abort = abort ?? throw new ArgumentNullException(nameof(abort)); - } - - /// - /// Starts watching one compile request and wires fault recovery for it. - /// - internal void StartWatchdog(TaskCompletionSource compileTask, CancellationToken ct) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - - Task watchdogTask = WatchCompileLifecycleAsync(ct); - _ = watchdogTask.ContinueWith( - faultedTask => HandleCompileLifecycleWatchdogFault(compileTask, faultedTask), - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); - } - - private Task WatchCompileLifecycleAsync(CancellationToken ct) - { - CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog( - _isEditorCompiling, - _isRequestCompleted, - WaitForCompileWatchdogPollAsync, - _ => { }, - HandleCompileStartTimeout, - HandleCompileStoppedWithoutFinishEvent, - _abort); - return watchdog.WatchAsync(ct); - } - - private static Task WaitForCompileWatchdogPollAsync() - { - return TimerDelay.Wait(UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS); - } - - private void HandleCompileLifecycleWatchdogFault( - TaskCompletionSource compileTask, - Task faultedTask) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - UnityEngine.Debug.Assert(faultedTask != null, "faultedTask must not be null"); - UnityEngine.Debug.Assert(faultedTask.IsFaulted, "faultedTask must be faulted"); - - if (!IsCurrentCompileRequest(_getCurrentCompileTask(), compileTask)) - { - return; - } - - Exception exception = faultedTask.Exception; - UnityEngine.Debug.Assert(exception != null, "faultedTask exception must not be null"); - if (exception != null) - { - UnityEngine.Debug.LogException(exception); - } - - EditorApplication.delayCall += () => AbortCompileAfterWatchdogFault(compileTask); - } - - /// - /// Aborts a compile request after its watchdog faulted, unless a newer request has replaced it. - /// - internal void AbortCompileAfterWatchdogFault(TaskCompletionSource compileTask) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - - if (!IsCurrentCompileRequest(_getCurrentCompileTask(), compileTask)) - { - return; - } - - _abort("Compilation watchdog failed unexpectedly."); - } - - internal static bool IsCurrentCompileRequest( - TaskCompletionSource currentCompileTask, - TaskCompletionSource compileTask) - { - UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null"); - return currentCompileTask != null && ReferenceEquals(currentCompileTask, compileTask); - } - - /// - /// Recovers from Unity never starting compilation before the watchdog's start timeout. - /// - internal void HandleCompileStartTimeout(int waitedMs) - { - AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = _findAssemblyDefinitionErrors(); - if (assemblyDefinitionErrors.HasErrors) - { - VibeLogger.LogWarning( - "compile_start_timeout_assembly_definition_error", - assemblyDefinitionErrors.Message, - _buildStateContext(new Dictionary - { - ["waited_ms"] = waitedMs - })); - _abortWithResult(CompileResultFactory.CreateAssemblyDefinitionFailureResult(assemblyDefinitionErrors)); - return; - } - - ValidationResult asmdefValidation = _validateNoDuplicateAsmdefNames(); - if (!asmdefValidation.IsValid) - { - VibeLogger.LogWarning( - "compile_start_timeout_duplicate_asmdef", - asmdefValidation.ErrorMessage, - _buildStateContext(new Dictionary - { - ["waited_ms"] = waitedMs - })); - _abort(asmdefValidation.ErrorMessage); - return; - } - - VibeLogger.LogWarning( - "compile_start_timeout", - "Compilation did not start before the start timeout.", - _buildStateContext(new Dictionary - { - ["waited_ms"] = waitedMs - })); - _abort( - "Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes." - ); - } - - /// - /// Recovers from Unity stopping compilation without firing the compilationFinished callback. - /// - internal void HandleCompileStoppedWithoutFinishEvent(int stoppedMs) - { - string message = - "Unity stopped compiling before Unity CLI Loop received the compilationFinished callback. " + - "The compile result is indeterminate; use get-logs to inspect the compiler output."; - AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = _findAssemblyDefinitionErrors(); - CompilerMessage[] compileMessages = _getCompileMessages(); - bool isForceCompile = _getIsForceCompile(); - CompileResult result = CompileResultFactory.CreateStoppedWithoutFinishResult( - assemblyDefinitionErrors, - compileMessages, - isForceCompile, - message); - VibeLogger.LogWarning( - "compile_finish_callback_missing", - result.Message ?? message, - new - { - force_recompile = isForceCompile, - stopped_ms = stoppedMs, - message_count = compileMessages.Length, - assembly_definition_error_count = assemblyDefinitionErrors.Errors.Length, - editor_compiling = EditorApplication.isCompiling, - editor_updating = EditorApplication.isUpdating, - editor_playing = EditorApplication.isPlaying, - editor_paused = EditorApplication.isPaused - }); - _abortWithResult(result); - } - } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs new file mode 100644 index 0000000000..22d3426a10 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEditor.Compilation; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Decides and triggers recovery actions when CompileLifecycleWatchdog observes a stalled or + /// faulted compile request. Assembly Definition validation, message building, and abort actions + /// are injected so these recovery decisions can be pinned with tests without running Unity compilation. + /// + internal sealed class CompileLifecycleRecoveryCoordinator + { + private readonly Func _isEditorCompiling; + private readonly Func _isRequestCompleted; + private readonly Func> _getCurrentCompileTask; + private readonly Func _findAssemblyDefinitionErrors; + private readonly Func _validateNoDuplicateAsmdefNames; + private readonly Func _getIsForceCompile; + private readonly Func _getCompileMessages; + private readonly Func, Dictionary> _buildStateContext; + private readonly Action _abortWithResult; + private readonly Action _abort; + + internal CompileLifecycleRecoveryCoordinator( + Func isEditorCompiling, + Func isRequestCompleted, + Func> getCurrentCompileTask, + Func findAssemblyDefinitionErrors, + Func validateNoDuplicateAsmdefNames, + Func getIsForceCompile, + Func getCompileMessages, + Func, Dictionary> buildStateContext, + Action abortWithResult, + Action abort) + { + Debug.Assert(isEditorCompiling != null, "isEditorCompiling must not be null"); + Debug.Assert(isRequestCompleted != null, "isRequestCompleted must not be null"); + Debug.Assert(getCurrentCompileTask != null, "getCurrentCompileTask must not be null"); + Debug.Assert(findAssemblyDefinitionErrors != null, "findAssemblyDefinitionErrors must not be null"); + Debug.Assert(validateNoDuplicateAsmdefNames != null, "validateNoDuplicateAsmdefNames must not be null"); + Debug.Assert(getIsForceCompile != null, "getIsForceCompile must not be null"); + Debug.Assert(getCompileMessages != null, "getCompileMessages must not be null"); + Debug.Assert(buildStateContext != null, "buildStateContext must not be null"); + Debug.Assert(abortWithResult != null, "abortWithResult must not be null"); + Debug.Assert(abort != null, "abort must not be null"); + + _isEditorCompiling = isEditorCompiling ?? throw new ArgumentNullException(nameof(isEditorCompiling)); + _isRequestCompleted = isRequestCompleted ?? throw new ArgumentNullException(nameof(isRequestCompleted)); + _getCurrentCompileTask = getCurrentCompileTask ?? throw new ArgumentNullException(nameof(getCurrentCompileTask)); + _findAssemblyDefinitionErrors = findAssemblyDefinitionErrors ?? + throw new ArgumentNullException(nameof(findAssemblyDefinitionErrors)); + _validateNoDuplicateAsmdefNames = validateNoDuplicateAsmdefNames ?? + throw new ArgumentNullException(nameof(validateNoDuplicateAsmdefNames)); + _getIsForceCompile = getIsForceCompile ?? throw new ArgumentNullException(nameof(getIsForceCompile)); + _getCompileMessages = getCompileMessages ?? throw new ArgumentNullException(nameof(getCompileMessages)); + _buildStateContext = buildStateContext ?? throw new ArgumentNullException(nameof(buildStateContext)); + _abortWithResult = abortWithResult ?? throw new ArgumentNullException(nameof(abortWithResult)); + _abort = abort ?? throw new ArgumentNullException(nameof(abort)); + } + + /// + /// Starts watching one compile request and wires fault recovery for it. + /// + internal void StartWatchdog(TaskCompletionSource compileTask, CancellationToken ct) + { + Debug.Assert(compileTask != null, "compileTask must not be null"); + + Task watchdogTask = WatchCompileLifecycleAsync(ct); + _ = watchdogTask.ContinueWith( + faultedTask => HandleCompileLifecycleWatchdogFault(compileTask, faultedTask), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + private Task WatchCompileLifecycleAsync(CancellationToken ct) + { + CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog( + _isEditorCompiling, + _isRequestCompleted, + WaitForCompileWatchdogPollAsync, + _ => { }, + HandleCompileStartTimeout, + HandleCompileStoppedWithoutFinishEvent, + _abort); + return watchdog.WatchAsync(ct); + } + + private static Task WaitForCompileWatchdogPollAsync() + { + return TimerDelay.Wait(UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS); + } + + private void HandleCompileLifecycleWatchdogFault( + TaskCompletionSource compileTask, + Task faultedTask) + { + Debug.Assert(compileTask != null, "compileTask must not be null"); + Debug.Assert(faultedTask != null, "faultedTask must not be null"); + Debug.Assert(faultedTask.IsFaulted, "faultedTask must be faulted"); + + if (!IsCurrentCompileRequest(_getCurrentCompileTask(), compileTask)) + { + return; + } + + Exception exception = faultedTask.Exception; + Debug.Assert(exception != null, "faultedTask exception must not be null"); + if (exception != null) + { + Debug.LogException(exception); + } + + EditorApplication.delayCall += () => AbortCompileAfterWatchdogFault(compileTask); + } + + /// + /// Aborts a compile request after its watchdog faulted, unless a newer request has replaced it. + /// + internal void AbortCompileAfterWatchdogFault(TaskCompletionSource compileTask) + { + Debug.Assert(compileTask != null, "compileTask must not be null"); + + if (!IsCurrentCompileRequest(_getCurrentCompileTask(), compileTask)) + { + return; + } + + _abort("Compilation watchdog failed unexpectedly."); + } + + internal static bool IsCurrentCompileRequest( + TaskCompletionSource currentCompileTask, + TaskCompletionSource compileTask) + { + Debug.Assert(compileTask != null, "compileTask must not be null"); + return currentCompileTask != null && ReferenceEquals(currentCompileTask, compileTask); + } + + /// + /// Recovers from Unity never starting compilation before the watchdog's start timeout. + /// + internal void HandleCompileStartTimeout(int waitedMs) + { + AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = _findAssemblyDefinitionErrors(); + if (assemblyDefinitionErrors.HasErrors) + { + VibeLogger.LogWarning( + "compile_start_timeout_assembly_definition_error", + assemblyDefinitionErrors.Message, + _buildStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); + _abortWithResult(CompileResultFactory.CreateAssemblyDefinitionFailureResult(assemblyDefinitionErrors)); + return; + } + + ValidationResult asmdefValidation = _validateNoDuplicateAsmdefNames(); + if (!asmdefValidation.IsValid) + { + VibeLogger.LogWarning( + "compile_start_timeout_duplicate_asmdef", + asmdefValidation.ErrorMessage, + _buildStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); + _abort(asmdefValidation.ErrorMessage); + return; + } + + VibeLogger.LogWarning( + "compile_start_timeout", + "Compilation did not start before the start timeout.", + _buildStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); + _abort( + "Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes." + ); + } + + /// + /// Recovers from Unity stopping compilation without firing the compilationFinished callback. + /// + internal void HandleCompileStoppedWithoutFinishEvent(int stoppedMs) + { + string message = + "Unity stopped compiling before Unity CLI Loop received the compilationFinished callback. " + + "The compile result is indeterminate; use get-logs to inspect the compiler output."; + AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = _findAssemblyDefinitionErrors(); + CompilerMessage[] compileMessages = _getCompileMessages(); + bool isForceCompile = _getIsForceCompile(); + CompileResult result = CompileResultFactory.CreateStoppedWithoutFinishResult( + assemblyDefinitionErrors, + compileMessages, + isForceCompile, + message); + VibeLogger.LogWarning( + "compile_finish_callback_missing", + result.Message ?? message, + new + { + force_recompile = isForceCompile, + stopped_ms = stoppedMs, + message_count = compileMessages.Length, + assembly_definition_error_count = assemblyDefinitionErrors.Errors.Length, + editor_compiling = EditorApplication.isCompiling, + editor_updating = EditorApplication.isUpdating, + editor_playing = EditorApplication.isPlaying, + editor_paused = EditorApplication.isPaused + }); + _abortWithResult(result); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs.meta new file mode 100644 index 0000000000..91ef97fdd0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleRecoveryCoordinator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6011c679b63b456fb3102fdb6001d996 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: