From 3cb98fa041dae55719506e748f355dc69ba41ccf Mon Sep 17 00:00:00 2001 From: Gudge Date: Wed, 28 Oct 2026 14:52:03 -0700 Subject: [PATCH] Fix .NET captured execution timeout cleanup This PR fixes captured execution that can wait indefinitely after a workload timeout when a descendant keeps an output pipe open. Details * Close output readers on timeout and return the collected partial output. * Report reader-close failures without waiting on the blocked readers. * Add a typed IsolationSession regression and document the capture behavior. Tests * C# syntax and git diff checks passed. * The regression is in the existing scheduled IsolationSession test class. * dotnet build --no-restore --nologo -v:q was blocked by NETSDK1004. The host-dependent runtime regression was not run locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38bb56f2-debb-4e64-a7a1-574321a83b89 Generated-with: gpt-6.1-sol --- docs/api-reference/dotnet/v1/api.md | 3 +++ .../V1/MxcLifecycleE2ETests.cs | 25 +++++++++++++++++++ sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs | 19 ++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/docs/api-reference/dotnet/v1/api.md b/docs/api-reference/dotnet/v1/api.md index 5a9fa4409..e51b6e184 100644 --- a/docs/api-reference/dotnet/v1/api.md +++ b/docs/api-reference/dotnet/v1/api.md @@ -27,6 +27,9 @@ is no separate public attached-console launch API. Typed V1 does not accept [V1.Dev exact-JSON API](https://github.com/microsoft/mxc/blob/main/docs/api-reference/dotnet/v1/dev.md) accepts caller-authored requests separately. +Captured existing-container execution closes its output readers on timeout +and returns the text collected so far with `TimedOut = true`. + Read the terminal output concurrently rather than only after waiting. On macOS a workload cannot exit while its terminal output is still unread, so a caller that waits first without reading can block indefinitely. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs index 2baa895e4..1fc807bbd 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcLifecycleE2ETests.cs @@ -290,6 +290,31 @@ public void Exec_ReportsConfiguredTimeout() } } + [Fact] + public async Task Exec_CapturedTimeoutClosesDescendantHeldOutput() + { + IsolationSessionHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + var request = new ExecutionRequest( + $"{Cmd} /c echo MXC_TIMEOUT_PARTIAL & start /b {Cmd} /c ping -n 60 127.0.0.1 >nul & ping -n 60 127.0.0.1 >nul") + { + TimeoutMs = 750, + }; + var capture = MxcLifecycle.RunInContainerAsync( + started.Id, request, cancellationToken: TestContext.Current.CancellationToken); + var finished = await Task.WhenAny( + capture, Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + Assert.Same(capture, finished); + + var result = await capture; + Assert.True(result.TimedOut); + Assert.Contains("MXC_TIMEOUT_PARTIAL", result.Stdout); + } + } + /// /// Stop and deprovision must be reachable through this binding, and a /// deprovisioned id must not still be usable. diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs index 7cdcbebdd..a29f66c86 100644 --- a/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs +++ b/sdk/dotnet/Microsoft.Mxc.Sdk/V1/MxcProcess.cs @@ -611,9 +611,21 @@ Task ReadAll(Stream? s) => var stdoutTask = ReadAll(outStream); var stderrTask = ReadAll(errStream); + var timeoutCloseFailed = false; try { var result = await WaitAsync(cancellationToken).ConfigureAwait(false); + if (result.TimedOut) + { + timeoutCloseFailed = + !CloseQuietly(outCloser, outStream is not null) | + !CloseQuietly(errCloser, errStream is not null); + if (timeoutCloseFailed) + { + throw new MxcException(ErrorCode.BackendError, + "timed-out capture could not close its output readers"); + } + } var stdout = await stdoutTask.ConfigureAwait(false); var stderr = await stderrTask.ConfigureAwait(false); return (result, stdout, stderr); @@ -635,6 +647,13 @@ await Task.WhenAll(SwallowAsync(stdoutTask), SwallowAsync(stderrTask)) } catch { + if (timeoutCloseFailed) + { + // Without a closer, waiting for a descendant-held pipe can never finish. + KillQuietly(); + _ = Task.WhenAll(SwallowAsync(stdoutTask), SwallowAsync(stderrTask)); + throw; + } // On cancellation/failure the reader tasks may be parked in a blocking // native read that only returns once the child's pipes reach EOF. Kill // the child first so those write ends close and the reads return;