Skip to content

fix(ui-sdk): await worker FIFOs and Claude's own sandbox off the event loop - #2845

Merged
schlessera merged 3 commits into
mainfrom
fix/2796-2799-async-sandbox-fifo-044
Oct 24, 2026
Merged

schlessera merged 3 commits into
mainfrom
fix/2796-2799-async-sandbox-fifo-044

Conversation

@schlessera

@schlessera schlessera commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Closes #2796
Closes #2799

What changed

Two event-loop stalls left on the hosted path after #2791/#2804 are gone. #2799: the asynchronous probe, agent launch, maintenance launch and maintenance run created their worker FIFOs with a synchronous mkfifo subprocess. createWorkerPipes and the agent launch's createWorkerStatusPipe now run as steps (createWorkerPipesSteps, createWorkerStatusPipeSteps) through a new dual-form step, workerStepRun (worker-steps.ts), and each asynchronous generator yield*s them; the synchronous wrappers keep their API and behaviour. #2796: a host without governed turns supplies no worker sandbox, so every Claude turn prepared one synchronously inside createClaudeSdkTurn, running the scratch area's git check in a maintenance worker on the event loop. The turn runner now awaits prepareClaudeTurnSandbox (sdk-options.ts, which drives prepareWorkerSandboxAsync) before it builds the SDK options and hands the result in as the turn's sandbox. It uses the same inputs as the old fallback (the turn environment's HOME and the backend's maintenance host) and returns the environment it resolved, so the options are built from that same one.

Why this shape

  • Steps, not a non-subprocess primitive. Bun has no mkfifo in node:fs, and FFI to libc would be a new native dependency on a security path. A dual-form step keeps one implementation, as CONTRACT: fix(ui-sdk): run per-turn worker checks off the event loop #2791 did for the other waits. Modes stay 0600, FIFOs are still unlinked before any worker starts, and the stdio is still the three FIFO descriptors only.
  • The status channel too. The issue named createWorkerPipes, but the test written for its acceptance criterion found a second synchronous mkfifo on the async agent launch: the bubblewrap status/gate FIFOs (worker-status.ts). Same bug class, same fix. An audit spying on Bun.spawnSync during the four async forms shows no synchronous subprocess left on them.
  • Claude's sandbox is prepared in the turn runner, not inside createClaudeSdkTurn. createClaudeSdkTurn is synchronous and has about 20 direct callers in tests and fixtures. It still falls back to the synchronous form for those callers; the hosted turn path no longer reaches that fallback. The sandbox goes in through the inner req.workerSandbox copy only. The outer request, which decides egress, recipient and route inputs, is untouched, and everything inside the assembly already read the resolved sandbox.
  • Ordering. Every new wait sits where the synchronous call was. Admission, reservations and probe fixtures still come before it; a failure still goes through the same catch, cleanup and refusal. For Claude, the await sits directly before the options are built, in the same try the old synchronous throw reached. A cancel accepted during it is handled like one accepted during the turn's probe. The deferred spawn still follows only the SDK's close (fix(ui-backend-claude): leave a running worker to the SDK's close on a turn abort #2826); nothing in worker-spawn.ts changed.

Proof

Base 15a5a3b4f (origin/main), head 01b95dce9.

Failing first. Base behaviour: main's source, with the two new *Steps names shimmed to main's synchronous bodies so the tests load. 5 fail on the expected assertions:

  • ASYNC_FIFO_CREATION_LEAVES_EVENT_LOOP_FREE (ticks 0).
  • ASYNC_FORM_AWAITS_MKFIFO (probe: sync 1, awaited false).
  • NO_WORKER_WITHOUT_FIFOS.
  • CLAUDE_FALLBACK_SANDBOX_AWAITED (calls 0).
  • The refused-fallback test (async preparation never called).

New tests

  • packages/ui-sdk/tests/worker-steps-event-loop.test.ts:
    • A delayed mkfifo shim, with 1 ms ticks counted while the async form runs. The sync form shows 0 ticks under the same shim, so the measure can fail. FIFO ends are FIFOs with mode 0600, and input flows through.
    • Bun.spawnSync/Bun.spawn spies over the real probe, maintenance run (sandbox scratch check), agent launch and maintenance launch. Each async form runs no sync mkfifo and awaits one; each sync form still runs it synchronously.
  • packages/ui-sdk/tests/worker-pipes.test.ts (private TMPDIR; leaks read from /proc/self/fd):
    • A failed mkfifo refuses with the sync form's message, for the pipes and the status channel, and leaves no directory or descriptor.
    • A partial setup (two of three FIFOs) closes the four descriptors it opened once. The original ENOENT survives; a double close would surface as EBADF.
    • A failed async launch spawns nothing but mkfifo.
    • An abort while mkfifo is held (explicit release handshake) rejects with DOMException, stops the worker and releases every pipe.
  • packages/ui-backend-claude/tests/worker-event-loop.test.ts:
    • A turn without a host sandbox awaits prepareWorkerSandboxAsync once, never calls the sync form, and ticks while the real maintenance worker runs. Its inputs equal { home: turnEnv(profile).HOME, maintenance }, and its result equals the sync form's for the same brain.
    • A supplied sandbox is not prepared again.
    • A refused preparation ends the turn on its named worker_host_unsupported failure, never reaches the SDK, never prepares synchronously and releases the session's claim.
    • Ordinary and unrestricted autonomous turns prepare from the same resolved environment that their SDK options carry.

Mutations. Each was broken, the named test failed, the mutation was restored, and the test passed again. Script and logs are kept locally.

# Mutation Fails
M1 runWorkerStepsAsync drives sync forms ASYNC_FIFO_CREATION_LEAVES_EVENT_LOOP_FREE, ASYNC_FORM_AWAITS_MKFIFO, NO_WORKER_WITHOUT_FIFOS, CLAUDE_FALLBACK_SANDBOX_LEAVES_EVENT_LOOP_FREE; abort test times out (the abort cannot land)
M2 workerStepRun's async form uses Bun.spawnSync ASYNC_FIFO_CREATION_LEAVES_EVENT_LOOP_FREE, ASYNC_FORM_AWAITS_MKFIFO
M3a–e probe / agent stdio / agent status / maintenance launch / maintenance run back to the sync creator ASYNC_FORM_AWAITS_MKFIFO for that form
M4 no cleanup() on a failed pipe setup PARTIAL_FIFO_RELEASED
M5 FIFO directory not removed FAILED_FIFO_RELEASED
M6 turn runner skips the awaited preparation CLAUDE_FALLBACK_SANDBOX_AWAITED, refused-fallback test
M7 prepare even when the host supplied a sandbox SUPPLIED_SANDBOX_NOT_PREPARED_AGAIN
M8 own sandbox loses home CLAUDE_FALLBACK_SANDBOX_SAME_INPUTS
M9 a refused preparation falls back to the sync form FALLBACK_REFUSAL_NO_UNPREPARED_SANDBOX
M10 no kill for an abort accepted during the awaited launch abort test times out (30 s)
M11 options resolve the environment again OWN_SANDBOX_SAME_ENVIRONMENT_AS_OPTIONS
M12 a refused turn keeps its claim BackendBusyError on the session's next turn

Review. gpt-6-astra, adversarial with a coverage ledger, on b339977a2. Findings:

  1. An unrestricted autonomous turn resolved its environment twice. Fixed in 01b95dce9; M11 guards it.
  2. Other synchronous subprocesses reachable on async forms. Output staging's FIFOs are not reached from the hosted backends, which pass a prepared output for writable areas. The separate-UID broker close is real but not a FIFO; it is filed as ui-sdk: a separate-UID worker's broker close still runs a synchronous subprocess on the asynchronous probe and launch #2844.
  3. The abort test lacked a handshake and teardown. Fixed.
  4. The claim-release check did not reuse a session. Fixed; M12 guards it.

Citations. Two turn-runner.ts anchors moved by 3 lines, updated in docs/decisions/hardening.md and docs/decisions/runtime-requirements.md. tests/decision-citations.test.ts passes 44/44.

Package suites (bun run test <dir> on 01b95dce9):

Suite Result
packages/ui-sdk 1,196 pass, 0 fail
packages/ui-backend-claude 769 pass, 0 fail (includes the provider-history guard)
packages/ui-server 3,805 pass, 0 fail
packages/ui-backend-pi 481 pass, 0 fail
./tests (root) 1,948 pass, 5 fail

All five root failures also fail on base 15a5a3b4f, which was rerun in a separate worktree:

The odysseus/penelope lines in the log are planned meta-test failures (PLANNED_WORKER_FAILURE_AFTER_DIAGNOSTICS). No package export changed and no bun: import was added, so the pack smoke was not required.

Native hosted-app proof (flock …/native.lock bun scripts/prove-native-host.ts, scratch worktree at 01b95dce9):

Run claude 1000 pi 1000 claude 0 pi 0 retained-index control Exit
1 pass fail not reached not reached not reached 1
2 pass pass pass pass failed as intended 0
3 pass pass pass pass failed as intended 0

Run 1 failed HOSTED_ADMISSION_HEALTH_WITHIN_1_SECOND. One of the 49 warm-phase health probes timed out at 1,000 ms; first-phase max was 220 ms. The host load average was about 11 at the time, from this branch's ui-server suite and other workers' suites. The cell's slow windows that run records are the journal and governed begin/finish commits (470–850 ms), the SQLite class-B stall tracked on #2416/#2789. This diff does not touch it, and #2810 recorded the same cell failing the same way under load. The probe records no per-sample timestamps, so the stall is not attributed more precisely than that. Runs 2 and 3 pass in full.

Preflight. bun run check:pr --base origin/main on 01b95dce9: "Local preflight passed" (936 affected fast-invariant tests across 40 files). bun run lint is clean and tsc --noEmit passes. The draft gates (changeset, contract, draft gates, sync) are green on 01b95dce9.

Main has since gained #2843 and one other ui-server commit. Neither touches this diff's files, and git merge-tree merges cleanly. I have not merged main into this branch; that refresh is left to the coordinator.

Checklist

  • Focused local preflight/behavioral receipts and complete selected hosted proof passed; actual head/base/job checkout evidence is in Proof.
  • Acceptance criteria from the issue are all met, or the gap is named below.
  • The PR and completed/advanced issues have verified release milestones under docs/process/github.md.
  • A changeset is included, or this changes nothing a consumer can see. (.changeset/awaited-worker-fifos.md: ui-sdk, backend-claude patch)
  • No personal data anywhere in the diff — the leakage gate covers the whole tree.
  • The integration contract index or relevant component is updated in this same commit, or nothing in the machine surface moved. (No public export changed; new functions are module-internal.)
  • Docs under docs/ reflect the change, or nothing documented moved.
  • Provider history stays append-only (docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.

Anything left open

🤖 Generated with Claude Code

schlessera and others added 3 commits October 24, 2026 19:35
The asynchronous probe, agent launch, maintenance launch and maintenance
run created their stdio FIFOs (and the launch its status channel) with a
synchronous `mkfifo` subprocess, blocking the hosted server's event loop
while a loaded host started it. `createWorkerPipes` and
`createWorkerStatusPipe` now run as steps (`workerStepRun`), so those
generators await the subprocess; the synchronous forms keep their exact
behaviour, modes, cleanup and unlink-before-launch boundary.

Closes #2799

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…lies none

A host without governed turns supplies no worker sandbox, so every Claude
turn prepared one synchronously while building its SDK options, running
the scratch area's git check in a maintenance worker on the event loop.
The turn runner now awaits `prepareWorkerSandboxAsync` with the same
inputs (the turn environment's home and the backend's maintenance host)
before it builds the options. A supplied sandbox and the restricted
envelope are unchanged, and a refusal still fails the turn by name.

Closes #2796

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… environment

An unrestricted autonomous turn has no turn environment of its own, so
preparing Claude's own sandbox resolved the profile's environment once and
assembling the options resolved it again. The preparation now returns the
environment it used and the turn passes that same one to its options.

Review follow-ups in the tests: the claim-release check reuses one session,
and the FIFO abort test hands mkfifo an explicit release and settles the
launch before its fixture is removed.

Refs #2796

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@schlessera schlessera added this to the 0.44.0 milestone Oct 24, 2026
@schlessera schlessera added type: fix Something is broken and must behave the way it is documented. area: ui-sdk Wire protocol, runtime schemas, the server and client SDK surfaces. area: backends AgentBackend implementations and the seam they share. labels Oct 24, 2026
@schlessera
schlessera marked this pull request as ready for review October 24, 2026 20:32
@schlessera
schlessera merged commit 2fe4005 into main Oct 24, 2026
15 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backends AgentBackend implementations and the seam they share. area: ui-sdk Wire protocol, runtime schemas, the server and client SDK surfaces. type: fix Something is broken and must behave the way it is documented.

Projects

None yet

1 participant