@@ -228,20 +228,42 @@ fn build_source_preamble(worktree: &Path) -> String {
228228 . join ( "; " )
229229}
230230
231+ /// Root `.ecluse` directory for a worktree: nearest ancestor containing one,
232+ /// falling back to the worktree itself (externally-registered worktrees).
233+ fn ecluse_dir_for ( worktree : & Path ) -> PathBuf {
234+ worktree
235+ . ancestors ( )
236+ . find ( |p| p. join ( ".ecluse" ) . exists ( ) )
237+ . unwrap_or ( worktree)
238+ . join ( ".ecluse" )
239+ }
240+
241+ /// Path of the per-session env preamble sourced by tmux windows.
242+ /// Namespaced by slug — a shared file would be overwritten by the next
243+ /// session's spawn and leak its ports into manual restarts here.
244+ pub fn env_preamble_path ( worktree : & Path , slug : & str ) -> PathBuf {
245+ ecluse_dir_for ( worktree)
246+ . join ( "preambles" )
247+ . join ( format ! ( "{}.sh" , slug) )
248+ }
249+
250+ /// Best-effort removal of a session's env preamble at teardown.
251+ pub fn remove_env_preamble ( worktree : & Path , slug : & str ) {
252+ let _ = std:: fs:: remove_file ( env_preamble_path ( worktree, slug) ) ;
253+ }
254+
231255fn write_env_preamble_file (
232256 worktree : & Path ,
257+ slug : & str ,
233258 env : & std:: collections:: HashMap < String , String > ,
234259) -> Option < std:: path:: PathBuf > {
235- // Write merged env as a sourceable file into .ecluse/ so tmux windows can source it
260+ // Write merged env as a sourceable file so tmux windows can source it
236261 // without sending a multi-KB export string through send-keys (which corrupts for
237262 // large envs due to terminal line-length limits and key-event reordering).
238- let ecluse_dir = worktree
239- . ancestors ( )
240- . find ( |p| p. join ( ".ecluse" ) . exists ( ) )
241- . unwrap_or ( worktree)
242- . join ( ".ecluse" ) ;
243- let _ = std:: fs:: create_dir_all ( & ecluse_dir) ;
244- let preamble_path = ecluse_dir. join ( "env-preamble.sh" ) ;
263+ let preamble_path = env_preamble_path ( worktree, slug) ;
264+ if let Some ( parent) = preamble_path. parent ( ) {
265+ let _ = std:: fs:: create_dir_all ( parent) ;
266+ }
245267 let mut lines: Vec < String > = env
246268 . iter ( )
247269 . map ( |( k, v) | format ! ( "export {}={}" , k, shell_escape( v) ) )
@@ -264,7 +286,7 @@ fn spawn_tmux(
264286
265287 // Write merged env to a file so tmux windows source it rather than receiving
266288 // a multi-KB export string through send-keys (safe for any env size).
267- let preamble_path = write_env_preamble_file ( worktree, & merged_env) ;
289+ let preamble_path = write_env_preamble_file ( worktree, slug , & merged_env) ;
268290
269291 // Build the source preamble: ecluse preamble file first, then the worktree env
270292 // files (.env → .env.local → .env.ecluse) so manual restarts (↑ Enter) also
@@ -368,58 +390,29 @@ fn spawn_nohup(
368390 worktree : & Path ,
369391 env : & std:: collections:: HashMap < String , String > ,
370392) -> Result < SpawnResult > {
371- use std:: fs:: File ;
372- use std:: os:: unix:: process:: CommandExt ;
373-
374- let log_dir = worktree
375- . ancestors ( )
376- . find ( |p| p. join ( ".ecluse" ) . exists ( ) )
377- . unwrap_or ( worktree)
378- . join ( ".ecluse" )
379- . join ( "logs" )
380- . join ( slug) ;
381- let pid_dir = worktree
382- . ancestors ( )
383- . find ( |p| p. join ( ".ecluse" ) . exists ( ) )
384- . unwrap_or ( worktree)
385- . join ( ".ecluse" )
386- . join ( "pids" )
387- . join ( slug) ;
393+ let ecluse_dir = ecluse_dir_for ( worktree) ;
394+ let log_dir = ecluse_dir. join ( "logs" ) . join ( slug) ;
395+ let pid_dir = ecluse_dir. join ( "pids" ) . join ( slug) ;
388396
389397 std:: fs:: create_dir_all ( & log_dir) ?;
390398 std:: fs:: create_dir_all ( & pid_dir) ?;
391399
392400 let merged_env = merge_worktree_env ( worktree, env) ;
393- let mut pid_files = vec ! [ ] ;
401+ let mut pid_files: Vec < PathBuf > = vec ! [ ] ;
394402
395403 for svc in services {
396- let cmd = svc. command . as_deref ( ) . unwrap ( ) ;
397- let log_path = log_dir. join ( format ! ( "{}.log" , svc. name) ) ;
398- let pid_path = pid_dir. join ( format ! ( "{}.pid" , svc. name) ) ;
399-
400- let log_file =
401- File :: create ( & log_path) . map_err ( |e| crate :: error:: EcluseError :: SpawnFailed {
402- service : svc. name . clone ( ) ,
403- reason : format ! ( "could not create log file: {}" , e) ,
404- } ) ?;
405- let log_file2 = log_file. try_clone ( ) ?;
406-
407- let child = Command :: new ( "sh" )
408- . arg ( "-c" )
409- . arg ( cmd)
410- . current_dir ( worktree)
411- . envs ( & merged_env)
412- . stdout ( log_file)
413- . stderr ( log_file2)
414- . process_group ( 0 )
415- . spawn ( )
416- . map_err ( |e| crate :: error:: EcluseError :: SpawnFailed {
417- service : svc. name . clone ( ) ,
418- reason : e. to_string ( ) ,
419- } ) ?;
420-
421- std:: fs:: write ( & pid_path, child. id ( ) . to_string ( ) ) ?;
422- pid_files. push ( pid_path) ;
404+ match spawn_one_nohup ( svc, worktree, & merged_env, & log_dir, & pid_dir) {
405+ Ok ( pid_path) => pid_files. push ( pid_path) ,
406+ Err ( e) => {
407+ // A partial spawn must not leave orphans: kill what already started.
408+ kill_nohup ( & SpawnResult {
409+ tmux_session : None ,
410+ pid_files,
411+ log_dir : Some ( log_dir. clone ( ) ) ,
412+ } ) ;
413+ return Err ( e) ;
414+ }
415+ }
423416 }
424417
425418 Ok ( SpawnResult {
@@ -429,21 +422,84 @@ fn spawn_nohup(
429422 } )
430423}
431424
425+ fn spawn_one_nohup (
426+ svc : & ServiceConfig ,
427+ worktree : & Path ,
428+ env : & std:: collections:: HashMap < String , String > ,
429+ log_dir : & Path ,
430+ pid_dir : & Path ,
431+ ) -> Result < PathBuf > {
432+ use std:: fs:: File ;
433+ use std:: os:: unix:: process:: CommandExt ;
434+
435+ let cmd = svc. command . as_deref ( ) . unwrap ( ) ;
436+ let log_path = log_dir. join ( format ! ( "{}.log" , svc. name) ) ;
437+ let pid_path = pid_dir. join ( format ! ( "{}.pid" , svc. name) ) ;
438+
439+ let log_file = File :: create ( & log_path) . map_err ( |e| crate :: error:: EcluseError :: SpawnFailed {
440+ service : svc. name . clone ( ) ,
441+ reason : format ! ( "could not create log file: {}" , e) ,
442+ } ) ?;
443+ let log_file2 = log_file. try_clone ( ) ?;
444+
445+ let child = Command :: new ( "sh" )
446+ . arg ( "-c" )
447+ . arg ( cmd)
448+ . current_dir ( worktree)
449+ . envs ( env)
450+ . stdout ( log_file)
451+ . stderr ( log_file2)
452+ . process_group ( 0 )
453+ . spawn ( )
454+ . map_err ( |e| crate :: error:: EcluseError :: SpawnFailed {
455+ service : svc. name . clone ( ) ,
456+ reason : e. to_string ( ) ,
457+ } ) ?;
458+
459+ std:: fs:: write ( & pid_path, child. id ( ) . to_string ( ) ) ?;
460+ Ok ( pid_path)
461+ }
462+
432463fn kill_nohup ( result : & SpawnResult ) {
433464 for pid_file in & result. pid_files {
434465 if let Ok ( content) = std:: fs:: read_to_string ( pid_file) {
435466 if let Ok ( pid) = content. trim ( ) . parse :: < u32 > ( ) {
436- Command :: new ( "kill" )
437- . args ( [ "-TERM" , & pid. to_string ( ) ] )
438- . output ( )
439- . ok ( ) ;
467+ kill_process_group ( pid) ;
440468 }
441469 }
442470 // Remove PID file regardless of kill success
443471 let _ = std:: fs:: remove_file ( pid_file) ;
444472 }
445473}
446474
475+ /// TERM an entire process group, escalating to KILL if anything survives the
476+ /// grace period. spawn_nohup runs each service in its own group
477+ /// (process_group(0), pgid == leader pid); signaling only the leader would
478+ /// orphan the service's children — the `sh -c` wrapper dies while the actual
479+ /// server keeps running and holds the port.
480+ fn kill_process_group ( pgid : u32 ) {
481+ let group = format ! ( "-{}" , pgid) ;
482+ let _ = Command :: new ( "kill" ) . args ( [ "-TERM" , "--" , & group] ) . output ( ) ;
483+
484+ let deadline = std:: time:: Instant :: now ( ) + std:: time:: Duration :: from_secs ( 2 ) ;
485+ while process_group_alive ( pgid) {
486+ if std:: time:: Instant :: now ( ) >= deadline {
487+ let _ = Command :: new ( "kill" ) . args ( [ "-KILL" , "--" , & group] ) . output ( ) ;
488+ return ;
489+ }
490+ std:: thread:: sleep ( std:: time:: Duration :: from_millis ( 50 ) ) ;
491+ }
492+ }
493+
494+ /// True while any process in the group still exists (kill -0 on the group).
495+ fn process_group_alive ( pgid : u32 ) -> bool {
496+ Command :: new ( "kill" )
497+ . args ( [ "-0" , "--" , & format ! ( "-{}" , pgid) ] )
498+ . output ( )
499+ . map ( |o| o. status . success ( ) )
500+ . unwrap_or ( false )
501+ }
502+
447503#[ cfg( test) ]
448504mod tests {
449505 use super :: * ;
@@ -657,4 +713,130 @@ mod tests {
657713 let merged = merge_worktree_env ( dir. path ( ) , & base) ;
658714 assert_eq ! ( merged. get( "KEY" ) . map( String :: as_str) , Some ( "from-local" ) ) ;
659715 }
716+
717+ fn native_svc ( name : & str , command : & str ) -> crate :: config:: ServiceConfig {
718+ crate :: config:: ServiceConfig {
719+ name : name. into ( ) ,
720+ base_port : 3000 ,
721+ run : crate :: config:: ServiceRun :: Native ,
722+ compose : None ,
723+ command : Some ( command. into ( ) ) ,
724+ port_env : vec ! [ ] ,
725+ debug_port : None ,
726+ extra_ports : vec ! [ ] ,
727+ host_port : None ,
728+ }
729+ }
730+
731+ fn wait_until ( timeout : std:: time:: Duration , mut cond : impl FnMut ( ) -> bool ) -> bool {
732+ let deadline = std:: time:: Instant :: now ( ) + timeout;
733+ while std:: time:: Instant :: now ( ) < deadline {
734+ if cond ( ) {
735+ return true ;
736+ }
737+ std:: thread:: sleep ( std:: time:: Duration :: from_millis ( 50 ) ) ;
738+ }
739+ cond ( )
740+ }
741+
742+ // The service command spawns a child; killing the session must take the
743+ // whole process group down, not just the `sh -c` group leader.
744+ #[ test]
745+ fn kill_nohup_kills_whole_process_group ( ) {
746+ let dir = TempDir :: new ( ) . unwrap ( ) ;
747+ std:: fs:: create_dir_all ( dir. path ( ) . join ( ".ecluse" ) ) . unwrap ( ) ;
748+ let child_pid_file = dir. path ( ) . join ( "child.pid" ) ;
749+ let svc = native_svc (
750+ "bg" ,
751+ & format ! ( "sleep 30 & echo $! > {}; wait" , child_pid_file. display( ) ) ,
752+ ) ;
753+ let result = spawn_services (
754+ & ProcessManager :: Nohup ,
755+ "pg-test" ,
756+ & [ & svc] ,
757+ dir. path ( ) ,
758+ & std:: collections:: HashMap :: new ( ) ,
759+ )
760+ . unwrap ( ) ;
761+
762+ assert ! (
763+ wait_until( std:: time:: Duration :: from_secs( 5 ) , || child_pid_file
764+ . exists( ) ) ,
765+ "child pid file never appeared"
766+ ) ;
767+ let child_pid: u32 = std:: fs:: read_to_string ( & child_pid_file)
768+ . unwrap ( )
769+ . trim ( )
770+ . parse ( )
771+ . unwrap ( ) ;
772+ assert ! ( pid_alive( child_pid) , "background child should be running" ) ;
773+
774+ kill_services ( & ProcessManager :: Nohup , & result) ;
775+
776+ assert ! (
777+ wait_until( std:: time:: Duration :: from_secs( 5 ) , || !pid_alive( child_pid) ) ,
778+ "background child must die with the process group"
779+ ) ;
780+ }
781+
782+ // Preamble files are per-slug; parallel sessions must never share one.
783+ #[ test]
784+ fn env_preamble_file_is_namespaced_per_slug ( ) {
785+ let dir = TempDir :: new ( ) . unwrap ( ) ;
786+ std:: fs:: create_dir_all ( dir. path ( ) . join ( ".ecluse" ) ) . unwrap ( ) ;
787+
788+ let mut env_a = std:: collections:: HashMap :: new ( ) ;
789+ env_a. insert ( "PORT" . to_string ( ) , "3001" . to_string ( ) ) ;
790+ let path_a = write_env_preamble_file ( dir. path ( ) , "sess-a" , & env_a) . unwrap ( ) ;
791+
792+ let mut env_b = std:: collections:: HashMap :: new ( ) ;
793+ env_b. insert ( "PORT" . to_string ( ) , "3002" . to_string ( ) ) ;
794+ let path_b = write_env_preamble_file ( dir. path ( ) , "sess-b" , & env_b) . unwrap ( ) ;
795+
796+ assert_ne ! ( path_a, path_b) ;
797+ assert ! ( std:: fs:: read_to_string( & path_a) . unwrap( ) . contains( "3001" ) ) ;
798+ assert ! ( std:: fs:: read_to_string( & path_b) . unwrap( ) . contains( "3002" ) ) ;
799+ }
800+
801+ #[ test]
802+ fn remove_env_preamble_deletes_only_that_slug ( ) {
803+ let dir = TempDir :: new ( ) . unwrap ( ) ;
804+ std:: fs:: create_dir_all ( dir. path ( ) . join ( ".ecluse" ) ) . unwrap ( ) ;
805+ let mut env = std:: collections:: HashMap :: new ( ) ;
806+ env. insert ( "PORT" . to_string ( ) , "3001" . to_string ( ) ) ;
807+ let path_a = write_env_preamble_file ( dir. path ( ) , "sess-a" , & env) . unwrap ( ) ;
808+ let path_b = write_env_preamble_file ( dir. path ( ) , "sess-b" , & env) . unwrap ( ) ;
809+
810+ remove_env_preamble ( dir. path ( ) , "sess-a" ) ;
811+ assert ! ( !path_a. exists( ) ) ;
812+ assert ! ( path_b. exists( ) ) ;
813+ }
814+
815+ // If service N fails to spawn, services 1..N-1 must be killed, not orphaned.
816+ #[ test]
817+ fn spawn_nohup_partial_failure_cleans_up_already_spawned ( ) {
818+ let dir = TempDir :: new ( ) . unwrap ( ) ;
819+ std:: fs:: create_dir_all ( dir. path ( ) . join ( ".ecluse" ) ) . unwrap ( ) ;
820+ // Make the second service's log file uncreatable: a directory in its place.
821+ std:: fs:: create_dir_all ( dir. path ( ) . join ( ".ecluse/logs/part/two.log" ) ) . unwrap ( ) ;
822+
823+ let one = native_svc ( "one" , "sleep 30" ) ;
824+ let two = native_svc ( "two" , "sleep 30" ) ;
825+ let err = spawn_services (
826+ & ProcessManager :: Nohup ,
827+ "part" ,
828+ & [ & one, & two] ,
829+ dir. path ( ) ,
830+ & std:: collections:: HashMap :: new ( ) ,
831+ )
832+ . unwrap_err ( ) ;
833+ assert ! ( err. to_string( ) . contains( "two" ) , "got: {}" , err) ;
834+
835+ // kill_nohup removes pid files after killing — service one must be cleaned up.
836+ let one_pid = dir. path ( ) . join ( ".ecluse/pids/part/one.pid" ) ;
837+ assert ! (
838+ !one_pid. exists( ) ,
839+ "service one's pid file should be removed by partial-spawn cleanup"
840+ ) ;
841+ }
660842}
0 commit comments