Skip to content

fix(controller): run Registry::reconcile under a single lock - #312

Merged
WaylandYang merged 1 commit into
deeplethe:devfrom
AmirF194:fix/registry-reconcile-single-lock-310
Aug 22, 2026
Merged

fix(controller): run Registry::reconcile under a single lock#312
WaylandYang merged 1 commit into
deeplethe:devfrom
AmirF194:fix/registry-reconcile-single-lock-310

Conversation

@AmirF194

Copy link
Copy Markdown
Contributor

Summary

Registry::reconcile() took K + 4 separate registry-mutex acquisitions to prune K stale 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_alive is a /proc stat 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 when pruned > 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 owned HashSet<String> (not borrowed) so the borrow checker allows the later mutable walk over g.workspaces through the same guard.

Verification

  • New test reconcile_prunes_dead_sandbox_and_marks_its_workspace_stale: a sandbox with a dead pid is pruned and its Running workspace flips to Stale with live_sandbox_id cleared; a sandbox with no recorded pid (pid: None) is left alone and its workspace stays Running; a Suspended workspace is never touched even though its sandbox is gone; reloading from disk confirms flush() ran.
  • New test reconcile_no_op_does_not_flush: a reconcile pass that prunes nothing leaves state.json byte-identical.
  • All four rust CI steps run clean in a rust:1-bookworm container against this branch (94 passed, 0 failed, in the forkd-controller unit crate):
    cargo fmt --all -- --check
    cargo clippy --all-targets --all-features -- -D warnings
    cargo build --all
    cargo test --all
    
  • Not verified: the actual interleaving/lost-accounting scenario from the issue. As you said, 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

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 WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@WaylandYang
WaylandYang merged commit d2d238a into deeplethe:dev Aug 22, 2026
6 checks passed
@AmirF194

Copy link
Copy Markdown
Contributor Author

Thanks for the quick review and merge. Glad the single-lock shape matched what you had in mind for #310.

@AmirF194
AmirF194 deleted the fix/registry-reconcile-single-lock-310 branch August 22, 2026 09:48
@WaylandYang

Copy link
Copy Markdown
Contributor

Thanks for the contribution, Amir — welcome aboard!

jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
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>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
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>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
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>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
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>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
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>
jrimmer added a commit to jrimmer/forkd that referenced this pull request Aug 22, 2026
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>
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.

Registry::reconcile re-acquires the lock for every stale entry

2 participants