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]";