fix(controller): run Registry::reconcile under a single lock - #312
Conversation
reconcile() took K+4 separate registry-mutex acquisitions for a pass that prunes K stale sandboxes: one lock per removed id, plus one each for the stale scan, the live-id snapshot, and the workspace walk. Two handler calls interleaved between the scan and the per-id removal could make the reported prune count diverge from what was actually removed. Collect the stale ids, remove them, and walk the workspaces for the Running -> Stale transition all under one `self.inner.lock()` acquisition. `pid_alive` is a `/proc` stat and takes no lock we care about, so holding the registry mutex across it is fine. flush() behavior is unchanged: it still runs once, after the lock is dropped, only when something changed. Adds coverage for reconcile()'s prune-count and workspace-transition behavior (previously untested): a dead sandbox is pruned and its Running workspace flips to Stale with live_sandbox_id cleared; a sandbox with no recorded pid is left alone and its workspace stays Running; a Suspended workspace is never touched; and a no-op pass does not rewrite state.json. Fixes deeplethe#310 Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
WaylandYang
left a comment
There was a problem hiding this comment.
The single-lock refactor matches the accepted shape from #310: stale discovery/removal, the live-id snapshot, and Running→Stale workspace transitions now occur under one registry guard, while persistence remains outside the lock and conditional on an actual change. The focused tests cover prune accounting, workspace transitions, suspended/no-PID preservation, and reload persistence. DCO and all required CI checks are green.
Approved. Non-blocking: the byte-equality no-op test confirms stable output but cannot by itself prove no rewrite occurred; the explicit if pruned > 0 || stale_ws_changed control flow is clear enough here.
|
Thanks for the quick review and merge. Glad the single-lock shape matched what you had in mind for #310. |
|
Thanks for the contribution, Amir — welcome aboard! |
Addresses review deeplethe#299 (both blockers) and rebases onto dev with deeplethe#312's single-lock reconcile preserved. Blocker 1 — cross-reboot false Match (PID+starttick collision): proc_starttime is ticks since boot, only unique within one host boot. The registry persists across reboots, so after a reboot an unrelated Firecracker can share both the numeric PID and the boot-relative start tick of a recorded entry, and a bare starttime check would SIGKILL the wrong process. Persist /proc/sys/kernel/random/boot_id at registration (SandboxInfo.boot_id) and gate the identity check on it: - different boot id -> PidReuse (prune WITHOUT signaling; the old process cannot still exist across a reboot) - missing recorded boot id (legacy state.json) -> Unknown (fail closed) - live boot id unreadable -> Unknown (fail closed) New read_boot_id() helper; recorded at all three production VM registration sites in http.rs. Blocker 2 — pid: None entries recreated the deeplethe#298 collision risk: Such rows were skipped by both reconcile and kill_orphans, so startup succeeded with an empty allocator/shared-tap ownership while a live VM may still hold those resources. kill_orphans now counts retained rows without a PID into KillOrphansResult.unresolved, and check_orphan_kill_result aborts startup on unresolved > 0 (fail closed). Rebase: preserved deeplethe#312's single-lock reconcile (whole in-memory pass under one registry lock, including inline workspace-stale marking); kept mark_stale_workspaces only for kill_orphans' use after pruning. Tests: - kill_orphans_does_not_kill_across_boot_id_mismatch: matching PID + real start time but a DIFFERENT recorded boot id -> pruned without signaling (killed==0, pruned_stale==1). - kill_orphans_counts_pid_none_entries_as_unresolved (replaces kill_orphans_skips_pid_none_entries): pid:None rows surface as unresolved and block startup via check_orphan_kill_result. - check_orphan_kill_result_aborts_on_unresolved: startup-decision regression. - Existing kill-path tests set boot_id to the current boot id where they exercise the Match/PidReuse path; the legacy fail-closed test keeps boot_id: None. Signed-off-by: jrimmer <jason@rimmer.net>
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review blocks on the staging/transport flow while keeping cache-versioning and failure-rollback. Blocker 1 — FC-visible rootfs path unstable across publish: Firecracker serializes the drive path_on_host INTO the binary vmstate and REOPENS it on restore (no PUT /drives override is accepted before /snapshot/load). Previously snapshot_cmd cloned rootfs to staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot renamed the whole dir to snap_dir — leaving the vmstate's recorded path nonexistent, so the first restore after publish failed. Fix: clone+boot from the STABLE final path snap_dir/rootfs.ext4 from the start; only vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by publish_snapshot_metadata, which renames the 3 metadata files into snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs. Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir, preserves-existing-on-missing-metadata. Blocker 2 — pack/unpack sidecar never placable: pack listed rootfs.ext4 in manifest.files but omitted it from the tar body; unpack verified EVERY declared file before satisfy_rootfs, hashing the missing extracted rootfs and failing. Fix: when a portable sidecar is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs. Blocker 3 — src==dst guard compared staging path, not final: extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose baseline is its own rootfs.ext4 is rejected. Test: same-tag regression. Blocker 4 — path traversal via RootfsRef.target_path: satisfy_rootfs now REQUIRES a safe single-component relative filename (no absolute / no separators / no ..), rejecting malicious ../../../ or legacy absolute paths (fail-closed; writes would otherwise land outside snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths. Signed-off-by: jrimmer <jason@rimmer.net>
Addresses review deeplethe#299 (both blockers) and rebases onto dev with deeplethe#312's single-lock reconcile preserved. Blocker 1 — cross-reboot false Match (PID+starttick collision): proc_starttime is ticks since boot, only unique within one host boot. The registry persists across reboots, so after a reboot an unrelated Firecracker can share both the numeric PID and the boot-relative start tick of a recorded entry, and a bare starttime check would SIGKILL the wrong process. Persist /proc/sys/kernel/random/boot_id at registration (SandboxInfo.boot_id) and gate the identity check on it: - different boot id -> PidReuse (prune WITHOUT signaling; the old process cannot still exist across a reboot) - missing recorded boot id (legacy state.json) -> Unknown (fail closed) - live boot id unreadable -> Unknown (fail closed) New read_boot_id() helper; recorded at all three production VM registration sites in http.rs. Blocker 2 — pid: None entries recreated the deeplethe#298 collision risk: Such rows were skipped by both reconcile and kill_orphans, so startup succeeded with an empty allocator/shared-tap ownership while a live VM may still hold those resources. kill_orphans now counts retained rows without a PID into KillOrphansResult.unresolved, and check_orphan_kill_result aborts startup on unresolved > 0 (fail closed). Rebase: preserved deeplethe#312's single-lock reconcile (whole in-memory pass under one registry lock, including inline workspace-stale marking); kept mark_stale_workspaces only for kill_orphans' use after pruning. Tests: - kill_orphans_does_not_kill_across_boot_id_mismatch: matching PID + real start time but a DIFFERENT recorded boot id -> pruned without signaling (killed==0, pruned_stale==1). - kill_orphans_counts_pid_none_entries_as_unresolved (replaces kill_orphans_skips_pid_none_entries): pid:None rows surface as unresolved and block startup via check_orphan_kill_result. - check_orphan_kill_result_aborts_on_unresolved: startup-decision regression. - Existing kill-path tests set boot_id to the current boot id where they exercise the Match/PidReuse path; the legacy fail-closed test keeps boot_id: None. Signed-off-by: jrimmer <jason@rimmer.net>
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review blocks on the staging/transport flow while keeping cache-versioning and failure-rollback. Blocker 1 — FC-visible rootfs path unstable across publish: Firecracker serializes the drive path_on_host INTO the binary vmstate and REOPENS it on restore (no PUT /drives override is accepted before /snapshot/load). Previously snapshot_cmd cloned rootfs to staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot renamed the whole dir to snap_dir — leaving the vmstate's recorded path nonexistent, so the first restore after publish failed. Fix: clone+boot from the STABLE final path snap_dir/rootfs.ext4 from the start; only vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by publish_snapshot_metadata, which renames the 3 metadata files into snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs. Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir, preserves-existing-on-missing-metadata. Blocker 2 — pack/unpack sidecar never placable: pack listed rootfs.ext4 in manifest.files but omitted it from the tar body; unpack verified EVERY declared file before satisfy_rootfs, hashing the missing extracted rootfs and failing. Fix: when a portable sidecar is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs. Blocker 3 — src==dst guard compared staging path, not final: extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose baseline is its own rootfs.ext4 is rejected. Test: same-tag regression. Blocker 4 — path traversal via RootfsRef.target_path: satisfy_rootfs now REQUIRES a safe single-component relative filename (no absolute / no separators / no ..), rejecting malicious ../../../ or legacy absolute paths (fail-closed; writes would otherwise land outside snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths. Signed-off-by: jrimmer <jason@rimmer.net>
Rebased onto dev (d2d238a, incl. deeplethe#312). Addresses all four review blocks on the staging/transport flow while keeping cache-versioning and failure-rollback. Blocker 1 — FC-visible rootfs path unstable across publish: Firecracker serializes the drive path_on_host INTO the binary vmstate and REOPENS it on restore (no PUT /drives override is accepted before /snapshot/load). Previously snapshot_cmd cloned rootfs to staging_dir/rootfs.ext4 and booted the VM from it, then publish_snapshot renamed the whole dir to snap_dir — leaving the vmstate's recorded path nonexistent, so the first restore after publish failed. Fix: clone+boot from the STABLE final path snap_dir/rootfs.ext4 from the start; only vmstate/memory/snapshot.json are staged. publish_snapshot is replaced by publish_snapshot_metadata, which renames the 3 metadata files into snap_dir (snapshot.json LAST = commit marker) and never moves the rootfs. Tests: metadata-replace-keeps-rootfs, into-nonexistent-snap_dir, preserves-existing-on-missing-metadata. Blocker 2 — pack/unpack sidecar never placable: pack listed rootfs.ext4 in manifest.files but omitted it from the tar body; unpack verified EVERY declared file before satisfy_rootfs, hashing the missing extracted rootfs and failing. Fix: when a portable sidecar is emitted, retain rootfs.ext4 out of manifest.files (sidecar carries sha integrity). Test: pack_unpack_roundtrip_with_sidecar_rootfs. Blocker 3 — src==dst guard compared staging path, not final: extracted to rootfs_clone_into_self(src, snap_dir) comparing canonical against the FINAL snap_dir/rootfs.ext4, so re-snapshotting a tag whose baseline is its own rootfs.ext4 is rejected. Test: same-tag regression. Blocker 4 — path traversal via RootfsRef.target_path: satisfy_rootfs now REQUIRES a safe single-component relative filename (no absolute / no separators / no ..), rejecting malicious ../../../ or legacy absolute paths (fail-closed; writes would otherwise land outside snap_dir under sudo). Test: satisfy_rootfs_rejects_unsafe_target_paths. Signed-off-by: jrimmer <jason@rimmer.net>
Addresses review deeplethe#299 (both blockers) and rebases onto dev with deeplethe#312's single-lock reconcile preserved. Blocker 1 — cross-reboot false Match (PID+starttick collision): proc_starttime is ticks since boot, only unique within one host boot. The registry persists across reboots, so after a reboot an unrelated Firecracker can share both the numeric PID and the boot-relative start tick of a recorded entry, and a bare starttime check would SIGKILL the wrong process. Persist /proc/sys/kernel/random/boot_id at registration (SandboxInfo.boot_id) and gate the identity check on it: - different boot id -> PidReuse (prune WITHOUT signaling; the old process cannot still exist across a reboot) - missing recorded boot id (legacy state.json) -> Unknown (fail closed) - live boot id unreadable -> Unknown (fail closed) New read_boot_id() helper; recorded at all three production VM registration sites in http.rs. Blocker 2 — pid: None entries recreated the deeplethe#298 collision risk: Such rows were skipped by both reconcile and kill_orphans, so startup succeeded with an empty allocator/shared-tap ownership while a live VM may still hold those resources. kill_orphans now counts retained rows without a PID into KillOrphansResult.unresolved, and check_orphan_kill_result aborts startup on unresolved > 0 (fail closed). Rebase: preserved deeplethe#312's single-lock reconcile (whole in-memory pass under one registry lock, including inline workspace-stale marking); kept mark_stale_workspaces only for kill_orphans' use after pruning. Tests: - kill_orphans_does_not_kill_across_boot_id_mismatch: matching PID + real start time but a DIFFERENT recorded boot id -> pruned without signaling (killed==0, pruned_stale==1). - kill_orphans_counts_pid_none_entries_as_unresolved (replaces kill_orphans_skips_pid_none_entries): pid:None rows surface as unresolved and block startup via check_orphan_kill_result. - check_orphan_kill_result_aborts_on_unresolved: startup-decision regression. - Existing kill-path tests set boot_id to the current boot id where they exercise the Match/PidReuse path; the legacy fail-closed test keeps boot_id: None. Signed-off-by: jrimmer <jason@rimmer.net>
Summary
Registry::reconcile()tookK + 4separate registry-mutex acquisitions to pruneKstale sandboxes: one lock per removed id, plus one each for the initial stale scan, the live-id snapshot, and the workspace walk. As you noted in the issue, nothing stops an HTTP handler from inserting or removing a sandbox between the scan and the per-id removal, which can make the reported prune count diverge from what was actually removed.This runs the whole in-memory pass (stale scan, removal, live-id snapshot, workspace walk) under a single
self.inner.lock()acquisition, matching the shape from the issue.pid_aliveis a/procstat and takes no lock the registry cares about, so holding the mutex across it is fine.flush()behavior is unchanged: still called once, after the lock is dropped, only whenpruned > 0 || stale_ws_changed.Why not a one-liner
The function reads sandboxes then mutates workspaces from the same
MutexGuard; the live-id set has to stay an ownedHashSet<String>(not borrowed) so the borrow checker allows the later mutable walk overg.workspacesthrough the same guard.Verification
reconcile_prunes_dead_sandbox_and_marks_its_workspace_stale: a sandbox with a dead pid is pruned and itsRunningworkspace flips toStalewithlive_sandbox_idcleared; a sandbox with no recorded pid (pid: None) is left alone and its workspace staysRunning; aSuspendedworkspace is never touched even though its sandbox is gone; reloading from disk confirmsflush()ran.reconcile_no_op_does_not_flush: a reconcile pass that prunes nothing leavesstate.jsonbyte-identical.rustCI steps run clean in arust:1-bookwormcontainer against this branch (94 passed, 0 failed, in theforkd-controllerunit crate):reconcile()only runs at startup today before the HTTP listener accepts requests, so it is not reachable through any current call path; the two new tests cover the prune-count and workspace-transition behavior the single-lock refactor must preserve, not a live race.Note on #299
#299 is open on the same function (extracts the workspace-marking half into
mark_stale_workspaces()); it does not touch the stale-id removal loop this issue is about, so it is not a competing fix, but merging either PR first will need a small rebase of the other.Fixes #310