Skip to content

sandboxd: drop the Firecracker engine axis - #86

Merged
CMGS merged 1 commit into
codex/close-repo-reviewfrom
fix/drop-fc
Aug 14, 2026
Merged

sandboxd: drop the Firecracker engine axis#86
CMGS merged 1 commit into
codex/close-repo-reviewfrom
fix/drop-fc

Conversation

@CMGS

@CMGS CMGS commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #81. Removes the engine pool-key axis and the Firecracker opt-in.

Why

  • fc offered nothing CH does not: volumes, checkpoint, fork, hibernate and the
    restore modes are all CH-only or CH-equal (clone performance measured equal
    on hardware), and cocoon's --fc remains for anyone driving cocoon directly.
  • The axis never made it through the template lifecycle: DELETE /v1/templates
    built its key without engine, so a template promoted with engine:"fc" over
    raw HTTP hashed to a key no delete could ever name — permanently undeletable,
    never converging. No SDK could send or read the axis either.
  • Zero e2e, script, or hardware coverage anywhere in the repo.

Threading a one-value-in-practice axis through claim, promote response, handles
and the delete query would have grown surface for an untested option; removing
it deletes code instead (the volumes-require-ch guard, the fc test rows, the
--fc cold-boot branch, the SDK/metrics engine plumbing #81 added).

PoolKey is (template, net, size) again and its hash drops the engine
component, so old pool-key hashes change — upgrades already never convert
state (deploy.md).

Validation

  • make go-lint 0 issues, asl dual-GOOS 0 findings, go test -race green
    across all modules, pytest 153
  • repo-wide grep: no EngineFC/EngineCH/--fc/"engine" wire key remains
    in code or docs

The fc opt-in never carried its weight: it offered nothing CH does not
(volumes, checkpoint, fork, hibernate and the restore modes are all CH;
clone performance measured equal), it had zero e2e or hardware coverage,
and the axis never made it through the template lifecycle - DELETE built
its key without engine, so an fc-promoted template could never be deleted
by name, and no SDK could send or read the axis at all. Rather than thread
a one-value-in-practice axis through claim, promote, handles and delete,
remove it: PoolKey is (template, net, size) again, the hash drops the
engine component, the volumes-require-ch guard and the fc test rows go
with it, and the pool gauges return to three labels. cocoon keeps --fc for
anyone driving it directly.

Old pool-key hashes change; upgrades already never convert state.
@CMGS
CMGS merged commit 855c678 into codex/close-repo-review Aug 14, 2026
1 check passed
@CMGS
CMGS deleted the fix/drop-fc branch August 14, 2026 03:27
CMGS added a commit that referenced this pull request Aug 14, 2026
* fix: close device, stream, and preview lifecycle gaps

* fix: watch overflow signals by dropping the sender, not a flag

The overflow flag was only checked at the loop top, so a callback preempted
between a full try_send and the store could leave the loop parked on an
empty channel forever - a silent quiet watch, the exact loss the terminal
error exists to prevent. Dropping the sender makes the closed channel the
wakeup itself: the buffered ordered prefix drains first, then recv() returns
None and the terminal overflow error goes out. Deletes the Arc/atomic
machinery outright. The pty close error never reached readers (pipe readers
always see ErrClosedPipe), so plain Close replaces CloseWithError.

* perf(preview): pooled guest conns with per-request touch authorization

DisableKeepAlives paid a measured +0.23ms relay dial per request to get
per-request revocation, activity, and audit. PreviewTouch does the same
three against the claim map for microseconds, so the kept-alive pool
returns for sub-resource fan-out; the idle pool stays keyed per claim.
Measured on .79: preview seq p50 back to the pre-change band (0.17-0.21ms
vs 0.44ms), warm claims unchanged. deploy.md documents the bearer-token
payload and the shared browser origin under one preview_advertise.

* review: tighten comments, converge relay close paths

Reattach the preview_advertise doc to its field at one line, drop two
edit-narration clauses and a restating test comment, compress the watch
overflow WHYs, and make PortConn.Close match Pty.Close (the writer-side
error value never reaches readers).

* fix(pool): scope promoted-template claims to the owning tenant

resolveGolden decoded the template record and dropped it, so a tenant token
could claim any other tenant's promoted template by name and boot its
snapshot - proven with a test that saw the owner's content digest come back
under a foreign tenant. Promote and delete were already tenant-scoped; only
the read path was not. A foreign template now resolves as absent, which is
byte-identical to a name that was never promoted, so existence stays
unconfirmed. Root-promoted templates remain shared, as pools are.

* fix(mesh): a departed node stays gone until it restarts

forget dropped a dead peer from the view, but merge re-inserted any node it
did not already know, so one push/pull from a peer that had not yet seen the
death resurrected it - permanently, since SWIM never fires NotifyLeave twice,
and the node was then re-gossiped cluster-wide. Tombstone the epoch it left
at: a lagging peer replaying that epoch is ignored, while the node's own
restart always seeds a higher epoch and clears the tombstone.

* docs: correct the preview audit op, bound verbs, and volume scope

The audit op is preview, once per request, not preview_dial. pty_open,
lsp_request and port_forward are connection-bound alongside fs_watch. Volume
discovery returns the gossiped union only to root; a tenant sees this node's
local entries its ACL permits.

* test(peer): bound the in-flight wait and give the fan-out test teeth

The Forget-mid-flight test spun on an atomic with no deadline, so a probe
that never arrived hung until the binary timeout instead of failing; wait on
a channel the handler closes. The swallows-failures test never checked the
healthy peer was reached, so it passed even if the fan-out stopped at the
first error - count that peer's hits.

* fix(egress): a missing policy on either side denies, never inherits

A tenant configured without an egress block inherited the pool's entire
allow-list including its secret injections, so adding a tenant token handed
that tenant the operator's credentials - the opposite of the documented
intersection. Both single-sided branches now deny: a tenant reaches only what
both layers allow, and a pool without a policy grants nothing to anyone.
Root keeps the pool policy whole, having no tenant layer to intersect.

* docs: correct the claim engine axis, metrics inventory, and config table

Document engine as the fourth pool-key axis (claim body, pools table, and the
three key examples that omitted it), list the archived/draining/digest-mismatch
gauges and the archive counters /metrics actually emits, add the secrets and
egress_internal_allow rows, name the 401 the keyed HEAD probe answers, and say
the usage journal's key is the pool key's hash. Also: the audit record's egress
decision/secret fields, sandboxd.dbg in the release assets, browser is amd64-only
while base is multi-arch, browser in the os-image list, bench.sh and release.yml
in the repo map, and the nft lock drops guest-initiated packets. Drops a
third-party registry namespace from the android README.

* fix: close preview and idle hibernate gaps

Guest connection pooling let a reused preview request skip PreviewDial, and
with it wakeResolved and the Transition lock that serializes a claim against
its own hibernation: once the idle sweep passed its re-check and entered the
seconds-long snapshot, a pooled request wrote into a freezing VM and never
triggered the wake. Every request dials again, so authorization, the activity
stamp, the audit record and the wake all ride the one path.

Egress pools accepted idle_hibernate_seconds even though the lane refuses to
hibernate at all, so the setting could only ever produce a failure. Config
now rejects it, and the sweep skips egress claims of unpooled keys, which the
config gate cannot reach - futile work whose only symptom was a logged error
every tick, so nothing observable is left to assert.

* fix(mesh): let SWIM membership decide who is in the view

The epoch tombstone the previous commit added carried two residuals of its
own: it lived only in memory, so a restart forgot every death and a lagging
peer could teach them back, and its entries were dropped only when the same
node returned with a higher epoch, so a permanently retired node_id stayed
forever. Membership already answers the question - merge now accepts gossip
only about nodes SWIM currently reports, and NotifyJoin/NotifyLeave maintain
that set. Both maps are bounded by live membership and rebuilt from SWIM on
restart, and a node returns to the view by rejoining, not by out-numbering a
tombstone.

* build: pin the toolchains that produce guest binaries, and gate shell

Every base image was a floating tag, so the kernel build, sandbox-init and
silkd were reproducible only by luck, and rust-toolchain named 'stable' —
a new release could change codegen or fail -D warnings with nothing changed
here. Bases are pinned by digest and both toolchains to the version the
Linux gate actually runs. A pinned digest goes stale silently, so dependabot
owns refreshing them (plus cargo, gomod and actions) rather than a human
remembering. shellcheck had no gate at all: all seven scripts already pass,
so this locks in what is already true.

* feat(sdk): expose the reply status, and close the parity gaps

httpError becomes APIError: the SDK's own redirect walk branches on the
status, so a caller distinguishing gone from full from mid-heal needed the
same field Python already exposes. WithHTTPClient lets a caller supply its
own transport or deadline; the client sets no blanket one, because checkpoint
and promote block for as long as the snapshot takes and ctx is where a Go
caller says otherwise.

claim_ref had no SDK at all despite being the aggregated apiserver's hook, and
neither did the listing that reads it back, so both sides gain WithClaimRef /
claim_ref and Sandboxes() / sandboxes(). Python gains attach, drain and
uncordon: the OpenAI adapter was reaching around the missing attach by
constructing a Sandbox by hand.

MCP claimed with the node's 5-minute default and renewed nothing, so an agent
session outliving it lost every sandbox mid-conversation; it now claims for an
hour, says so in the tool description, accepts net and size, and releases what
it claimed when the stdio session ends.

* docs: document the node-operation verbs, and stop restating delete semantics

cluster.md told operators to call SetPools and Drain, which docs/sdk.md never
mentioned despite claiming a per-method reference; both SDK pages now carry
them alongside the new claim_ref and sandboxes surfaces. The checkpoint-delete
lifecycle was written out in full in three places, so the API page keeps what
a caller of that endpoint needs and links cluster.md for the rest.

* feat(sdk): finish the Go/Python surface parity

exec() threw stdout away on a non-zero exit, so a failing build's log was
lost where Go returns it alongside ExitError. Pty dropped the shell's exit
code the exit frame carries, and Watcher ended iteration identically for a
clean close and a dropped relay, so a caller could not tell them apart. Go
gains Checkpoint(id), which is what Python already had and what MCP needed —
it was listing every checkpoint on the node to resolve one id, and only ever
saw the connected node's listing. The two adapters floored the base SDK at
0.1 while calling APIs that landed in 0.1.5.

* docs: record the Python exit, pty and watch contracts

ExitError now carries the stdout produced before the failure, Pty exposes the
shell's exit code, and a Watcher tells a dropped relay from a clean close.

* review: cut the restating half of the admit godoc

* fix: carry the engine axis through the SDK and metrics, and true up the docs

This PR made engine a documented pool-key axis, which exposed two places that
never had it. The SDK's PoolKey and PoolSpec omitted it, so an Info-then-
SetPools round trip — a declarative full replace — silently drained every fc
pool. The two pool gauges omitted it too, so two pools differing only by engine
emitted the same label set and the scrape failed.

The rest is truthfulness: resolveGolden's godoc still claimed only a true
absence cold-boots, which the tenant gate changed; watch.rs still called itself
the one connection-bound verb the docs page just stopped saying; the SetPools
cluster example did not compile; ClaimRef claimed to be empty on warm-pool
claims that in fact carry it; and the egress intersection change needed to say
out loud that a tenant inheriting its pool's policy now reaches nothing.

PortConn keeps CloseWithError(net.ErrClosed): it is a net.Conn, and callers
test for that sentinel — only Pty, which is not, takes the plain Close.

* fix(scripts): spell the daemon teardown as an if, not A && B || true

The new shellcheck gate failed on its first CI run: SC2015 flags the cleanup
idiom because C runs whether or not B did, which here is the intent but reads
as if-then-else. Older shellcheck releases report it and the version this
laptop has does not, so the code says what it means instead.

* test(e2e): pin claim_ref and the engine axis on the wire

The drift guard had no assertion for either, and engine is exactly the axis
whose absence from the SDK key silently drained fc pools on an Info-SetPools
round trip. Mutating the tag to json:"-" fails this test.

* fix(pool): routing must answer what a claim would about a template

HasPromotedTemplate was tenant-blind while resolveGolden is not, so the two
disagreed for a foreign tenant: routing saw a local golden, skipped the peer
hop, and the claim cold-booted with routing believing a template had served
it. Both now apply the same tenant test.

The contract, stated precisely: a plain claim of a foreign name behaves
exactly like a claim of a never-promoted name - the template axis is an
image ref, so it cold-boots the public image or fails provisioning, and the
owner's content is never touched. Answering 404 instead would be worse on
two counts: it hands every tenant an existence oracle over other tenants'
template names, and it lets one tenant deny everyone else a public image
name by promoting a private template under it. Only require_promoted (the
volume path, redirect retries) answers ErrUnknownTemplate.

tplSet caches the owning tenant next to each id, so the ownership test stays
in memory: the claim path must not grow an s3 read per warm miss.

* fix(mcp): fork children and checkpoint branches get the session lease

create_sandbox claims for an hour, but fork passed a zero TTL and a branch
claim set no timeout, so both fell back to the node's 5-minute default while
being session-tracked with nothing to renew them - the exact mid-conversation
expiry the create fix closed. Both now use the same lease and their tool
descriptions say so. Also drops deploy.md's stale CH-only tag: Firecracker
is a live opt-in engine axis, and the sentence's real content is that old
state is not converted.

* review: compress the tenant-cache comments to their WHY

* sandboxd: drop the Firecracker engine axis (#86)

The fc opt-in never carried its weight: it offered nothing CH does not
(volumes, checkpoint, fork, hibernate and the restore modes are all CH;
clone performance measured equal), it had zero e2e or hardware coverage,
and the axis never made it through the template lifecycle - DELETE built
its key without engine, so an fc-promoted template could never be deleted
by name, and no SDK could send or read the axis at all. Rather than thread
a one-value-in-practice axis through claim, promote, handles and delete,
remove it: PoolKey is (template, net, size) again, the hash drops the
engine component, the volumes-require-ch guard and the fc test rows go
with it, and the pool gauges return to three labels. cocoon keeps --fc for
anyone driving it directly.

Old pool-key hashes change; upgrades already never convert state.

* fix(mesh): tenant-scope the template gossip hashes

TemplateOwners answered from raw key hashes, so a foreign tenant's promoted
template escalated another tenant's claim: a volume claim that would have
cold-booted locally was forced onto the promoted path and refused, and a
plain claim earned a useless redirect that also signalled the name exists.
The local check is tenant-aware; the remote one had nothing to be aware
with — gossip deliberately carries no tenant.

Scope the hash instead: gossip advertises hash(keyHash|owner), and an owner
query probes the requester's own scope plus the operator's (root probes
every configured tenant). A foreign template simply never matches, which is
exactly how the claim path treats it, and the wire still carries nothing
but opaque hashes. Salting costs two sha256 on the warm-miss redirect path
only; mixed-version meshes disagree on hashes, and upgrades are lockstep.
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