diff --git a/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs b/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs
new file mode 100644
index 000000000..7968c161a
--- /dev/null
+++ b/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Tests compile lifecycle recovery decisions without invoking Unity's real compiler.
+ ///
+ [TestFixture]
+ public sealed class CompileLifecycleWatchdogTests
+ {
+ [Test]
+ public async Task WatchAsync_WhenEditorStopsCompilingWithoutCallback_RequestsMissedCallbackRecovery()
+ {
+ // Verifies the watchdog recovers when Unity leaves compiling state without firing completion callback.
+ SequenceCompilationState compilationState = new SequenceCompilationState(
+ new bool[] { false, true, false, false, false, false, false });
+ int missedCallbackStoppedMs = -1;
+ int startTimeoutCount = 0;
+ int cancellationCount = 0;
+ CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog(
+ compilationState.IsCompiling,
+ () => false,
+ () => Task.CompletedTask,
+ _ => { },
+ _ => startTimeoutCount++,
+ stoppedMs => missedCallbackStoppedMs = stoppedMs,
+ _ => cancellationCount++);
+
+ await watchdog.WatchAsync(CancellationToken.None);
+
+ Assert.That(missedCallbackStoppedMs, Is.EqualTo(UnityCliLoopConstants.COMPILE_FINISH_MISSED_CALLBACK_GRACE_MS));
+ Assert.That(startTimeoutCount, Is.EqualTo(0));
+ Assert.That(cancellationCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task WatchAsync_WhenRequestCompletesAfterStart_StopsWithoutRecovery()
+ {
+ // Verifies the watchdog does not recover a request already completed by Unity's callback.
+ SequenceCompilationState compilationState = new SequenceCompilationState(
+ new bool[] { false, true, false, false, false });
+ int waitCount = 0;
+ int missedCallbackCount = 0;
+ int startTimeoutCount = 0;
+ CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog(
+ compilationState.IsCompiling,
+ () => waitCount >= 2,
+ () =>
+ {
+ waitCount++;
+ return Task.CompletedTask;
+ },
+ _ => { },
+ _ => startTimeoutCount++,
+ _ => missedCallbackCount++,
+ _ => { });
+
+ await watchdog.WatchAsync(CancellationToken.None);
+
+ Assert.That(missedCallbackCount, Is.EqualTo(0));
+ Assert.That(startTimeoutCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task WatchAsync_WhenCompileNeverStarts_RequestsStartTimeout()
+ {
+ // Verifies the watchdog keeps the existing start-timeout recovery path.
+ ConstantCompilationState compilationState = new ConstantCompilationState(false);
+ int startTimeoutMs = -1;
+ int missedCallbackCount = 0;
+ CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog(
+ compilationState.IsCompiling,
+ () => false,
+ () => Task.CompletedTask,
+ _ => { },
+ waitedMs => startTimeoutMs = waitedMs,
+ _ => missedCallbackCount++,
+ _ => { });
+
+ await watchdog.WatchAsync(CancellationToken.None);
+
+ Assert.That(startTimeoutMs, Is.EqualTo(UnityCliLoopConstants.COMPILE_START_TIMEOUT_MS));
+ Assert.That(missedCallbackCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void WatchAsync_WhenPollingFails_ExposesFaultForControllerRecovery()
+ {
+ // Verifies watchdog faults remain observable so the controller can abort the active compile request.
+ ConstantCompilationState compilationState = new ConstantCompilationState(false);
+ InvalidOperationException expectedException = new InvalidOperationException("poll failed");
+ CompileLifecycleWatchdog watchdog = new CompileLifecycleWatchdog(
+ compilationState.IsCompiling,
+ () => false,
+ () => Task.FromException(expectedException),
+ _ => { },
+ _ => { },
+ _ => { },
+ _ => { });
+
+ InvalidOperationException actualException = Assert.ThrowsAsync(
+ async () => await watchdog.WatchAsync(CancellationToken.None));
+
+ Assert.That(actualException, Is.SameAs(expectedException));
+ }
+
+ [Test]
+ 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);
+
+ Assert.That(isCurrentCompileRequest, Is.True);
+ }
+
+ [Test]
+ public void IsCurrentCompileRequest_WhenTaskDiffers_ReturnsFalse()
+ {
+ // Verifies late watchdog faults from older requests cannot abort a newer compile request.
+ TaskCompletionSource currentCompileTask = new();
+ TaskCompletionSource staleCompileTask = new();
+
+ bool isCurrentCompileRequest = CompileController.IsCurrentCompileRequest(
+ currentCompileTask,
+ staleCompileTask);
+
+ Assert.That(isCurrentCompileRequest, Is.False);
+ }
+
+ private sealed class SequenceCompilationState
+ {
+ private readonly bool[] _states;
+ private int _index;
+
+ public SequenceCompilationState(bool[] states)
+ {
+ _states = states ?? throw new ArgumentNullException(nameof(states));
+ }
+
+ public bool IsCompiling()
+ {
+ int index = Math.Min(_index, _states.Length - 1);
+ _index++;
+ return _states[index];
+ }
+ }
+
+ private sealed class ConstantCompilationState
+ {
+ private readonly bool _isCompiling;
+
+ public ConstantCompilationState(bool isCompiling)
+ {
+ _isCompiling = isCompiling;
+ }
+
+ public bool IsCompiling()
+ {
+ return _isCompiling;
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs.meta b/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs.meta
new file mode 100644
index 000000000..b7fbd1d2a
--- /dev/null
+++ b/Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b8ff68a991de24b20aa0c8c76c5a3737
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs
index 82d78f31b..0cfbbc775 100644
--- a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs
+++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs
@@ -162,7 +162,7 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati
"Unity script compilation request returned.",
new { force_recompile = forceRecompile });
- _ = WatchCompileStartAsync(ct);
+ StartCompileLifecycleWatchdog(compileTask, ct);
VibeLogger.LogInfo(
"compile_controller_waiting_for_finish",
"Waiting for Unity compilationFinished callback.",
@@ -189,38 +189,94 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati
}
}
- private async Task WatchCompileStartAsync(CancellationToken ct)
+ private void StartCompileLifecycleWatchdog(TaskCompletionSource compileTask, CancellationToken ct)
{
- int waitedMs = 0;
+ 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,
+ HandleCompileStartedObserved,
+ HandleCompileStartTimeout,
+ HandleCompileStoppedWithoutFinishEvent,
+ AbortCompile);
+ return watchdog.WatchAsync(ct);
+ }
- while (waitedMs < UnityCliLoopConstants.COMPILE_START_TIMEOUT_MS)
+ 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))
{
- if (ct.IsCancellationRequested)
- {
- AbortCompile("Compilation request was cancelled before it started.");
- return;
- }
+ return;
+ }
- if (EditorApplication.isCompiling)
- {
- VibeLogger.LogInfo(
- "compile_editor_compiling_observed",
- "EditorApplication.isCompiling became true.",
- new { waited_ms = waitedMs });
- return;
- }
+ Exception exception = faultedTask.Exception;
+ UnityEngine.Debug.Assert(exception != null, "faultedTask exception must not be null");
+ if (exception != null)
+ {
+ UnityEngine.Debug.LogException(exception);
+ }
- if (_currentCompileTask == null || _currentCompileTask.Task.IsCompleted)
- {
- return;
- }
+ EditorApplication.delayCall += () => AbortCompileAfterWatchdogFault(compileTask);
+ }
+
+ private void AbortCompileAfterWatchdogFault(TaskCompletionSource compileTask)
+ {
+ UnityEngine.Debug.Assert(compileTask != null, "compileTask must not be null");
- // Do not pass CancellationToken here: this method is fire-and-forget and must not throw.
- // Cancellation is handled by explicit checks above to avoid leaving _currentCompileTask pending.
- await TimerDelay.Wait(UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS);
- waitedMs += UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS;
+ 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 void HandleCompileStartedObserved(int waitedMs)
+ {
+ VibeLogger.LogInfo(
+ "compile_editor_compiling_observed",
+ "EditorApplication.isCompiling became true.",
+ new { waited_ms = waitedMs });
+ }
+
+ private void HandleCompileStartTimeout(int waitedMs)
+ {
AssemblyDefinitionConsoleErrorValidationService assemblyDefinitionValidationService = new();
AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors =
assemblyDefinitionValidationService.FindCurrentErrors();
@@ -255,6 +311,23 @@ private async Task WatchCompileStartAsync(CancellationToken ct)
);
}
+ 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.";
+ VibeLogger.LogWarning(
+ "compile_finish_callback_missing",
+ message,
+ new
+ {
+ force_recompile = _isForceCompile,
+ stopped_ms = stoppedMs,
+ message_count = _compileMessages.Count
+ });
+ AbortCompileWithResult(CreateIndeterminateCompileResult(message));
+ }
+
///
/// Completes an active compile request with a prepared failure result before Unity reports compilationFinished.
///
@@ -447,6 +520,26 @@ private CompileResult CreateCompileResult()
);
}
+ private CompileResult CreateIndeterminateCompileResult(string message)
+ {
+ CompilerMessage[] errors = _compileMessages.Where(m => m.type == CompilerMessageType.Error).ToArray();
+ CompilerMessage[] warnings = _compileMessages.Where(m => m.type == CompilerMessageType.Warning).ToArray();
+ CompilerMessage[] messages = _isForceCompile ? Array.Empty() : _compileMessages.ToArray();
+ CompilerMessage[] resultErrors = _isForceCompile ? Array.Empty() : errors;
+ CompilerMessage[] resultWarnings = _isForceCompile ? Array.Empty() : warnings;
+ return new CompileResult(
+ success: null,
+ errorCount: errors.Length,
+ warningCount: warnings.Length,
+ completedAt: DateTime.Now,
+ messages: messages,
+ errors: resultErrors,
+ warnings: resultWarnings,
+ isIndeterminate: true,
+ message: message
+ );
+ }
+
///
/// Creates a failed compile result from Assembly Definition and Assembly Reference Console errors.
///
@@ -503,6 +596,8 @@ public void Cleanup()
_currentCompileTask.SetCanceled();
_currentCompileTask = null;
}
+ _isCompiling = false;
+ _isForceCompile = false;
}
///
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleWatchdog.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleWatchdog.cs
new file mode 100644
index 000000000..b3fc9c3af
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleWatchdog.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Watches one Unity compile request so the controller can recover when Unity misses lifecycle callbacks.
+ ///
+ internal sealed class CompileLifecycleWatchdog
+ {
+ private readonly Func _isEditorCompiling;
+ private readonly Func _isRequestCompleted;
+ private readonly Func _waitForPollAsync;
+ private readonly Action _onCompileStartedObserved;
+ private readonly Action _onStartTimeout;
+ private readonly Action _onMissedCompletionCallback;
+ private readonly Action _onCancelled;
+
+ internal CompileLifecycleWatchdog(
+ Func isEditorCompiling,
+ Func isRequestCompleted,
+ Func waitForPollAsync,
+ Action onCompileStartedObserved,
+ Action onStartTimeout,
+ Action onMissedCompletionCallback,
+ Action onCancelled)
+ {
+ Debug.Assert(isEditorCompiling != null, "isEditorCompiling must not be null");
+ Debug.Assert(isRequestCompleted != null, "isRequestCompleted must not be null");
+ Debug.Assert(waitForPollAsync != null, "waitForPollAsync must not be null");
+ Debug.Assert(onCompileStartedObserved != null, "onCompileStartedObserved must not be null");
+ Debug.Assert(onStartTimeout != null, "onStartTimeout must not be null");
+ Debug.Assert(onMissedCompletionCallback != null, "onMissedCompletionCallback must not be null");
+ Debug.Assert(onCancelled != null, "onCancelled must not be null");
+
+ _isEditorCompiling = isEditorCompiling ?? throw new ArgumentNullException(nameof(isEditorCompiling));
+ _isRequestCompleted = isRequestCompleted ?? throw new ArgumentNullException(nameof(isRequestCompleted));
+ _waitForPollAsync = waitForPollAsync ?? throw new ArgumentNullException(nameof(waitForPollAsync));
+ _onCompileStartedObserved = onCompileStartedObserved ?? throw new ArgumentNullException(nameof(onCompileStartedObserved));
+ _onStartTimeout = onStartTimeout ?? throw new ArgumentNullException(nameof(onStartTimeout));
+ _onMissedCompletionCallback = onMissedCompletionCallback ?? throw new ArgumentNullException(nameof(onMissedCompletionCallback));
+ _onCancelled = onCancelled ?? throw new ArgumentNullException(nameof(onCancelled));
+ }
+
+ ///
+ /// Observes compile start and finish transitions for a single compile request.
+ ///
+ internal async Task WatchAsync(CancellationToken ct)
+ {
+ bool observedStart = false;
+ int waitedForStartMs = 0;
+ int stoppedAfterStartMs = 0;
+
+ while (!_isRequestCompleted())
+ {
+ if (ct.IsCancellationRequested)
+ {
+ string reason = observedStart
+ ? "Compilation request was cancelled before Unity reported completion."
+ : "Compilation request was cancelled before it started.";
+ _onCancelled(reason);
+ return;
+ }
+
+ bool isEditorCompiling = _isEditorCompiling();
+ if (!observedStart)
+ {
+ if (isEditorCompiling)
+ {
+ observedStart = true;
+ stoppedAfterStartMs = 0;
+ _onCompileStartedObserved(waitedForStartMs);
+ }
+ else if (waitedForStartMs >= UnityCliLoopConstants.COMPILE_START_TIMEOUT_MS)
+ {
+ if (!_isRequestCompleted())
+ {
+ _onStartTimeout(waitedForStartMs);
+ }
+ return;
+ }
+ }
+ else if (isEditorCompiling)
+ {
+ stoppedAfterStartMs = 0;
+ }
+ else
+ {
+ stoppedAfterStartMs += UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS;
+ if (stoppedAfterStartMs >= UnityCliLoopConstants.COMPILE_FINISH_MISSED_CALLBACK_GRACE_MS)
+ {
+ if (!_isRequestCompleted())
+ {
+ _onMissedCompletionCallback(stoppedAfterStartMs);
+ }
+ return;
+ }
+ }
+
+ // The delay is not cancelled directly because the watchdog must convert cancellation into cleanup.
+ await _waitForPollAsync();
+ if (!observedStart)
+ {
+ waitedForStartMs += UnityCliLoopConstants.COMPILE_START_POLL_INTERVAL_MS;
+ }
+ }
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleWatchdog.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleWatchdog.cs.meta
new file mode 100644
index 000000000..60c7f89e6
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileLifecycleWatchdog.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 203b5ab6d45ce4879a5389e422da4d3e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs
index 1ed44b567..b38c39f5e 100644
--- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs
+++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs
@@ -94,6 +94,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo
public const int COMPILE_START_TIMEOUT_MS = 5000;
public const int COMPILE_START_POLL_INTERVAL_MS = 100;
+ public const int COMPILE_FINISH_MISSED_CALLBACK_GRACE_MS = 500;
public const int MAX_SETTINGS_SIZE_BYTES = 1024 * 16;
public const string SECURITY_LOG_PREFIX = "[UnityCliLoop Security]";