forked from admin/VolcanicAgenticCore
336 lines
12 KiB
C#
336 lines
12 KiB
C#
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
|
|
|
/// <summary>How to start one Claude Code child.</summary>
|
|
public sealed record ClaudeProcessOptions(
|
|
string Executable,
|
|
IReadOnlyList<string> Arguments,
|
|
string WorkingDirectory,
|
|
IReadOnlyDictionary<string, string> Environment,
|
|
TimeSpan IdleTimeout,
|
|
TimeSpan TotalTimeout);
|
|
|
|
/// <summary>
|
|
/// A supervised <c>claude</c> child: replaced (allow-listed) environment, redirected stdio, a rolling stderr tail, an
|
|
/// idle timeout reset on every stdout record, a total timeout, and graceful termination (SIGTERM, then the whole tree).
|
|
/// </summary>
|
|
public sealed partial class ClaudeProcess : IDisposable
|
|
{
|
|
public const int MaxStderrChars = 64 * 1024;
|
|
public const string IdleTimeoutVariable = "VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS";
|
|
public const string TotalTimeoutVariable = "VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS";
|
|
public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(5);
|
|
public static readonly TimeSpan DefaultTotalTimeout = TimeSpan.FromMinutes(30);
|
|
private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(500);
|
|
private static readonly TimeSpan KillWait = TimeSpan.FromSeconds(5);
|
|
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
|
private const int SigTerm = 15;
|
|
|
|
private readonly Process _process;
|
|
private readonly ClaudeProcessOptions _options;
|
|
private readonly StringBuilder _stderr = new();
|
|
private readonly Task _stderrPump;
|
|
private readonly CancellationTokenSource _idle = new();
|
|
private readonly CancellationTokenSource _total = new();
|
|
private readonly Lock _lock = new();
|
|
private Task? _termination;
|
|
private bool _disposed;
|
|
|
|
private ClaudeProcess(Process process, ClaudeProcessOptions options)
|
|
{
|
|
_process = process;
|
|
_options = options;
|
|
_stderrPump = Task.Run(PumpStderrAsync);
|
|
_idle.Token.Register(() => OnTimeout($"Claude Code produced no protocol activity for {options.IdleTimeout.TotalMilliseconds:0}ms"));
|
|
_total.Token.Register(() => OnTimeout($"Claude Code request exceeded {options.TotalTimeout.TotalMilliseconds:0}ms"));
|
|
_idle.CancelAfter(options.IdleTimeout);
|
|
_total.CancelAfter(options.TotalTimeout);
|
|
}
|
|
|
|
/// <summary>Starts the child. Throws <c>executable_missing</c> when it cannot be launched.</summary>
|
|
public static ClaudeProcess Start(ClaudeProcessOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
var psi = new ProcessStartInfo(options.Executable)
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
WorkingDirectory = options.WorkingDirectory,
|
|
StandardInputEncoding = Utf8NoBom,
|
|
StandardErrorEncoding = Utf8NoBom,
|
|
};
|
|
foreach (var argument in options.Arguments) psi.ArgumentList.Add(argument);
|
|
psi.Environment.Clear();
|
|
foreach (var (name, value) in options.Environment) psi.Environment[name] = value;
|
|
|
|
var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
|
try
|
|
{
|
|
process.Start();
|
|
}
|
|
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
|
|
{
|
|
process.Dispose();
|
|
var missing = !File.Exists(options.Executable);
|
|
throw new ClaudeCodeException(
|
|
missing ? ClaudeCodeException.Codes.ExecutableMissing : ClaudeCodeException.Codes.ProcessExit,
|
|
missing ? $"Claude Code executable was not found: {options.Executable}" : $"Claude Code could not be started ({options.Executable}): {ex.Message}");
|
|
}
|
|
return new ClaudeProcess(process, options);
|
|
}
|
|
|
|
public int Pid => _process.Id;
|
|
|
|
/// <summary>Raw stdout bytes (JSONL).</summary>
|
|
public Stream Stdout => _process.StandardOutput.BaseStream;
|
|
|
|
public bool HasExited
|
|
{
|
|
get
|
|
{
|
|
try
|
|
{
|
|
return _process.HasExited;
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Exit code; -1 while running.</summary>
|
|
public int ExitCode
|
|
{
|
|
get
|
|
{
|
|
try
|
|
{
|
|
return _process.HasExited ? _process.ExitCode : -1;
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>True once <see cref="TerminateAsync"/> was requested (by the provider, a timeout or cancellation).</summary>
|
|
public bool TerminationRequested { get; private set; }
|
|
|
|
/// <summary>Set when the idle or total timeout terminated the child.</summary>
|
|
public ClaudeCodeException? TimeoutError { get; private set; }
|
|
|
|
/// <summary>Resets the idle timeout; call on every stdout record.</summary>
|
|
public void Touch()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_disposed || _idle.IsCancellationRequested) return;
|
|
_idle.CancelAfter(_options.IdleTimeout);
|
|
}
|
|
}
|
|
|
|
/// <summary>The tail of stderr fit for an error message: one line, bounded, private paths redacted.</summary>
|
|
public string StderrExcerpt(int maxChars = 1000, IEnumerable<string>? privatePaths = null)
|
|
{
|
|
string raw;
|
|
lock (_stderr) raw = _stderr.ToString();
|
|
var safe = ControlCharacters().Replace(raw, " ").Trim();
|
|
foreach (var path in (privatePaths ?? []).Where(p => !string.IsNullOrEmpty(p)).OrderByDescending(p => p.Length))
|
|
safe = safe.Replace(path, "<PRIVATE>", StringComparison.Ordinal);
|
|
return safe.Length <= maxChars ? safe : safe[^maxChars..].Trim();
|
|
}
|
|
|
|
/// <summary>Writes one line to stdin and closes it. A child that already exited is reported by the exit path, not here.</summary>
|
|
public async Task WriteStdinAsync(string line, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var writer = _process.StandardInput;
|
|
await writer.WriteAsync(line.AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
await writer.WriteAsync("\n".AsMemory(), cancellationToken).ConfigureAwait(false);
|
|
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
writer.Close();
|
|
}
|
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
|
{
|
|
// Broken pipe: the child exited before reading; its exit code and stderr explain it.
|
|
}
|
|
}
|
|
|
|
/// <summary>Waits for the child to exit and for its stderr to drain (bounded), then returns the exit code.</summary>
|
|
public async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
|
|
{
|
|
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await _stderrPump.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
// A descendant still holds stderr; the tail collected so far is enough.
|
|
}
|
|
return _process.ExitCode;
|
|
}
|
|
|
|
/// <summary>Memoised termination: SIGTERM, then after a grace period the whole process tree. Never throws.</summary>
|
|
public Task TerminateAsync()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
TerminationRequested = true;
|
|
return _termination ??= TerminateCoreAsync();
|
|
}
|
|
}
|
|
|
|
private async Task TerminateCoreAsync()
|
|
{
|
|
try
|
|
{
|
|
if (HasExited) return;
|
|
if (!OperatingSystem.IsWindows() && TrySendSigterm(Pid))
|
|
{
|
|
if (await WaitForExitAsync(GracePeriod).ConfigureAwait(false)) return;
|
|
}
|
|
KillTree();
|
|
await WaitForExitAsync(KillWait).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or ObjectDisposedException)
|
|
{
|
|
// The child is gone or the handle was released; either way there is nothing left to terminate.
|
|
}
|
|
}
|
|
|
|
private async Task<bool> WaitForExitAsync(TimeSpan timeout)
|
|
{
|
|
if (HasExited) return true;
|
|
using var cts = new CancellationTokenSource(timeout);
|
|
try
|
|
{
|
|
await _process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return HasExited;
|
|
}
|
|
}
|
|
|
|
private void KillTree()
|
|
{
|
|
try
|
|
{
|
|
if (!HasExited) _process.Kill(entireProcessTree: true);
|
|
}
|
|
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException)
|
|
{
|
|
// Already exited, or a descendant could not be signalled; the wait that follows reports what happened.
|
|
}
|
|
}
|
|
|
|
private void OnTimeout(string message)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_disposed || TerminationRequested || HasExited) return;
|
|
TimeoutError ??= new ClaudeCodeException(ClaudeCodeException.Codes.Timeout, message);
|
|
}
|
|
_ = TerminateAsync();
|
|
}
|
|
|
|
private async Task PumpStderrAsync()
|
|
{
|
|
try
|
|
{
|
|
var reader = _process.StandardError;
|
|
var buffer = new char[4096];
|
|
int read;
|
|
while ((read = await reader.ReadAsync(buffer).ConfigureAwait(false)) > 0)
|
|
{
|
|
lock (_stderr)
|
|
{
|
|
_stderr.Append(buffer, 0, read);
|
|
if (_stderr.Length > MaxStderrChars) _stderr.Remove(0, _stderr.Length - MaxStderrChars);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
|
{
|
|
// stderr closed underneath us; the tail collected so far stands.
|
|
}
|
|
}
|
|
|
|
[DllImport("libc", EntryPoint = "kill", SetLastError = true)]
|
|
private static extern int SysKill(int pid, int signal);
|
|
|
|
/// <summary>SIGTERM through libc, else through the <c>kill</c> command. Returns false when neither could signal the child.</summary>
|
|
private static bool TrySendSigterm(int pid)
|
|
{
|
|
try
|
|
{
|
|
return SysKill(pid, SigTerm) == 0;
|
|
}
|
|
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException)
|
|
{
|
|
// Fall through to the command.
|
|
}
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo("kill")
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
};
|
|
psi.ArgumentList.Add("-TERM");
|
|
psi.ArgumentList.Add(pid.ToString(CultureInfo.InvariantCulture));
|
|
using var kill = Process.Start(psi);
|
|
return kill is not null && kill.WaitForExit(2000) && kill.ExitCode == 0;
|
|
}
|
|
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>A positive millisecond count from the environment, or the fallback when unset. Invalid values fail loudly.</summary>
|
|
public static TimeSpan TimeoutSetting(Func<string, string?> getEnv, string name, TimeSpan fallback)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(getEnv);
|
|
var raw = getEnv(name)?.Trim();
|
|
if (string.IsNullOrEmpty(raw)) return fallback;
|
|
if (!int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var milliseconds) || milliseconds <= 0)
|
|
throw new ClaudeCodeException(ClaudeCodeException.Codes.Timeout, $"{name} must be a positive integer number of milliseconds");
|
|
return TimeSpan.FromMilliseconds(milliseconds);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
}
|
|
try { _idle.Cancel(); } catch (ObjectDisposedException) { }
|
|
try { _total.Cancel(); } catch (ObjectDisposedException) { }
|
|
KillTree();
|
|
_idle.Dispose();
|
|
_total.Dispose();
|
|
_process.Dispose();
|
|
}
|
|
|
|
[GeneratedRegex(@"[\u0000-\u001f\u007f]+")]
|
|
private static partial Regex ControlCharacters();
|
|
}
|