@@ -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,27 @@ 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
673+ . load_or_update_vm ( & vm_path, & occupied_cids, !is_removing)
674+ . await
675+ {
553676 Ok ( is_new) => {
554677 if is_new {
555678 loaded += 1 ;
@@ -561,9 +684,19 @@ impl App {
561684 error ! ( "Failed to load or update VM: {err:?}" ) ;
562685 }
563686 }
687+ if is_removing {
688+ if let Some ( id) = vm_path. file_name ( ) . and_then ( |n| n. to_str ( ) ) {
689+ removing_ids. push ( id. to_string ( ) ) ;
690+ }
691+ }
564692 }
565693 }
566694 }
695+ for id in & removing_ids {
696+ if self . spawn_finish_remove ( id) {
697+ info ! ( "Resuming cleanup for VM {id} (.removing marker)" ) ;
698+ }
699+ }
567700
568701 // Clean up any orphaned CIDs that aren't being used
569702 {
@@ -901,6 +1034,9 @@ impl App {
9011034 . lock ( )
9021035 . iter_vms ( )
9031036 . filter ( |vm| {
1037+ if vm. state . removing {
1038+ return false ;
1039+ }
9041040 let workdir = self . work_dir ( & vm. config . manifest . id ) ;
9051041 let started = workdir. started ( ) . unwrap_or ( false ) ;
9061042 started && !running_vms. contains ( & vm. config . manifest . id )
@@ -1002,6 +1138,8 @@ struct VmStateMut {
10021138 guest_ip : String ,
10031139 devices : GpuConfig ,
10041140 events : VecDeque < pb:: GuestEvent > ,
1141+ /// True when the VM is being removed (cleanup in progress).
1142+ removing : bool ,
10051143}
10061144
10071145impl VmStateMut {
0 commit comments