fix(controller): kill orphaned Firecracker processes on startup - #299
fix(controller): kill orphaned Firecracker processes on startup#299jrimmer wants to merge 9 commits into
Conversation
WaylandYang
left a comment
There was a problem hiding this comment.
Startup recovery still fails open when an orphan cannot be killed. kill_orphans() correctly keeps the registry entry and increments kill_failed on EPERM or when /proc/ remains after the 5-second wait, but run_daemon() only logs that count and continues constructing an empty NetnsAllocator active set and shared_tap_owner=None. Registry entries do not participate in netns/TAP admission, so the next spawn can immediately reuse the failed orphan's resources—the exact collision #298 is meant to prevent.
Please either abort startup when kill_failed > 0, or reconstruct/reserve netns and shared-TAP ownership from every retained entry before accepting requests. Add a startup-level regression test that forces a kill failure/timeout and proves no conflicting spawn can be admitted.
Also, /proc//comm == firecracker is not sufficient PID-reuse identity: another Firecracker process can legitimately inherit the recycled PID and would be killed despite not belonging to the persisted sandbox. Prefer pidfd signaling plus durable identity validation (for example process start time and executable/work directory recorded with the registry entry). At minimum, do not describe the comm comparison as complete PID-reuse protection.
Fix: abort startup on kill_failed, document PID-reuse limitationFinding 1 (startup fail-open): Fix: Finding 2 (PID-reuse identity): Fix (documentation): The Added test: |
Update: testable startup abort decisionThe startup abort logic was inline in Tests:
Both tests verified passing on Linux. The PID-reuse limitation of |
WaylandYang
left a comment
There was a problem hiding this comment.
Thank you for changing startup recovery to fail closed when orphan termination fails; that addresses the primary finding in this PR. The current branch still carries the older netns lifecycle implementation, including the VmNetnsGuard::into_vm() mem::forget Arc leak that has since been fixed in #282.
Please rebase this branch onto current main after #282 lands, resolve the allocator/recovery integration against that implementation, and keep the fail-closed behavior. Then rerun the restart recovery tests and CI. This is primarily a dependency refresh rather than a rejection of the orphan-kill fix itself; thanks for the contribution and I’ll re-review the clean rebased diff.
717538f to
3c4b536
Compare
|
Rebased onto current
Verified: |
WaylandYang
left a comment
There was a problem hiding this comment.
Thank you for the clean rebase and for preserving the fail-closed startup behavior. The old allocator/Arc-leak stack is gone, and aborting when an orphan cannot be killed correctly prevents the controller from admitting conflicting spawns.
One safety blocker remains in the automatic SIGKILL path. pid_is_firecracker validates only the numeric PID and comm == "firecracker". If the recorded process exits and that PID is reused by another legitimate Firecracker before startup recovery, the check passes and this code kills the unrelated VM. The implementation's own comment acknowledges this case; because the action is an irreversible SIGKILL, it should not be deferred as a follow-up.
Please persist a durable process identity when the VM is registered (at minimum /proc/<pid>/stat starttime, ideally also the expected API-socket/work-dir or cgroup identity), open a pidfd during recovery, compare the live identity with the stored one, and signal through that pidfd. A mismatch should prune the stale registry record without killing the live process; an unverifiable identity should fail closed. Add tests for same-name Firecracker PID reuse and the identity-mismatch path.
The rewritten commits also need the repository-required DCO Signed-off-by trailers before approval. Thanks again for the contribution—the startup fail-closed change is sound, but process identity must be trustworthy before it can safely auto-kill.
8388845 to
2dca7e4
Compare
Three changes to stop a single child failure from dooming the whole
restore batch, and to surface contention from orphaned firecracker
processes before a restore is attempted.
1. Configurable per-child socket wait timeout (ForkOpts field)
`ForkOpts::socket_wait_timeout_secs` (default 10s, matching the
historical hardcoded budget) now drives the `wait_for_sock` calls in
`restore_many_with`. The daemon's create_sandbox and
spawn_one_for_workspace paths set 30s — under warm-pool refill bursts
the 10s budget can be too tight when prior-VM tap/netns teardown races
with new firecracker spawns. A warning fires when a child takes >80%
of the budget to appear, so operators see contention before it pushes
the next spawn over. The CLI `forkd fork` and `forkd run` paths keep
the default (10s).
2. Per-VM partial-failure reporting (RestoreError/RestoreFailure)
`restore_many_with` previously bailed on the first child failure
(spawn, socket-wait, or restore), silently dropping already-spawned
siblings. Now each phase collects per-child results:
- Spawn phase: failures are recorded but remaining children still
spawn; on any failure, spawned children are dropped (killing
firecracker) and a `RestoreError` carrying every `RestoreFailure`
is returned.
- Socket-wait phase: a timed-out child is recorded; on any timeout,
all children (including those whose sockets appeared) are dropped
and a `RestoreError` is returned.
- Restore phase: each thread's result is collected; a panicked
thread is recorded as a restore failure rather than panicking the
caller. On any restore failure, all children are dropped and a
`RestoreError` is returned.
`RestoreError` implements Display + std::error::Error and names every
failed child (index, phase, error, pid when known) so callers can
report which specific child failed rather than a single bail that
loses batch context. The controller's restore retry loop
(`create_sandbox`) already handles `anyhow::Error`, so the structured
error propagates without caller changes.
3. Pre-restore orphan detection (scan_firecracker_orphans)
`forkd_vmm::scan_firecracker_orphans(known_pids)` scans `/proc` for
running firecracker processes whose PIDs are NOT in the caller's
`live_vms` set. These are potential orphans from a prior (crashed)
run that still hold tap devices, netns slots, or fd budget, and may
doom a new `restore_many_with` to socket/restore failures. The scan
is read-only (never kills) and cheap (one `/proc` readdir + per-PID
`/proc/<pid>/comm` read). The controller calls it before both
`create_sandbox` and `spawn_one_for_workspace` restores, logging every
orphan so operators can see contention before it causes a cascade.
A separate `kill_orphans` startup path (state.rs, PR deeplethe#299) handles
reaping; this scan is the runtime visibility layer.
Tests:
- `restore_error_display_lists_all_failures` — Display lists every
failure with phase, pid, and error.
- `restore_error_single_failure_display` — single Spawn-phase
failure (no pid).
- `restore_phase_eq_distinguishes_phases` — phase enum equality.
- `scan_firecracker_orphans_non_linux_returns_empty` — stub
contract on non-Linux.
- `scan_firecracker_orphans_empty_known_set_non_linux` — empty
known set contract.
- `orphan_process_struct_shape` — OrphanProcess field stability.
The concurrent-RW-mount-of-shared-rootfs issue (restore_many_with
sends the same vmstate to all children) is out of scope and tracked
separately — it needs an immutable baseline + per-VM writable layer.
Closes deeplethe#301.
Signed-off-by: jrimmer <jason@rimmer.net>
WaylandYang
left a comment
There was a problem hiding this comment.
Thank you for preserving fail-closed startup behavior and rebasing cleanly. The automatic SIGKILL path still has the previously identified safety blocker. Registry state persists only a numeric PID, and recovery validates only comm == firecracker before signaling that PID. If the original VM exits and the PID is reused by another legitimate Firecracker, the check passes and startup kills the unrelated VM. The current source explicitly documents this case; tracking it in #304 does not make this irreversible path safe to merge.
Please persist a durable identity at registration, at minimum proc starttime and preferably expected API socket/work-dir or cgroup identity, verify it during recovery, and signal through pidfd. Identity mismatch should prune the stale record without killing the live process; unverifiable identity should fail closed. Add a same-name Firecracker PID-reuse regression test. Thanks for the contribution; the cleanup behavior is otherwise careful, but the kill target must be trustworthy.
|
Repository branch flow has moved to |
2dca7e4 to
d4d7b2d
Compare
Review r6 follow-ups — durable process identity + pidfd TOCTOUThanks for the detailed r6 review. I ran a ce-code-review pass (5 parallel persona reviewers: correctness, testing, reliability, security, adversarial) against the durable-identity rework and addressed every actionable finding before pushing. Here's what changed in Residual TOCTOU window — closed (security P1 / adversarial ADV-1)You were right that pidfd alone doesn't fully close the window. pidfd closes only the open→signal interval; the check→open gap remained: the original could exit and the PID could be reused by another Firecracker between Fix: after Off-Linux comment/behavior mismatch (adversarial ADV-2)The module comment claimed off-Linux "prunes without killing" but Cross-platform test guards (adversarial ADV-3)The PidReuse-asserting tests (mismatched New test coverage
Acknowledged, not fixed (advisory)
VerificationLinux (Docker, rust 1.83, matching CI):
The branch is rebased onto |
|
Thanks for holding the line on the irreversible SIGKILL path. The durable-identity + pidfd implementation is now in place ( What changed
Tests
Off-Linux stubs return |
WaylandYang
left a comment
There was a problem hiding this comment.
Thank you — the current head fixes the substantive PID-reuse issue: start time is persisted on both registration paths, mismatches prune without signaling, unverifiable identity fails closed, and pidfd signaling prevents a later PID reuse from redirecting SIGKILL.
One safety-critical coverage blocker remains. kill_orphans_match_arm_kills_verified_child_via_pidfd does not do what its name/doc says: it starts sleep, intentionally fails comm_is_firecracker, and asserts killed == 0 / kill_failed == 1. That covers entry into IdentityCheck::Match and the comm fail-closed branch, but never executes the irreversible success path (pidfd_send_kill → wait_for_death → registry removal/accounting). Please spawn a disposable process whose /proc/<pid>/comm is firecracker (for example a temporary symlink/copy of sleep named firecracker), record its real start time, and assert that it is killed, killed == 1, kill_failed == 0, and its registry entry is removed. Keep the existing comm-mismatch test as a separate test because it is useful.
Please also correct the two misleading comments while touching this area: SandboxInfo::proc_starttime says legacy None state may use comm-only verification, and IdentityCheck::Unknown says the caller chooses comm-only kill or prune. The implementation correctly does neither; it keeps the entry and aborts startup via kill_failed.
Once that Linux success-path regression is present and CI is green on the rebased dev, this should be ready to approve.
Issue deeplethe#298: when the controller crashes or restarts while Firecracker processes are still alive, the restarted controller cannot reconstruct resource ownership for orphaned VMs. Both the NetnsAllocator (active set) and shared_tap_owner are initialized empty, so a new spawn can reserve an orphaned VM's netns index or claim the shared tap even though the orphan still owns it. Fix: add `Registry::kill_orphans()` called after `reconcile()` on startup. After reconcile() prunes dead-PID entries, any remaining sandbox entries have alive PIDs but no live_vms handle — they are unmanageable orphans. kill_orphans() kills each orphan (after verifying the PID belongs to a Firecracker process via /proc/<pid>/comm to guard against PID reuse), prunes the registry entry, and marks workspaces whose sandbox was killed as Stale. After this, the NetnsAllocator (empty active set) and shared_tap_owner (None) are safe: no orphaned VM holds a netns index or the shared tap. Closes deeplethe#298 Signed-off-by: jrimmer <jason@rimmer.net>
Address all P0 and P1 findings from the 7-reviewer ce-* panel: P0: kill_pid failure no longer prunes the registry entry. ESRCH (process already dead from TOCTOU race) is distinguished from real errors (EPERM etc.). On ESRCH, the entry is safely pruned. On real errors, the entry is kept in the registry to prevent the exact resource collision issue deeplethe#298 fixes. P1 fixes: - Wait for process death after SIGKILL (5s bounded timeout polling /proc/<pid> disappearance). A D-state process stuck on I/O can hold netns/tap resources past the kill return; if it doesn't exit within the timeout, the registry entry is kept. - pid_is_firecracker uses exact match (== "firecracker") instead of substring match to avoid false-positive kills of unrelated processes. - Separate KillOrphansResult struct (killed, pruned_stale, kill_failed) replaces the misleading single usize counter. The startup log now reports all three counts. - Extract mark_stale_workspaces() helper to eliminate the ~22-line duplicated workspace stale-marking block between reconcile() and kill_orphans(). - kill_orphans is pub(crate) with a doc invariant: "MUST only be called before any Vm handle is held" — prevents future post-startup misuse. - Defensive pid <= 1 guard in pid_is_firecracker (defense-in-depth against corrupted state.json). - ESRCH downgraded from error! to debug! (benign TOCTOU race). - SAFETY comment added on unsafe libc::kill. New tests: pid:None entries skipped, multiple workspaces same sandbox, persistence to disk after kill_orphans, reconcile+kill_orphans integration. Signed-off-by: jrimmer <jason@rimmer.net>
WaylandYang round 4 found that kill_orphans correctly keeps registry entries on EPERM/timeout (kill_failed > 0), but run_daemon only logs the count and continues. The NetnsAllocator active set and shared_tap_owner start empty, so the next spawn can reuse the failed orphan's netns index or tap lease — the exact collision deeplethe#298 prevents. Fix: run_daemon now aborts startup when kill_failed > 0, forcing operator intervention rather than silently admitting conflicting spawns. The error message names the count and tells the operator to kill the orphans manually and restart. Also updated the pid_is_firecracker doc comment to be honest about the PID-reuse limitation: the comm check is a defense-in-depth heuristic, not complete PID-reuse protection. A robust fix would use pidfd signaling plus durable identity validation (process start time recorded with the registry entry at spawn time, compared on startup). That is tracked as a follow-up. Added test: kill_orphans_result_kill_failed_contract verifies the KillOrphansResult contract (kill_failed == 0 on empty registry, so baseline startup does NOT abort). Signed-off-by: jrimmer <jason@rimmer.net>
…decision The startup abort logic was inline in run_daemon, making it impossible to unit-test the kill_failed > 0 → bail decision path. Extracted into pub(crate) check_orphan_kill_result(&KillOrphansResult) -> Result<()> so the abort contract is directly testable. Tests: - check_orphan_kill_result_aborts_on_kill_failure: verifies that kill_failed=1 returns Err with "aborting startup" message, and kill_failed=0 returns Ok even with killed/pruned entries. - check_orphan_kill_result_error_contains_count: verifies the error message contains the exact kill_failed count so operators know how many orphans need manual intervention. The existing kill_orphans_result_kill_failed_contract test (zero-baseline) is retained as documentation of the baseline contract. The new tests exercise the actual abort decision logic. Signed-off-by: jrimmer <jason@rimmer.net>
The dead-last_err cleanup dropped the declaration and the unreachable expect, but the `last_err = Some(e)` assignment (guarded by the tap/cgroup-busy warn block on main) survived the rebase and referenced a now-missing binding. Remove it — the loop already returns on the final attempt, so the assignment is dead. Signed-off-by: jrimmer <jason@rimmer.net>
Replace the comm=="firecracker" check with durable process identity verification and pidfd signaling, addressing review r6 (WaylandYang) and the ce-code-review security findings. The comm check was spoofable (prctl PR_SET_NAME) and could not distinguish two Firecracker processes sharing a PID over time — a same-name PID-reuse attack would kill a legitimate new Firecracker, and on a multi-tenant host a cross-tenant DoS was possible. The TOCTOU window between the check and kill(2) was also acknowledged. Changes: 1. SandboxInfo::proc_starttime (serde default) — persists the process start time (field 22 of /proc/<pid>/stat, clock ticks since boot) captured at VM registration. Backward-compatible: old state.json files without this field deserialize to None. 2. read_proc_starttime — robust parse of /proc/<pid>/stat that handles the parenthesized comm field (field 2, can contain spaces/parens) by splitting at the last ')' and indexing field 22 in the remainder. 3. process_identity_matches — compares the live start time against the recorded one. Returns Match / PidReuse / Dead / Unknown. PidReuse means the original process exited and the PID was recycled — prune the stale entry without killing. Unknown (no recorded start time, /proc unreadable, or off-Linux) fails closed. 4. pidfd_open + pidfd_send_kill — signal through a pidfd opened AFTER the identity check, closing the TOCTOU window: the fd is pinned to the verified process, so a PID reuse between check and signal cannot redirect the kill. Uses raw libc::syscall with SYS_pidfd_open / SYS_pidfd_send_signal (Linux 5.3+; libc 0.2.x exposes the syscall numbers but not named fns). 5. comm_is_firecracker — retained as a secondary confirmation after the primary start-time check passes, to catch corrupted state.json. Not a security boundary. 6. kill_orphans reworked: Match → pidfd kill + wait_for_death; PidReuse → prune stale (no kill); Dead → prune stale; Unknown → fail closed (keep entry, increment kill_failed so run_daemon aborts startup). Tests updated to reflect the new fail-closed semantics: - kill_orphans_prunes_alive_pid_entries now uses a mismatched start time (u64::MAX) to trigger PidReuse → prune, not the old None path. - kill_orphans_fails_closed_on_unknown_identity (new): alive PID with proc_starttime: None → fail closed, entry kept, kill_failed=1. - kill_orphans_prunes_same_name_pid_reuse_without_killing (new regression): the same-name Firecracker PID-reuse attack the reviewer flagged — a mismatched start time must prune without killing, even if comm were "firecracker". - All other kill_orphans tests updated to use mismatched start times where they previously relied on the comm-check prune path. Verified on Linux (rust 1.83, x86_64) via Docker: clippy clean for forkd-controller; cargo test --all-features passes 104 unit + 8 integration tests, 0 failed. Signed-off-by: jrimmer <jason@rimmer.net>
Address ce-code-review findings (security P1, testing P1, adversarial P2/P3, plus the cross-reviewer coverage gaps) on the durable-identity rework from 2e275fe. 1. Residual TOCTOU window (security P1, adversarial ADV-1): pidfd closes only the open→signal window, NOT the check→open window. The original could exit and the PID could be reused by another Firecracker between process_identity_matches (which reads /proc/<pid>/stat) and pidfd_open. Fix: after pidfd_open succeeds, re-read the live start time and compare to the recorded one. A mismatch means the PID was reused between the check and the open → prune without killing (PidReuse). The pidfd pins the process at open time, so /proc/<pid> reflects the pinned process. This closes the check→open window; the comm check is now a pure sanity check, not a load-bearing defense. Design comment corrected to not overstate the guarantee. 2. Off-Linux comment/behavior mismatch (adversarial ADV-2): the module comment claimed off-Linux "prunes without killing" but Unknown actually fails closed (keeps entry, increments kill_failed, run_daemon aborts). Fixed the comment to state the real behavior. 3. Cross-platform test guards (adversarial ADV-3): the PidReuse- asserting tests (mismatched starttime → pruned_stale) now carry #[cfg(target_os = "linux")] because off-Linux returns Unknown (kill_failed), not PidReuse. The cross-platform fail-closed test (proc_starttime: None → Unknown) stays ungated. 4. Match-arm coverage (testing P1, +reliability/security/adversarial): new kill_orphans_match_arm_kills_verified_child_via_pidfd spawns a real child, records its true start time (Match), and asserts the secondary comm_is_firecracker check fails closed for a non-firecracker process — proving the Match arm is entered and the comm gate works as defense-in-depth. (Killing a real Firecracker would require a Firecracker binary; the comm gate is the testable boundary here.) 5. start-time parser unit test (testing P2): read_proc_starttime now has a direct test (parses our own /proc/<pid>/stat, rejects pid<=1, returns None for nonexistent PID). 6. Backward-compat serde test (testing safe_auto): new proc_starttime_defaults_to_none_when_absent_in_old_state_json deserializes a hand-written pre-r6 state.json (no proc_starttime key) and asserts #[serde(default)] yields None — the contract that lets existing deployments upgrade without wiping state. Verified on Linux (rust 1.83, x86_64) via Docker: clippy clean for forkd-controller; cargo test --all-features passes 107 unit + 8 integration tests, 0 failed (3 new tests pass). Signed-off-by: jrimmer <jason@rimmer.net>
…ding comments Review r8 (2026-08-20) found that kill_orphans_match_arm_kills_verified_child_via_pidfd did not do what its name claimed: it spawned 'sleep' (comm='sleep'), so the secondary comm_is_firecracker check failed closed and the irreversible success path (pidfd_send_kill -> wait_for_death -> registry removal) was never executed. - Rename the existing test to kill_orphans_match_arm_comm_mismatch_fails_closed so its name and doc accurately describe the comm-gate fail-closed branch it covers. - Add kill_orphans_match_arm_kills_verified_firecracker_via_pidfd, which spawns a disposable process whose /proc/<pid>/comm is literally 'firecracker' (a copy of 'sleep' renamed to 'firecracker'), records its true start time, and asserts killed == 1, kill_failed == 0, pruned_stale == 0, and the registry entry is removed — exercising the full irreversible pidfd kill path. Also correct two misleading comments flagged in the same review: - SandboxInfo::proc_starttime claimed legacy None state may use comm-only verification; the implementation fails closed (keeps the entry, increments kill_failed) and never does comm-only kill. - IdentityCheck::Unknown claimed the caller chooses between a comm-only kill or prune; kill_orphans keeps the entry and aborts startup via kill_failed. Signed-off-by: jrimmer <jason@rimmer.net>
d4d7b2d to
ce0c39c
Compare
|
Rebased onto current What was addressedSIGKILL success-path regression test: added The new test:
Corrected misleading comments:
RebaseRebased onto |
Signed-off-by: jrimmer <jason@rimmer.net>
Summary
Fixes the controller restart recovery gap identified in #298.
When the forkd controller crashes or restarts while Firecracker processes are still alive, the restarted controller cannot reconstruct resource ownership for orphaned VMs. Both the
NetnsAllocator(active set) andshared_tap_ownerare initialized empty on restart, so a new spawn can reserve an orphaned VM'sforkd-child-Nnamespace or claim the shared tap even though the orphan still owns it.Approach
Add
Registry::kill_orphans()called afterreconcile()on startup. Afterreconcile()prunes dead-PID entries, any remaining sandbox entries have alive PIDs but nolive_vmshandle — they are unmanageable orphans. For each orphan:/proc/<pid>/comm(exact match, not substring — a partial PID-reuse guard; see Known limitation below)libc::kill/proc/<pid>disappearance) — prevents a D-state process from holding resources past startupStaleIf a kill fails with a real error (not
ESRCH), the entry is not pruned — a live orphan holding resources must stay registered to prevent the exact resource collision this fix exists to prevent. Startup fails closed when any orphan cannot be killed:run_daemon()aborts rather than continuing with an incomplete ownership set.Design decisions
live_vmshandle — they're alive but unmanageable (can't exec, branch, or delete through the controller). Killing them is the cleanest recovery.ESRCHvs real errors:ESRCH(process already dead — benign TOCTOU race) is safely pruned. Real errors (EPERM, etc.) keep the registry entry to prevent resource collision.kill_failed > 0, startup aborts instead of accepting requests with an incomplete netns/TAP ownership set (the exact collision Controller restart loses netns allocator and shared-tap ownership #298 exists to prevent).pub(crate)visibility:kill_orphans()is only safe beforelive_vmsis populated.pub(crate)+ doc invariant prevents future post-startup misuse.KillOrphansResult { killed, pruned_stale, kill_failed }instead of a single misleadingusize.Known limitation (tracked follow-up)
The
/proc/<pid>/comm == "firecracker"check is a partial PID-reuse guard, not complete protection. If a PID is recycled and now belongs to a different Firecracker process, the comm check still matches and the wrong VM would be killed. Closing this race requires durable process identity (recorded start time + executable + cwd) validated at kill time, plus pidfd signaling on Linux ≥ 5.3 — tracked in #304.Stacking
Rebased onto current
main(after #282 landed, which fixed theVmNetnsGuard::into_vm()Arc leak this branch previously inherited).Closes #298