diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index 950d57a2e0..51413deadf 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Text; +using System.Threading.Tasks; using NUnit.Framework; using io.github.hatayama.UnityCliLoop.FirstPartyTools; @@ -27,6 +28,71 @@ public void ConfigureWorkerDotnetRuntimeEnvironment_WhenCalled_ShouldDisableMult Is.EqualTo(SharedRoslynCompilerWorkerAssemblyBuilder.DotnetMultilevelLookupDisabledValue)); } + /// + /// Verifies a pending compiler stream does not keep the bounded drain waiting. + /// + [Test] + public void WaitForCompilerStreamDrain_WhenStreamIsPending_ShouldReturnFalse() + { + TaskCompletionSource pendingStream = new(); + + bool completed = SharedRoslynCompilerWorkerAssemblyBuilder.WaitForCompilerStreamDrain( + Task.FromResult("stdout"), + pendingStream.Task, + 0); + pendingStream.SetResult("stderr"); + + Assert.That(completed, Is.False); + } + + /// + /// Verifies completed compiler streams satisfy the bounded drain immediately. + /// + [Test] + public void WaitForCompilerStreamDrain_WhenStreamsAreCompleted_ShouldReturnTrue() + { + bool completed = SharedRoslynCompilerWorkerAssemblyBuilder.WaitForCompilerStreamDrain( + Task.FromResult("stdout"), + Task.FromResult("stderr"), + 0); + + Assert.That(completed, Is.True); + } + + /// + /// Verifies a faulted compiler stream is treated as drained without escaping timeout recovery. + /// + [Test] + public void WaitForCompilerStreamDrain_WhenStreamFaults_ShouldReturnTrue() + { + Task faultedStream = Task.FromException(new IOException("stream read failed")); + + bool completed = SharedRoslynCompilerWorkerAssemblyBuilder.WaitForCompilerStreamDrain( + Task.FromResult("stdout"), + faultedStream, + 0); + + Assert.That(completed, Is.True); + } + + /// + /// Verifies one faulted stream does not make a still-pending drain appear complete. + /// + [Test] + public void WaitForCompilerStreamDrain_WhenFaultedStreamHasPendingPeer_ShouldReturnFalse() + { + TaskCompletionSource pendingStream = new(); + Task faultedStream = Task.FromException(new IOException("stream read failed")); + + bool completed = SharedRoslynCompilerWorkerAssemblyBuilder.WaitForCompilerStreamDrain( + faultedStream, + pendingStream.Task, + 0); + pendingStream.SetResult("stderr"); + + Assert.That(completed, Is.False); + } + [Test] public void CreateCompileRequestCommand_WhenPathIsWindowsAbsolutePath_ShouldEncodeAsciiPayload() { diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs index 468d84030a..dc67e1e254 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs @@ -16,6 +16,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class SharedRoslynCompilerWorkerAssemblyBuilder { private const int WorkerAssemblyBuildTimeoutMilliseconds = 30000; + private const int WorkerCompilerTerminationTimeoutMilliseconds = 500; internal const string DotnetMultilevelLookupEnvironmentVariableName = "DOTNET_MULTILEVEL_LOOKUP"; internal const string DotnetMultilevelLookupDisabledValue = "0"; @@ -107,10 +108,13 @@ internal static WorkerAssemblyBuildResult CompileWorkerAssembly( if (!process.HasExited) { process.Kill(); - process.WaitForExit(500); + process.WaitForExit(WorkerCompilerTerminationTimeoutMilliseconds); } - Task.WaitAll(stdoutTask, stderrTask); + WaitForCompilerStreamDrain( + stdoutTask, + stderrTask, + WorkerCompilerTerminationTimeoutMilliseconds); return WorkerAssemblyBuildResult.StartFailure( "worker_compiler_timeout", new @@ -129,6 +133,33 @@ internal static WorkerAssemblyBuildResult CompileWorkerAssembly( return WorkerAssemblyBuildResult.Started(compilerMessages); } + internal static bool WaitForCompilerStreamDrain( + Task stdoutTask, + Task stderrTask, + int timeoutMilliseconds) + { + Debug.Assert(stdoutTask != null, "stdoutTask must not be null"); + Debug.Assert(stderrTask != null, "stderrTask must not be null"); + Debug.Assert(timeoutMilliseconds >= 0, "timeoutMilliseconds must not be negative"); + + Task streamDrainTask = Task.WhenAll(stdoutTask, stderrTask); + bool completed = Task.WaitAny( + new[] { streamDrainTask }, + timeoutMilliseconds) == 0; + ObserveCompilerStreamFailure(stdoutTask); + ObserveCompilerStreamFailure(stderrTask); + ObserveCompilerStreamFailure(streamDrainTask); + return completed; + } + + private static void ObserveCompilerStreamFailure(Task streamDrainTask) + { + // Why: disposing the process after a bounded drain can fault a pending read task later. + _ = streamDrainTask.ContinueWith( + task => { _ = task.Exception; }, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + } + private static List BuildWorkerReferenceSet(ExternalCompilerPaths externalCompilerPaths) { string sharedRuntimeDirectoryPath = externalCompilerPaths.NetCoreRuntimeSharedDirectoryPath;