Files
Playa/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs

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();
}