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;