11using System . Diagnostics ;
22using System . Globalization ;
3+ using System . Text ;
34using ManagedCode . ClaudeCodeSharpSDK . Execution ;
45using ManagedCode . ClaudeCodeSharpSDK . Models ;
56using Microsoft . Extensions . Logging . Abstractions ;
67
78namespace ManagedCode . ClaudeCodeSharpSDK . Tests . Unit ;
89
10+ [ NotInParallel ( "CliProcess" ) ]
911public class ProcessRunnerCancellationTests
1012{
1113 private const string LongRunningScript = "#!/bin/sh\n echo $$\n exec /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 ]
0 commit comments