11using System . Diagnostics ;
2+ using System . Globalization ;
23using ManagedCode . ClaudeCodeSharpSDK . Internal ;
34
45namespace ManagedCode . ClaudeCodeSharpSDK . Tests . Unit ;
56
7+ [ NotInParallel ]
68public class BoundedCliProcessProbeTests
79{
810 private const string ShellExecutableUnix = "/bin/sh" ;
@@ -13,11 +15,12 @@ public class BoundedCliProcessProbeTests
1315 private const string SentinelValue = "explicit-environment-value" ;
1416 private const string OverflowMessage = "CLI metadata process exceeded the configured output limit." ;
1517 private const string TimeoutMessage = "CLI metadata process did not exit within the configured time limit." ;
18+ private const string OutputTimeoutMessage = "CLI metadata process output did not close within the configured time limit." ;
1619 private const string OutputCleanupFailedMessage = "CLI metadata process output readers did not stop within the configured time limit." ;
1720 private const string ProbeBlockedMessage = "CLI metadata process probe is blocked by an earlier incomplete cleanup." ;
1821 private const string ProbeBusyMessage = "CLI metadata process probe could not acquire its bounded process lease." ;
1922 private const string LinuxFixtureSkipReason = "The detached inherited-pipe fixture requires Linux setsid." ;
20- private const string DetachedChildCommandPrefix = "/usr/bin/setsid /bin/sleep 30 >&2 & echo $! > '" ;
23+ private const string DetachedChildCommandPrefix = "/usr/bin/setsid /bin/sleep 30 & echo $! > '" ;
2124 private const string DetachedChildCommandSuffix = "'; exit 0" ;
2225 private const string FixtureDirectoryPrefix = "BoundedCliProcessProbe-" ;
2326 private const string ChildProcessIdFilePattern = "child-*.pid" ;
@@ -26,7 +29,7 @@ public class BoundedCliProcessProbeTests
2629 private const string StandardInputEofCommandUnix = "cat >/dev/null" ;
2730 private const string StandardInputEofCommandWindows = "more >NUL" ;
2831 private const int DetachedPipeProbeCount = 3 ;
29- private static readonly TimeSpan ConcurrentProbeTimeout = TimeSpan . FromSeconds ( 3 ) ;
32+ private static readonly TimeSpan ConcurrentLeaseWait = TimeSpan . FromMilliseconds ( 100 ) ;
3033 private const string SentinelCommandUnix = "printf '%s' \" $SDK_METADATA_PROBE_SENTINEL\" " ;
3134 private const string SentinelCommandWindows = "echo %SDK_METADATA_PROBE_SENTINEL%" ;
3235 private const string PressureCommandUnix =
@@ -37,6 +40,10 @@ public class BoundedCliProcessProbeTests
3740 private const string OverflowCommandWindows = "for /L %i in (1,1,200) do @<nul set /p =0123456789" ;
3841 private const string LongRunningCommandUnix = "sleep 20" ;
3942 private const string LongRunningCommandWindows = "ping -n 21 127.0.0.1 >NUL" ;
43+ private const string ActiveLeaseCommandUnix = "sleep 2" ;
44+ private const string ActiveLeaseCommandWindows = "ping -n 3 127.0.0.1 >NUL" ;
45+ private const string CompletedProbeCommandUnix = "printf probe" ;
46+ private const string CompletedProbeCommandWindows = "echo probe" ;
4047 private static readonly TimeSpan ProbeTimeout = TimeSpan . FromMilliseconds ( 400 ) ;
4148 private static readonly TimeSpan ProcessTimeout = TimeSpan . FromSeconds ( 10 ) ;
4249
@@ -125,8 +132,8 @@ public async Task Run_RejectsConcurrentProcessBeforeStartingIt()
125132 firstReadersStarted . TrySetResult ( ) ;
126133 }
127134 } ;
128- var firstProbe = Task . Run ( ( ) => Run ( LongRunningCommandUnix , LongRunningCommandWindows ,
129- environment : null , inheritEnvironmentVariables : true , timeout : ConcurrentProbeTimeout ,
135+ var firstProbe = Task . Run ( ( ) => Run ( ActiveLeaseCommandUnix , ActiveLeaseCommandWindows ,
136+ environment : null , inheritEnvironmentVariables : true , timeout : ProcessTimeout ,
130137 readerCountChanged : firstReaderCountChanged ) ) ;
131138
132139 await firstReadersStarted . Task . WaitAsync ( ProcessTimeout ) ;
@@ -142,24 +149,45 @@ public async Task Run_RejectsConcurrentProcessBeforeStartingIt()
142149 timeout : ProbeTimeout ,
143150 maximumOutputCharacters : 64 ,
144151 readerCountChanged : secondReaderCountChanged ,
145- leaseAcquisitionTimeout : ProbeTimeout ) ) . ThrowsException ( ) ;
152+ leaseAcquisitionTimeout : ConcurrentLeaseWait ) ) . ThrowsException ( ) ;
146153
147154 await Assert . That ( secondException ) . IsTypeOf < InvalidOperationException > ( ) ;
148155 await Assert . That ( secondException ! . Message ) . IsEqualTo ( ProbeBusyMessage ) ;
149156 await Assert . That ( secondReaderCount ) . IsEqualTo ( 0 ) ;
150157
151- Exception ? firstException = null ;
152- try
153- {
154- await firstProbe ;
155- }
156- catch ( Exception exception )
157- {
158- firstException = exception ;
159- }
158+ var firstResult = await firstProbe ;
159+ await Assert . That ( firstResult . ExitCode ) . IsEqualTo ( 0 ) ;
160+ await Assert . That ( BoundedCliProcessProbe . PendingReaderCleanupCount ) . IsEqualTo ( 0 ) ;
161+ }
160162
161- await Assert . That ( firstException ) . IsTypeOf < TimeoutException > ( ) ;
163+ [ Test ]
164+ public async Task Run_RetainedReaderCleanupLeaseBlocksLaunchUntilBothReadersSettle ( )
165+ {
166+ var drains = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
167+ var leaseReleased = BoundedCliProcessProbe . RetainProbeLeaseForTesting ( drains . Task ) ;
168+ await Assert . That ( BoundedCliProcessProbe . PendingReaderCleanupCount ) . IsEqualTo ( 1 ) ;
169+
170+ var readersStarted = 0 ;
171+ var exception = await Assert . That ( ( ) => BoundedCliProcessProbe . Run (
172+ OperatingSystem . IsWindows ( ) ? Path . Combine ( Environment . SystemDirectory , ShellExecutableWindows ) : ShellExecutableUnix ,
173+ [ OperatingSystem . IsWindows ( ) ? ShellArgumentWindows : ShellArgumentUnix ,
174+ OperatingSystem . IsWindows ( ) ? CompletedProbeCommandWindows : CompletedProbeCommandUnix ] ,
175+ environment : null ,
176+ inheritEnvironmentVariables : true ,
177+ timeout : ProbeTimeout ,
178+ maximumOutputCharacters : 64 ,
179+ readerCountChanged : delta => Interlocked . Add ( ref readersStarted , delta ) ) ) . ThrowsException ( ) ;
180+
181+ await Assert . That ( exception ) . IsTypeOf < InvalidOperationException > ( ) ;
182+ await Assert . That ( exception ! . Message ) . IsEqualTo ( ProbeBlockedMessage ) ;
183+ await Assert . That ( readersStarted ) . IsEqualTo ( 0 ) ;
184+
185+ drains . SetResult ( ) ;
186+ await leaseReleased . WaitAsync ( ProcessTimeout ) ;
162187 await Assert . That ( BoundedCliProcessProbe . PendingReaderCleanupCount ) . IsEqualTo ( 0 ) ;
188+ var result = Run ( CompletedProbeCommandUnix , CompletedProbeCommandWindows ,
189+ environment : null , inheritEnvironmentVariables : true , timeout : ProbeTimeout ) ;
190+ await Assert . That ( result . ExitCode ) . IsEqualTo ( 0 ) ;
163191 }
164192
165193 [ Test ]
@@ -202,26 +230,50 @@ public async Task Run_FailsWithinBoundWhenExitedRootHasDescendantHoldingPipes()
202230 leaseAcquisitionTimeout : ProcessTimeout ) ) . ThrowsException ( ) ;
203231 stopwatch . Stop ( ) ;
204232
205- await Assert . That ( exception ) . IsTypeOf < InvalidOperationException > ( ) ;
206- await Assert . That ( exception ! . Message ) . IsEqualTo ( OutputCleanupFailedMessage ) ;
207233 await Assert . That ( stopwatch . Elapsed ) . IsLessThan ( ProcessTimeout ) ;
208- await Assert . That ( activeReaders ) . IsEqualTo ( 2 ) ;
209- await Assert . That ( BoundedCliProcessProbe . PendingReaderCleanupCount ) . IsEqualTo ( 1 ) ;
234+ var childProcessId = int . Parse ( File . ReadAllText ( childProcessIdPath ) , CultureInfo . InvariantCulture ) ;
235+ using ( var child = Process . GetProcessById ( childProcessId ) )
236+ {
237+ await Assert . That ( child . HasExited ) . IsFalse ( ) ;
238+ }
210239
211- var blockedChildPath = Path . Combine ( sandbox , $ "blocked-{ attempt } .pid") ;
212- var blockedCommand = string . Concat ( DetachedChildCommandPrefix , blockedChildPath ,
213- DetachedChildCommandSuffix ) ;
214- var blockedException = await Assert . That ( ( ) => BoundedCliProcessProbe . Run (
215- ShellExecutableUnix ,
216- [ ShellArgumentUnix , blockedCommand ] ,
217- environment : null ,
218- inheritEnvironmentVariables : true ,
219- timeout : ProbeTimeout ,
220- maximumOutputCharacters : 64 ) ) . ThrowsException ( ) ;
240+ if ( exception is TimeoutException )
241+ {
242+ await Assert . That ( exception . Message ) . IsEqualTo ( OutputTimeoutMessage ) ;
243+ await Assert . That ( activeReaders ) . IsEqualTo ( 0 ) ;
244+ await Assert . That ( BoundedCliProcessProbe . PendingReaderCleanupCount ) . IsEqualTo ( 0 ) ;
245+ var subsequentProbe = BoundedCliProcessProbe . Run (
246+ ShellExecutableUnix ,
247+ [ ShellArgumentUnix , CompletedProbeCommandUnix ] ,
248+ environment : null ,
249+ inheritEnvironmentVariables : true ,
250+ timeout : ProbeTimeout ,
251+ maximumOutputCharacters : 64 ) ;
252+ await Assert . That ( subsequentProbe . ExitCode ) . IsEqualTo ( 0 ) ;
253+ }
254+ else
255+ {
256+ await Assert . That ( exception ) . IsTypeOf < InvalidOperationException > ( ) ;
257+ await Assert . That ( exception ! . Message ) . IsEqualTo ( OutputCleanupFailedMessage ) ;
258+ await Assert . That ( activeReaders > 0 && activeReaders <= 2 ) . IsTrue ( ) ;
259+ await Assert . That ( BoundedCliProcessProbe . PendingReaderCleanupCount ) . IsEqualTo ( 1 ) ;
260+
261+ var blockedChildPath = Path . Combine ( sandbox , $ "blocked-{ attempt } .pid") ;
262+ var blockedCommand = string . Concat ( DetachedChildCommandPrefix , blockedChildPath ,
263+ DetachedChildCommandSuffix ) ;
264+ var blockedException = await Assert . That ( ( ) => BoundedCliProcessProbe . Run (
265+ ShellExecutableUnix ,
266+ [ ShellArgumentUnix , blockedCommand ] ,
267+ environment : null ,
268+ inheritEnvironmentVariables : true ,
269+ timeout : ProbeTimeout ,
270+ maximumOutputCharacters : 64 ) ) . ThrowsException ( ) ;
271+
272+ await Assert . That ( blockedException ) . IsTypeOf < InvalidOperationException > ( ) ;
273+ await Assert . That ( blockedException ! . Message ) . IsEqualTo ( ProbeBlockedMessage ) ;
274+ await Assert . That ( File . Exists ( blockedChildPath ) ) . IsFalse ( ) ;
275+ }
221276
222- await Assert . That ( blockedException ) . IsTypeOf < InvalidOperationException > ( ) ;
223- await Assert . That ( blockedException ! . Message ) . IsEqualTo ( ProbeBlockedMessage ) ;
224- await Assert . That ( File . Exists ( blockedChildPath ) ) . IsFalse ( ) ;
225277 StopChildProcess ( childProcessIdPath ) ;
226278 await readersDrained . Task . WaitAsync ( ProcessTimeout ) ;
227279 await Assert . That ( activeReaders ) . IsEqualTo ( 0 ) ;
0 commit comments