Skip to content

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

Closed
CMGS wants to merge 5 commits into
mainfrom
feat/pool-warmup
Closed

CMGS wants to merge 5 commits into
mainfrom
feat/pool-warmup

Conversation

@CMGS

@CMGS CMGS commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Three additions that the ComputeSDK sandbox leaderboard dry runs surfaced, each its own commit:

  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} (node lives there).
  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. A timeout closes the guest connection so silkd kills the child (504); 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 opencode's typecheck (~10 tsgo processes at 1–2 GB each) thrashed on page cache at 11.5G used and froze for the whole lease — deflate-on-OOM never fired. Off by default; only a node that sets it passes the flag, so an older cocoon is unaffected.

Hot path: zero on the claim path. Warmup runs once per golden build; the exec endpoint is opt-in per request.

Evidence: make go-lint 10 × 0 issues.; go test -race on sandboxd and e2e green; asl clean apart from the pre-existing forwarder advisories. On the dry-run node (Caddy h2 in front), the upstream TTI harness at 100 concurrency went from 382 ms median (relay, per-RPC connections) to 78–239 ms with the exec endpoint over one HTTP/2 session, 100/100 success; the DAX build completes 7/7 phases with no_balloon (it froze at 6/7 before).

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.
@CMGS

CMGS commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #148, which carries these five commits rebased on main after #142 plus the two fixes and the review round that followed. Keeping the branch; nothing else references it.

@CMGS CMGS closed this Sep 11, 2026
CMGS added a commit that referenced this pull request Sep 11, 2026
* sandboxd: add a per-pool warmup command for golden builds

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.

* sandboxd: add a buffered POST /v1/sandboxes/{id}/exec

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.

* sandboxd: add no_balloon for cocoon VMs

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).

* fix: close buffered exec stdin and scope timeout

* review: buffered exec test import, client-cancel path, warmup doc bounds

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.

* fix: buffered exec kills a command it gives up on and rejects unknown 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.

* review: whole-repo round after #143

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.

* fix: bound the exec kill by its timeout and check silkd's reply

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.
@CMGS
CMGS deleted the feat/pool-warmup branch September 11, 2026 06:25
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