@@ -40,6 +40,7 @@ public sealed class CliInstallationTests
4040 private const string PipeHolderPidFileName = "pipe-holder.pid" ;
4141 private const string SetsidCommandName = "setsid" ;
4242 private const string CleanupMessage = "CLI installation process or output cleanup could not be confirmed within its configured timeout." ;
43+ private const string TimeoutMessage = "CLI installation exceeded its configured process timeout." ;
4344 private const string SetsidRequiredMessage = "The detached pipe-holder cleanup fixture requires Linux setsid." ;
4445 private const string OverflowMarkerName = "overflow" ;
4546 private const string ScriptPlaceholderPackage = "PACKAGE_NAME" ;
@@ -96,6 +97,7 @@ public sealed class CliInstallationTests
9697 process.stdout.write('x'.repeat(8192));
9798 setInterval(() => {}, 1000);
9899 }
100+ fs.writeFileSync(path.join(__dirname, 'child.pid'), String(process.pid));
99101 console.log('installation output');
100102 console.error('installation diagnostics');
101103 """ ;
@@ -171,6 +173,140 @@ await Assert.That(updates.Any(update => update.Stage == CliInstallationStage.Out
171173 }
172174 }
173175
176+ [ Test ]
177+ public async Task InstallOrUpdate_ReportsFinalOutputWhenConsumerResumesAfterProcessExit ( )
178+ {
179+ var fixture = await CreateFixtureAsync ( ) ;
180+ try
181+ {
182+ await using var updates = ClaudeCliInstallation . InstallOrUpdateAsync ( fixture . Options ,
183+ fixture . LocalApplicationDataRoot , CancellationToken . None ) . GetAsyncEnumerator ( ) ;
184+ await Assert . That ( await updates . MoveNextAsync ( ) ) . IsTrue ( ) ;
185+ await Assert . That ( updates . Current . Stage ) . IsEqualTo ( CliInstallationStage . PackageManagerStarted ) ;
186+ var childPidPath = Path . Combine ( fixture . NpmRoot , BinDirectoryName , ChildPidFileName ) ;
187+ using var markerTimeout = new CancellationTokenSource ( TimeSpan . FromSeconds ( 3 ) ) ;
188+ await WaitForFileAsync ( childPidPath , markerTimeout . Token ) ;
189+ var childPid = int . Parse ( await File . ReadAllTextAsync ( childPidPath ) ,
190+ System . Globalization . CultureInfo . InvariantCulture ) ;
191+ await WaitForProcessExitAsync ( childPid , markerTimeout . Token ) ;
192+
193+ var sawFinalOutput = false ;
194+ var sawInstalled = false ;
195+ while ( await updates . MoveNextAsync ( ) )
196+ {
197+ var update = updates . Current ;
198+ sawFinalOutput |= update . Stage == CliInstallationStage . OutputObserved &&
199+ update . StandardOutputCharactersObserved > 0 && update . StandardErrorCharactersObserved > 0 ;
200+ sawInstalled |= update . Stage == CliInstallationStage . Installed && update . Result is not null ;
201+ }
202+
203+ await Assert . That ( sawFinalOutput ) . IsTrue ( ) ;
204+ await Assert . That ( sawInstalled ) . IsTrue ( ) ;
205+ }
206+ finally
207+ {
208+ DeleteFixture ( fixture . FixtureRoot ) ;
209+ }
210+ }
211+
212+ [ Test ]
213+ public async Task InstallOrUpdate_EnforcesTimeoutWhileConsumerIsPausedAfterStart ( )
214+ {
215+ var fixture = await CreateFixtureAsync ( ) ;
216+ await File . WriteAllTextAsync ( Path . Combine ( fixture . NpmRoot , BinDirectoryName , HangMarkerName ) , string . Empty ) ;
217+ var childPidPath = Path . Combine ( fixture . NpmRoot , BinDirectoryName , ChildPidFileName ) ;
218+ try
219+ {
220+ var options = fixture . Options with
221+ {
222+ InstallTimeout = TimeSpan . FromSeconds ( 1 ) ,
223+ ProcessTerminationTimeout = TimeSpan . FromSeconds ( 1 )
224+ } ;
225+ await using var updates = ClaudeCliInstallation . InstallOrUpdateAsync ( options ,
226+ fixture . LocalApplicationDataRoot , CancellationToken . None ) . GetAsyncEnumerator ( ) ;
227+ await Assert . That ( await updates . MoveNextAsync ( ) ) . IsTrue ( ) ;
228+ await Assert . That ( updates . Current . Stage ) . IsEqualTo ( CliInstallationStage . PackageManagerStarted ) ;
229+
230+ using var deadline = new CancellationTokenSource ( TimeSpan . FromSeconds ( 5 ) ) ;
231+ await WaitForFileAsync ( childPidPath , deadline . Token ) ;
232+ var childPid = int . Parse ( await File . ReadAllTextAsync ( childPidPath ) ,
233+ System . Globalization . CultureInfo . InvariantCulture ) ;
234+ await WaitForProcessExitAsync ( childPid , deadline . Token ) ;
235+ await Assert . That ( IsProcessRunning ( childPid ) ) . IsFalse ( ) ;
236+
237+ var exception = await Assert . That ( async ( ) =>
238+ {
239+ while ( await updates . MoveNextAsync ( ) )
240+ {
241+ }
242+ } ) . ThrowsException ( ) ;
243+ await Assert . That ( exception ) . IsTypeOf < TimeoutException > ( ) ;
244+ await Assert . That ( exception ! . Message ) . IsEqualTo ( TimeoutMessage ) ;
245+ }
246+ finally
247+ {
248+ DeleteFixture ( fixture . FixtureRoot ) ;
249+ }
250+ }
251+
252+ [ Test ]
253+ public async Task InstallOrUpdate_ExitedRootWithDetachedPipeHolderSurfacesBoundedCleanupFailure ( )
254+ {
255+ if ( ! OperatingSystem . IsLinux ( ) )
256+ {
257+ Skip . Test ( SetsidRequiredMessage ) ;
258+ return ;
259+ }
260+
261+ var fixture = await CreateFixtureAsync ( ) ;
262+ var holderPidFile = Path . Combine ( fixture . NpmRoot , BinDirectoryName , PipeHolderPidFileName ) ;
263+ var rootPidFile = Path . Combine ( fixture . NpmRoot , BinDirectoryName , ChildPidFileName ) ;
264+ var holderPid = 0 ;
265+ try
266+ {
267+ await File . WriteAllTextAsync ( Path . Combine ( fixture . NpmRoot , BinDirectoryName , PipeHolderMarkerName ) ,
268+ string . Empty ) ;
269+ var options = fixture . Options with
270+ {
271+ InstallTimeout = TimeSpan . FromSeconds ( 2 ) ,
272+ ProcessTerminationTimeout = TimeSpan . FromSeconds ( 1 )
273+ } ;
274+ await using var updates = ClaudeCliInstallation . InstallOrUpdateAsync ( options ,
275+ fixture . LocalApplicationDataRoot , CancellationToken . None ) . GetAsyncEnumerator ( ) ;
276+ await Assert . That ( await updates . MoveNextAsync ( ) ) . IsTrue ( ) ;
277+ using var markerTimeout = new CancellationTokenSource ( TimeSpan . FromSeconds ( 3 ) ) ;
278+ await WaitForFileAsync ( rootPidFile , markerTimeout . Token ) ;
279+ await WaitForFileAsync ( holderPidFile , markerTimeout . Token ) ;
280+ var rootPid = int . Parse ( await File . ReadAllTextAsync ( rootPidFile ) ,
281+ System . Globalization . CultureInfo . InvariantCulture ) ;
282+ holderPid = int . Parse ( await File . ReadAllTextAsync ( holderPidFile ) ,
283+ System . Globalization . CultureInfo . InvariantCulture ) ;
284+ await WaitForProcessExitAsync ( rootPid , markerTimeout . Token ) ;
285+ await Assert . That ( IsProcessRunning ( rootPid ) ) . IsFalse ( ) ;
286+ await Assert . That ( IsProcessRunning ( holderPid ) ) . IsTrue ( ) ;
287+
288+ var exception = await Assert . That ( async ( ) =>
289+ {
290+ while ( await updates . MoveNextAsync ( ) )
291+ {
292+ }
293+ } ) . ThrowsException ( ) ;
294+ await Assert . That ( exception ) . IsTypeOf < InvalidOperationException > ( ) ;
295+ await Assert . That ( exception ! . Message ) . IsEqualTo ( CleanupMessage ) ;
296+ await Assert . That ( IsProcessRunning ( rootPid ) ) . IsFalse ( ) ;
297+ await Assert . That ( IsProcessRunning ( holderPid ) ) . IsTrue ( ) ;
298+ }
299+ finally
300+ {
301+ if ( holderPid > 0 )
302+ {
303+ StopProcess ( holderPid ) ;
304+ }
305+
306+ DeleteFixture ( fixture . FixtureRoot ) ;
307+ }
308+ }
309+
174310 [ Test ]
175311 public async Task InstallOrUpdate_RejectsPackageManagerVersionMismatch ( )
176312 {
@@ -391,6 +527,19 @@ private static async Task WaitForFileAsync(string path, CancellationToken cancel
391527 }
392528 }
393529
530+ private static async Task WaitForProcessExitAsync ( int processId , CancellationToken cancellationToken )
531+ {
532+ try
533+ {
534+ using var process = Process . GetProcessById ( processId ) ;
535+ await process . WaitForExitAsync ( cancellationToken ) ;
536+ }
537+ catch ( ArgumentException )
538+ {
539+ return ;
540+ }
541+ }
542+
394543 private static void StopProcess ( int processId )
395544 {
396545 try
0 commit comments