Repository navigation
fix(ui-sdk): await worker FIFOs and Claude's own sandbox off the event loop - #2845
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 24, 2026 20:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mkfifosubprocess.createWorkerPipesand the agent launch'screateWorkerStatusPipenow run as steps (createWorkerPipesSteps,createWorkerStatusPipeSteps) through a new dual-form step,workerStepRun(worker-steps.ts), and each asynchronous generatoryield*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 insidecreateClaudeSdkTurn, running the scratch area's git check in a maintenance worker on the event loop. The turn runner now awaitsprepareClaudeTurnSandbox(sdk-options.ts, which drivesprepareWorkerSandboxAsync) 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'sHOMEand the backend's maintenance host) and returns the environment it resolved, so the options are built from that same one.Why this shape
mkfifoinnode: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 stay0600, FIFOs are still unlinked before any worker starts, and the stdio is still the three FIFO descriptors only.createWorkerPipes, but the test written for its acceptance criterion found a second synchronousmkfifoon the async agent launch: the bubblewrap status/gate FIFOs (worker-status.ts). Same bug class, same fix. An audit spying onBun.spawnSyncduring the four async forms shows no synchronous subprocess left on them.createClaudeSdkTurn.createClaudeSdkTurnis 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 innerreq.workerSandboxcopy only. The outer request, which decides egress, recipient and route inputs, is untouched, and everything inside the assembly already read the resolvedsandbox.trythe 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 inworker-spawn.tschanged.Proof
Base
15a5a3b4f(origin/main), head01b95dce9.Failing first. Base behaviour: main's source, with the two new
*Stepsnames 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).New tests
packages/ui-sdk/tests/worker-steps-event-loop.test.ts:mkfifoshim, 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 mode0600, and input flows through.Bun.spawnSync/Bun.spawnspies over the real probe, maintenance run (sandbox scratch check), agent launch and maintenance launch. Each async form runs no syncmkfifoand awaits one; each sync form still runs it synchronously.packages/ui-sdk/tests/worker-pipes.test.ts(privateTMPDIR; leaks read from/proc/self/fd):mkfiforefuses with the sync form's message, for the pipes and the status channel, and leaves no directory or descriptor.ENOENTsurvives; a double close would surface asEBADF.mkfifo.mkfifois held (explicit release handshake) rejects withDOMException, stops the worker and releases every pipe.packages/ui-backend-claude/tests/worker-event-loop.test.ts:prepareWorkerSandboxAsynconce, 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.worker_host_unsupportedfailure, never reaches the SDK, never prepares synchronously and releases the session's claim.Mutations. Each was broken, the named test failed, the mutation was restored, and the test passed again. Script and logs are kept locally.
runWorkerStepsAsyncdrives sync formsASYNC_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)workerStepRun's async form usesBun.spawnSyncASYNC_FIFO_CREATION_LEAVES_EVENT_LOOP_FREE,ASYNC_FORM_AWAITS_MKFIFOASYNC_FORM_AWAITS_MKFIFOfor that formcleanup()on a failed pipe setupPARTIAL_FIFO_RELEASEDFAILED_FIFO_RELEASEDCLAUDE_FALLBACK_SANDBOX_AWAITED, refused-fallback testSUPPLIED_SANDBOX_NOT_PREPARED_AGAINhomeCLAUDE_FALLBACK_SANDBOX_SAME_INPUTSFALLBACK_REFUSAL_NO_UNPREPARED_SANDBOXOWN_SANDBOX_SAME_ENVIRONMENT_AS_OPTIONSBackendBusyErroron the session's next turnReview. gpt-6-astra, adversarial with a coverage ledger, on
b339977a2. Findings:01b95dce9; M11 guards it.Citations. Two
turn-runner.tsanchors moved by 3 lines, updated indocs/decisions/hardening.mdanddocs/decisions/runtime-requirements.md.tests/decision-citations.test.tspasses 44/44.Package suites (
bun run test <dir>on01b95dce9):packages/ui-sdkpackages/ui-backend-claudepackages/ui-serverpackages/ui-backend-pi./tests(root)All five root failures also fail on base
15a5a3b4f, which was rerun in a separate worktree:tests/native-diagram-inputs.test.ts×4: this host's frozen native installation fingerprint (also recorded on test: follow #2791's awaited worker checks in the main-red tests #2810).tests/integration-command.test.ts"confirmation executable renews automatically…": a 30 s timeout in the same test on base.The
odysseus/penelopelines in the log are planned meta-test failures (PLANNED_WORKER_FAILURE_AFTER_DIAGNOSTICS). No package export changed and nobun: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 at01b95dce9):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/mainon01b95dce9: "Local preflight passed" (936 affected fast-invariant tests across 40 files).bun run lintis clean andtsc --noEmitpasses. The draft gates (changeset,contract,draft gates,sync) are green on01b95dce9.Main has since gained #2843 and one other ui-server commit. Neither touches this diff's files, and
git merge-treemerges cleanly. I have not merged main into this branch; that refresh is left to the coordinator.Checklist
.changeset/awaited-worker-fifos.md: ui-sdk, backend-claude patch)docs/reflect the change, or nothing documented moved.docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.Anything left open
--cpu-profprofile.🤖 Generated with Claude Code