Repository navigation
Writable catalog volumes with a guest filesystem shutdown lifecycle - #72
Merged
Merged
Conversation
…onfirms A live VM keeps the hypervisor's image lock until it is gone, so releasing the admission hold or clearing the dirty marker before removal confirms let a failed removal re-dirty a clean image and turned contracted 409s into attach-time failures. Quiesce now records per-mount outcomes, the payload rides pendingRemoval across retries, and readers re-check markers after reservation. Umount failures fall back to one guest-wide sync; the quiesce budget scales with the writable mount count.
A healthy writer's own write-ahead marker made a concurrent read-only claim report needs-recovery instead of busy. The post-reservation check is the single dirty authority now, and it runs before the warm pop so a refused claim never consumes a VM.
This was referenced Aug 13, 2026
CMGS
added a commit
that referenced
this pull request
Nov 4, 2026
* silkd: a client hang-up reaps an attached language server The pump only watched the server's stdout, so a silent server that ignored stdin EOF pinned its memory after the client left. A request frame during the stream ends it the same way, as a pty does. * silkd: git_branch list names an unborn branch rev-parse --abbrev-ref fails on an unborn HEAD and printed HEAD, while git_status on the same repo reports the branch. A detached HEAD still reads HEAD. * review: silkd docs: attach never ends in done, a dropped port_forward can linger * review: silkd and sandbox-init borrow what they only read claim_env keeps its one read buffer and parses into borrowed pairs, so an exec, pty or session drops 2N String allocations. BootCfg and IpParam borrow the cmdline, read_manifest returns its text, apply_config sets literals, pty and upload signatures take plain borrows, and scan_serials formats a path only when it reads it. Tests changed only where a literal's type or a temporary's lifetime named the old owned form. * review: silkd drops code that cannot run Child::id is Some right after spawn, a session body is never empty, Path::file_name is never empty, abort on a finished task is a no-op, and the exec rebinds did nothing. Table::new and Broker::new only aliased Default; git hand-rolled error_frame; the imds consts and handle, DEFAULT_INIT and PROTO_VERSION had no outside user; the ignored bench asserted nothing. * review: silkd statics and test helpers follow the file layout Function-local statics and consts move to the top-of-file block, and the one-caller proxy_vars and silkd_net wrappers go with them; test helpers move above the tests and private below public. * review: silkd comments shrink to what the code cannot carry Module narratives go to one line, the imds token fact moves to its const, a history comment goes, make_controlling_tty becomes safe because it has no memory precondition, and find's at_root stops shadowing the root param. * review: silkd tests use the shared request and assert_error helpers * sdk/python: ship the PEP 561 py.typed marker The package is fully annotated and mypy-strict, but the wheel carried no marker, so a consumer's type checker treated every cocoonsandbox import as untyped (reveal_type gave Any). Add the marker and the setuptools package-data entry; a scratch wheel now lists cocoonsandbox/py.typed and mypy on a consumer reveals cocoonsandbox.sandbox.Sandbox. * review: one-line docstrings where several lines restated the signature or sibling docs * review: drop duplicate test fixtures and a subset test; share FakeConn through conftest * review: tests before helpers in test_stream and test_client * review: adapters floor the SDK at 0.1.13, where the in-guest kill after a timeout cut first shipped * review: silkdtest's default arm calls serveStateless The arm re-spelled serveStateless byte for byte. * review: trim multi-line comments on unexported decls, capitalise own-line comments Own-line comments start uppercase per /code Comment Style; multi-line godocs on unexported names cut to one line. * review: guestserver keeps its report utility below the halfCloseSink block Layout only: /code File Organization puts utility funcs below the type's methods. * review: volumesmoke rejects non-local probes with filepath.IsLocal Same verdict as the hand-rolled path test on 15 inputs. * review: harness owns the guest proxy address, RelayConn and a package-level Request The proxy address was declared in four drivers. Dial returns RelayConn so portsmoke calls CloseWrite directly. PortRelay.Request never read its receiver. credsmoke's expectPlaceholder locals stop shadowing the entry and header consts. * docs: deploy's upgrade notes name the release each starts from At v0.1.21 "this release" read as every upgrade needing a drain, a fresh data dir and an emptied bucket. Releases come from the first tag holding each commit: #81 v0.1.6 (the pool key's engine axis went), #364 v0.1.18, #59 v0.1.5, #69 v0.1.5 and #72 v0.1.6, #171 and #169 v0.1.11. * docs: deploy and egress samples load and name a pulled image by its full ref A pulled image is keyed by its whole ref, so template base:24.04 fails the golden build with image not found, as the README already fixed in #425. The deploy sample's cluster_key and the egress sample's missing egress_ca failed config.Load. * docs: deploy states what the code requires of a failed claim, a golden and a dump A wake of a failed egress-lane or volume claim answers 409, tenants get 403 on two more root routes, golden dumps queue, an idle default acts on running claims at the next sweep, and an adopted replica always wakes on a read; none of these had a line. * docs: deploy gives the archive registry's DDL A role without CREATE could not enable archive_failover from the docs: the other three shared tables have a pre-create DDL and this one had none. * docs: deploy keeps the S3 layout and the replica bound in one place each The checkpoint_store row repeated the Reclaiming S3 storage section, which takes the facts only the row carried (lineage scope, marker order, re-stamped manifests); the TTL, replica-loss, tenant-outage and cluster-invariant passages become links to the pages that own them. * docs: cluster lists registered volumes and the TTL, and says when the digest covers secrets An operator rebuilding data_dir from config.json lost runtime-volumes.json silently, and gauge 0 read as a check of two secrets a keyless digest never covers. * docs: security names the TLS edge, the re-admitted prefix and the shared usage table Stated as hard constraints these ruled out what deploy ships: a TLS edge for SDK clients, egress_internal_allow, and meta_store.usage. * docs: benchmarks and performance keep each caveat once * docs: api states redirects, operator tokens, fork env and the checkpoint list as the server does The mint endpoint never answers the 503 and 429 its paragraph listed: the browser request does. A template another node deleted stays in tplSet, so the cluster page's never-fires claim was false for that entry. * docs: api lists the errors of a call that reaches a guest once Wake, agent, ports and exec carried the same four tails. Restore failures, 409s, release, hibernate and the preview listener differ, so they keep their own lines. * docs: sdk, silkd, langchain and android match what the code does Go Exec runs the argv given, with no implicit shell; every control-plane verb returns APIError; the checkpoint option refusals and a fork child's env were incomplete. * docs: the sdk pages link the volume and deadline rules the api page owns Go-specific and Python-specific names stay. The settled-entry sentence stays on the Go page, its only source. * sandboxd: a preview listener that cannot bind is fatal A taken preview_listen was logged and ignored: the node stayed healthy while the preview endpoint kept minting URLs for a dead port. The main listener already exits on the same failure, and systemd Restart= is the retry loop. http.ErrServerClosed stays clean. * tenants: the contract replaces the set with no tenants An empty Replace must delete every tenant on both backends: pgx sends a nil slice as NULL, so a Collect-built name list would delete nothing on pg. * review: one owner for the max fork default, the routable-host test, the s3 store test and the tenant claim mapping config.DefaultMaxForkCount replaces the second literal in pool, config.RoutableHost replaces the copy in server.New, Config.S3Checkpoints replaces three spellings of the kind test, and forTenant replaces the tenant-to-options mapping written in both claim paths. Behaviour-preserving, net zero lines. * review: store package docs fit the comment budget, constant errors use errors.New Package docs of store, s3 and peer shrink to one line (docs/deploy.md owns the key layout); the SweepStaging godoc and the redirectCacheEntry comment restate their names; three message-only fmt.Errorf become errors.New; VolumePatch is decode-only, so its slices follow the omitempty rule. * pool: a fenced node captures no replicated claim Fork, Checkpoint and Promote reached captureAllowed with no fence gate, so a node past its lease window could hand out children, a checkpoint or a template from a claim a peer may already own. ErrLeaseLost maps to 503 through the error table and is benign to the archive and replicate sweeps. * pool: consumeArchive reads the row epoch under the manager lock An unfence rewrites ArchiveEpoch holding only m.mu, and a release or replica retirement read it holding only Transition, so the two raced. The stale read cost at most a refused Consume and a settle round trip. * review: pool comments name what the code does now Two field comments named fields that moved to configView, checkTemplateOwner never deletes, HasPromotedTemplate's cache can go stale, and poolSeedHash's field list drifted against the lines below it. * review: checkpoint.go keeps const and var above its types * review: fold pool helpers with one caller archiveDeleteFor, unpooledTemplates, archiveFetchErr, dropStaleRow, hasRegularFile, deliverGuestEnv and TenantRecords each had one caller. reputArchive's held parameter was dead: its only caller guards a non-zero epoch. * review: one owner for pool facts stated twice replicaPools repeated view.poolReplicate, whose key set a reload cannot change and PUT /v1/pools cannot set. The view's map guards bought nothing, since every reader takes the zero value. env and replicable spelled out runningShaped, AgentSocket spelled out isFenced, and the two reader teardowns stripped a cancel that removeVM strips itself. addClaimLocked gains one atomic load on nodes with archives and nothing elsewhere. * review: pool sweeps and wakes carry what they have commitWake logged its persist failure at Warn and its caller rebuilt the error with the cause dropped; it now returns the error. idleOnce copied id and token so idleHibernate could look the claim up again, and hibernateLocked's setIfLive already answers ErrUnknownSandbox for a released claim. Fork and Checkpoint no longer wait on Transition for a usage field, since every write of VMName after publication holds m.mu; lockedVMName stays for tests, which read under Transition. * review: types.go splits into env.go and volume.go Pure move: the env types, limits and patterns go to env.go and the volume types, modes and mount validation to volume.go, matching the existing env_test.go and volume_test.go. envView and namedVar stay beside the Sandbox that owns them. No line changes beyond imports and gofmt alignment. * egress: an upstream dying mid-body truncates the guest's response relay dropped the copy error, so net/http wrote the terminating chunk and the guest read a partial body as complete. Hijack and close the conn on a copy error, which leaves the chunked stream unterminated. Success costs one nil check. * outbound: ArmProxy closes a displaced door before it binds The close unlinks the socket path, so a re-arm that bound first left the guest with no door while Route reported relay. A re-arm with no policy left now closes the door too, which makes the Disarm in restartLocked dead. The claim path pays one map delete under a lock it already holds. * engine: a restore gets the dump budget A dump may take 15 minutes while an eager-copy restore of the same image had 2, so a slow page cache could fail the wake it had just made possible. cocoon gives vm.restore the same limit as vm.snapshot. Clone and cold boot keep the command budget. * review: drop checks and fields the code already guarantees ReadSlice caps a line at the reader's buffer, IsGlobalUnicast already excludes link-local and multicast, UnixListener.Close already unlinks, marshalCA's public key is its PEM key's, and interDER copied interCert.Raw. * review: DialFunc's Router methods serve only tests The only production egress.New takes a claim as its router, so the two methods move to the egress test file. * review: a CA key error names the key its caller parsed parseECKey said intermediate key while parsing the root, so IssueIntermediate reported root key: intermediate key. validHostName takes the package's maxHostLen for the same DNS limit. * review: cut a restating godoc and fold the engine package doc to one line * review: layout: silkd helpers live in silkd.go, tcpAddrPort beside proxy.go's other address helpers installca.go kept the generic guest write and exec helpers every other engine file uses; installOutputMax becomes guestOutputMax since it caps every guest exec. * docs: name the capture fence, the restore budget, a preview bind failure and a cut upstream * review: VolumePatch keeps the SDK's omitzero tags, as the wire parity test requires * engine: a clone restores memory like a restore, so it gets the dump budget cocoon's vm clone and vm restore share restoreVM and its 10 minute memory transfer, and an archived wake and a fork go through clone. A hung refill clone now holds its slot for up to 18 minutes, which stays bounded and loud. * review: silkd's language server feed needs no flag or channel, and one helper sets the claim env * review: refill.go keeps the refill loop; golden builds go to golden.go, VM provisioning to provision.go, destroyAll and dropSnap beside destroy, runBounded to pool.go with its callers across the package * review: the reaper leaves claim.go for reap.go, since expiry handling is not claim admission * review: claim and authed resolve a credential, so they sit in operator.go with resolve and byID * review: the pool type joins its watermark methods in watermark.go, so its type block is atomic * review: rewriteArchiveRow writes an archive row, so it sits in failover.go beside refusedReplace * review: NewManager delegates template and volume seeding to loadTemplates in template.go and seedVolumes in volume_catalog.go, keeping the order * review: the catalog owns its entries and holders, so catalogVolume, catalogSnapshot and the holder bookkeeping move to volume_catalog.go as volumeCatalog methods * review: the seven single-flight sweep guards group into one sweeps field on Manager * review: recordLocks owns the per-record lock maps and their mutex, and recLock's doc says it returns the lock without taking it * review: healVetoes owns the heal veto maps and their mutex, replacing the three Manager methods and fields * review: ckMarkers owns the archive delete markers, and the delete and retry chain moves with it to archive_delete.go * pool: an uncordon during an evacuation's drain stops it Evacuate waits for the warm VMs to go before it starts its run. A DELETE /v1/drain in that wait found nothing to stop, and the run then moved every claim off an uncordoned node. * pool: a tier claim woken past its lease before the export gets a fresh lease The reaper archives a hibernated claim of a tier pool at its lease end, and the same call a moment after the export restores it with a fresh lease. A call in between woke it with the past deadline, so the next reap tick destroyed it. * pool: a running claim that moved to a peer closes its egress doors forgetAdopted removes the VM with no sandbox id, so the removal never disarms. The doors, their listeners and their goroutines stayed until the next restart, one set per moved claim, unlogged. * docs: a moved claim's token, its first wake, and the 503s an evacuation and the tenant store answer * sdk: Close follows a sandbox that moved off a retired node Close looked the sandbox up only on a 404 from its old owner. Once that node is retired, the first release fails to connect, so the claim stayed on the adopter until its deadline, and every retry hit the same dead address. A connection failure now looks it up too, and keeps its error when no node answers.
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.
Implements #70: write-enabled catalog volumes with the guest filesystem
shutdown lifecycle. Closes #70.
What ships
mode(ro|rw, default ro):rwrequires operatorwritable: trueon the catalog entry. Read-only requests and responses staybyte-identical to v1 (
modeomitted,"ro"normalized away everywhere).writer refuses every other claim on the name, live readers refuse a writer —
both as fast 409s before any attach cost. Rebuilt from persisted claims at
restart.
<path>.dirtybeside the image): durablycreated before the first rw attach, cleared only after a clean unmount AND
confirmed VM removal. Dirty + ro claim → 409 needs-recovery; dirty + rw
claim → allowed (journal replay is the recovery). Stat failures read as
dirty (fail closed).
rollback, quarantine): reverse-order umount of rw mounts, one guest-wide
sync fallback when an umount fails, budget scaling with the mount count
(capped 10s). Marker clearing and hold release ride
pendingRemovalacrossremoval retries and finish only when the VM is confirmed gone — a surviving
VM still holds the hypervisor lock, so the name stays truthfully busy.
reader never proceeds onto an image a failed writer dirtied in the gap.
Volume.Mode+VolumeInfo.Writable; Python{name, mount?, mode?}mapping +writablepassthrough. Both emitmodeonly for rw. Checkpoint claims keep rejecting volumes.
fleet-uniform metadata; only the image file lives on one node. Routing
reuses
VolumeOwnersunchanged.refused for any volume claim; idle sweep still excludes them.
HTTP, and a writable leg in
volumesmoke+scripts/sandboxd-e2e.sh(
VOLUME_RW_IMAGE=opt-in).Review rounds applied
Two adversarial correctness lenses (concurrency/lifecycle, contract/wire) and
three simplify lenses (reuse, altitude, efficiency) ran against the branch;
all confirmed findings are fixed in
ed6dca5/b2098b2. The main structuralfix: teardown originally cleared markers and released holds before VM removal
confirmed — a failed removal could re-dirty a clean image and turn contracted
409s into attach-time failures. Ordering pins are mutation-tested (moving the
teardown ahead of removal fails both pins).
Deliberate deviations from the issue text
UnmountVolume/SyncGuestprimitives; reverse order,budget, and marker bookkeeping live in pool's
quiesceVolumes(the issuenamed an engine-level
QuiesceVolumes— the split keeps the enginesingle-purpose).
confirmVolumesClean), not aresolve-time pre-filter: admission answers a live writer with 409 busy,
and needs-recovery is reserved for a genuinely crashed writer's marker.
The first hardware round surfaced the earlier asymmetry (a healthy
writer's own marker misreported needs-recovery); fixed in
b061f35,which also moves the check ahead of the warm pop so a refused claim
never consumes a VM.
Accepted residuals (do not fix)
pendingRemovalteardown payload: markersstay until an rw cycle clears them, holds die with the process. Conservative
direction, converges.
removal is still queued: an instant re-claim can hit the hypervisor lock as
an attach error instead of a 409. Transient, retry-convergent.
teardown (mutating the catalog under live claims is documented operator
error).
Hot-path cost
Volume-less and ro claim/release paths: zero new allocations, iterations, or
syscalls (escape-analysis verified; the only addition under
m.muis rangingan empty slice). rw-only costs: one stat per ro entry at admission, N umount
execs + optional sync at release.
Gates
go build+go test -race -count=1: sandboxd (13 packages), sdk/go,mcp, protocol/wire, e2e (GOWORK=off) — all ok
gofmt/gofumpt: cleansandboxd-e2e.sh: 0 issuesHardware verification (bare metal, Cloud Hypervisor v54, cocoon master-07f1d69)
Full round on the testbed at
b2098b2vs main218eee1, everything inside aprivileged container with an isolated cocoon root:
VOLUME RW PASS(rw claim92.9ms), read-only image sha256 unchanged from creation through every leg,
writable image sha256 advanced, no leftover
.dirtymarkers.samples tier-proven warm; pooled p50/p90 main 0.488/0.527ms vs branch
0.435/0.545ms; |A−B| mean 0.006ms against an A/A same-binary drift of
0.029ms. Release path also inside the A/A envelope.
kill -9the writer's VMM →marker persists → release of the dead claim returns in 12ms with exactly the
two tolerated quiesce errors (no panic, no hang) → next ro claim answers
409 needs-recovery → rw claim mounts with journal replay (106ms), pre-crash
stamp read back byte-identical → clean release clears the marker → ro
claims serve again.
(write-ahead marker fsync + rw attach); rw release equals a volume-less
release when the guest wrote nothing (42.7 vs 42.2ms p50); a 16MiB-dirty
guest adds ~8ms of quiesce flush. The ~40ms volume attach+mount base is
pre-existing (sandboxd: add read-only catalog volumes #69), not added by this PR. The ro-volume control arm
reproduced sandboxd: add read-only catalog volumes #69's settled ~38ms claim figure.