Skip to content

Commit 67f42ce

Browse files
committed
Add VM removing state for reliable lifecycle cleanup
Introduce a "removing" intermediate state so that VM deletion is non-blocking and crash-recoverable: - remove_vm() marks the VM as removing, writes a .removing marker file, and spawns a background coroutine that stops the supervisor process, polls until it exits, removes it from supervisor, deletes the workdir, and finally frees the CID. - On startup (reload_vms) and on RPC reload (reload_vms_sync), VMs with a .removing marker are loaded into memory (visible in UI) and cleanup is resumed via spawn_finish_remove. - spawn_finish_remove checks the in-memory removing flag to avoid duplicate cleanup tasks. - start_vm and try_restart_exited_vms skip VMs in removing state. - UI shows "removing" status badge (amber) for VMs being cleaned up.
1 parent 7ad3460 commit 67f42ce

4 files changed

Lines changed: 205 additions & 36 deletions

File tree

vmm/src/app.rs

Lines changed: 166 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ impl App {
209209
}
210210

211211
pub async fn start_vm(&self, id: &str) -> Result<()> {
212+
{
213+
let state = self.lock();
214+
if let Some(vm) = state.get(id) {
215+
if vm.state.removing {
216+
bail!("VM is being removed");
217+
}
218+
}
219+
}
212220
self.sync_dynamic_config(id)?;
213221
let is_running = self
214222
.supervisor
@@ -266,33 +274,123 @@ impl App {
266274
}
267275

268276
pub async fn remove_vm(&self, id: &str) -> Result<()> {
269-
let info = self.supervisor.info(id).await?;
270-
let is_running = info.as_ref().is_some_and(|i| i.state.status.is_running());
271-
if is_running {
272-
bail!("VM is running, stop it first");
277+
{
278+
let mut state = self.lock();
279+
let vm = state.get_mut(id).context("VM not found")?;
280+
if vm.state.removing {
281+
// Already being removed — idempotent
282+
return Ok(());
283+
}
284+
vm.state.removing = true;
273285
}
274286

275-
if let Some(info) = info {
276-
if !info.state.status.is_stopped() {
277-
self.supervisor.stop(id).await?;
287+
// Persist the removing marker so crash recovery can resume
288+
let work_dir = self.work_dir(id);
289+
if let Err(err) = work_dir.set_removing() {
290+
warn!("Failed to write .removing marker for {id}: {err:?}");
291+
}
292+
293+
// Clean up port forwarding immediately
294+
self.cleanup_port_forward(id).await;
295+
296+
// Spawn background cleanup coroutine
297+
let app = self.clone();
298+
let id = id.to_string();
299+
tokio::spawn(async move {
300+
if let Err(err) = app.finish_remove_vm(&id).await {
301+
error!("Background cleanup failed for {id}: {err:?}");
302+
}
303+
});
304+
305+
Ok(())
306+
}
307+
308+
/// Background cleanup: stop supervisor process, wait for it to exit,
309+
/// remove from supervisor, delete workdir, and free CID.
310+
async fn finish_remove_vm(&self, id: &str) -> Result<()> {
311+
// Stop the supervisor process (idempotent if already stopped)
312+
if let Err(err) = self.supervisor.stop(id).await {
313+
debug!("supervisor.stop({id}) during removal: {err:?}");
314+
}
315+
316+
// Poll until the process is no longer running, then remove it.
317+
// Some VMs take a long time to stop (e.g. 2+ hours), so we wait indefinitely.
318+
let mut poll_count: u64 = 0;
319+
loop {
320+
match self.supervisor.info(id).await {
321+
Ok(Some(info)) if info.state.status.is_running() => {
322+
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
323+
poll_count += 1;
324+
if poll_count.is_multiple_of(30) {
325+
info!(
326+
"VM {id} still running after {}m during removal, waiting...",
327+
poll_count * 2 / 60
328+
);
329+
}
330+
}
331+
Ok(Some(_)) => {
332+
// Not running — remove from supervisor
333+
if let Err(err) = self.supervisor.remove(id).await {
334+
warn!("supervisor.remove({id}) failed: {err:?}");
335+
}
336+
break;
337+
}
338+
Ok(None) => {
339+
// Already gone from supervisor
340+
break;
341+
}
342+
Err(err) => {
343+
warn!("supervisor.info({id}) failed during removal: {err:?}");
344+
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
345+
}
346+
}
347+
}
348+
349+
// Delete the workdir (may already be gone, e.g. manual deletion before reload)
350+
let vm_path = self.work_dir(id);
351+
if vm_path.path().exists() {
352+
if let Err(err) = fs::remove_dir_all(&vm_path) {
353+
error!("Failed to remove VM directory for {id}: {err:?}");
278354
}
279-
self.supervisor.remove(id).await?;
280355
}
281356

357+
// Free CID and remove from memory (last step)
282358
{
283359
let mut state = self.lock();
284360
if let Some(vm_state) = state.remove(id) {
285361
state.cid_pool.free(vm_state.config.cid);
286362
}
287363
}
288364

289-
self.cleanup_port_forward(id).await;
290-
291-
let vm_path = self.work_dir(id);
292-
fs::remove_dir_all(&vm_path).context("Failed to remove VM directory")?;
365+
info!("VM {id} removed successfully");
293366
Ok(())
294367
}
295368

369+
/// Spawn a background task to clean up a VM (stop + remove from supervisor + delete workdir).
370+
/// Returns false if a cleanup task is already running for this VM.
371+
fn spawn_finish_remove(&self, id: &str) -> bool {
372+
{
373+
let mut state = self.lock();
374+
if let Some(vm) = state.get_mut(id) {
375+
if vm.state.removing {
376+
// Already being cleaned up — skip
377+
return false;
378+
}
379+
vm.state.removing = true;
380+
}
381+
// If VM is not in memory (e.g. orphaned supervisor process), no entry to guard
382+
// but we still need to clean up the supervisor process and workdir.
383+
}
384+
let app = self.clone();
385+
let id = id.to_string();
386+
tokio::spawn(async move {
387+
if let Err(err) = app.finish_remove_vm(&id).await {
388+
error!("Background cleanup failed for {id}: {err:?}");
389+
}
390+
});
391+
true
392+
}
393+
296394
/// Handle a DHCP lease notification: look up VM by MAC address, persist
297395
/// the guest IP, and reconfigure port forwarding.
298396
pub async fn report_dhcp_lease(&self, mac: &str, ip: &str) {
@@ -435,18 +533,48 @@ impl App {
435533
state.cid_pool.occupy(*cid)?;
436534
}
437535
}
536+
537+
// Track VMs with .removing marker — load them but resume cleanup
538+
let mut removing_ids = Vec::new();
539+
438540
if vm_path.exists() {
439-
for entry in fs::read_dir(vm_path).context("Failed to read VM directory")? {
541+
for entry in fs::read_dir(&vm_path).context("Failed to read VM directory")? {
440542
let entry = entry.context("Failed to read directory entry")?;
441543
let vm_path = entry.path();
442544
if vm_path.is_dir() {
443-
if let Err(err) = self.load_vm(vm_path, &occupied_cids, true).await {
545+
let workdir = VmWorkDir::new(&vm_path);
546+
let is_removing = workdir.is_removing();
547+
// Load all VMs into memory (including removing ones, so they show in UI)
548+
if let Err(err) = self.load_vm(&vm_path, &occupied_cids, !is_removing).await {
444549
error!("Failed to load VM: {err:?}");
445550
}
551+
if is_removing {
552+
if let Some(id) = vm_path.file_name().and_then(|n| n.to_str()) {
553+
info!("Found VM {id} with .removing marker, resuming cleanup");
554+
removing_ids.push(id.to_string());
555+
}
556+
}
446557
}
447558
}
448559
}
449560

561+
// Resume cleanup for VMs with .removing marker
562+
for id in removing_ids {
563+
self.spawn_finish_remove(&id);
564+
}
565+
566+
// Clean up orphaned supervisor processes (in supervisor but not loaded as VMs)
567+
let loaded_vm_ids: HashSet<String> = self.lock().vms.keys().cloned().collect();
568+
for (_, process) in &running_vms {
569+
if !loaded_vm_ids.contains(&process.config.id) {
570+
info!(
571+
"Cleaning up orphaned supervisor process: {}",
572+
process.config.id
573+
);
574+
self.spawn_finish_remove(&process.config.id);
575+
}
576+
}
577+
450578
// Restore port forwarding for running bridge-mode VMs with persisted guest IPs
451579
let vm_ids: Vec<String> = self.lock().vms.keys().cloned().collect();
452580
for id in vm_ids {
@@ -524,32 +652,24 @@ impl App {
524652

525653
// Remove VMs that no longer exist in filesystem
526654
let to_remove: Vec<String> = memory_vm_ids.difference(&fs_vm_ids).cloned().collect();
527-
if !to_remove.is_empty() {
528-
for vm_id in &to_remove {
529-
// Stop the VM process first if it's running
530-
if running_vms_map.contains_key(vm_id) {
531-
if let Err(err) = self.supervisor.stop(vm_id).await {
532-
warn!("Failed to stop VM process {vm_id}: {err:?}");
533-
}
534-
}
535-
536-
// Remove from memory and free CID
537-
let mut state = self.lock();
538-
if let Some(vm) = state.vms.remove(vm_id) {
539-
state.cid_pool.free(vm.config.cid);
540-
removed += 1;
541-
info!("Removed VM {vm_id} from memory (directory no longer exists)");
542-
}
655+
for vm_id in &to_remove {
656+
if self.spawn_finish_remove(vm_id) {
657+
removed += 1;
658+
info!("VM {vm_id} scheduled for removal (directory no longer exists)");
543659
}
544660
}
545661

546662
// Load or update VMs from filesystem
663+
let mut removing_ids = Vec::new();
547664
if vm_path.exists() {
548665
for entry in fs::read_dir(vm_path).context("Failed to read VM directory")? {
549666
let entry = entry.context("Failed to read directory entry")?;
550667
let vm_path = entry.path();
551668
if vm_path.is_dir() {
552-
match self.load_or_update_vm(&vm_path, &occupied_cids, true).await {
669+
let workdir = VmWorkDir::new(&vm_path);
670+
let is_removing = workdir.is_removing();
671+
// Load all VMs (including removing ones, so they show in UI)
672+
match self.load_or_update_vm(&vm_path, &occupied_cids, !is_removing).await {
553673
Ok(is_new) => {
554674
if is_new {
555675
loaded += 1;
@@ -561,9 +681,19 @@ impl App {
561681
error!("Failed to load or update VM: {err:?}");
562682
}
563683
}
684+
if is_removing {
685+
if let Some(id) = vm_path.file_name().and_then(|n| n.to_str()) {
686+
removing_ids.push(id.to_string());
687+
}
688+
}
564689
}
565690
}
566691
}
692+
for id in &removing_ids {
693+
if self.spawn_finish_remove(id) {
694+
info!("Resuming cleanup for VM {id} (.removing marker)");
695+
}
696+
}
567697

568698
// Clean up any orphaned CIDs that aren't being used
569699
{
@@ -901,6 +1031,9 @@ impl App {
9011031
.lock()
9021032
.iter_vms()
9031033
.filter(|vm| {
1034+
if vm.state.removing {
1035+
return false;
1036+
}
9041037
let workdir = self.work_dir(&vm.config.manifest.id);
9051038
let started = workdir.started().unwrap_or(false);
9061039
started && !running_vms.contains(&vm.config.manifest.id)
@@ -1002,6 +1135,8 @@ struct VmStateMut {
10021135
guest_ip: String,
10031136
devices: GpuConfig,
10041137
events: VecDeque<pb::GuestEvent>,
1138+
/// True when the VM is being removed (cleanup in progress).
1139+
removing: bool,
10051140
}
10061141

10071142
impl VmStateMut {

vmm/src/app/qemu.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,15 @@ impl VmState {
314314
None => false,
315315
};
316316
let started = workdir.started().unwrap_or(false);
317-
let status = match (started, is_running) {
318-
(true, true) => "running",
319-
(true, false) => "exited",
320-
(false, true) => "stopping",
321-
(false, false) => "stopped",
317+
let status = if self.state.removing {
318+
"removing"
319+
} else {
320+
match (started, is_running) {
321+
(true, true) => "running",
322+
(true, false) => "exited",
323+
(false, true) => "stopping",
324+
(false, false) => "stopped",
325+
}
322326
};
323327

324328
fn display_ts(t: Option<&SystemTime>) -> String {
@@ -1066,6 +1070,18 @@ impl VmWorkDir {
10661070
self.workdir.join("qmp.sock")
10671071
}
10681072

1073+
pub fn removing_marker(&self) -> PathBuf {
1074+
self.workdir.join(".removing")
1075+
}
1076+
1077+
pub fn is_removing(&self) -> bool {
1078+
self.removing_marker().exists()
1079+
}
1080+
1081+
pub fn set_removing(&self) -> Result<()> {
1082+
fs::write(self.removing_marker(), "").context("Failed to write .removing marker")
1083+
}
1084+
10691085
pub fn path(&self) -> &Path {
10701086
&self.workdir
10711087
}

vmm/src/console_v1.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,15 @@
517517
animation: none;
518518
}
519519

520+
.status-removing {
521+
background: #fde68a;
522+
color: #92400e;
523+
}
524+
525+
.status-removing .status-dot {
526+
background: #92400e;
527+
}
528+
520529
.status-stopped {
521530
background: #fee2e2;
522531
color: var(--color-danger);

vmm/ui/src/styles/main.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,15 @@ h1, h2, h3, h4, h5, h6 {
505505
animation: none;
506506
}
507507

508+
.status-removing {
509+
background: #fde68a;
510+
color: #92400e;
511+
}
512+
513+
.status-removing .status-dot {
514+
background: #92400e;
515+
}
516+
508517
.status-stopped {
509518
background: #fee2e2;
510519
color: var(--color-danger);

0 commit comments

Comments
 (0)