Repository navigation
Read-only file access to hibernated or archived sandboxes without waking them - #365
Merged
Merged
Conversation
CMGS
force-pushed
the
feat/351-reader-vm
branch
from
October 18, 2026 07:21
070d18b to
9b2bc22
Compare
CMGS
force-pushed
the
feat/363-snapshot-replication
branch
from
October 18, 2026 07:52
3a60473 to
d882187
Compare
CMGS
force-pushed
the
feat/351-reader-vm
branch
2 times, most recently
from
October 18, 2026 08:37
4877f44 to
f365b0b
Compare
A reader of an archived claim needs its disk and sidecar, not the guest memory image Fetch pulls with them.
… by a chrooted silkd
A reader is a warm VM that gets the claim's layer blobs read-only and a private reflinked copy of its disk, mounts them as a read-only overlay and runs a silkd chrooted into it. Setup is shared per claim, the reader is kept for 30s after its last connection, and a hibernate syncs the guest first so the snapshot disk holds the page cache.
A connection to a hibernated or archived claim gets its 101 first, so the opening frame can be read before the wake is chosen. Read-only calls (fs_stat, fs_list, fs_read, fs_pull) go to the claim's reader; an info frame pipelined ahead of one rides along. The first other call, or a claim that woke or slept again, hands the same connection to the woken guest with the unread bytes replayed, so a parked SDK connection never sees a close.
…stration replaces A reader registered with no idle timer leaked when no connection ever took it (a read racing an exec that wakes the claim), and a second setup overwrote the first, orphaning its VM and disk. Registration now arms the idle timer, retires any reader it replaces, and a setup skips building when a live reader already serves the sleep.
…stead of waking it A connection whose claim was archived or re-hibernated meanwhile handed over and woke the claim, which defeats reading it asleep. It now closes the stale reader and dials one for the new sleep, and hands over only when none can be built.
The detached spawn reports started even when the binary is missing, so an image without silkd (android) cost a 10s probe timeout before falling back to a wake. A test -x in the mounted view fails the setup at once.
…bernate sync, order types in the relay router One dropReader(ctx, r, force) replaces reap/retire/unregister and detaches from the caller's cancellation; popWarm serves the claim and reader paths; the sync is inlined into hibernateLocked; agentSocket stamps activity once after the wake; io.NopCloser replaces noCloser; the router type block leads the file and two comments go.
A write the guest makes after the sync returns stays in memory and misses the disk copy, and a failed sync was only logged. Hibernate now records ReaderSafe (persisted with the claim, cleared on wake and on adopting a pending hibernate, absent on sleeps from before the feature), and reads of an unsafe sleep wake the claim as without the feature. The docs state that a reader shows the disk as of the sync right before the pause.
…eaves nothing in the shared cache A FetchFile racing a release could recreate cache/<id> in the s3 staging and leave a file nothing swept until restart. FetchFile now takes a destination: the reader fetches into its own directory, which idle release, retire and boot reconcile already delete. The dir backend clones the record's file there.
… and log why a read woke the claim cocoon refuses to attach any path under its own root, run and log directories, and layer blobs always live in <root>/oci/blobs, so no reader was ever built and every read silently woke the claim. The blobs are now hard-linked into the reader dir (a CloneFile copy where the link fails) and the links are attached. A link is safe: the blob is immutable, the attach is read-only, and removing the reader dir only drops the link. The relay logs a warning, with the claim id and the error, when it falls back to a wake. The fake engines refuse a path under a configured cocoon root, as cocoon does, so the tests fail if an attach regresses to a blob path.
Layout only: the exported Manager methods lead the file and the readerSet and reader types follow the last method, as the type-block layout rules require; the fake engine's unexported helper moves below its exported methods.
Keeps the contract under the cyclomatic limit; behavior unchanged.
The reader VM is now a warm VM of the claim's own pool, so it boots the claim's image and layers. A hibernated claim's disk comes from --from-snapshot, an archived claim's from --from-dir over snapshot.json and the files its disk_attach_files lists. The guest mounts the attached disk by the returned serial and the layers from its own cocoon.layers= cmdline. sandboxd no longer reads cocoon file names, the sidecar schema or layer blob paths. reader becomes a per-pool, reloadable, config-owned flag; the single reader-pool setting is removed.
…ader keeps the claim's NIC
A replicated claim's reader dial skips the fence the wake path applies, and a reader connection or a handed-over relay of a sleeping claim stays open after the lease turns overdue. DialReader now gates through fenceWatch and closes its connection on a trip, and the router's openGuest closes the guest the same way wakeGuest does.
The flag is set before the engine call, so a failed hibernate left it on a running claim, and a replica row written from that claim carried it into an adoption that would then serve the replica from a reader.
…er reads of replicated claims
A replica checkpoint captures a running claim and is never synced for a reader, but its row copied the claim's flag, which a replication round racing a hibernate or a restart that kept the flag over a restored wake leaves true. Adoption would then serve the unsynced disk from a reader. putRow clears the flag on the row's copy for replica rows; archive rows keep it. This replaces clearing the flag after a failed hibernate, whose only harm was reaching a replica row.
closeReader marks the old pump superseded so it leaves the client open; the flag stayed set for the next reader, so a fresh reader that a fence or a crash ended never closed the client and an in-flight read hung until the client gave up.
CMGS
force-pushed
the
feat/351-reader-vm
branch
from
October 18, 2026 09:00
f365b0b to
6a5daca
Compare
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.
Read-only file access to hibernated or archived sandboxes without waking them
Closes #351
Needs cocoon v0.7.3 or newer (
vm disk attach --from-snapshot/--from-dir, cocoonstack/cocoon#285). Builds on #364 and #366 (both merged).What changes
A per-pool flag opts in:
{"pools": [{"template": "rt:24.04", "warm": 4, "reader": true, "idle_hibernate_seconds": 600}]}Absent, nothing changes.
readerneedswarm >= 1, is config-owned (PUT /v1/poolsrejects it), and is reloadable; turning it off leaves open reader connections to finish and the next connection wakes.With it on, a
/v1/sandboxes/{id}/agentconnection to a hibernated or archived claim of that pool is answered with the101first, and the opening frame is read before any wake is chosen:fs_stat,fs_list,fs_read,fs_pullgo to the claim's reader VM. The claim stays hibernated or archived, no wake event is recorded, no idle clock is stamped.infoframe pipelined ahead of a read (what the SDKs send on a first dial) rides along. A loneinfowaits 100 ms and then wakes.disk_attach_files, an image without silkd at/usr/local/bin/silkd, a sleep that is not reader-safe): wakes as today, with a warning naming the claim and the reason.WakeAgentSocketOrReaddiffers fromWakeAgentSocketonly on the non-running path.lease lost; retry), and a fence trip closes an open reader connection and a handed-over relay, as it closes any relay.Why hand over instead of closing
Closing a parked connection when a write frame arrives would fail that call: the SDK takes a parked connection after its liveness peek, sends the frame, and a close after that surfaces as an error on the in-flight RPC. Replaying the frame into the woken guest on the same connection costs nothing and the client never sees a close.
Reader
sandboxd talks to cocoon only through its CLI contract: it never reads cocoon file names, the sidecar, or blob paths. One reader per sleeping claim, never shared across claims or tenants:
cocoon vm disk attach READER --from-snapshot SNAP, under the claim'sTransitionlock so a racing wake waits for cocoon's copy (a reflink: 58 ms on hardware); cocoon also holds the snapshot's lease. Archived: onlysnapshot.jsonand the files itsdisk_attach_fileslists are fetched into<data_dir>/readers/<id>-<rand>(store.FetchFile, never the shared fetch cache; on S3 only the chunks of those files are downloaded, holes stay holes; names with a separator or..are refused), then--from-dir, and the fetched files are deleted once cocoon has its copy. An archive exported by an older cocoon (nodisk_attach_files) wakes before any VM is taken. cocoon attaches the copy read-write and reports its guest serial, so the guest replays the ext4 journal onto the copy, never the original./proc/cmdlinecocoon.layers=, the boot/init contract), mount a read-only overlay (ro,metacopy=on,redirect_dir=follow,lowerdir=<upper>:<layers>) at/view, and start the claim's own silkd asenv SILKD_PORT=2051 chroot /view /usr/local/bin/silkd. The chroot confines paths and symlinks to the view without a protocol change; the read-only overlay makes writes fail at the filesystem.A reader has its pool's lane: no NIC on the none lane, the claim's bridge NIC on the bridge lane (egress-lane claims never hibernate). It is the pool's size, so a medium pool's reader costs a medium VM. Leave
readeroff on a Firecracker node or with an older cocoon: sandboxd does not know the hypervisor, so there each read would spend a warm VM on a failing attach before waking.Freshness
With the flag on, a guest
syncruns right before the hibernate pause of that pool's claims. A reader shows the disk as of thatsync: a write in the instant between thesyncand the pause is visible only after a wake. A hibernate whosesyncfails or times out is marked not reader-safe (reader_safe, persisted, cleared on wake), as is any sleep from before the pool hadreader, and its reads wake the claim. A replica row (#366) never carriesreader_safe: a replica is a capture of a running claim, so after an adoption a read of it wakes the claim instead of serving a disk that misses dirty guest pages.Also in this PR
wire.RequestOp: op tag of a request line.store.Store.FetchFile(dir and s3, today's layouts) andutils.CloneFile(FICLONE with a hole-skipping copy fallback, used by the dir backend).engine.DiskAttachSnapshot/DiskAttachDir(parse the attach JSONname),StartReader/DialReader;dialVsock/probetake a port.deploy.md(pool flag and a section on the reader),sandboxd-api.md(/agentbehavior),sdk.md,silkd.md, README.Size of this layer (against #366): prod Go +1111/−102, tests +2044/−14.
Verification
Codex rounds 1–6 on one thread, CLOSED at the head. Two rounds after the rebase found that a replica row could carry
reader_safeinto an adoption (fixed inputRow) and that the reader path skipped the lease fence (fixed). A later review found that a connection moved to a fresh reader never closed its client when that reader ended, because the superseded flag stayed set; fixed with a regression test.make go-lint: 10 ×0 issues.(5 modules × linux/darwin), fmt clean;go testfor config, engine, pool, server, store green;aslclean.git diff origin/mainhas no reference to cocoon's file names, sidecar schema or blob paths.Unit tests cover interception, reader lifecycle (attach then mount order, singleflight, idle release, stale sleep, setup failure cleanup, the
Transitionlock held only around the copy), the archived path (onlysnapshot.jsonplus listed files fetched, no VM taken when the list is absent, unsafe names refused),FetchFilefor dir and s3, and an in-process e2e through the real relay and SDK.Hardware acceptance
On the full stack (with #364 and #366)
Two bare-metal nodes (one cell), PostgreSQL registry, SeaweedFS, cocoon v0.7.3, pools with
reader: true(one of them also replicating)./etc/shadowreturned the claim's own file. Usage events: claim and hibernate only; the claim stayed hibernated.cow.raw18/18,cocoon.json1/1,snapshot.json1/1, memory image 0/30. The reader dir was emptied right after the attach.lease lost; retry (http 503), and no reader was opened.reader_safefalse both before and after the adoption. A read on B woke the claim from A's replica (unarchive, 599 ms, data intact), and B opened no reader.On main with cocoon master 2375369 (before stacking)
Bare-metal node, cocoon master 2375369 (
v0.7.2-2-g2375369),rt:24.04, pools small and medium withwarm: 2, reader: true, head 310761d (later commits are docs only).snap-*disk; 30.3 s after the last close the reader VM and its run dir were gone, no copy left, pool back to 2/2./etc/shadowreturned the claim's shadow (afteruseradd), not the image's. A write on the same handle after a reader-served stat woke the claim and succeeded (141 ms); the SDK saw no error.disk_attach_files: ["cocoon.json","cow.raw"]; read served in 264 ms with no unarchive.straceof sandboxd shows store opens limited to the record meta,snapshot.jsonand the two listed files; the memory image was never opened, and the reader dir was emptied 10 ms after the attach.archive envelope lists no disk_attach_files; no warm VM was taken.warm: 0→ the read woke the claim; no cold boot (tier="cold"0), VM set unchanged.Setup breakdown (by hand on a medium VM): attach
--from-snapshot58 ms, ext4 mount with journal replay 42 ms, erofs 3.6 ms, overlay 1.7 ms, the rest cmdline read, silkd spawn and probe.As before, the cost against a plain wake is about even: a wake restores memory lazily. What the feature provides is reading a sleeping claim without running it: its processes do not resume, so no CPU time, no egress side effects, no idle-clock reset, and the claim stays asleep.