Skip to content

Commit 8afe462

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(lifecycle): two signal/restart races in the daemonless monitor + kill paths (#144)
From an adversarial concurrency audit. Both are real multi-actor races (the monitor daemon and CLI processes coordinate only via the per-modify flock, which does NOT span an await), with concrete interleavings. 1. (HIGH) Monitor health-restart resurrects a box the user explicitly stopped. The unhealthy branch awaits graceful_stop (up to 10s) OUTSIDE any lock, then unconditionally marks the box dead and re-boots it. If the user runs `a3s-box stop` during that window (setting status=stopped, stopped_by_user), the monitor erases the stop and resurrects the box. Fix: after the await, re-validate the freshly-loaded record (new `health_restart_still_wanted`) and ABORT the restart if it was stopped/stopped_by_user in the meantime. 2. (MED) `kill` fallback host-signal fires with no PID-identity re-check. After a guest-delivery attempt that can block up to ~10s, the `!delivered` fallback calls a bare `send_signal(pid)` with no re-check — the one-shot identity check in require_live_pid is stale by then, so a concurrent force-kill that frees the shim PID (and a kernel PID reuse) could route the signal to an unrelated host process. Fix: re-check is_process_alive_with_identity immediately before the host signal; refuse rather than blind-kill on mismatch. Test: health_restart_still_wanted unit test (neuter-verified — forcing it true makes the test fail on the stopped case). #2 reuses the existing identity check (a defensive re-check, not cleanly unit-testable end-to-end). Full a3s-box-cli lib suite (598) green; fmt + clippy clean. Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent 3fcd059 commit 8afe462

2 files changed

Lines changed: 61 additions & 10 deletions

File tree

src/cli/src/commands/kill.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ async fn kill_one(
131131
process::deliver_signal_via_guest(&exec_socket, signal).await
132132
};
133133
if !delivered {
134+
// The guest-delivery attempt above can block up to ~10s. Re-verify the
135+
// PID is still THIS box's shim before a bare host kill: during that
136+
// window a concurrent force-kill could have made the shim exit and the
137+
// kernel reuse its PID for an unrelated process, which we must never
138+
// signal. The one-shot identity check in require_live_pid is now stale.
139+
if !process::is_process_alive_with_identity(pid, record.pid_start_time) {
140+
return Err(format!(
141+
"box {} is no longer running its original shim (PID {pid} exited or was reused); \
142+
not sending {signal} to a possibly-reused PID",
143+
record.name
144+
)
145+
.into());
146+
}
134147
process::send_signal(pid, signal).map_err(|err| {
135148
format!(
136149
"Failed to send signal {signal} to box {} (PID {pid}): {err}",

src/cli/src/commands/monitor.rs

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -290,17 +290,32 @@ async fn poll_once(tracker: &mut BackoffTracker) -> Result<(), Box<dyn std::erro
290290
}
291291
}
292292
tracker.mark_dead(&box_id);
293-
// Mark as dead so boot_from_record works; re-load fresh under the
294-
// lock and touch only this box's fields.
295-
StateFile::modify(|s| {
296-
if let Some(rec) = s.find_by_id_mut(&box_id) {
297-
rec.status = "dead".to_string();
298-
rec.pid = None;
299-
rec.health_status = "none".to_string();
300-
rec.health_retries = 0;
301-
}
302-
Ok::<(), std::io::Error>(())
293+
// Mark as dead so boot_from_record works; re-load fresh under the lock.
294+
// graceful_stop above can take up to 10s, during which the user may have
295+
// `stop`ped (or `rm`ed) the box. Re-validate fresh state and ABORT the
296+
// restart if so — otherwise we silently resurrect a box the user
297+
// explicitly stopped (overwriting their stopped/stopped_by_user record).
298+
let proceed = StateFile::modify(|s| {
299+
Ok::<bool, std::io::Error>(match s.find_by_id_mut(&box_id) {
300+
Some(rec) if health_restart_still_wanted(rec) => {
301+
rec.status = "dead".to_string();
302+
rec.pid = None;
303+
rec.health_status = "none".to_string();
304+
rec.health_retries = 0;
305+
true
306+
}
307+
// User stopped/removed the box during the graceful-stop window.
308+
_ => false,
309+
})
303310
})?;
311+
if !proceed {
312+
println!(
313+
"monitor: box {name} ({short_id}) health-restart aborted — stopped by the user during shutdown",
314+
name = record.name,
315+
short_id = record.short_id,
316+
);
317+
continue;
318+
}
304319
} else {
305320
tracker.mark_dead(&box_id);
306321
println!("{}", restart_log_line(&record, RestartReason::Dead));
@@ -366,6 +381,14 @@ fn is_unhealthy_restart_candidate(record: &BoxRecord) -> bool {
366381
&& policy::should_restart(record)
367382
}
368383

384+
/// Whether a health-restart should still proceed after the (up-to-10s)
385+
/// graceful-stop await, given the FRESHLY-loaded record. If the user `stop`ped
386+
/// the box (status=stopped or stopped_by_user) during that window, the restart
387+
/// must abort so we don't resurrect a box the user explicitly stopped.
388+
fn health_restart_still_wanted(record: &BoxRecord) -> bool {
389+
record.status != "stopped" && !record.stopped_by_user
390+
}
391+
369392
fn restart_log_line(record: &BoxRecord, reason: RestartReason) -> String {
370393
match reason {
371394
RestartReason::Dead => format!(
@@ -491,6 +514,21 @@ mod tests {
491514
use super::*;
492515
use crate::test_helpers::fixtures::make_record;
493516

517+
#[test]
518+
fn health_restart_aborts_if_user_stopped_during_window() {
519+
// Still running → a health-restart proceeds.
520+
let running = make_record("id-1", "box", "running", Some(1));
521+
assert!(health_restart_still_wanted(&running));
522+
// User `stop`ped the box during the up-to-10s graceful-stop window → abort
523+
// (do NOT resurrect a box the user explicitly stopped).
524+
let stopped = make_record("id-1", "box", "stopped", None);
525+
assert!(!health_restart_still_wanted(&stopped));
526+
// stopped_by_user set (even if the status field still reads running) → abort.
527+
let mut by_user = make_record("id-1", "box", "running", Some(1));
528+
by_user.stopped_by_user = true;
529+
assert!(!health_restart_still_wanted(&by_user));
530+
}
531+
494532
// --- BackoffTracker tests ---
495533

496534
#[test]

0 commit comments

Comments
 (0)