@@ -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
10071142impl VmStateMut {
0 commit comments