Skip to content

sandboxd: pool warmup, buffered exec endpoint, no_balloon - #148

Merged
CMGS merged 8 commits into
mainfrom
feat/warmup-exec
Sep 11, 2026
Merged

CMGS merged 8 commits into
mainfrom
feat/warmup-exec

Conversation

@CMGS

@CMGS CMGS commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Supersedes #143: the same three additions rebased on main after #142, plus the fixes and the whole-repo review round that followed the PR review. Eight commits, each self-contained.

The feature commits (from #143)

  1. Pool warmup argv — runs in the golden VM after readiness and before the snapshot, so what it touches is page-cache-resident in every clone. Measured on a Node flavor: the first node -v in a fresh clone cost 150–385 ms (cold read of the node binary), the second 6–37 ms; with warmup: ["node", "-e", "0"] the first exec is 15–41 ms. Config-owned like egress (PUT /v1/pools rejects it, the seed hash excludes it); a .warmup sidecar stamp makes a golden built with a different warmup rebuild. The guest exec PATH now includes /usr/local/{sbin,bin}.
  2. POST /v1/sandboxes/{id}/exec — a buffered exec (argv/cwd/env/timeout_seconds in, exit_code/stdout/stderr out) for clients that cannot hold an upgraded connection or want to multiplex over HTTP/2 through a TLS proxy. 8 MiB output cap (413). The relay is untouched and stays the streaming path.
  3. no_balloon — boots pool and template VMs with cocoon's --no-balloon (vm: add --no-balloon; whole-repo comment, signature and layout round cocoon#221); clones inherit it from the golden. cocoon balloons 25% of guest memory by default, and an 8-CPU/16G guest running a large typecheck thrashed on page cache and froze for the whole lease. Off by default; only a node that sets it passes the flag, so an older cocoon is unaffected.
  4. fix: close buffered exec stdin and scope timeout and review: buffered exec test import, client-cancel path, warmup doc bounds — the sandboxd: pool warmup, buffered exec endpoint, no_balloon #143 review fixes.

Added after #143

  1. fix: buffered exec kills a command it gives up on and rejects unknown fields — closing the guest connection only reaches a child that writes; a silent one (sleep 600) ran to completion behind the 504 and retries stacked copies. The handler keeps the started pid and sends silkd kill on a fresh connection on every give-up path (timeout, client gone, output cap). The body is decoded strictly: an unknown field such as user was silently dropped. Regression tests TestExecTimeoutKillsTheCommand and TestExecRejectsUnknownFields.
  2. review: whole-repo round after #143 — the batch-end round over the whole tree (Go, Rust, Python): wakeGuest shared by the relay and the exec, unreachable error branches and guards dropped, errors.AsType/cmp.Or/slices.Concat/strings.Cut/omitzero where they remove code, Rust find walks one reusable buffer, Python _claim_from shared by claim and checkpoint claim. 74 files, prod −29 / test −3, comments +8/−15.
  3. fix: bound the exec kill by its timeout and check silkd's replykillExec derived a 5-second context but only the wake and the dial honoured it; the kill frame's write and the reply read ran unbounded, so a silkd that accepted the connection and never answered pinned the handler and the 504 behind it. The connection is now closed when the context ends, and the reply is decoded: done and not_found mean the child is gone, anything else is logged.

Filed as follow-ups rather than folded in: #144 (buffered exec response memory bound and non-UTF-8 output), #145 (silkd spawn failures reported as internal), #146 (idle sweep vs a live exec).

Hot path

Zero on the warm-claim path and the relay path — wakeGuest is the relay's existing wake+dial sequence moved into a function; warmup runs once per golden build; the exec endpoint is opt-in per request.

Evidence

  • make go-lint: exit 0, 10 × 0 issues. (5 modules × linux/darwin)
  • make go-test: 18 packages ok, race detector on
  • asl ./... and GOOS=linux asl ./...: 0 findings (forwarder advisories recorded in the hygiene ledger)
  • silkd + boot/init: cargo fmt --check, clippy -D warnings, cargo test green on macOS and in a linux/arm64 container
  • sdk/python, sdk/openai, sdk/langchain: ruff format --check, ruff check, pytest green (155 / 4 / 6)
  • Hardware: the exec endpoint, warmup and no_balloon were exercised on the leaderboard dry-run node behind an HTTP/2 proxy (TTI harness at 100 concurrency 382 ms median on the relay → 78–239 ms on the exec endpoint, 100/100 success; the DAX build completes 7/7 phases with no_balloon).

A pool entry's warmup argv runs in the golden VM after readiness and before the snapshot, so the files it touches (a Node or Python runtime) are page-cache-resident in every clone instead of being read from disk on the first exec of each claim. Config-owned like egress: PUT /v1/pools rejects it, the seed hash excludes it, and a golden built with a different warmup is rebuilt via a sidecar stamp. Hot path: zero; the exec runs once per golden build.
One command in, its exit code and complete output out as JSON. Clients that cannot hold an upgraded connection (or want to multiplex over HTTP/2 through a TLS proxy) get a single request per command instead of a TCP connection per RPC; the relay stays the streaming path. Timeout closes the guest connection so silkd kills the child. Hot path: none; the relay is untouched.
Boots pool and template VMs with cocoon's --no-balloon so a guest keeps its whole memory; clones inherit it from the golden. cocoon balloons 25% of guest memory by default, and a 16G build guest running a large typecheck thrashes on the remaining 12G before deflate-on-OOM fires (measured: opencode's bun typecheck at 8c/16G froze the guest at ~11.5G used with a 4G balloon inflated).
exec_test.go used context without importing it, so the server package
failed typecheck in CI and none of its tests ran. A client that drops the
request mid-command now returns silently instead of logging an error and
writing 502 to a closed connection; a negative timeout_seconds is a 400.
Docs: warmup runs under the 2-minute engine command timeout with only PATH
set and a failure fails the golden build; the exec endpoint lists 401 and
the negative-timeout 400.
… fields

Closing the guest connection only reaches a child that writes: silkd learns
the client is gone from a failed write, so a silent command ran to
completion behind a 504, and every retry stacked another copy. The handler
now keeps the pid from the started frame and sends silkd a kill on a fresh
connection whenever it stops collecting output early (timeout, client gone,
output cap). The body is decoded strictly, so a client asking for
`user`/`session` gets a 400 instead of silently running as root; the API doc
states the kill and that output is UTF-8 text.
Go: unreachable EncodeRequest branches, the SetPools 128-bit hash-collision
guard, the readoptEgressTap lane re-check, the journal ErrClosed filter and
the SignLeaf expiry guard (the notAfter clamp already covers it) are gone;
pooledHash scans became an m.pools lookup; handleAgent and handleExec share
wakeGuest; the exec output cap is checked before the append; errors.AsType
replaces the typed-var errors.As shape in prod and tests; omitzero on every
bool/numeric JSON field; the three store/peer nil-client fallbacks use
cmp.Or (the asl cmpor gate); the sdk post forwarder is inlined; Connect sits
under the Client type; mcp's rpc vocabulary types sit ahead of server;
enum-listing field comments and the runCA godoc are dropped; test files lose
their no-op nolint:gosec directives, use slices.Concat and strings.Cut.
Rust: find reuses one scratch String across the walk; is_valid_pid and
is_oversized; boot resolves a disk's serial once and hoists the device path;
test files get std/external/crate use groups, helpers ahead of tests and no
per-match to_string. Python: Checkpoint.new reuses Client._claim_from
(drops the cycle-breaking local import); __exit__ catches APIError only;
tests parametrize the malformed-frame cases; ruff 0.16 format. Docs: the
warmup environment is silkd's base environment, not PATH alone.

Comment lines: +8/-15 (Go+Rust), +0/-2 (Python). Prod net -29, test -3.
Gates: make go-lint 10x "0 issues.", make go-test 18 ok, asl 0 findings on
both GOOS (5 forwarder advisories kept: guardsEgressLane, matchMethod,
skipIdle, refillGated, benignSweepErr), cargo fmt/clippy/test green for
silkd and boot/init on macOS and linux/arm64, ruff format/check and pytest
green for the three Python packages.
killExec derived a 5-second context but only the wake and the dial honoured
it; the kill frame's write and the reply read ran unbounded, so a silkd that
accepted the connection and never answered pinned the handler goroutine and
the 504 behind it forever. The connection is now closed when the context
ends, the same shape as the exec itself, and the reply is decoded: done and
not_found mean the child is gone, anything else is logged at the call site
together with the wake, dial and write failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant