Skip to content

Commit 249af31

Browse files
committed
Bound CLI cancellation and sanitize diagnostics
1 parent f4c9aac commit 249af31

8 files changed

Lines changed: 327 additions & 30 deletions

File tree

‎ClaudeCodeSharpSDK.Tests/MEAI/ClaudeServiceCollectionExtensionsTests.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public async Task CreateCoreClientOptions_PropagatesClaudeEnvironmentPolicy()
1919
var claudeOptions = new ClaudeOptions
2020
{
2121
InheritEnvironmentVariables = false,
22+
ProcessTerminationTimeout = TimeSpan.FromSeconds(7),
2223
EnvironmentVariables = new Dictionary<string, string> { ["MEAI_TEST_ENV"] = "explicit" },
2324
};
2425

@@ -29,6 +30,7 @@ public async Task CreateCoreClientOptions_PropagatesClaudeEnvironmentPolicy()
2930

3031
await Assert.That(coreOptions.ClaudeOptions).IsSameReferenceAs(claudeOptions);
3132
await Assert.That(coreOptions.ClaudeOptions!.InheritEnvironmentVariables).IsFalse();
33+
await Assert.That(coreOptions.ClaudeOptions.ProcessTerminationTimeout).IsEqualTo(TimeSpan.FromSeconds(7));
3234
await Assert.That(coreOptions.ClaudeOptions.EnvironmentVariables!["MEAI_TEST_ENV"]).IsEqualTo("explicit");
3335
}
3436

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
using System.Diagnostics;
2+
using System.Globalization;
3+
using ManagedCode.ClaudeCodeSharpSDK.Execution;
4+
using Microsoft.Extensions.Logging.Abstractions;
5+
6+
namespace ManagedCode.ClaudeCodeSharpSDK.Tests.Unit;
7+
8+
public class ProcessRunnerCancellationTests
9+
{
10+
private const string LongRunningScript = "#!/bin/sh\necho $$\nexec /bin/sleep 30\n";
11+
private const string DescendantHoldingStderrScript = "/usr/bin/setsid /bin/sleep 30 >&2 & echo $!; exit 0";
12+
private const string PosixFixtureSkipReason = "The public CLI yield-boundary fixture currently uses a POSIX executable script.";
13+
private const string LinuxFixtureSkipReason = "The detached stderr-retention fixture requires Linux setsid.";
14+
private const string StderrClosureFailure = "stderr stream closed";
15+
16+
[Test]
17+
public async Task PublicExec_PreCanceledTokenDoesNotStartCliProcess()
18+
{
19+
var executablePath = Path.Combine(GetTestSandboxDirectory(), $"missing-claude-cli-{Guid.NewGuid():N}");
20+
using var cancellation = new CancellationTokenSource();
21+
cancellation.Cancel();
22+
var exec = new ClaudeExec(false, TimeSpan.FromSeconds(5), executablePath, CreateEnvironment());
23+
24+
await using var enumerator = exec.RunAsync(new ClaudeExecArgs
25+
{
26+
Input = "test",
27+
CancellationToken = cancellation.Token,
28+
}).GetAsyncEnumerator(cancellation.Token);
29+
30+
var action = async () => await enumerator.MoveNextAsync();
31+
var exception = await Assert.That(action).ThrowsException();
32+
33+
await Assert.That(exception).IsTypeOf<OperationCanceledException>();
34+
}
35+
36+
[Test]
37+
public async Task RootExitWithDescendantHoldingStderr_FailsWithinConfiguredBound()
38+
{
39+
if (!OperatingSystem.IsLinux())
40+
{
41+
Skip.Test(LinuxFixtureSkipReason);
42+
return;
43+
}
44+
45+
const int terminationTimeoutMilliseconds = 250;
46+
var invocation = new ClaudeProcessInvocation(
47+
"/bin/sh",
48+
Environment.CurrentDirectory,
49+
["-c", DescendantHoldingStderrScript],
50+
CreateEnvironment(),
51+
string.Empty,
52+
TimeSpan.FromMilliseconds(terminationTimeoutMilliseconds));
53+
var runner = new DefaultClaudeProcessRunner();
54+
await using var enumerator = runner.RunAsync(invocation, NullLogger.Instance, CancellationToken.None)
55+
.GetAsyncEnumerator();
56+
var childProcessId = 0;
57+
58+
try
59+
{
60+
await Assert.That(await enumerator.MoveNextAsync()).IsTrue();
61+
childProcessId = int.Parse(enumerator.Current, CultureInfo.InvariantCulture);
62+
var stopwatch = Stopwatch.StartNew();
63+
var action = async () => await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3));
64+
var exception = await Assert.That(action).ThrowsException();
65+
stopwatch.Stop();
66+
67+
await Assert.That(exception).IsTypeOf<InvalidOperationException>();
68+
await Assert.That(exception!.Message).Contains(StderrClosureFailure);
69+
await Assert.That(stopwatch.Elapsed < TimeSpan.FromSeconds(2)).IsTrue();
70+
}
71+
finally
72+
{
73+
try
74+
{
75+
using var child = Process.GetProcessById(childProcessId);
76+
child.Kill(entireProcessTree: true);
77+
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5));
78+
}
79+
catch (ArgumentException)
80+
{
81+
// The detached fixture child already exited.
82+
}
83+
}
84+
}
85+
86+
[Test]
87+
public async Task PublicExec_CancellationBetweenYieldedLinesIsNotReportedAsSuccess()
88+
{
89+
if (OperatingSystem.IsWindows())
90+
{
91+
Skip.Test(PosixFixtureSkipReason);
92+
return;
93+
}
94+
95+
var scriptPath = CreateLongRunningCliScript();
96+
using var cancellation = new CancellationTokenSource();
97+
var exec = new ClaudeExec(
98+
inheritEnvironmentVariables: false,
99+
processTerminationTimeout: TimeSpan.FromSeconds(5),
100+
executablePath: scriptPath,
101+
environmentOverride: CreateEnvironment());
102+
103+
try
104+
{
105+
await using var enumerator = exec.RunAsync(new ClaudeExecArgs
106+
{
107+
Input = "test",
108+
CancellationToken = cancellation.Token,
109+
}).GetAsyncEnumerator(cancellation.Token);
110+
111+
await Assert.That(await enumerator.MoveNextAsync()).IsTrue();
112+
var processId = int.Parse(enumerator.Current, CultureInfo.InvariantCulture);
113+
using var process = Process.GetProcessById(processId);
114+
cancellation.Cancel();
115+
116+
var action = async () => await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10));
117+
var exception = await Assert.That(action).ThrowsException();
118+
119+
await Assert.That(exception).IsTypeOf<OperationCanceledException>();
120+
await Assert.That(process.HasExited).IsTrue();
121+
}
122+
finally
123+
{
124+
File.Delete(scriptPath);
125+
}
126+
}
127+
128+
[Test]
129+
public async Task Cancellation_WaitsForStartedProcessToExit()
130+
{
131+
using var cancellation = new CancellationTokenSource();
132+
var invocation = OperatingSystem.IsWindows()
133+
? new ClaudeProcessInvocation("powershell.exe", Environment.CurrentDirectory, ["-NoProfile", "-NonInteractive", "-Command", "Write-Output $PID; Start-Sleep -Seconds 30"], CreateEnvironment(), string.Empty, TimeSpan.FromSeconds(5))
134+
: new ClaudeProcessInvocation("/bin/sh", Environment.CurrentDirectory, ["-c", "echo $$; exec /bin/sleep 30"], CreateEnvironment(), string.Empty, TimeSpan.FromSeconds(5));
135+
var runner = new DefaultClaudeProcessRunner();
136+
137+
await using var enumerator = runner.RunAsync(invocation, NullLogger.Instance, cancellation.Token)
138+
.GetAsyncEnumerator(cancellation.Token);
139+
140+
await Assert.That(await enumerator.MoveNextAsync()).IsTrue();
141+
var processId = int.Parse(enumerator.Current, CultureInfo.InvariantCulture);
142+
using var process = Process.GetProcessById(processId);
143+
144+
cancellation.Cancel();
145+
146+
var action = async () => await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10));
147+
var exception = await Assert.That(action).ThrowsException();
148+
149+
await Assert.That(exception).IsTypeOf<OperationCanceledException>();
150+
await Assert.That(process.HasExited).IsTrue();
151+
}
152+
153+
private static Dictionary<string, string> CreateEnvironment()
154+
{
155+
return new Dictionary<string, string>(StringComparer.Ordinal);
156+
}
157+
158+
private static string CreateLongRunningCliScript()
159+
{
160+
var scriptPath = Path.Combine(GetTestSandboxDirectory(), $"claude-cli-{Guid.NewGuid():N}.sh");
161+
File.WriteAllText(scriptPath, LongRunningScript);
162+
if (!OperatingSystem.IsWindows())
163+
{
164+
File.SetUnixFileMode(scriptPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
165+
}
166+
return scriptPath;
167+
}
168+
169+
private static string GetTestSandboxDirectory()
170+
{
171+
var path = Path.Combine(Environment.CurrentDirectory, "tests", ".sandbox", "ProcessRunnerCancellationTests");
172+
Directory.CreateDirectory(path);
173+
return path;
174+
}
175+
}

‎ClaudeCodeSharpSDK/Client/ClaudeClient.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ private ClaudeExec CreateExec()
8282
{
8383
return new ClaudeExec(
8484
inheritEnvironmentVariables: _options.InheritEnvironmentVariables,
85+
processTerminationTimeout: _options.ProcessTerminationTimeout,
8586
executablePath: _options.ClaudeExecutablePath,
8687
environmentOverride: _options.EnvironmentVariables,
8788
baseSettings: _options.Settings,

‎ClaudeCodeSharpSDK/Configuration/ClaudeOptions.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ namespace ManagedCode.ClaudeCodeSharpSDK.Configuration;
55

66
public sealed record ClaudeOptions
77
{
8+
public static readonly TimeSpan DefaultProcessTerminationTimeout = TimeSpan.FromSeconds(5);
9+
810
public string? ClaudeExecutablePath { get; init; }
911

1012
public string? BaseUrl { get; init; }
@@ -17,5 +19,7 @@ public sealed record ClaudeOptions
1719

1820
public bool InheritEnvironmentVariables { get; init; } = true;
1921

22+
public TimeSpan ProcessTerminationTimeout { get; init; } = DefaultProcessTerminationTimeout;
23+
2024
public ILogger? Logger { get; init; }
2125
}

0 commit comments

Comments
 (0)