@@ -255,6 +255,26 @@ pub struct App {
255255 pub ( crate ) pull_status : Arc < Mutex < std:: collections:: HashMap < String , PullStatus > > > ,
256256}
257257
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+
258278impl App {
259279 pub ( crate ) fn lock ( & self ) -> MutexGuard < ' _ , AppState > {
260280 self . state . lock ( ) . or_panic ( "mutex poisoned" )
@@ -278,6 +298,7 @@ impl App {
278298 cid_pool,
279299 vms : HashMap :: new ( ) ,
280300 active_forwards : HashMap :: new ( ) ,
301+ dependency_monitors : HashSet :: new ( ) ,
281302 } ) ) ,
282303 config : Arc :: new ( config) ,
283304 forward_service : Arc :: new ( tokio:: sync:: Mutex :: new ( ForwardService :: new ( ) ) ) ,
@@ -376,6 +397,7 @@ impl App {
376397 vm_state. config . clone ( )
377398 } ;
378399 if !is_running {
400+ self . wait_for_dependent_processes_stopped ( id) . await ?;
379401 let work_dir = self . work_dir ( id) ;
380402 for path in [ work_dir. serial_pty ( ) , work_dir. qmp_socket ( ) ] {
381403 if path. symlink_metadata ( ) . is_ok ( ) {
@@ -397,6 +419,7 @@ impl App {
397419 let vm_state = state. get_mut ( id) . context ( "VM not found" ) ?;
398420 vm_state. state . runtime_networks = runtime_networks;
399421 }
422+ let has_dependent_processes = processes. len ( ) > 1 ;
400423 for process in processes {
401424 if let Err ( err) = self . supervisor . deploy ( & process) . await {
402425 self . stop_dependent_processes ( id) . await ;
@@ -413,6 +436,9 @@ impl App {
413436 . with_context ( || format ! ( "failed to start process {}" , process. id) ) ;
414437 }
415438 }
439+ if has_dependent_processes {
440+ self . spawn_dependency_monitor ( id) ;
441+ }
416442
417443 let mut state = self . lock ( ) ;
418444 let vm_state = state. get_mut ( id) . context ( "VM not found" ) ?;
@@ -451,6 +477,85 @@ impl App {
451477 }
452478 }
453479
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" )
493+ }
494+
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 ;
503+ }
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+ }
553+ }
554+ }
555+ app. lock ( ) . dependency_monitors . remove ( & id) ;
556+ } ) ;
557+ }
558+
454559 pub async fn remove_vm ( & self , id : & str ) -> Result < ( ) > {
455560 {
456561 let mut state = self . lock ( ) ;
@@ -802,6 +907,13 @@ impl App {
802907 }
803908 }
804909
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+
805917 // Restore port forwarding for running bridge-mode VMs with persisted guest IPs
806918 let vm_ids: Vec < String > = self . lock ( ) . vms . keys ( ) . cloned ( ) . collect ( ) ;
807919 for id in vm_ids {
@@ -1593,6 +1705,24 @@ mod tests {
15931705 hex:: encode ( vec ! [ byte; len] )
15941706 }
15951707
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+
15961726 #[ test]
15971727 fn gpu_config_has_gpus_only_when_resolved_gpu_list_is_non_empty ( ) {
15981728 assert ! ( !GpuConfig :: default ( ) . has_gpus( ) ) ;
@@ -2194,6 +2324,8 @@ pub(crate) struct AppState {
21942324 vms : HashMap < String , VmState > ,
21952325 /// Tracks active port forwarding rules per VM ID (bridge mode only).
21962326 active_forwards : HashMap < String , Vec < ForwardRule > > ,
2327+ /// VM IDs with an active auxiliary-process lifecycle monitor.
2328+ dependency_monitors : HashSet < String > ,
21972329}
21982330
21992331impl AppState {
0 commit comments