@@ -14,7 +14,7 @@ public class ProcessRunnerCancellationTests
1414 "/usr/bin/setsid /bin/sh -c '/usr/bin/setsid /bin/sleep 30 >&2 & echo $!; exit 0' & exec /bin/sleep 30";
1515 private const string PosixFixtureSkipReason = "The public CLI yield-boundary fixture currently uses a POSIX executable script.";
1616 private const string LinuxFixtureSkipReason = "The detached stderr-retention fixture requires Linux setsid.";
17- private const string StderrClosureFailure = "stderr stream closed ";
17+ private const string StderrClosureFailure = "Claude Code process and output cleanup could not be confirmed. ";
1818 private const string ProcessOutputLimitMessage = "Claude Code process exceeded the configured output limit.";
1919 private const string ExpectedFirstLine = "first";
2020 private const string ExpectedSecondLine = "second";
@@ -24,6 +24,8 @@ public class ProcessRunnerCancellationTests
2424 private const string PosixNormalMultiLineCommand = "printf 'first\\nsecond\\n'";
2525 private const string PosixNonZeroExitCommand = "printf 'provider failed\\n' >&2; exit 23";
2626 private const string PosixNonZeroExitBeforeInputCommand = "printf 'provider failed\\n' >&2; /bin/sleep 0.1; exit 23";
27+ private const string PosixZeroExitAfterClosingInputCommand = "exec 0<&-; /bin/sleep 0.1; exit 0";
28+ private const string PosixStaysRunningAfterClosingInputCommand = "printf 'started\\n'; exec 0<&-; exec /bin/sleep 30";
2729 private const string PosixLargePromptPipePressureCommand =
2830 "head -c 262144 /dev/zero | tr '\\0' s; printf '\\n'; head -c 262144 /dev/zero | tr '\\0' e >&2; cat";
2931 private const string WindowsLargePromptPipePressureCommand =
@@ -42,6 +44,8 @@ public class ProcessRunnerCancellationTests
4244 private const string WindowsNormalMultiLineCommand = "Write-Output 'first'; Write-Output 'second'";
4345 private const string WindowsNonZeroExitCommand = "[Console]::Error.WriteLine('provider failed'); exit 23";
4446 private const string WindowsNonZeroExitBeforeInputCommand = "[Console]::Error.WriteLine('provider failed'); Start-Sleep -Milliseconds 100; exit 23";
47+ private const string WindowsZeroExitAfterClosingInputCommand = "[Console]::OpenStandardInput().Dispose(); Start-Sleep -Milliseconds 100; exit 0";
48+ private const string WindowsStaysRunningAfterClosingInputCommand = "Write-Output 'started'; [Console]::OpenStandardInput().Dispose(); Start-Sleep -Seconds 30";
4549 private const string SystemRootVariableName = "SystemRoot";
4650 private const string WindowsDirectoryVariableName = "WINDIR";
4751 private const string PathVariableName = "PATH";
@@ -225,6 +229,49 @@ public async Task DefaultRunner_NonZeroExitWithCompletedBrokenPipeKeepsConfirmed
225229 await Assert.That(((CliExecutionFailureException)exception).RootProcessExitConfirmed).IsTrue();
226230 }
227231
232+ [Test]
233+ public async Task DefaultRunner_ZeroExitAfterClosingInputRemainsAnInputFailure()
234+ {
235+ var prompt = new string(PromptCharacter[0], LargePromptCharacters);
236+ var invocation = CreateOutputInvocation(
237+ OperatingSystem.IsWindows() ? WindowsZeroExitAfterClosingInputCommand : PosixZeroExitAfterClosingInputCommand,
238+ TimeSpan.FromSeconds(5), LargeProcessOutputCharacters, input: prompt);
239+ var action = async () =>
240+ {
241+ await foreach (var _ in new DefaultClaudeProcessRunner().RunAsync(invocation, NullLogger.Instance, CancellationToken.None))
242+ {
243+ }
244+ };
245+
246+ var exception = await Assert.That(action).ThrowsException();
247+
248+ await Assert.That(exception).IsTypeOf<IOException>();
249+ await Assert.That(exception).IsNotTypeOf<CliExecutionFailureException>();
250+ }
251+
252+ [Test]
253+ public async Task DefaultRunner_StillRunningAfterClosingInputIsBoundedAndUnconfirmed()
254+ {
255+ var prompt = new string(PromptCharacter[0], LargePromptCharacters);
256+ var invocation = CreateOutputInvocation(
257+ OperatingSystem.IsWindows() ? WindowsStaysRunningAfterClosingInputCommand : PosixStaysRunningAfterClosingInputCommand,
258+ TimeSpan.FromMilliseconds(250), LargeProcessOutputCharacters, input: prompt);
259+ var stopwatch = Stopwatch.StartNew();
260+ var action = async () =>
261+ {
262+ await foreach (var _ in new DefaultClaudeProcessRunner().RunAsync(invocation, NullLogger.Instance, CancellationToken.None))
263+ {
264+ }
265+ };
266+
267+ var exception = await Assert.That(action).ThrowsException();
268+ stopwatch.Stop();
269+
270+ await Assert.That(exception).IsTypeOf<TimeoutException>();
271+ await Assert.That(exception).IsNotTypeOf<CliExecutionFailureException>();
272+ await Assert.That(stopwatch.Elapsed < TimeSpan.FromSeconds(3)).IsTrue();
273+ }
274+
228275 [Test]
229276 public async Task DefaultRunner_RejectsSingleLineAboveConfiguredBudget()
230277 {
0 commit comments