Skip to content

Commit bebf488

Browse files
committed
Keep CLI installation process owned through timeout
1 parent ab962f8 commit bebf488

3 files changed

Lines changed: 318 additions & 5 deletions

File tree

‎ClaudeCodeSharpSDK.Tests/Unit/CliInstallationTests.cs‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)