Give Firecracker forks their own mem-file instead of deferring the copy#298
Conversation
…the copy Standby forks previously skipped the mem-file copy and stored a path to the source's memory file, serving UFFD faults from it and copying it only at the fork's next standby. That left forks silently dependent on the source snapshot or instance: deleting or stopping the source stranded standby forks, and the source's next diff snapshot mutated the shared file in place under running forks. Fork copies now always include the mem-file via the existing reflink-first directory copy, so a fork owns its memory from creation and the source can be deleted immediately. UFFD one-shot restore is unchanged except that the pager serves from the fork-local file; the shared page cache still works because forks inherit the source's cache key and the cloned files are byte-identical. Removes the deferred-path machinery: FirecrackerDeferredSnapshotMemoryPath, materialize-on-standby, base/latest alternate path resolution, snapshot source locks, the repoint step after running-source forks, and SnapshotOptions.
Benchmarks showed per-fork reflinked mem-files regress concurrent fanout: reflink shares disk extents but not the kernel page cache, so each fork's pager misses re-read the same pages from cold disk instead of hitting the cache warmed by sibling forks. Standby forks now hardlink the source's snapshot mem-file: fanout does no memory I/O and all forks of a snapshot fault against one inode, restoring the shared read path. Independence is preserved by inode refcount (deleting the source only unlinks a name) plus one invariant: the standby path never diff-writes into a mem-file with nlink > 1 — it replaces it with a private reflink/sparse copy first. That guard also covers a source instance re-entering standby while forks still share its retained base, and moves the one-time copy cost to each fork's first standby, off the fanout burst. Falls back from hardlink to copy with a warning when linking fails.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a43a9ba. Configure here.
hiroTamada
left a comment
There was a problem hiding this comment.
approved — reviewed the whole diff end to end (23 files). solid change: it replaces the fragile deferred-mem-file scheme (a fork stored a path into the source's file and copied later) with hardlink-at-fork + copy-on-write-at-standby, keyed on the inode link count. i verified the guard sits before the in-place diff-write, that the per-instance RWMutex serializes fork-hardlink (read lock) against source-standby (write lock) — so the removed per-source snapshotSourceLocks is redundant, not a lost guard — that every diff-write path routes through the guard, and that the symbol removal is complete (no dangling refs). net simplification (~‑98 lines).
no correctness bugs found. a couple of things worth confirming plus some nits, none blocking.
questions
lib/instances/fork.go:260/lib/instances/snapshot.go:420—shareMemFileis broader than the oldshouldDeferFirecrackerSnapshotMemoryCopyit replaces: it drops both the UFFD-configured gate and thetargetState != Stoppedgate. so non-UFFD standby-source forks and stopped-target forks now hardlink instead of full-copy. looks intentional and safe (hardlink + guard covers them), just want to confirm it's deliberate. related:TestForkInstanceFromStandbyDoesNotArmOneShotUFFDForStoppedTargetonly assertsFileExists, so the new stopped-target sharing isn't pinned by a test either way.lib/instances/firecracker_memfile.go:28— cross-filesystem forks failos.Linkwith EXDEV and silently fall back to a full copy, losing the whole zero-copy / shared-page-cache win; the only signal is a per-fork warning log. are fork and source guest dirs guaranteed on the same filesystem? if a data-dir override can split them, worth a metric (or an explicit assertion) so the regression is detectable instead of silent.
nits
lib/instances/firecracker_memfile.go:44—ensureExclusiveSnapshotMemoryOwnershipis only safe because the caller (standby) holds the source's per-instance write lock; thestat → copy → renameisn't internally synchronized. worth a line in the doc comment so a future caller doesn't reintroduce the TOCTOU.lib/instances/firecracker_memfile.go:59— a hard crash between the copy and the rename leaves a full-mem-file-sizedmemory.unshare.tmpinsnapshot-latest; the fork copy-walk (which only skipssnapshots/snapshot-latest/memory) would then propagate it into new forks. consider skipping*.unshare.tmpin the walk, or sweeping stale ones on standby entry.lib/instances/snapshot_alias_lock.go:136— theos.Stat(srcMem)check disables sharing on any error, not justIsNotExist, so a real I/O or permission error is treated the same as "compressed source, no raw file." consider gating onos.IsNotExist(err)and returning other errors.lib/instances/snapshot_alias_lock.go:50,73,94,115— trace attribute renameddeferred_snapshot_memory→share_mem_file; heads-up for any fork-latency dashboards/alerts keyed on the old name.
test coverage / rollout
- the two integration tests that actually exercise the fix are gated:
TestFirecrackerForkIsolationself-skips without/dev/kvm+/dev/userfaultfd(so a greentestjob doesn't prove it ran), andTestFCUFFDOneShotLifecycle— which carries the new "DeleteSnapshot while a fork is running" assertion — is unconditionally skipped (KERNEL-1354). worth confirming the fork-isolation test runs on a KVM-capable runner (and ideally the 25-fork bench) before rollout, given this is a silent-corruption-class change. - rollout: drain/restore any standby forks created by older builds first — they carry a deferred path and no local mem-file, so they won't restore after this ships (already called out in the description).
- Count hardlink-to-copy fallbacks in a metric (labeled by errno) alongside the existing warning, so a host silently losing zero-copy fanout is visible in dashboards, not just per-fork logs. - Propagate non-IsNotExist stat errors on the source mem-file instead of silently disabling sharing. - Sweep stale memory.unshare.tmp on standby entry and skip *.unshare.tmp in the fork copy walk, so a crash between the unshare copy and rename cannot leak a mem-file-sized artifact into future forks. - Document that ensureExclusiveSnapshotMemoryOwnership relies on the caller's instance write lock for the stat/copy/rename sequence.

Summary
Firecracker standby forks (fork-from-standby-instance and fork-from-standby-snapshot) previously skipped copying the mem-file and stored a path to the source's memory file (
FirecrackerDeferredSnapshotMemoryPath), serving UFFD faults from it while running and copying it only at the fork's next standby. Until that first standby completed, every fork silently depended on a file it didn't own:DeleteSnapshot/DeleteInstance/StopInstanceon the source made standby-parked forks permanently unrestorable and broke running forks' next standby. Nothing tracked or guarded these dependencies.This PR removes the deferral. Standby forks now hardlink the source's snapshot mem-file into the fork's guest dir (fallback to reflink/sparse copy with a warning if linking fails), and the standby path enforces one invariant: never diff-write into a mem-file with
nlink > 1— it replaces a shared mem-file with a private reflink/sparse copy before Firecracker writes the diff.Why hardlink (not a per-fork copy or reflink)
Benchmarked on a 25-fork burst: per-fork reflinked copies regressed fork p50 ~4x. Reflink shares disk extents but the kernel page cache is per-inode, so each fork's pager cache misses re-read the same pages from cold encrypted disk instead of hitting pages warmed by sibling forks (5.7s aggregate backing reads vs ~0s). Hardlinks keep all forks on one inode:
FirecrackerSnapshotCacheKey) plus kernel page cache are both shared across forks.DeleteSnapshotis safe the moment the fork API returns.Firecracker mmaps the mem-file MAP_PRIVATE, so guest writes never reach the file; the standby diff merge is the only file writer and is covered by the guard.
Removed (now dead)
FirecrackerDeferredSnapshotMemoryPathmetadata field and all wiring in fork/snapshot-fork pathsmaterializeDeferredSnapshotMemory+ base/latest alternate-path resolution (both copies)repointForkDeferredSnapshotMemoryToSourceBaseafter running-source forkslockFirecrackerSnapshotSource/snapshotSourceLockshypervisor.SnapshotOptions(existed only to carry the deferred path;Snapshot()drops the param across all hypervisors)Behavior changes / caveats
lib/instances/firecracker_uffd.gochanges).Tests
ensureExclusiveSnapshotMemoryOwnershipunshares hardlinked mem-files (source bytes untouched) and no-ops on private/missing ones. Passing locally withlib/forkvm,lib/hypervisor/...,lib/guestmemory.TestFCUFFDOneShotLifecycle(currently skip-gated, KERNEL-1354) additionally assertsDeleteSnapshotsucceeds while a fork is running from it. KVM/UFFD integration not validated locally (sandbox has no KVM images/reflink fs) — needs the CI run.TestForkCloudHypervisorFromRunningNetwork/TestStandbyAndRestorefail in my sandbox on image-pull timeouts — both reproduce identically without these changes (environmental).🤖 Generated with Claude Code
Note
High Risk
Changes core Firecracker fork/standby snapshot memory lifecycle and removes deferred-memory metadata (older standby forks may not restore after upgrade); correctness depends on unshare running under the instance write lock before any diff write.
Overview
Replaces deferred snapshot memory (forks pointing at the source mem-file until standby) with hardlinked mem-files on Firecracker standby fork fanout, plus a guard so diff snapshots never write into a shared inode.
Fork path: Standby instance/snapshot forks skip copying
snapshots/snapshot-latest/memory, hardlink it from the source (reflink/sparse copy + metric on link failure), and dropFirecrackerDeferredSnapshotMemoryPath, snapshot-source locks, andrepointForkDeferredSnapshotMemoryToSourceBase. UFFD one-shot restore now faults against the fork’s local hardlinked file and inherited cache key.Standby path: Before creating a diff snapshot when reusing a retained base,
ensureExclusiveSnapshotMemoryOwnershipcopies the mem-file to a private inode whennlink > 1(viamemory.unshare.tmp), so source or sibling forks are not mutated in place. Guest directory copy skips*.unshare.tmpfiles.API cleanup:
hypervisor.SnapshotOptionsand FirecrackermaterializeDeferredSnapshotMemoryare removed;Snapshot(ctx, destPath)is the same across hypervisors. UFFD pager version bumps to 0.1.6.Docs/tests:
forkvmREADME documents hardlink + unshare semantics; integration/unit tests assert inode sharing at fork, unshare at standby, and safe source deletion while forks run.Reviewed by Cursor Bugbot for commit 82f9a06. Bugbot is set up for automated code reviews on this repo. Configure here.