vm: record the firecracker PID, not sudo's - #433
Conversation
Firecracker is spawned via `Cmd::sudo()`, so `child.id()` is the sudo wrapper: sudo forks instead of exec'ing when `use_pty` is on, its default since 1.9.14. The PID file therefore named the wrapper, and stop()'s SIGKILL fallback killed sudo while leaving the VM running, holding the TAP device and API socket — with the PID file removed, so status reported the instance stopped. Take the PID from the process that owns the API socket instead, which Firecracker opens itself, falling back to the wrapper PID if lsof reports nothing. This also un-vacuums `is_firecracker_process`, which passed on the wrapper because sudo's cmdline contains the binary path. Verified in a Linux container (sudo 1.9.15p5): the observed PID is sudo, `lsof -t` on the socket names the firecracker child, and SIGKILL on the wrapper leaves that child alive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hbrodin
left a comment
There was a problem hiding this comment.
Thanks for digging this out — real bug, and a genuinely non-obvious one. I reproduced it before reviewing: on sudo 1.9.15p5, sudo sleep 7 gives a wrapper (comm=sudo) plus a forked child, and SIGKILL to the wrapper leaves the child running. The use_pty-since-1.9.14 note checks out against sudo's release notes too.
One thing that makes the case stronger than the description: recording the wrapper also breaks crash detection. An exited-but-unreaped sudo wrapper is a zombie, and kill -0 succeeds on a zombie, so check_alive() and Instance::is_running() today report ALIVE for a Firecracker that already died. The reason nobody noticed is that is_firecracker_process substring-matches the cmdline, and the wrapper's is sudo firecracker …. Probably worth a line in the commit message.
Suggested alternative
The current approach discovers the PID. We could have the spawn establish it, which removes the sleep, the lsof dependency, pid_on_socket, the fallback and the race together:
sudo sh -c 'echo $$ > "$1"; shift; exec "$@"' sh <pidfile> <firecracker-bin> <args…>
Because of exec, the shell's $$ is the Firecracker PID, written before exec. I verified it: the pidfile named the exec'd process rather than the wrapper, and SIGKILL of that PID killed the real process. Passing each path as a positional parameter keeps quoting/injection at zero, and a root-written pidfile still unlinks from the user-owned instance dir, so stop()'s remove_file and the stale-PID cleanup keep working.
Two dead ends I checked so you don't have to: Defaults !use_pty is not a fix (PAM setup and I/O logging also make sudo fork a monitor), and process-group kill doesn't help since use_pty puts the child in its own session.
Worth adding either way (both outside this diff)
stop()never calls the existingkill_process_on_socket— one line after the SIGKILL branch would catch the orphan even when the pidfile was never written.test_stoponly asserts exit 0; nothing checks the VMM is actually gone. The socket isn't a usable proxy, sincestop()rm -fs it either way.
For the record: cargo fmt, clippy -D warnings and a security pass over the new privileged lsof call are all clean at c183cd4; .cargo/mutants.toml already covers src/vm.rs, so nothing is owed there; and I confirmed the new assertion really would have failed pre-fix (comm=sudo). The deny failure is the ambient h2 RUSTSEC-2026-0258, unrelated to this PR.
The rest is inline and mostly minor. Glad to help rework it to the exec form if you'd prefer.
|
|
||
| let pid = child.id(); | ||
| // Give Firecracker a moment to start and open its API socket. | ||
| std::thread::sleep(Duration::from_secs(1)); |
There was a problem hiding this comment.
One fixed sleep and a single probe: if the socket isn't bound within 1s, the wrong PID is recorded silently. sudo does PAM/NSS work and pty allocation before it execs Firecracker, so >1s is plausible on a loaded host — and CI is where that bites, since the new integration assertion would then fail as a flake rather than diagnose a slow boot.
wait_for_exit (line 584) is the polling idiom already in this file; a bounded re-probe every ~100–200ms would make this deterministic.
There was a problem hiding this comment.
No socket probe left, so there is no timing dependency to get wrong. The sleep(1) is back where it was — crash detection before check_alive — and the PID is read from the file the shell wrote.
There was a problem hiding this comment.
Thanks for the rework — the exec form is much better, and the PID file genuinely names the VMM now. I think this thread is still live though, for a different reason than before.
The dependency moved rather than disappeared: it's no longer "socket bound within 1s", it's "sudo + sh reach the echo within 1s". The sleep is now load-bearing for acquiring the PID, not just for crash detection — if the file isn't there yet, start() fails with Failed to read PID file, which base couldn't do since child.id() was synchronous.
In fairness the window got much wider: I measured the pidfile appearing ~23ms after spawn (sudo 1.9.15p5), so ~40x headroom. Not alarming, and it self-heals on re-run. But it's a new failure mode and the fix is small.
The wrinkle is that the sleep is doing two jobs now. A bounded poll fixes the pidfile wait, but crash detection still wants a real delay — checking early always passes. So I'd split them and put the sleep back where it was:
let pid = wait_for_pid_file(&pid_path, Duration::from_secs(5))?;
// …
// Give Firecracker a moment to start, then check it didn't crash
std::thread::sleep(Duration::from_secs(1));
running.check_alive()?;wait_for_exit (line 566) is the shape to mirror. Worth polling for "parses as a u32" rather than exists() — > creates the file before echo writes it.
Also flagging: the comment above the sleep is now stale, since check_alive() is 17 lines further down.
Aside, entirely your call: echo $$ to stdout and a blocking read_line removes the race instead of bounding it, and coop goes back to writing the pidfile itself. Needs >/dev/null inside the shell so Firecracker doesn't inherit the pipe on exec. Bigger change than this deserves — just noting it.
Replaces the lsof-based PID probe with `sudo sh -c 'echo $$ > "$1"; shift; exec "$@"'`: the shell writes its own PID and execs Firecracker, so the PID file names the VMM with no timing dependency, no fallback to the sudo wrapper PID, and no lsof on the start path. lsof stays best-effort in kill_process_on_socket, as before. Also restores the sleep to its crash-detection position before check_alive, and makes the integration assertion skip visibly on macOS and distinguish an unparsed PID from a wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reworked to drop Firecracker is now spawned as Re-verified in a fresh container (
One thing worth recording: the orphan is tty-dependent, though the wrong PID was recorded either way. With no controlling terminal, SIGKILL on the wrapper leaves the VMM in Still to run before merge: the integration suite against real Firecracker on a KVM host. The container can't exercise VM boot or |
| // records its own PID and execs, or SIGKILL in stop() would hit | ||
| // sudo and leave the VM orphaned. | ||
| let fc_cmd = Cmd::new("sh") | ||
| .args(["-c", r#"echo $$ > "$1" || exit 1; shift; exec "$@""#, "sh"]) |
There was a problem hiding this comment.
Small hardening idea while you're in here — not a vulnerability, since the instance dir is 0755 owner-only and that owner already has sudo.
If firecracker.pid is a dangling symlink, the stale-file loop above skips it: stale.exists() follows the link and returns false for a broken target, so sudo rm -f never runs. Then root's > follows the link and creates a root-owned file wherever it points.
Cheapest fix is a leading rm -f inside the one-liner, so the redirect always lands on a fresh regular file:
rm -f "$1"; echo $$ > "$1" || exit 1; shift; exec "$@"One token, and it makes the trampoline self-contained rather than depending on the caller's cleanup having worked.
| // didn't crash | ||
| std::thread::sleep(Duration::from_secs(1)); | ||
|
|
||
| let pid = |
There was a problem hiding this comment.
Tiny consistency nit: status() (455) and check_alive() (523) both use the pid_str / pid shadowing pair, which keeps the inline capture working:
let pid_str = fs::read_to_string(&pid_path).context("Failed to read PID file")?;
let pid = pid_str.trim();
tracing::info!("Firecracker started with PID {pid} (instance '{}')", self.inst.name);Worth a parse::<u32>() too — a truncated or empty pidfile currently logs blank and only surfaces later in check_alive().
Also pid_path is already bound at 233, so this and line 260 don't need to re-derive it.
| if [[ -n "$fc_pid" ]]; then | ||
| comm=$(cat "/proc/$fc_pid/comm" 2>/dev/null || true) | ||
| fi | ||
| if [[ "$comm" == "firecracker" ]]; then |
There was a problem hiding this comment.
Nice — I checked this would actually have failed on base: pre-fix the recorded PID is the sudo wrapper, so comm reads sudo and the assertion trips. Real regression coverage, not a tautology.
One robustness thought: comm is truncated to 15 chars and could be firecracker-v1.x on some installs, and /proc mounted with hidepid= makes it unreadable. [[ "$comm" == *firecracker* ]] would absorb both. Only false failures either way, so low stakes.
Worth noting for anyone who touches this later: don't be tempted to match /proc/<pid>/cmdline the way is_firecracker_process does. The sudo wrapper's cmdline contains "firecracker" via the socket path, so that check passes on the broken PID too — which is exactly why the production predicate missed this bug.
Firecracker is spawned via
Cmd::sudo(), sochild.id()is the sudo wrapper: sudo forks instead of exec'ing whenuse_ptyis on. The PID file therefore named the wrapper, and stop()'s SIGKILL fallback killed sudo while leaving the VM running, holding the TAP device and API socket.Verified in a Linux container (sudo 1.9.15p5).