Skip to content

Commit 00c5103

Browse files
committed
refactor(vmm): supervise QEMU and swtpm with VM launcher
1 parent 013bd47 commit 00c5103

7 files changed

Lines changed: 427 additions & 223 deletions

File tree

dstack/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dstack/vmm/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ rocket-vsock-listener = { workspace = true }
1515
tracing.workspace = true
1616
tracing-subscriber = { workspace = true, features = ["env-filter"] }
1717
anyhow.workspace = true
18+
libc.workspace = true
1819
serde = { workspace = true, features = ["derive"] }
1920
serde_json.workspace = true
2021
shared_child.workspace = true
@@ -64,6 +65,7 @@ tar.workspace = true
6465

6566
[dev-dependencies]
6667
insta.workspace = true
68+
tempfile.workspace = true
6769

6870
[build-dependencies]
6971
or-panic.workspace = true

dstack/vmm/src/app.rs

Lines changed: 54 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ pub(crate) mod registry;
4848
mod vm_info;
4949
mod workdir;
5050

51+
fn signal_pidfd(pid: u32, signal: libc::c_int) -> std::io::Result<()> {
52+
// SAFETY: pidfd syscalls receive scalar arguments and a null siginfo pointer.
53+
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
54+
if fd < 0 {
55+
return Err(std::io::Error::last_os_error());
56+
}
57+
let result = unsafe {
58+
libc::syscall(
59+
libc::SYS_pidfd_send_signal,
60+
fd,
61+
signal,
62+
std::ptr::null::<libc::siginfo_t>(),
63+
0,
64+
)
65+
};
66+
// SAFETY: fd was returned by pidfd_open and is owned by this function.
67+
unsafe { libc::close(fd as libc::c_int) };
68+
if result < 0 {
69+
Err(std::io::Error::last_os_error())
70+
} else {
71+
Ok(())
72+
}
73+
}
74+
5175
#[derive(Deserialize, Serialize, Debug, Clone)]
5276
pub struct PortMapping {
5377
pub address: IpAddr,
@@ -255,26 +279,6 @@ pub struct App {
255279
pub(crate) pull_status: Arc<Mutex<std::collections::HashMap<String, PullStatus>>>,
256280
}
257281

258-
#[derive(Debug, PartialEq, Eq)]
259-
enum DependencyAction {
260-
KeepRunning,
261-
StopDependents,
262-
StopVm,
263-
Finished,
264-
}
265-
266-
fn dependency_action(main_running: bool, dependents_running: &[bool]) -> DependencyAction {
267-
if dependents_running.is_empty() {
268-
DependencyAction::Finished
269-
} else if !main_running {
270-
DependencyAction::StopDependents
271-
} else if dependents_running.iter().any(|running| !running) {
272-
DependencyAction::StopVm
273-
} else {
274-
DependencyAction::KeepRunning
275-
}
276-
}
277-
278282
impl App {
279283
pub(crate) fn lock(&self) -> MutexGuard<'_, AppState> {
280284
self.state.lock().or_panic("mutex poisoned")
@@ -298,7 +302,6 @@ impl App {
298302
cid_pool,
299303
vms: HashMap::new(),
300304
active_forwards: HashMap::new(),
301-
dependency_monitors: HashSet::new(),
302305
})),
303306
config: Arc::new(config),
304307
forward_service: Arc::new(tokio::sync::Mutex::new(ForwardService::new())),
@@ -397,7 +400,6 @@ impl App {
397400
vm_state.config.clone()
398401
};
399402
if !is_running {
400-
self.wait_for_dependent_processes_stopped(id).await?;
401403
let work_dir = self.work_dir(id);
402404
for path in [work_dir.serial_pty(), work_dir.qmp_socket()] {
403405
if path.symlink_metadata().is_ok() {
@@ -419,10 +421,8 @@ impl App {
419421
let vm_state = state.get_mut(id).context("VM not found")?;
420422
vm_state.state.runtime_networks = runtime_networks;
421423
}
422-
let has_dependent_processes = processes.len() > 1;
423424
for process in processes {
424425
if let Err(err) = self.supervisor.deploy(&process).await {
425-
self.stop_dependent_processes(id).await;
426426
if let Err(clear_err) = work_dir.clear_runtime_networks() {
427427
warn!(
428428
id,
@@ -436,9 +436,6 @@ impl App {
436436
.with_context(|| format!("failed to start process {}", process.id));
437437
}
438438
}
439-
if has_dependent_processes {
440-
self.spawn_dependency_monitor(id);
441-
}
442439

443440
let mut state = self.lock();
444441
let vm_state = state.get_mut(id).context("VM not found")?;
@@ -457,103 +454,36 @@ impl App {
457454
pub async fn stop_vm(&self, id: &str) -> Result<()> {
458455
self.set_started(id, false)?;
459456
self.cleanup_port_forward(id).await;
460-
let result = self.supervisor.stop(id).await;
461-
self.stop_dependent_processes(id).await;
462-
result
463-
}
464-
465-
async fn stop_dependent_processes(&self, id: &str) {
466-
for process in self.supervisor.list().await.unwrap_or_default() {
467-
let note =
468-
serde_json::from_str::<ProcessAnnotation>(&process.config.note).unwrap_or_default();
469-
if note.live_for.as_deref() == Some(id) {
470-
if let Err(err) = self.supervisor.stop(&process.config.id).await {
471-
debug!(
472-
process_id = process.config.id,
473-
"failed to stop dependent process: {err:?}"
474-
);
475-
}
476-
}
477-
}
478-
}
479-
480-
async fn wait_for_dependent_processes_stopped(&self, id: &str) -> Result<()> {
481-
for _ in 0..50 {
482-
let running = self.supervisor.list().await?.into_iter().any(|process| {
483-
let note = serde_json::from_str::<ProcessAnnotation>(&process.config.note)
484-
.unwrap_or_default();
485-
note.live_for.as_deref() == Some(id) && process.state.status.is_running()
486-
});
487-
if !running {
488-
return Ok(());
489-
}
490-
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
491-
}
492-
bail!("timed out waiting for dependent processes of VM {id} to stop")
457+
self.stop_vm_process(id).await?;
458+
Ok(())
493459
}
494460

495-
/// Keep auxiliary processes fail-closed with their owning VM. If QEMU
496-
/// exits, all dependents are stopped. If a required dependent exits first,
497-
/// QEMU is stopped rather than continuing with a broken emulated device.
498-
fn spawn_dependency_monitor(&self, id: &str) {
499-
{
500-
let mut state = self.lock();
501-
if !state.dependency_monitors.insert(id.to_string()) {
502-
return;
461+
async fn stop_vm_process(&self, id: &str) -> Result<()> {
462+
let Some(info) = self.supervisor.info(id).await? else {
463+
return Ok(());
464+
};
465+
if info.state.status.is_running() {
466+
let pid = info.state.pid.context("running VM launcher has no PID")?;
467+
if let Err(error) = signal_pidfd(pid, libc::SIGTERM) {
468+
warn!(id, %pid, %error, "failed to signal VM launcher gracefully; forcing shutdown");
469+
return self.supervisor.stop(id).await;
503470
}
504-
}
505-
let app = self.clone();
506-
let id = id.to_string();
507-
tokio::spawn(async move {
508-
loop {
509-
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
510-
let processes = match app.supervisor.list().await {
511-
Ok(processes) => processes,
512-
Err(err) => {
513-
warn!(vm_id = id, "failed to inspect dependent processes: {err:?}");
514-
continue;
515-
}
516-
};
517-
let main_running = processes
518-
.iter()
519-
.find(|process| process.config.id == id)
520-
.is_some_and(|process| process.state.status.is_running());
521-
let dependents = processes
522-
.iter()
523-
.filter(|process| {
524-
serde_json::from_str::<ProcessAnnotation>(&process.config.note)
525-
.unwrap_or_default()
526-
.live_for
527-
.as_deref()
528-
== Some(id.as_str())
529-
})
530-
.collect::<Vec<_>>();
531-
let dependent_states = dependents
532-
.iter()
533-
.map(|process| process.state.status.is_running())
534-
.collect::<Vec<_>>();
535-
match dependency_action(main_running, &dependent_states) {
536-
DependencyAction::KeepRunning => continue,
537-
DependencyAction::Finished => break,
538-
DependencyAction::StopDependents => {
539-
app.stop_dependent_processes(&id).await;
540-
break;
541-
}
542-
DependencyAction::StopVm => {
543-
error!(vm_id = id, "required VM process exited; stopping VM");
544-
if let Err(err) = app.supervisor.stop(&id).await {
545-
debug!(
546-
vm_id = id,
547-
"failed to stop VM after dependent exit: {err:?}"
548-
);
549-
}
550-
app.stop_dependent_processes(&id).await;
551-
break;
552-
}
471+
for _ in 0..150 {
472+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
473+
let running = self
474+
.supervisor
475+
.info(id)
476+
.await?
477+
.is_some_and(|info| info.state.status.is_running());
478+
if !running {
479+
// Synchronize Supervisor's `started` flag after the launcher
480+
// completed its graceful child cleanup.
481+
return self.supervisor.stop(id).await;
553482
}
554483
}
555-
app.lock().dependency_monitors.remove(&id);
556-
});
484+
warn!(id, "VM launcher did not stop gracefully; forcing shutdown");
485+
}
486+
self.supervisor.stop(id).await
557487
}
558488

559489
pub async fn remove_vm(&self, id: &str) -> Result<()> {
@@ -594,10 +524,9 @@ impl App {
594524
/// `delete_workdir`: true for user-initiated removal, false for orphan cleanup.
595525
async fn finish_remove_vm(&self, id: &str, delete_workdir: bool) -> Result<()> {
596526
// Stop the supervisor process (idempotent if already stopped)
597-
if let Err(err) = self.supervisor.stop(id).await {
598-
debug!("supervisor.stop({id}) during removal: {err:?}");
527+
if let Err(err) = self.stop_vm_process(id).await {
528+
debug!("graceful VM stop during removal failed: {err:?}");
599529
}
600-
self.stop_dependent_processes(id).await;
601530

602531
// Poll until the process is no longer running, then remove it.
603532
// Some VMs take a long time to stop (e.g. 2+ hours), so we wait indefinitely.
@@ -632,31 +561,6 @@ impl App {
632561
}
633562
}
634563

635-
// Auxiliary processes (currently swtpm) are owned by the VM through
636-
// ProcessAnnotation::live_for. Remove their stopped supervisor entries
637-
// before deleting the VM workdir.
638-
for process in self.supervisor.list().await.unwrap_or_default() {
639-
let note =
640-
serde_json::from_str::<ProcessAnnotation>(&process.config.note).unwrap_or_default();
641-
if note.live_for.as_deref() != Some(id) {
642-
continue;
643-
}
644-
for _ in 0..50 {
645-
match self.supervisor.info(&process.config.id).await {
646-
Ok(Some(info)) if info.state.status.is_running() => {
647-
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
648-
}
649-
_ => break,
650-
}
651-
}
652-
if let Err(err) = self.supervisor.remove(&process.config.id).await {
653-
warn!(
654-
process_id = process.config.id,
655-
"failed to remove dependent process: {err:?}"
656-
);
657-
}
658-
}
659-
660564
// Only delete the workdir for user-initiated removal or if .removing marker exists.
661565
// Orphaned supervisor processes without the marker keep their data intact.
662566
let vm_path = self.work_dir(id);
@@ -896,24 +800,16 @@ impl App {
896800

897801
// Clean up orphaned supervisor processes (in supervisor but not loaded as VMs)
898802
let loaded_vm_ids: HashSet<String> = self.lock().vms.keys().cloned().collect();
899-
for (note, process) in &running_vms {
900-
let owner = note.live_for.as_deref().unwrap_or(&process.config.id);
901-
if !loaded_vm_ids.contains(owner) {
803+
for (_, process) in &running_vms {
804+
if !loaded_vm_ids.contains(&process.config.id) {
902805
info!(
903806
"Cleaning up orphaned supervisor process: {}",
904807
process.config.id
905808
);
906-
self.spawn_finish_remove(owner);
809+
self.spawn_finish_remove(&process.config.id);
907810
}
908811
}
909812

910-
// Re-establish lifecycle monitoring after a VMM restart. The monitor
911-
// also reconciles a QEMU or dependent process that exited while VMM was
912-
// unavailable.
913-
for id in self.lock().vms.keys().cloned().collect::<Vec<_>>() {
914-
self.spawn_dependency_monitor(&id);
915-
}
916-
917813
// Restore port forwarding for running bridge-mode VMs with persisted guest IPs
918814
let vm_ids: Vec<String> = self.lock().vms.keys().cloned().collect();
919815
for id in vm_ids {
@@ -1705,24 +1601,6 @@ mod tests {
17051601
hex::encode(vec![byte; len])
17061602
}
17071603

1708-
#[test]
1709-
fn dependency_lifecycle_is_fail_closed() {
1710-
assert_eq!(
1711-
dependency_action(true, &[true]),
1712-
DependencyAction::KeepRunning
1713-
);
1714-
assert_eq!(
1715-
dependency_action(false, &[true]),
1716-
DependencyAction::StopDependents
1717-
);
1718-
assert_eq!(dependency_action(true, &[false]), DependencyAction::StopVm);
1719-
assert_eq!(
1720-
dependency_action(false, &[false]),
1721-
DependencyAction::StopDependents
1722-
);
1723-
assert_eq!(dependency_action(true, &[]), DependencyAction::Finished);
1724-
}
1725-
17261604
#[test]
17271605
fn gpu_config_has_gpus_only_when_resolved_gpu_list_is_non_empty() {
17281606
assert!(!GpuConfig::default().has_gpus());
@@ -2324,8 +2202,6 @@ pub(crate) struct AppState {
23242202
vms: HashMap<String, VmState>,
23252203
/// Tracks active port forwarding rules per VM ID (bridge mode only).
23262204
active_forwards: HashMap<String, Vec<ForwardRule>>,
2327-
/// VM IDs with an active auxiliary-process lifecycle monitor.
2328-
dependency_monitors: HashSet<String>,
23292205
}
23302206

23312207
impl AppState {

0 commit comments

Comments
 (0)