From f26acbce6a3fecb750f72b3568e7e063d7c58c4e Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 01:48:05 +0900 Subject: [PATCH 1/3] Bound worker compiler stream draining Prevent compiler timeout recovery from blocking indefinitely when redirected streams remain open after process termination. Observe late stream faults and cover both bounded-drain outcomes without starting background work. --- .../SharedRoslynCompilerWorkerHostTests.cs | 32 ++++++++++++++++ ...aredRoslynCompilerWorkerAssemblyBuilder.cs | 38 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index 950d57a2e0..ca5a490591 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,37 @@ 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); + } + [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..e55d1d9118 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,36 @@ 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"); + + bool completed = Task.WaitAll( + new Task[] { stdoutTask, stderrTask }, + timeoutMilliseconds); + if (completed) + { + return true; + } + + ObserveCompilerStreamFailure(stdoutTask); + ObserveCompilerStreamFailure(stderrTask); + return false; + } + + private static void ObserveCompilerStreamFailure(Task streamTask) + { + // Why: disposing the process after a bounded drain can fault a pending read task later. + _ = streamTask.ContinueWith( + task => { _ = task.Exception; }, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + } + private static List BuildWorkerReferenceSet(ExternalCompilerPaths externalCompilerPaths) { string sharedRuntimeDirectoryPath = externalCompilerPaths.NetCoreRuntimeSharedDirectoryPath; From 4c7f7e5a193447f629803ba2b65f9a4a1b172fb2 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 02:02:02 +0900 Subject: [PATCH 2/3] Handle faulted compiler stream drains Use a non-throwing bounded wait for the aggregate stream task so timeout recovery still returns its existing failure when redirected reads fault. Observe the aggregate fault and cover the regression directly. --- .../SharedRoslynCompilerWorkerHostTests.cs | 16 ++++++++++++++ ...aredRoslynCompilerWorkerAssemblyBuilder.cs | 21 +++++++------------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index ca5a490591..77956d2e5c 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -59,6 +59,22 @@ public void WaitForCompilerStreamDrain_WhenStreamsAreCompleted_ShouldReturnTrue( 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); + } + [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 e55d1d9118..29a1642f52 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs @@ -142,23 +142,18 @@ internal static bool WaitForCompilerStreamDrain( Debug.Assert(stderrTask != null, "stderrTask must not be null"); Debug.Assert(timeoutMilliseconds >= 0, "timeoutMilliseconds must not be negative"); - bool completed = Task.WaitAll( - new Task[] { stdoutTask, stderrTask }, - timeoutMilliseconds); - if (completed) - { - return true; - } - - ObserveCompilerStreamFailure(stdoutTask); - ObserveCompilerStreamFailure(stderrTask); - return false; + Task streamDrainTask = Task.WhenAll(stdoutTask, stderrTask); + bool completed = Task.WaitAny( + new[] { streamDrainTask }, + timeoutMilliseconds) == 0; + ObserveCompilerStreamFailure(streamDrainTask); + return completed; } - private static void ObserveCompilerStreamFailure(Task streamTask) + private static void ObserveCompilerStreamFailure(Task streamDrainTask) { // Why: disposing the process after a bounded drain can fault a pending read task later. - _ = streamTask.ContinueWith( + _ = streamDrainTask.ContinueWith( task => { _ = task.Exception; }, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); } From 3527c2804b0734a1a2e6cde44196d55961832962 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 02:13:22 +0900 Subject: [PATCH 3/3] Observe partial compiler stream failures Observe each redirected read independently as well as the aggregate drain task so one late fault cannot remain hidden behind a peer stream that never completes. --- .../SharedRoslynCompilerWorkerHostTests.cs | 18 ++++++++++++++++++ ...haredRoslynCompilerWorkerAssemblyBuilder.cs | 2 ++ 2 files changed, 20 insertions(+) diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs index 77956d2e5c..51413deadf 100644 --- a/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs +++ b/Assets/Tests/Editor/DynamicCodeToolTests/SharedRoslynCompilerWorkerHostTests.cs @@ -75,6 +75,24 @@ public void WaitForCompilerStreamDrain_WhenStreamFaults_ShouldReturnTrue() 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 29a1642f52..dc67e1e254 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/SharedRoslynCompilerWorkerAssemblyBuilder.cs @@ -146,6 +146,8 @@ internal static bool WaitForCompilerStreamDrain( bool completed = Task.WaitAny( new[] { streamDrainTask }, timeoutMilliseconds) == 0; + ObserveCompilerStreamFailure(stdoutTask); + ObserveCompilerStreamFailure(stderrTask); ObserveCompilerStreamFailure(streamDrainTask); return completed; }