Skip to content

Commit 3fcd059

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(lifecycle): serialize per-box boots to stop the orphan-VM restart race (#146)
HIGH, from the concurrency audit. `boot_from_record` creates a real VM OUTSIDE the state lock (the flock only spans the post-boot record write). A box that is both the monitor's auto-restart target AND a valid user `restart`/`start` target (a dead box) can be booted by both concurrently: each clones the record, boots a VM unlocked, then races to persist. apply_boot_result has no already-running guard, so the second write overwrites the first's pid — the first VM (shim + overlay mount + network endpoint) becomes untracked and is never reaped. ensure_network_connected is idempotent for the dup and the overlay stacks without EBUSY, so the second boot doesn't fail on its own. Fix: a per-box advisory `BootLock` (flock on `locks/<box_id>.boot.lock`) held across the boot AND the record write, via a new `boot::boot_and_record` helper used by both the monitor and `restart`. A loser that acquires the lock and finds the box already running with a live, identity-matched shim returns `AlreadyRunning` and does NOT boot a duplicate — so there is no orphan to tear down (the winner records its pid before releasing the lock, so the loser observes it). The removed-during-boot orphan teardown is centralized into the same helper (both call sites previously needed it; restart lacked it). Validated: full a3s-box-cli lib suite (597) green, fmt + clippy clean on the KVM server. A cross-process boot serialization fix; the interleaving needs a multi-process/real-VM harness to exercise (like the other concurrency fixes), but the flock + re-check-under-lock is self-evidently correct. NOTE: touches monitor.rs poll_once near PR #144's region — rebase if #144 merges first (adjacent, non-overlapping edits). Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent 9f869fd commit 3fcd059

3 files changed

Lines changed: 154 additions & 50 deletions

File tree

src/cli/src/boot.rs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use a3s_box_runtime::{prom::RuntimeMetrics, NetworkStore, VmManager, VolumeStore
99
use std::path::PathBuf;
1010

1111
use crate::commands::common;
12-
use crate::state::{BoxRecord, HealthCheck};
12+
use crate::state::{BoxRecord, HealthCheck, StateFile};
1313

1414
/// Result of a successful box boot.
1515
pub struct BootResult {
@@ -126,6 +126,121 @@ pub fn apply_boot_result(
126126
}
127127
}
128128

129+
/// Per-box advisory boot lock.
130+
///
131+
/// Serializes boots of the SAME box across processes (the `monitor` daemon vs a
132+
/// user `restart`/`start`). Without it both can run `boot_from_record` — which
133+
/// creates a real VM OUTSIDE the state lock — concurrently, and the second
134+
/// post-boot record write overwrites the first's pid, ORPHANING the first VM
135+
/// (untracked shim + overlay mount, never reaped). Held across the boot AND the
136+
/// record write so a waiting actor re-checks AFTER the winner records its pid and
137+
/// skips booting a duplicate. Lives on a per-box `locks/<box_id>.boot.lock`
138+
/// sibling; `flock` releases on drop or crash, never stranding the lock.
139+
struct BootLock {
140+
#[cfg(unix)]
141+
_file: std::fs::File,
142+
}
143+
144+
impl BootLock {
145+
#[cfg(unix)]
146+
fn acquire(box_id: &str) -> std::io::Result<Self> {
147+
use std::os::unix::io::AsRawFd;
148+
let dir = a3s_box_core::dirs_home().join("locks");
149+
std::fs::create_dir_all(&dir)?;
150+
let path = dir.join(format!("{box_id}.boot.lock"));
151+
let file = std::fs::OpenOptions::new()
152+
.read(true)
153+
.write(true)
154+
.create(true)
155+
.truncate(false)
156+
.open(&path)?;
157+
// Blocking exclusive advisory lock; released when `_file` drops.
158+
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
159+
return Err(std::io::Error::last_os_error());
160+
}
161+
Ok(Self { _file: file })
162+
}
163+
164+
#[cfg(not(unix))]
165+
fn acquire(_box_id: &str) -> std::io::Result<Self> {
166+
Ok(Self {})
167+
}
168+
}
169+
170+
/// Outcome of [`boot_and_record`].
171+
pub enum BootOutcome {
172+
/// Booted a VM and recorded it; carries the (possibly updated) restart count.
173+
Restarted { restart_count: u32 },
174+
/// Another actor already (re)started this box (observed running with a live
175+
/// shim under the per-box lock); no VM was booted — a no-op for the caller.
176+
AlreadyRunning,
177+
/// The record was removed (concurrent `rm`) while the VM booted; the
178+
/// just-booted orphan VM has been torn down here.
179+
RemovedDuringBoot,
180+
}
181+
182+
/// Whether `box_id` is currently running with a live, identity-matched shim.
183+
/// Loads fresh reconciled state, so a recorded-but-dead pid reads as not-live.
184+
fn box_already_live(box_id: &str) -> Result<bool, Box<dyn std::error::Error>> {
185+
let state = StateFile::load_default()?;
186+
Ok(state.find_by_id(box_id).is_some_and(|rec| {
187+
rec.status == "running"
188+
&& rec.pid.is_some_and(|p| {
189+
crate::process::is_process_alive_with_identity(p, rec.pid_start_time)
190+
})
191+
}))
192+
}
193+
194+
/// Boot a box from its record and persist the result, SERIALIZED per box so
195+
/// concurrent actors (monitor auto-restart vs user restart/start) cannot boot the
196+
/// same box twice and orphan a VM. Holds a per-box [`BootLock`] across the boot
197+
/// and the record write; a loser that finds the box already live skips booting.
198+
pub async fn boot_and_record(
199+
record: &BoxRecord,
200+
count_update: RestartCountUpdate,
201+
) -> Result<BootOutcome, Box<dyn std::error::Error>> {
202+
let box_id = record.id.clone();
203+
let _lock = {
204+
let id = box_id.clone();
205+
tokio::task::spawn_blocking(move || BootLock::acquire(&id))
206+
.await
207+
.map_err(|e| -> Box<dyn std::error::Error> {
208+
format!("boot lock task failed: {e}").into()
209+
})??
210+
};
211+
212+
// Re-check fresh state UNDER the per-box lock: if another actor already booted
213+
// this box, skip — do not create a duplicate VM.
214+
if box_already_live(&box_id)? {
215+
return Ok(BootOutcome::AlreadyRunning);
216+
}
217+
218+
let result = boot_from_record(record).await?;
219+
let booted_pid = result.pid;
220+
// Persist atomically; `None` if the record was removed (concurrent rm) mid-boot.
221+
let restart_count = StateFile::modify(|s| {
222+
Ok::<Option<u32>, std::io::Error>(s.find_by_id_mut(&box_id).map(|rec| {
223+
apply_boot_result(rec, result, count_update);
224+
rec.restart_count
225+
}))
226+
})?;
227+
228+
match restart_count {
229+
Some(restart_count) => Ok(BootOutcome::Restarted { restart_count }),
230+
None => {
231+
// The box was removed while booting: the VM we started has no record.
232+
// Tear it down so it doesn't leak as an orphan shim + overlay mount.
233+
if let Some(pid) = booted_pid {
234+
crate::process::graceful_stop(pid, libc::SIGTERM, 5).await;
235+
}
236+
crate::cleanup::cleanup_removed_box(record);
237+
Ok(BootOutcome::RemovedDuringBoot)
238+
}
239+
}
240+
// `_lock` drops here — AFTER the record write — so a waiting actor's re-check
241+
// observes our recorded pid.
242+
}
243+
129244
fn ensure_boot_resources(
130245
record: &BoxRecord,
131246
) -> Result<BootResourceGuard, Box<dyn std::error::Error>> {

src/cli/src/commands/monitor.rs

Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -306,44 +306,36 @@ async fn poll_once(tracker: &mut BackoffTracker) -> Result<(), Box<dyn std::erro
306306
println!("{}", restart_log_line(&record, RestartReason::Dead));
307307
}
308308

309-
// Attempt restart
310-
match boot::boot_from_record(&record).await {
311-
Ok(result) => {
312-
// Capture the shim pid before `result` is moved into the closure,
313-
// so we can tear the VM down if the record vanished mid-boot.
314-
let booted_pid = result.pid;
315-
// Re-load fresh under the lock and apply only this box's restart
316-
// fields. Returns the new restart count, or None if the record is
317-
// gone — a concurrent `rm` removed the box while we were booting.
318-
let new_count = StateFile::modify(|s| {
319-
let count = s.find_by_id_mut(&box_id).map(|rec| {
320-
boot::apply_boot_result(rec, result, boot::RestartCountUpdate::Increment);
321-
rec.restart_count
322-
});
323-
Ok::<Option<u32>, std::io::Error>(count)
324-
})?;
309+
// Attempt restart, SERIALIZED per box via a per-box boot lock so a
310+
// concurrent user `restart`/`start` and this monitor restart cannot both
311+
// boot the same box (the second record write would overwrite the first's
312+
// pid, orphaning a VM). The orphan-on-`rm`-during-boot teardown now lives
313+
// inside `boot_and_record`.
314+
match boot::boot_and_record(&record, boot::RestartCountUpdate::Increment).await {
315+
Ok(boot::BootOutcome::Restarted { restart_count }) => {
325316
tracker.record_attempt(&box_id);
326-
match new_count {
327-
Some(new_count) => println!(
328-
"monitor: box {name} ({short_id}) restarted (count: {new_count})",
329-
name = record.name,
330-
short_id = record.short_id,
331-
),
332-
None => {
333-
// The box was removed during the restart boot: the VM we
334-
// just started has no record tracking it. Tear it down so
335-
// it doesn't leak as an orphan shim + overlay mount.
336-
eprintln!(
337-
"monitor: box {name} ({short_id}) was removed during restart; tearing down the orphaned VM",
338-
name = record.name,
339-
short_id = record.short_id,
340-
);
341-
if let Some(pid) = booted_pid {
342-
crate::process::graceful_stop(pid, libc::SIGTERM, 5).await;
343-
}
344-
crate::cleanup::cleanup_removed_box(&record);
345-
}
346-
}
317+
println!(
318+
"monitor: box {name} ({short_id}) restarted (count: {restart_count})",
319+
name = record.name,
320+
short_id = record.short_id,
321+
);
322+
}
323+
Ok(boot::BootOutcome::AlreadyRunning) => {
324+
// Another actor (a user restart/start) already brought this box
325+
// back under the per-box boot lock — nothing to do.
326+
println!(
327+
"monitor: box {name} ({short_id}) already restarted by another actor; skipping",
328+
name = record.name,
329+
short_id = record.short_id,
330+
);
331+
}
332+
Ok(boot::BootOutcome::RemovedDuringBoot) => {
333+
tracker.record_attempt(&box_id);
334+
eprintln!(
335+
"monitor: box {name} ({short_id}) was removed during restart; tore down the orphaned VM",
336+
name = record.name,
337+
short_id = record.short_id,
338+
);
347339
}
348340
Err(e) => {
349341
tracker.record_attempt(&box_id);

src/cli/src/commands/restart.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,16 @@ async fn restart_one(
8989
// (image, cmd, dirs) is immutable across the stop, so the in-memory handle is
9090
// fine to boot from; only the post-boot status write must be atomic.
9191
let record = resolve::resolve(state, &box_id)?;
92-
let result = boot::boot_from_record(record).await?;
93-
94-
// Persist the boot result atomically; the closure re-resolves by id against
95-
// fresh state so it never persists a stale snapshot (and Preserve keeps the
96-
// freshly-loaded restart_count).
97-
StateFile::modify(move |s| {
98-
if let Some(record) = s.find_by_id_mut(&box_id) {
99-
boot::apply_boot_result(record, result, boot::RestartCountUpdate::Preserve);
92+
// Boot + persist under a per-box boot lock (see boot::boot_and_record): if the
93+
// monitor (or another concurrent restart) already brought this box back, skip
94+
// rather than boot a duplicate VM that orphans one of the two.
95+
match boot::boot_and_record(record, boot::RestartCountUpdate::Preserve).await? {
96+
boot::BootOutcome::Restarted { .. } => println!("{name}"),
97+
boot::BootOutcome::AlreadyRunning => println!("{name} (already started)"),
98+
boot::BootOutcome::RemovedDuringBoot => {
99+
return Err(format!("{name} was removed during restart").into());
100100
}
101-
Ok::<(), std::io::Error>(())
102-
})?;
103-
104-
println!("{name}");
101+
}
105102
Ok(())
106103
}
107104

0 commit comments

Comments
 (0)