Skip to content

Commit bcfa2f3

Browse files
committed
fix: classify early stdin close after process exit
1 parent 9252847 commit bcfa2f3

3 files changed

Lines changed: 67 additions & 2 deletions

File tree

‎CodexSharpSDK.Tests/Integration/CodexWindowsNpmLaunchTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public sealed class CodexWindowsNpmLaunchTests
2424
"if (args.includes('--version')) { console.log('codex-cli 0.160.1'); process.exit(0); }\n" +
2525
"console.log(JSON.stringify({type:'thread.started', thread_id:'fixture'}));\n" +
2626
"console.log(JSON.stringify({type:'item.completed', item:{id:'message', type:'agent_message', text:input}}));\n" +
27-
"console.log(JSON.stringify({type:'turn.completed'}));\n";
27+
"console.log(JSON.stringify({type:'turn.completed',usage:{input_tokens:1,cached_input_tokens:0,output_tokens:1}}));\n";
2828

2929
[Test]
3030
public async Task WindowsNpmShim_UsesNodeAndLiteralArgumentsForMetadataCoreAndMeai()

‎CodexSharpSDK.Tests/Unit/CodexExecTests.cs‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ public class CodexExecTests
2323
private const string DuplexPressureTemplatePlaceholder = "{0}";
2424
private const string PosixFixtureSkipReason = "The public CLI yield-boundary fixture currently uses a POSIX executable script.";
2525
private const string LinuxFixtureSkipReason = "The detached stderr-retention fixture requires Linux setsid.";
26-
private const string StderrClosureFailure = "stderr stream closed";
26+
private const string StderrClosureFailure = "Codex CLI process and output cleanup could not be confirmed.";
2727
private const string ProcessOutputLimitMessage = "Codex CLI process exceeded the configured output limit.";
2828
private const string PosixSingleLineOverflowCommand = "printf '%100s\\n' x; exec /bin/sleep 30";
2929
private const string PosixMultiLineOverflowCommand = "printf '1234567890\\n1234567890\\n1234567890\\n'";
3030
private const string PosixStandardErrorPressureCommand = "printf '%100s' x >&2; exec /bin/sleep 30";
3131
private const string PosixNormalMultiLineCommand = "printf 'first\\nsecond\\n'";
3232
private const string PosixNonZeroExitBeforeInputCommand = "printf 'provider failed\\n' >&2; /bin/sleep 0.1; exit 23";
33+
private const string PosixZeroExitAfterClosingInputCommand = "exec 0<&-; /bin/sleep 0.1; exit 0";
34+
private const string PosixStaysRunningAfterClosingInputCommand = "printf 'started\\n'; exec 0<&-; exec /bin/sleep 30";
3335
private const string WindowsPowerShellPath = "powershell.exe";
3436
private const string WindowsNoProfileFlag = "-NoProfile";
3537
private const string WindowsNonInteractiveFlag = "-NonInteractive";
@@ -39,6 +41,8 @@ public class CodexExecTests
3941
private const string WindowsStandardErrorPressureCommand = "[Console]::Error.Write('x' * 100); Start-Sleep -Seconds 30";
4042
private const string WindowsNormalMultiLineCommand = "Write-Output 'first'; Write-Output 'second'";
4143
private const string WindowsNonZeroExitBeforeInputCommand = "[Console]::Error.WriteLine('provider failed'); Start-Sleep -Milliseconds 100; exit 23";
44+
private const string WindowsZeroExitAfterClosingInputCommand = "[Console]::OpenStandardInput().Dispose(); Start-Sleep -Milliseconds 100; exit 0";
45+
private const string WindowsStaysRunningAfterClosingInputCommand = "Write-Output 'started'; [Console]::OpenStandardInput().Dispose(); Start-Sleep -Seconds 30";
4246
private const string ExpectedFirstLine = "first";
4347
private const string ExpectedSecondLine = "second";
4448
private const int SmallOutputLimitCharacters = 64;
@@ -808,6 +812,51 @@ public async Task DefaultProcessRunner_NonZeroExitWithCompletedBrokenPipeKeepsCo
808812
}
809813
}
810814

815+
[Test]
816+
public async Task DefaultProcessRunner_ZeroExitAfterClosingInputRemainsAnInputFailure()
817+
{
818+
var prompt = new string(PromptCharacter, DuplexPromptCharacters);
819+
var invocation = CreateOutputInvocation(
820+
OperatingSystem.IsWindows() ? WindowsZeroExitAfterClosingInputCommand : PosixZeroExitAfterClosingInputCommand,
821+
TimeSpan.FromSeconds(5), DuplexMaximumProcessOutputCharacters) with
822+
{ Input = prompt };
823+
var action = async () =>
824+
{
825+
await foreach (var _ in new DefaultCodexProcessRunner().RunAsync(invocation, NullLogger.Instance, CancellationToken.None))
826+
{
827+
}
828+
};
829+
830+
var exception = await Assert.That(action).ThrowsException();
831+
832+
await Assert.That(exception).IsTypeOf<IOException>();
833+
await Assert.That(exception).IsNotTypeOf<CliExecutionFailureException>();
834+
}
835+
836+
[Test]
837+
public async Task DefaultProcessRunner_StillRunningAfterClosingInputIsBoundedAndUnconfirmed()
838+
{
839+
var prompt = new string(PromptCharacter, DuplexPromptCharacters);
840+
var invocation = CreateOutputInvocation(
841+
OperatingSystem.IsWindows() ? WindowsStaysRunningAfterClosingInputCommand : PosixStaysRunningAfterClosingInputCommand,
842+
TimeSpan.FromMilliseconds(250), DuplexMaximumProcessOutputCharacters) with
843+
{ Input = prompt };
844+
var stopwatch = Stopwatch.StartNew();
845+
var action = async () =>
846+
{
847+
await foreach (var _ in new DefaultCodexProcessRunner().RunAsync(invocation, NullLogger.Instance, CancellationToken.None))
848+
{
849+
}
850+
};
851+
852+
var exception = await Assert.That(action).ThrowsException();
853+
stopwatch.Stop();
854+
855+
await Assert.That(exception).IsTypeOf<TimeoutException>();
856+
await Assert.That(exception).IsNotTypeOf<CliExecutionFailureException>();
857+
await Assert.That(stopwatch.Elapsed < TimeSpan.FromSeconds(3)).IsTrue();
858+
}
859+
811860
[Test]
812861
public async Task DefaultProcessRunner_CancellationWhileProcessStillRunning_ThrowsOperationCanceledException()
813862
{

‎CodexSharpSDK/Execution/CodexExec.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,22 @@ private static async Task AwaitStandardInputWriteAsync(
10051005

10061006
throw;
10071007
}
1008+
catch (IOException) when (standardInputWriteTask.IsFaulted &&
1009+
standardInputWriteTask.Exception?.GetBaseException() is IOException)
1010+
{
1011+
await process.WaitForExitAsync(CancellationToken.None)
1012+
.WaitAsync(invocation.ProcessTerminationTimeout, CancellationToken.None)
1013+
.ConfigureAwait(false);
1014+
var capturedStandardError = await ReadStandardErrorAsync(standardErrorTask, invocation.ProcessTerminationTimeout)
1015+
.ConfigureAwait(false);
1016+
if (process.ExitCode != 0)
1017+
{
1018+
throw CliExecutionFailureException.FromProcessExit(process.ExitCode,
1019+
$"Codex Exec exited with code {process.ExitCode}: {capturedStandardError.Text}");
1020+
}
1021+
1022+
throw;
1023+
}
10081024
catch (Exception) when (process.HasExited)
10091025
{
10101026
await process.WaitForExitAsync(CancellationToken.None)

0 commit comments

Comments
 (0)