diff --git a/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs b/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs index 1af64a8..29b1043 100644 --- a/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs +++ b/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs @@ -219,7 +219,7 @@ public sealed class BwrapSandbox : ISandboxRunner private async Task RunProcessAsync(ProcessStartInfo start, SandboxRequest request, CancellationToken ct) { - using var process = Process.Start(start) ?? throw new InvalidOperationException("The sandbox did not start."); + using var process = await Spawner.StartAsync(start); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(request.Limits.Timeout); @@ -411,6 +411,52 @@ public sealed class BwrapSandbox : ISandboxRunner } } +/// +/// #654: Starts every sandbox from one thread that lives as long as the process. +/// +/// Why. --die-with-parent uses the kernel's parent-death signal, and that +/// is bound to the thread that forked, not to the process. .NET forks on the calling +/// thread — a thread-pool thread, and the pool retires idle threads. Seen on the test machine +/// under load: the pool dropped a thread, bwrap got SIGKILL (exit 137), and when it happened +/// while bwrap was still building the sandbox, the inside lived on as an orphan holding the +/// output pipes. Started from a thread that never ends, the signal fires only when the +/// instance itself dies — which is what it is for. +/// +internal static class Spawner +{ + private static readonly System.Collections.Concurrent.BlockingCollection<(ProcessStartInfo Start, TaskCompletionSource Done)> Queue = new(); + + static Spawner() + { + var thread = new Thread(() => + { + foreach (var (start, done) in Queue.GetConsumingEnumerable()) + { + try + { + done.SetResult(Process.Start(start) ?? throw new InvalidOperationException("The sandbox did not start.")); + } + catch (Exception ex) + { + done.SetException(ex); + } + } + }) + { + IsBackground = true, + Name = "ClawdDotNet sandbox spawner" + }; + thread.Start(); + } + + public static Task StartAsync(ProcessStartInfo start) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Queue.Add((start, done)); + return done.Task; + } +} + /// #654: What the installed bwrap can do beyond the basics. public sealed record BwrapFeatures(bool DisableUserns, bool ClearEnv) {