Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Tests compile lifecycle recovery decisions without invoking Unity's real compiler.
/// </summary>
[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<InvalidOperationException>(
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<CompileResult> 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<CompileResult> currentCompileTask = new();
TaskCompletionSource<CompileResult> 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;
}
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/CompileLifecycleWatchdogTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

145 changes: 120 additions & 25 deletions Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ public async Task<CompileResult> 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.",
Expand All @@ -189,38 +189,94 @@ public async Task<CompileResult> TryCompileAsync(bool forceRecompile, Cancellati
}
}

private async Task WatchCompileStartAsync(CancellationToken ct)
private void StartCompileLifecycleWatchdog(TaskCompletionSource<CompileResult> 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<CompileResult> 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<CompileResult> 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<CompileResult> currentCompileTask,
TaskCompletionSource<CompileResult> 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();
Expand Down Expand Up @@ -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));
}

/// <summary>
/// Completes an active compile request with a prepared failure result before Unity reports compilationFinished.
/// </summary>
Expand Down Expand Up @@ -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<CompilerMessage>() : _compileMessages.ToArray();
CompilerMessage[] resultErrors = _isForceCompile ? Array.Empty<CompilerMessage>() : errors;
CompilerMessage[] resultWarnings = _isForceCompile ? Array.Empty<CompilerMessage>() : 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
);
}

/// <summary>
/// Creates a failed compile result from Assembly Definition and Assembly Reference Console errors.
/// </summary>
Expand Down Expand Up @@ -503,6 +596,8 @@ public void Cleanup()
_currentCompileTask.SetCanceled();
_currentCompileTask = null;
}
_isCompiling = false;
_isForceCompile = false;
}

/// <summary>
Expand Down
Loading
Loading