@@ -201,6 +201,15 @@ impl ProcessRuntime {
201201 if let Some ( pid) = live. record . pid . take ( ) {
202202 let starttime = live. record . pid_starttime . take ( ) ;
203203 terminate_managed_pid ( pid, starttime, & self . stitch_bin , STOP_GRACE_SECS ) ;
204+ // If graceful stop didn't land (stuck child, ignored SIGTERM),
205+ // don't leave the orphan running beside the respawn.
206+ if pid_is_our_stitch ( pid, starttime, & self . stitch_bin ) {
207+ tracing:: warn!(
208+ pid,
209+ "orphan survived graceful stop on restore; sending SIGKILL"
210+ ) ;
211+ terminate_pid ( pid, 0 ) ;
212+ }
204213 let _ = persist_record ( & self . state_dir , & live. record ) ;
205214 }
206215 if wanted {
@@ -592,39 +601,41 @@ fn stop_child(child: &mut Child, grace_secs: i64) -> Result<()> {
592601 }
593602}
594603
595- /// True when `pid` is a live (non-zombie) process. Zombies still have a
596- /// `/proc/<pid>` entry and answer `kill(pid, 0)`, but they aren't running
597- /// market-maker code — safe to ignore and respawn.
604+ /// True when `pid` still exists as a non-zombie process.
605+ ///
606+ /// Zombies keep a `/proc/<pid>` entry and answer `kill(pid, 0)`, but they aren't
607+ /// running market-maker code — safe to ignore and respawn.
608+ ///
609+ /// Important: a transient failure to read `/proc/<pid>/status` must NOT be
610+ /// treated as "dead". That skips terminate and leaves orphans running; under
611+ /// parallel CI load the later assertion then sees the original starttime still
612+ /// alive.
598613fn process_alive ( pid : u32 ) -> bool {
599614 if pid == 0 {
600615 return false ;
601616 }
602617 #[ cfg( target_os = "linux" ) ]
603618 {
604- let Ok ( status) = std:: fs:: read_to_string ( format ! ( "/proc/{pid}/status" ) ) else {
605- return false ;
606- } ;
607- for line in status. lines ( ) {
608- let Some ( state) = line. strip_prefix ( "State:" ) else {
609- continue ;
610- } ;
611- // `State:\tZ (zombie)` — anything else (R/S/D/T…) counts as alive.
612- return !state. trim_start ( ) . starts_with ( 'Z' ) ;
619+ match std:: fs:: read_to_string ( format ! ( "/proc/{pid}/status" ) ) {
620+ Ok ( status) => {
621+ for line in status. lines ( ) {
622+ let Some ( state) = line. strip_prefix ( "State:" ) else {
623+ continue ;
624+ } ;
625+ // `State:\tZ (zombie)` — anything else (R/S/D/T…) counts as alive.
626+ return !state. trim_start ( ) . starts_with ( 'Z' ) ;
627+ }
628+ }
629+ Err ( err) if err. kind ( ) == std:: io:: ErrorKind :: NotFound => return false ,
630+ Err ( _) => {
631+ // Fall through to the kill(0) probe.
632+ }
613633 }
614- // status file without State: treat as alive if the pid exists.
615- true
634+ pid_kill0_exists ( pid)
616635 }
617636 #[ cfg( all( unix, not( target_os = "linux" ) ) ) ]
618637 {
619- let Ok ( pid) = i32:: try_from ( pid) else {
620- return false ;
621- } ;
622- // SAFETY: kill(pid, 0) is a liveness probe.
623- let rc = unsafe { libc:: kill ( pid, 0 ) } ;
624- if rc == 0 {
625- return true ;
626- }
627- std:: io:: Error :: last_os_error ( ) . raw_os_error ( ) == Some ( libc:: EPERM )
638+ pid_kill0_exists ( pid)
628639 }
629640 #[ cfg( windows) ]
630641 {
@@ -639,6 +650,21 @@ fn process_alive(pid: u32) -> bool {
639650 }
640651}
641652
653+ /// `kill(pid, 0)` existence probe. True when the pid exists (including zombies)
654+ /// or we lack permission to signal it.
655+ #[ cfg( unix) ]
656+ fn pid_kill0_exists ( pid : u32 ) -> bool {
657+ let Ok ( pid) = i32:: try_from ( pid) else {
658+ return false ;
659+ } ;
660+ // SAFETY: kill(pid, 0) is a liveness probe; it does not deliver a signal.
661+ let rc = unsafe { libc:: kill ( pid, 0 ) } ;
662+ if rc == 0 {
663+ return true ;
664+ }
665+ std:: io:: Error :: last_os_error ( ) . raw_os_error ( ) == Some ( libc:: EPERM )
666+ }
667+
642668/// Linux starttime from `/proc/<pid>/stat` (field 22), used as a pid-reuse guard.
643669fn process_starttime ( pid : u32 ) -> Option < u64 > {
644670 #[ cfg( target_os = "linux" ) ]
@@ -763,24 +789,28 @@ fn terminate_pid(pid: u32, grace_secs: i64) {
763789 }
764790 #[ cfg( unix) ]
765791 {
766- // kill(1) rather than libc::kill — clearer and matches what operators run.
767- let _ = Command :: new ( "kill" )
768- . args ( [ "-TERM" , & pid. to_string ( ) ] )
769- . stdout ( Stdio :: null ( ) )
770- . stderr ( Stdio :: null ( ) )
771- . status ( ) ;
772- let deadline = Instant :: now ( ) + Duration :: from_secs ( grace_secs. max ( 0 ) as u64 ) ;
773- while Instant :: now ( ) < deadline {
774- if !process_alive ( pid) {
775- return ;
792+ let Ok ( pid_i) = i32:: try_from ( pid) else {
793+ return ;
794+ } ;
795+ // libc::kill — same path as setup::terminate. Shelling out to kill(1)
796+ // can fail silently (PATH / wrapper) and leave orphans running.
797+ if grace_secs > 0 {
798+ // SAFETY: pid came from a process we spawned or persisted; signal is SIGTERM.
799+ unsafe {
800+ libc:: kill ( pid_i, libc:: SIGTERM ) ;
801+ }
802+ let deadline = Instant :: now ( ) + Duration :: from_secs ( grace_secs as u64 ) ;
803+ while Instant :: now ( ) < deadline {
804+ if !process_alive ( pid) {
805+ return ;
806+ }
807+ std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
776808 }
777- std:: thread:: sleep ( Duration :: from_millis ( 100 ) ) ;
778809 }
779- let _ = Command :: new ( "kill" )
780- . args ( [ "-KILL" , & pid. to_string ( ) ] )
781- . stdout ( Stdio :: null ( ) )
782- . stderr ( Stdio :: null ( ) )
783- . status ( ) ;
810+ // SAFETY: last-resort SIGKILL for a pid that survived SIGTERM (or grace 0).
811+ unsafe {
812+ libc:: kill ( pid_i, libc:: SIGKILL ) ;
813+ }
784814 let deadline = Instant :: now ( ) + Duration :: from_secs ( 2 ) ;
785815 while Instant :: now ( ) < deadline && process_alive ( pid) {
786816 std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
@@ -1357,6 +1387,12 @@ mod tests {
13571387 process_alive( orphan_pid) ,
13581388 "precondition: orphan must be running"
13591389 ) ;
1390+ // Identity must accept this orphan before we forget the Child — otherwise
1391+ // restore would skip the kill and the assertion below is meaningless.
1392+ assert ! (
1393+ pid_is_our_stitch( orphan_pid, orphan_start, & stitch_bin) ,
1394+ "precondition: orphan must match stitch identity checks"
1395+ ) ;
13601396 std:: mem:: forget ( orphan) ;
13611397 let record = PersistedBot {
13621398 id : "proc-stitch-bot-a" . into ( ) ,
@@ -1374,7 +1410,7 @@ mod tests {
13741410 } ;
13751411 persist_record ( & state, & record) . unwrap ( ) ;
13761412
1377- let rt = ProcessRuntime :: new ( stitch_bin, & bots) . unwrap ( ) ;
1413+ let rt = ProcessRuntime :: new ( stitch_bin. clone ( ) , & bots) . unwrap ( ) ;
13781414 let new_pid = {
13791415 let inner = rt. inner . lock ( ) . unwrap ( ) ;
13801416 inner[ "stitch-bot-a" ] . record . pid
@@ -1397,10 +1433,18 @@ mod tests {
13971433 }
13981434 std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
13991435 }
1400- assert ! (
1401- orphan_gone,
1402- "orphan pid {orphan_pid} still running with original starttime after restore"
1403- ) ;
1436+ if !orphan_gone {
1437+ let status = std:: fs:: read_to_string ( format ! ( "/proc/{orphan_pid}/status" ) )
1438+ . unwrap_or_else ( |e| format ! ( "<status unreadable: {e}>" ) ) ;
1439+ let exe = std:: fs:: read_link ( format ! ( "/proc/{orphan_pid}/exe" ) )
1440+ . map ( |p| p. display ( ) . to_string ( ) )
1441+ . unwrap_or_else ( |e| format ! ( "<exe unreadable: {e}>" ) ) ;
1442+ panic ! (
1443+ "orphan pid {orphan_pid} still running with original starttime after restore \
1444+ (new_pid={new_pid:?}, exe={exe}, still_ours={still_ours}, status=\n {status})",
1445+ still_ours = pid_is_our_stitch( orphan_pid, orphan_start, & stitch_bin) ,
1446+ ) ;
1447+ }
14041448 drop ( rt) ;
14051449 let _ = std:: fs:: remove_dir_all ( root) ;
14061450 }
@@ -1539,8 +1583,12 @@ mod tests {
15391583 }
15401584
15411585 fn temp_root ( tag : & str ) -> PathBuf {
1586+ // Include a monotonic seq: now_unix() is 1s resolution and all tests share
1587+ // one pid, so two fixtures in the same second must not collide / wipe each other.
1588+ static SEQ : std:: sync:: atomic:: AtomicU64 = std:: sync:: atomic:: AtomicU64 :: new ( 0 ) ;
1589+ let seq = SEQ . fetch_add ( 1 , Ordering :: Relaxed ) ;
15421590 let root = std:: env:: temp_dir ( ) . join ( format ! (
1543- "stitch-process-rt-{tag}-{}-{}" ,
1591+ "stitch-process-rt-{tag}-{}-{}-{seq} " ,
15441592 std:: process:: id( ) ,
15451593 now_unix( )
15461594 ) ) ;
0 commit comments