Skip to content

Commit c422ee2

Browse files
committed
test: prove stdin-close cleanup while CLI root stays live
1 parent 70219fa commit c422ee2

4 files changed

Lines changed: 219 additions & 37 deletions

File tree

‎ClaudeCodeSharpSDK.Tests/Integration/ClaudeCliSmokeTests.cs‎

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
namespace ManagedCode.ClaudeCodeSharpSDK.Tests.Integration;
99

10+
[NotInParallel("CliProcess")]
1011
public class ClaudeCliSmokeTests
1112
{
1213
private const string SandboxPrefix = "ClaudeCliSmokeTests-";
@@ -42,7 +43,8 @@ public class ClaudeCliSmokeTests
4243
private const string MessageQuote = "'";
4344
private const string MessageSuffix = ".";
4445
private static readonly string[] StandardLineSeparators = [Environment.NewLine, NewLine, CarriageReturn];
45-
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30);
46+
private static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(2);
47+
private static readonly TimeSpan TestCleanupTimeout = TimeSpan.FromSeconds(10);
4648

4749
[Test]
4850
public async Task ClaudeCli_Smoke_FindExecutablePath_ResolvesExistingBinary()
@@ -216,32 +218,59 @@ private static async Task<ClaudeProcessResult> RunClaudeAsync(
216218
MessageSuffix));
217219
}
218220

219-
process.StandardInput.Close();
220-
221-
using var registration = cancellationToken.Register(() =>
221+
Task<string>? standardOutputTask = null;
222+
Task<string>? standardErrorTask = null;
223+
try
224+
{
225+
process.StandardInput.Close();
226+
standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
227+
standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken);
228+
await process.WaitForExitAsync(cancellationToken);
229+
var output = await Task.WhenAll(standardOutputTask, standardErrorTask)
230+
.WaitAsync(TestCleanupTimeout, cancellationToken);
231+
return new ClaudeProcessResult(process.ExitCode, output[0], output[1]);
232+
}
233+
finally
222234
{
223-
try
235+
if (!process.HasExited)
224236
{
225-
if (!process.HasExited)
237+
try
226238
{
227239
process.Kill(entireProcessTree: true);
228240
}
241+
catch (InvalidOperationException) when (process.HasExited)
242+
{
243+
// The CLI exited between the state check and the kill request.
244+
}
229245
}
230-
catch (InvalidOperationException)
246+
247+
if (!process.HasExited)
231248
{
232-
// Process already exited between check and kill — safe to ignore.
249+
await process.WaitForExitAsync().WaitAsync(TestCleanupTimeout);
233250
}
234-
});
235251

236-
var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
237-
var standardErrorTask = process.StandardError.ReadToEndAsync(cancellationToken);
238-
239-
await process.WaitForExitAsync(cancellationToken);
252+
process.StandardOutput.Dispose();
253+
process.StandardError.Dispose();
254+
if (standardOutputTask is not null && standardErrorTask is not null)
255+
{
256+
await ObserveReadersAsync(standardOutputTask, standardErrorTask, cancellationToken);
257+
}
258+
}
259+
}
240260

241-
return new ClaudeProcessResult(
242-
process.ExitCode,
243-
await standardOutputTask,
244-
await standardErrorTask);
261+
private static async Task ObserveReadersAsync(
262+
Task<string> standardOutputTask,
263+
Task<string> standardErrorTask,
264+
CancellationToken cancellationToken)
265+
{
266+
try
267+
{
268+
await Task.WhenAll(standardOutputTask, standardErrorTask).WaitAsync(TestCleanupTimeout, CancellationToken.None);
269+
}
270+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
271+
{
272+
// A timed-out smoke process has had its owned output reads canceled and observed.
273+
}
245274
}
246275

247276
private sealed record ClaudeProcessResult(int ExitCode, string StandardOutput, string StandardError);

‎ClaudeCodeSharpSDK.Tests/Unit/ProcessRunnerCancellationTests.cs‎

Lines changed: 153 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using System.Diagnostics;
22
using System.Globalization;
3+
using System.Text;
34
using ManagedCode.ClaudeCodeSharpSDK.Execution;
45
using ManagedCode.ClaudeCodeSharpSDK.Models;
56
using Microsoft.Extensions.Logging.Abstractions;
67

78
namespace ManagedCode.ClaudeCodeSharpSDK.Tests.Unit;
89

10+
[NotInParallel("CliProcess")]
911
public class ProcessRunnerCancellationTests
1012
{
1113
private const string LongRunningScript = "#!/bin/sh\necho $$\nexec /bin/sleep 30\n";
@@ -25,7 +27,7 @@ public class ProcessRunnerCancellationTests
2527
private const string PosixNonZeroExitCommand = "printf 'provider failed\\n' >&2; exit 23";
2628
private const string PosixNonZeroExitBeforeInputCommand = "printf 'provider failed\\n' >&2; /bin/sleep 0.1; exit 23";
2729
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";
30+
private const string NodeExecutableName = "node";
2931
private const string PosixLargePromptPipePressureCommand =
3032
"head -c 262144 /dev/zero | tr '\\0' s; printf '\\n'; head -c 262144 /dev/zero | tr '\\0' e >&2; cat";
3133
private const string WindowsLargePromptPipePressureCommand =
@@ -45,7 +47,22 @@ public class ProcessRunnerCancellationTests
4547
private const string WindowsNonZeroExitCommand = "[Console]::Error.WriteLine('provider failed'); exit 23";
4648
private const string WindowsNonZeroExitBeforeInputCommand = "[Console]::Error.WriteLine('provider failed'); Start-Sleep -Milliseconds 100; exit 23";
4749
private const string WindowsZeroExitAfterClosingInputCommand = "[Console]::OpenStandardInput().Dispose(); Start-Sleep -Milliseconds 100; exit 0";
48-
private const string WindowsStaysRunningAfterClosingInputCommand = "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class CliTestNativeInput { [DllImport(\"kernel32.dll\")] public static extern bool CloseHandle(IntPtr handle); [DllImport(\"kernel32.dll\")] public static extern IntPtr GetStdHandle(int handle); }'; [CliTestNativeInput]::CloseHandle([CliTestNativeInput]::GetStdHandle(-10)); Write-Output 'started'; Start-Sleep -Seconds 30";
50+
private const string WindowsNodeExecutableName = "node.exe";
51+
private const string NodeEvaluationFlag = "-e";
52+
private const string NodeReadyLine = "ready";
53+
private const string NodeHandleClosedLine = "closed";
54+
private const string NodeMissingMessage = "Node.js was not found on PATH.";
55+
private const string UnexpectedFixtureOutputMessage = "The Node.js child fixture emitted an unexpected protocol line.";
56+
private const string RootExitedBeforeStdinCloseWasObservedMessage =
57+
"CLI root PID {0} exited before the stdin-close callback was observed (HasExited={1}, process-termination-timeout={2}).";
58+
private static readonly CompositeFormat RootExitedBeforeStdinCloseWasObservedFormat =
59+
CompositeFormat.Parse(RootExitedBeforeStdinCloseWasObservedMessage);
60+
private const string FixtureGuidFormat = "N";
61+
private const string ReadyFileExtension = ".ready";
62+
private const string ReleaseFileExtension = ".release";
63+
private const string ClosedFileExtension = ".closed";
64+
private const string EmptyFileContent = "";
65+
private const string NodeClosingInputFixture = "const fs=require('node:fs');const ready=process.argv[1],release=process.argv[2],closed=process.argv[3];fs.writeFileSync(ready,String(process.pid));console.log('ready');const timer=setInterval(()=>{if(fs.existsSync(release)){clearInterval(timer);process.stdin._handle.close(error=>{if(error){process.exitCode=1;return;}fs.closeSync(0);fs.writeFileSync(closed,String(process.pid));console.log('closed');});}},10);setTimeout(()=>{},30000);";
4966
private const string SystemRootVariableName = "SystemRoot";
5067
private const string WindowsDirectoryVariableName = "WINDIR";
5168
private const string PathVariableName = "PATH";
@@ -67,6 +84,11 @@ public class ProcessRunnerCancellationTests
6784
private const string FixtureDirectoryName = "ProcessRunnerCancellationTests";
6885
private const int SmallOutputLimitCharacters = 64;
6986
private const int AggregateOutputLimitCharacters = 24;
87+
private static readonly TimeSpan WindowsFixtureStartupTimeout = TimeSpan.FromSeconds(10);
88+
private static readonly TimeSpan WindowsFixtureCompletionTimeout = TimeSpan.FromSeconds(5);
89+
private static readonly TimeSpan WindowsFixturePollInterval = TimeSpan.FromMilliseconds(10);
90+
private static readonly TimeSpan WindowsCleanupAssertionBound = TimeSpan.FromSeconds(3);
91+
private static readonly TimeSpan ClosedInputProcessTerminationTimeout = TimeSpan.FromMilliseconds(250);
7092
private static readonly TimeSpan ProcessOutputCleanupAssertionBound = TimeSpan.FromSeconds(8);
7193

7294
[Test]
@@ -110,7 +132,7 @@ public async Task CancellationWithDescendantHoldingStderrSurfacesUnconfirmedClea
110132
[PosixShellCommandFlag, DescendantHoldingStderrWhileParentRunsScript],
111133
CreateEnvironment(),
112134
EmptyStandardInput,
113-
TimeSpan.FromMilliseconds(250))
135+
ClosedInputProcessTerminationTimeout)
114136
{
115137
StandardErrorReaderCompleted = () => standardErrorReaderCompleted.TrySetResult(),
116138
StandardOutputReadCompleted = () => standardOutputReadCompleted.TrySetResult(),
@@ -252,24 +274,139 @@ public async Task DefaultRunner_ZeroExitAfterClosingInputRemainsAnInputFailure()
252274
[Test]
253275
public async Task DefaultRunner_StillRunningAfterClosingInputIsBoundedAndUnconfirmed()
254276
{
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 () =>
277+
await Runner_StillRunningAfterClosingInputIsBoundedAndUnconfirmed();
278+
}
279+
280+
private static async Task Runner_StillRunningAfterClosingInputIsBoundedAndUnconfirmed()
281+
{
282+
var sandbox = GetTestSandboxDirectory();
283+
var fixtureId = Guid.NewGuid().ToString(FixtureGuidFormat, CultureInfo.InvariantCulture);
284+
var readyPath = Path.Combine(sandbox, string.Concat(fixtureId, ReadyFileExtension));
285+
var releasePath = Path.Combine(sandbox, string.Concat(fixtureId, ReleaseFileExtension));
286+
var closedPath = Path.Combine(sandbox, string.Concat(fixtureId, ClosedFileExtension));
287+
var stdinFailureObserved = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
288+
var invocation = new ClaudeProcessInvocation(
289+
FindNodeExecutablePath(),
290+
sandbox,
291+
[NodeEvaluationFlag, NodeClosingInputFixture, readyPath, releasePath, closedPath],
292+
OperatingSystem.IsWindows() ? CreateWindowsProcessEnvironment() : CreateEnvironment(),
293+
new string(PromptCharacter[0], LargePromptCharacters),
294+
TimeSpan.FromMilliseconds(250))
261295
{
262-
await foreach (var _ in new DefaultClaudeProcessRunner().RunAsync(invocation, NullLogger.Instance, CancellationToken.None))
296+
MaximumProcessOutputCharacters = LargeProcessOutputCharacters,
297+
StandardInputWriteFailed = rootExited => stdinFailureObserved.TrySetResult(rootExited),
298+
};
299+
using var cancellation = new CancellationTokenSource();
300+
await using var enumerator = new DefaultClaudeProcessRunner()
301+
.RunAsync(invocation, NullLogger.Instance, cancellation.Token)
302+
.GetAsyncEnumerator(cancellation.Token);
303+
Task? consumeTask = null;
304+
305+
try
306+
{
307+
await Assert.That(await enumerator.MoveNextAsync().AsTask().WaitAsync(WindowsFixtureStartupTimeout)).IsTrue();
308+
await Assert.That(enumerator.Current).IsEqualTo(NodeReadyLine);
309+
await Assert.That(File.Exists(readyPath)).IsTrue();
310+
File.WriteAllText(releasePath, EmptyFileContent);
311+
312+
consumeTask = ConsumeAsync(enumerator);
313+
await WaitForFileAsync(closedPath, WindowsFixtureStartupTimeout);
314+
await stdinFailureObserved.Task.WaitAsync(WindowsFixtureStartupTimeout);
315+
await Assert.That(await stdinFailureObserved.Task).IsFalse();
316+
var processId = int.Parse(File.ReadAllText(closedPath), CultureInfo.InvariantCulture);
317+
using var child = Process.GetProcessById(processId);
318+
if (child.HasExited)
263319
{
320+
throw new InvalidOperationException(string.Format(
321+
CultureInfo.InvariantCulture,
322+
RootExitedBeforeStdinCloseWasObservedFormat,
323+
processId,
324+
child.HasExited,
325+
ClosedInputProcessTerminationTimeout));
264326
}
265-
};
266327

267-
var exception = await Assert.That(action).ThrowsException();
268-
stopwatch.Stop();
328+
var stopwatch = Stopwatch.StartNew();
329+
var observedTask = CaptureExceptionAsync(consumeTask);
330+
using var assertionTimeout = new CancellationTokenSource(WindowsFixtureCompletionTimeout);
331+
var completedTask = await Task.WhenAny(observedTask, Task.Delay(Timeout.InfiniteTimeSpan, assertionTimeout.Token));
332+
stopwatch.Stop();
269333

270-
await Assert.That(exception).IsTypeOf<TimeoutException>();
271-
await Assert.That(exception).IsNotTypeOf<CliExecutionFailureException>();
272-
await Assert.That(stopwatch.Elapsed < TimeSpan.FromSeconds(3)).IsTrue();
334+
await Assert.That(ReferenceEquals(completedTask, observedTask)).IsTrue();
335+
var exception = await observedTask;
336+
await Assert.That(exception).IsTypeOf<TimeoutException>();
337+
await Assert.That(exception).IsNotTypeOf<CliExecutionFailureException>();
338+
await Assert.That(stopwatch.Elapsed < WindowsCleanupAssertionBound).IsTrue();
339+
}
340+
finally
341+
{
342+
cancellation.Cancel();
343+
if (!File.Exists(releasePath))
344+
{
345+
File.WriteAllText(releasePath, EmptyFileContent);
346+
}
347+
348+
if (consumeTask is not null)
349+
{
350+
var cleanupTask = CaptureExceptionAsync(consumeTask);
351+
using var cleanupTimeout = new CancellationTokenSource(WindowsFixtureCompletionTimeout);
352+
var completedTask = await Task.WhenAny(
353+
cleanupTask,
354+
Task.Delay(Timeout.InfiniteTimeSpan, cleanupTimeout.Token));
355+
await Assert.That(ReferenceEquals(completedTask, cleanupTask)).IsTrue();
356+
}
357+
358+
File.Delete(readyPath);
359+
File.Delete(releasePath);
360+
File.Delete(closedPath);
361+
}
362+
}
363+
364+
private static async Task ConsumeAsync(IAsyncEnumerator<string> enumerator)
365+
{
366+
while (await enumerator.MoveNextAsync().ConfigureAwait(false))
367+
{
368+
if (!string.Equals(enumerator.Current, NodeHandleClosedLine, StringComparison.Ordinal))
369+
{
370+
throw new InvalidOperationException(UnexpectedFixtureOutputMessage);
371+
}
372+
}
373+
}
374+
375+
private static async Task<Exception?> CaptureExceptionAsync(Task task)
376+
{
377+
try
378+
{
379+
await task.ConfigureAwait(false);
380+
return null;
381+
}
382+
catch (Exception exception)
383+
{
384+
return exception;
385+
}
386+
}
387+
388+
private static async Task WaitForFileAsync(string path, TimeSpan timeout)
389+
{
390+
using var cancellation = new CancellationTokenSource(timeout);
391+
while (!File.Exists(path))
392+
{
393+
await Task.Delay(WindowsFixturePollInterval, cancellation.Token).ConfigureAwait(false);
394+
}
395+
}
396+
397+
private static string FindNodeExecutablePath()
398+
{
399+
var path = Environment.GetEnvironmentVariable(PathVariableName) ?? string.Empty;
400+
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
401+
{
402+
var candidate = Path.Combine(directory, OperatingSystem.IsWindows() ? WindowsNodeExecutableName : NodeExecutableName);
403+
if (File.Exists(candidate))
404+
{
405+
return Path.GetFullPath(candidate);
406+
}
407+
}
408+
409+
throw new InvalidOperationException(NodeMissingMessage);
273410
}
274411

275412
[Test]

‎ClaudeCodeSharpSDK/Execution/ClaudeExec.cs‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,8 @@ internal sealed record ClaudeProcessInvocation(
593593
public Action? StandardOutputReadCompleted { get; init; }
594594

595595
public Action? StandardErrorOutputLimitExceeded { get; init; }
596+
597+
public Action<bool>? StandardInputWriteFailed { get; init; }
596598
}
597599

598600
internal interface IClaudeProcessRunner
@@ -696,7 +698,11 @@ public async IAsyncEnumerable<string> RunAsync(
696698
standardOutputReader = new BoundedProcessOutputReader(process.StandardOutput,
697699
invocation.MaximumProcessOutputCharacters, invocation.StandardOutputReadCompleted);
698700
standardOutputReadTask = standardOutputReader.ReadLineAsync(CancellationToken.None).AsTask();
699-
standardInputWriteTask = WriteStandardInputAsync(process.StandardInput, invocation.Input, outputCancellation.Token);
701+
standardInputWriteTask = WriteStandardInputAsync(process.StandardInput, invocation.Input,
702+
invocation.StandardInputWriteFailed is null
703+
? null
704+
: () => invocation.StandardInputWriteFailed(process.HasExited),
705+
outputCancellation.Token);
700706
string? line;
701707
while (true)
702708
{
@@ -1053,11 +1059,20 @@ private static async Task<BoundedProcessOutput> ReadBoundedProcessOutputAsync(
10531059
private static async Task WriteStandardInputAsync(
10541060
StreamWriter standardInput,
10551061
string input,
1062+
Action? onWriteFailure,
10561063
CancellationToken cancellationToken)
10571064
{
1058-
await standardInput.WriteAsync(input.AsMemory(), cancellationToken).ConfigureAwait(false);
1059-
await standardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
1060-
await standardInput.DisposeAsync().ConfigureAwait(false);
1065+
try
1066+
{
1067+
await standardInput.WriteAsync(input.AsMemory(), cancellationToken).ConfigureAwait(false);
1068+
await standardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
1069+
await standardInput.DisposeAsync().ConfigureAwait(false);
1070+
}
1071+
catch (IOException)
1072+
{
1073+
onWriteFailure?.Invoke();
1074+
throw;
1075+
}
10611076
}
10621077

10631078
private static async Task AwaitStandardInputWriteAsync(

‎docs/Testing/strategy.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Verify `ManagedCode.ClaudeCodeSharpSDK` behavior against real Claude Code CLI co
2424
- Keep protocol parser coverage for all supported event/item kinds.
2525
- Keep a large-stream parser performance profile test to catch regressions.
2626
- Treat `claude -p --output-format json|stream-json` as the protocol source of truth for smoke and parser tests.
27+
- Tests that launch installed CLIs or exercise SDK process cleanup share the TUnit `CliProcess` non-parallel constraint, preventing real CLI startup and cleanup fixtures from contending for process and pipe resources.
2728

2829
## Commands
2930

0 commit comments

Comments
 (0)