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
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,56 @@ public async Task ExecuteAsync_WhenKnownCompilePatternExistsOnlyInCompilationErr
Assert.That(response.Logs, Contains.Item("Solutions:"));
}

[Test]
public async Task ExecuteAsync_WhenRuntimeThrowsObjectDisposedException_ReturnsRetryableRestartingError()
{
// Verifies disposed-runtime ODE during execute maps to Success=false with restarting guidance.
MarkForegroundWarmupCompleted();
ObjectDisposedDynamicCodeExecutionRuntime runtime = new(
throwOnExecuteCount: 1,
successAfterThrow: false);
ExecuteDynamicCodeUseCase useCase = new(runtime);
ExecuteDynamicCodeResponse response = await useCase.ExecuteAsync(
new ExecuteDynamicCodeSchema
{
Code = "return 1;",
CompileOnly = false
},
CancellationToken.None);

Assert.That(response.Success, Is.False);
Assert.That(
response.ErrorMessage,
Is.EqualTo(UnityCliLoopConstants.ERROR_MESSAGE_DYNAMIC_CODE_RUNTIME_RESTARTING));
Assert.That(response.NextActions, Is.EqualTo(UnityCliLoopConstants.DYNAMIC_CODE_RUNTIME_RESTARTING_NEXT_ACTIONS));
Assert.That(response.NextActions[0], Does.Contain("Retry").IgnoreCase);
Assert.That(response.NextActions[1], Does.Contain("launch -r"));
Assert.That(response.NextActions[1], Does.Contain("not needed"));
}

[Test]
public async Task ExecuteAsync_WhenWarmupThrowsObjectDisposedException_ContinuesAsSilentNoOp()
{
// Verifies Warm-path ODE is incomplete warm (no CLI error), while Execute still succeeds afterward.
ObjectDisposedDynamicCodeExecutionRuntime runtime = new(
throwOnExecuteCount: 1,
successAfterThrow: true);
ExecuteDynamicCodeUseCase useCase = new(runtime);
ExecuteDynamicCodeResponse response = await useCase.ExecuteAsync(
new ExecuteDynamicCodeSchema
{
Code = "return \"ok\";",
CompileOnly = false
},
CancellationToken.None);

Assert.That(response.Success, Is.True);
Assert.That(response.Result, Is.EqualTo("ok"));
Assert.That(response.ErrorMessage, Is.Null.Or.Empty);
Assert.That(response.NextActions, Is.Null);
Assert.That(runtime.ExecuteCount, Is.GreaterThanOrEqualTo(2));
}

/// <summary>
/// Test support type used by editor and play mode fixtures.
/// </summary>
Expand Down Expand Up @@ -777,6 +827,53 @@ public Task<ExecutionResult> ExecuteAsync(
}
}

/// <summary>
/// Throws ObjectDisposedException on the Nth ExecuteAsync, optionally returning success afterward.
/// </summary>
private sealed class ObjectDisposedDynamicCodeExecutionRuntime : IDynamicCodeExecutionRuntime
{
private readonly int _throwOnExecuteCount;
private readonly bool _successAfterThrow;

public ObjectDisposedDynamicCodeExecutionRuntime(int throwOnExecuteCount, bool successAfterThrow)
{
_throwOnExecuteCount = throwOnExecuteCount;
_successAfterThrow = successAfterThrow;
}

public int ExecuteCount { get; private set; }

public Task<ExecutionResult> ExecuteAsync(
DynamicCodeExecutionRequest request,
CancellationToken cancellationToken = default)
{
ExecuteCount++;
if (ExecuteCount == _throwOnExecuteCount)
{
// ObjectName matches the production scheduler dispose path without requiring type visibility.
throw new ObjectDisposedException("DynamicCodeExecutionScheduler");
}

if (!_successAfterThrow)
{
throw new InvalidOperationException("Unexpected ExecuteAsync after ObjectDisposedException");
}

return Task.FromResult(new ExecutionResult
{
Success = true,
Result = "ok"
});
}

public Task<(bool Entered, ExecutionResult Result)> TryExecuteIfIdleAsync(
DynamicCodeExecutionRequest request,
CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("TryExecuteIfIdleAsync is not used by these tests");
}
}

private static void MarkForegroundWarmupCompleted()
{
bool started = DynamicCodeForegroundWarmupState.TryBegin();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Text;
using System.Threading.Tasks;
using NUnit.Framework;
using UnityEditor.Compilation;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

Expand Down Expand Up @@ -45,6 +46,143 @@ public void Shutdown_WhenWorkerWasNeverStarted_ShouldRemainIdempotent()
Assert.DoesNotThrow(() => session.Shutdown(unusedWorkerDirectoryPath));
}

/// <summary>
/// Verifies full Shutdown advances lifecycle generation so in-flight retries cannot restart the worker.
/// </summary>
[Test]
public void Shutdown_WhenCalled_ShouldAdvanceLifecycleGeneration()
{
SharedRoslynCompilerWorkerSession session = new();
string unusedWorkerDirectoryPath = Path.Combine(
Path.GetTempPath(),
$"SharedRoslynCompilerWorkerSessionTests_{Guid.NewGuid():N}");

int generationBefore = session.ExecuteWithStateLock(session.GetLifecycleGenerationLocked);
session.Shutdown(unusedWorkerDirectoryPath);
int generationAfter = session.ExecuteWithStateLock(session.GetLifecycleGenerationLocked);

Assert.That(generationBefore, Is.EqualTo(0));
Assert.That(generationAfter, Is.EqualTo(1));
Assert.That(
session.ExecuteWithStateLock(() => session.IsLifecycleGenerationCurrentLocked(generationBefore)),
Is.False);
Assert.That(
session.ExecuteWithStateLock(() => session.IsLifecycleGenerationCurrentLocked(generationAfter)),
Is.True);
}

/// <summary>
/// Verifies retry-path process kill keeps the lifecycle open so a replacement worker may start.
/// </summary>
[Test]
public void ShutdownProcessLocked_WhenCalledAlone_ShouldStillAllowWorkerRestart()
{
SharedRoslynCompilerWorkerSession session = new();
Process startedProcess = null;
int startCallCount = 0;
session.SwapProcessStarterForTests(_ =>
{
startCallCount++;
startedProcess = new Process();
return startedProcess;
});

try
{
int generationAtStart = session.ExecuteWithStateLock(session.GetLifecycleGenerationLocked);
session.ExecuteWithStateLock(session.ShutdownProcessLocked);

bool started = session.ExecuteWithStateLock(
() =>
{
if (!session.IsLifecycleGenerationCurrentLocked(generationAtStart))
{
return false;
}

return session.StartProcessLocked(new ProcessStartInfo());
});

Assert.That(started, Is.True);
Assert.That(startCallCount, Is.EqualTo(1));
Assert.That(
session.ExecuteWithStateLock(session.GetLifecycleGenerationLocked),
Is.EqualTo(generationAtStart));
}
finally
{
startedProcess?.Dispose();
}
}

/// <summary>
/// Verifies a stale lifecycle generation refuses StartProcess after full Shutdown.
/// </summary>
[Test]
public void StartProcessLocked_WhenLifecycleGenerationIsStale_ShouldBeDetectableBeforeStart()
{
SharedRoslynCompilerWorkerSession session = new();
string unusedWorkerDirectoryPath = Path.Combine(
Path.GetTempPath(),
$"SharedRoslynCompilerWorkerSessionTests_{Guid.NewGuid():N}");
int startCallCount = 0;
session.SwapProcessStarterForTests(_ =>
{
startCallCount++;
return new Process();
});

int generationAtStart = session.ExecuteWithStateLock(session.GetLifecycleGenerationLocked);
session.Shutdown(unusedWorkerDirectoryPath);

bool wouldStart = session.ExecuteWithStateLock(
() => session.IsLifecycleGenerationCurrentLocked(generationAtStart));
Assert.That(wouldStart, Is.False);

bool started = session.ExecuteWithStateLock(
() =>
{
if (!session.IsLifecycleGenerationCurrentLocked(generationAtStart))
{
return false;
}

return session.StartProcessLocked(new ProcessStartInfo());
});

Assert.That(started, Is.False);
Assert.That(startCallCount, Is.Zero);
}

/// <summary>
/// Verifies the async offload path returns the test-hook build result without requiring a real csc.
/// </summary>
[Test]
public async Task CompileWorkerAssemblyAsync_WhenTestHookIsInstalled_ShouldReturnHookResult()
{
SharedRoslynCompilerWorkerSession session = new();
CompilerMessage[] expectedMessages =
{
new CompilerMessage
{
type = CompilerMessageType.Error,
message = "hooked"
}
};
session.SwapWorkerAssemblyCompilerForTests(
(paths, sourcePath, assemblyPath, responsePath) => expectedMessages);

SharedRoslynCompilerWorkerAssemblyBuilder.WorkerAssemblyBuildResult result =
await session.CompileWorkerAssemblyAsync(
externalCompilerPaths: null,
workerSourcePath: "unused.cs",
workerAssemblyPath: "unused.dll",
workerCompileResponseFilePath: "unused.rsp");

Assert.That(result.StartedSuccessfully, Is.True);
Assert.That(result.Messages, Is.SameAs(expectedMessages));
}

/// <summary>
/// Verifies replacing the cached worker releases the previously owned process handle.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ internal static bool IsCancelledResult(ExecutionResult executionResult)
StringComparison.Ordinal);
}

internal static bool IsRuntimeRestartingResult(ExecutionResult executionResult)
{
return executionResult != null
&& !executionResult.Success
&& string.Equals(
executionResult.ErrorMessage,
UnityCliLoopConstants.ERROR_MESSAGE_DYNAMIC_CODE_RUNTIME_RESTARTING,
StringComparison.Ordinal);
}

internal static ExecuteDynamicCodeResponse CreateCancelledResponse()
{
return new ExecuteDynamicCodeResponse
Expand All @@ -40,6 +50,35 @@ internal static ExecuteDynamicCodeResponse CreateCancelledResponse()
};
}

internal static ExecuteDynamicCodeResponse CreateRuntimeRestartingResponse()
{
return new ExecuteDynamicCodeResponse
{
Success = false,
Result = string.Empty,
Logs = new List<string>
{
UnityCliLoopConstants.ERROR_MESSAGE_DYNAMIC_CODE_RUNTIME_RESTARTING
},
CompilationErrors = new List<CompilationErrorDto>(),
ErrorMessage = UnityCliLoopConstants.ERROR_MESSAGE_DYNAMIC_CODE_RUNTIME_RESTARTING,
NextActions = UnityCliLoopConstants.DYNAMIC_CODE_RUNTIME_RESTARTING_NEXT_ACTIONS
};
}

internal static ExecutionResult CreateRuntimeRestartingExecutionResult()
{
return new ExecutionResult
{
Success = false,
ErrorMessage = UnityCliLoopConstants.ERROR_MESSAGE_DYNAMIC_CODE_RUNTIME_RESTARTING,
Logs = new List<string>
{
UnityCliLoopConstants.ERROR_MESSAGE_DYNAMIC_CODE_RUNTIME_RESTARTING
}
};
}

internal ExecuteDynamicCodeResponse ConvertExecutionResultToResponse(
ExecutionResult result,
string originalUserSnippet = null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,25 @@ internal static WorkerAssemblyBuildResult CompileWorkerAssembly(
return WorkerAssemblyBuildResult.Started(compilerMessages);
}

/// <summary>
/// Runs <see cref="CompileWorkerAssembly"/> on the thread pool.
/// Why: the sync WaitForExit(timeout) path can otherwise block the Unity main thread when
/// EnsureWorkerReady runs before the first await of an idle compile-gate acquire.
/// </summary>
internal static Task<WorkerAssemblyBuildResult> CompileWorkerAssemblyOffMainThreadAsync(
ExternalCompilerPaths externalCompilerPaths,
string workerSourcePath,
string workerAssemblyPath,
string workerCompileResponseFilePath)
{
return Task.Run(
() => CompileWorkerAssembly(
externalCompilerPaths,
workerSourcePath,
workerAssemblyPath,
workerCompileResponseFilePath));
}

internal static bool WaitForCompilerStreamDrain(
Task<string> stdoutTask,
Task<string> stderrTask,
Expand Down
Loading
Loading