Description of the task
Make the per-user WSLc daemon run exec off the single SDK worker thread, and add
per-container single-flight admission (ErrKind::Busy) so a long-running command in one
sandbox cannot stall lifecycle operations on other sandboxes.
Current behavior (the limitation). The daemon confines all WSLc SDK calls to one
worker thread, which processes WorkerCommands serially (rx.blocking_recv() in
src/backends/wslc/daemon/src/session_manager.rs). When it handles a WorkerCommand::Exec
it calls worker.exec(config, container, sink), which blocks the worker thread for the
entire duration of the run (until the container process exits or the exec times out).
While that run is in flight, every other queued command — Provision / Start / Stop
/ Deprovision and Exec for any other container — waits behind it (head-of-line
blocking).
Priority: non-critical. This is an availability / latency limitation, not a
correctness or security bug:
- Results stay correct and container isolation is unaffected.
- It is bounded by the exec's
scriptTimeout — a blocked run is killed at timeout, so
there is no unbounded hang.
- The state-aware WSLc surface is experimental and not yet released.
When it bites (the scenario). It only manifests under concurrent multi-container
use of a single daemon:
- Sandbox A starts a long-running
exec (e.g. a build, a test suite, or a
scriptTimeout set to minutes).
- Meanwhile the caller (or another caller sharing the same per-user daemon) tries to
provision / start / exec / stop / deprovision sandbox B.
- Those B operations block until A's exec finishes (or times out), even though A and B
are independent containers. To the B caller the daemon looks hung / unresponsive for up
to A's full timeout window.
For the common one-sandbox-per-agent flow (a single container executing one command at a
time) this contention does not occur, which is why it is safe to defer.
Additional context
Proposed fix.
session_manager.rs: run the blocking worker.exec(...) off the single worker thread
(e.g. hand the started process off to a dedicated per-exec thread / task) so the worker
loop stays responsive to other WorkerCommands during a run. Preserve the existing
atomic admission (validate + start in one step) and the live OutputSink streaming.
- Per-container single-flight: admit at most one in-flight
exec per sandbox_id; reject
a second concurrent exec on the same container with ErrKind::Busy (a new typed
admission error) rather than queuing it.
control_server.rs: wire the Busy admission into the client frame sequence (typed
pre-admission error, same shape as the existing not-provisioned / not-started
rejections).
Deferral trail. Deferred from PR 2a/2b during the #767 daemon-hardening review
(2026-08-07), and again while scoping PR 2c (live output streaming). Not appropriate for
PR 3/3, which is the TypeScript SDK surface (types only) — this is a Rust daemon
concurrency change and should land as its own follow-up PR.
Key code references.
src/backends/wslc/daemon/src/session_manager.rs — single worker loop (spawn →
rx.blocking_recv()), WorkerCommand::Exec arm, worker.exec(...).
src/backends/wslc/daemon/src/control_server.rs — exec admission → frame sequence
(write_exec_result); where a Busy typed rejection would be surfaced.
Description of the task
Make the per-user WSLc daemon run
execoff the single SDK worker thread, and addper-container single-flight admission (
ErrKind::Busy) so a long-running command in onesandbox cannot stall lifecycle operations on other sandboxes.
Current behavior (the limitation). The daemon confines all WSLc SDK calls to one
worker thread, which processes
WorkerCommands serially (rx.blocking_recv()insrc/backends/wslc/daemon/src/session_manager.rs). When it handles aWorkerCommand::Execit calls
worker.exec(config, container, sink), which blocks the worker thread for theentire duration of the run (until the container process exits or the exec times out).
While that run is in flight, every other queued command —
Provision/Start/Stop/
DeprovisionandExecfor any other container — waits behind it (head-of-lineblocking).
Priority: non-critical. This is an availability / latency limitation, not a
correctness or security bug:
scriptTimeout— a blocked run is killed at timeout, sothere is no unbounded hang.
When it bites (the scenario). It only manifests under concurrent multi-container
use of a single daemon:
exec(e.g. a build, a test suite, or ascriptTimeoutset to minutes).provision/start/exec/stop/deprovisionsandbox B.are independent containers. To the B caller the daemon looks hung / unresponsive for up
to A's full timeout window.
For the common one-sandbox-per-agent flow (a single container executing one command at a
time) this contention does not occur, which is why it is safe to defer.
Additional context
Proposed fix.
session_manager.rs: run the blockingworker.exec(...)off the single worker thread(e.g. hand the started process off to a dedicated per-exec thread / task) so the worker
loop stays responsive to other
WorkerCommands during a run. Preserve the existingatomic admission (validate + start in one step) and the live
OutputSinkstreaming.execpersandbox_id; rejecta second concurrent exec on the same container with
ErrKind::Busy(a new typedadmission error) rather than queuing it.
control_server.rs: wire theBusyadmission into the client frame sequence (typedpre-admission error, same shape as the existing not-provisioned / not-started
rejections).
Deferral trail. Deferred from PR 2a/2b during the #767 daemon-hardening review
(2026-08-07), and again while scoping PR 2c (live output streaming). Not appropriate for
PR 3/3, which is the TypeScript SDK surface (types only) — this is a Rust daemon
concurrency change and should land as its own follow-up PR.
Key code references.
src/backends/wslc/daemon/src/session_manager.rs— single worker loop (spawn→rx.blocking_recv()),WorkerCommand::Execarm,worker.exec(...).src/backends/wslc/daemon/src/control_server.rs— exec admission → frame sequence(
write_exec_result); where aBusytyped rejection would be surfaced.