@@ -39,6 +39,11 @@ const RESTART_BACKOFF_MAX: Duration = Duration::from_secs(60);
3939/// Must not depend on UI/API polls — desktop `--autostart` has no browser traffic.
4040const SUPERVISOR_TICK : Duration = Duration :: from_secs ( 2 ) ;
4141
42+ /// Grace period when killing a persisted orphan on panel restore. Keep this short:
43+ /// the previous panel is already gone, and blocking startup for [`STOP_GRACE_SECS`]
44+ /// per orphan makes cold start feel broken. SIGKILL follows if needed.
45+ const ORPHAN_STOP_GRACE_SECS : i64 = 2 ;
46+
4247#[ derive( Debug , Clone , Serialize , Deserialize ) ]
4348struct PersistedBot {
4449 id : String ,
@@ -200,7 +205,7 @@ impl ProcessRuntime {
200205 // the starttime matches) so a recycled PID is never killed.
201206 if let Some ( pid) = live. record . pid . take ( ) {
202207 let starttime = live. record . pid_starttime . take ( ) ;
203- terminate_managed_pid ( pid, starttime, & self . stitch_bin , STOP_GRACE_SECS ) ;
208+ terminate_managed_pid ( pid, starttime, & self . stitch_bin , ORPHAN_STOP_GRACE_SECS ) ;
204209 // If graceful stop didn't land (stuck child, ignored SIGTERM),
205210 // don't leave the orphan running beside the respawn.
206211 if pid_is_our_stitch ( pid, starttime, & self . stitch_bin ) {
@@ -546,7 +551,8 @@ fn spawn_bot(stitch_bin: &Path, state_dir: &Path, live: &mut LiveBot) -> Result<
546551 . with_context ( || format ! ( "spawning {} for {}" , stitch_bin. display( ) , live. record. name) ) ?;
547552 let pid = child. id ( ) ;
548553 live. record . pid = Some ( pid) ;
549- live. record . pid_starttime = process_starttime ( pid) ;
554+ // execve can race spawn(); retry so we persist a stable starttime.
555+ live. record . pid_starttime = wait_process_starttime ( pid) ;
550556 live. child = Some ( child) ;
551557 live. record . wanted_up = true ;
552558 live. restart_after = None ;
@@ -665,6 +671,21 @@ fn pid_kill0_exists(pid: u32) -> bool {
665671 std:: io:: Error :: last_os_error ( ) . raw_os_error ( ) == Some ( libc:: EPERM )
666672}
667673
674+ /// Poll briefly for [`process_starttime`] after spawn — right after `execve` the
675+ /// `/proc` entry can briefly be missing under load.
676+ fn wait_process_starttime ( pid : u32 ) -> Option < u64 > {
677+ for _ in 0 ..50 {
678+ if let Some ( start) = process_starttime ( pid) {
679+ return Some ( start) ;
680+ }
681+ if !process_alive ( pid) {
682+ return None ;
683+ }
684+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
685+ }
686+ process_starttime ( pid)
687+ }
688+
668689/// Linux starttime from `/proc/<pid>/stat` (field 22), used as a pid-reuse guard.
669690fn process_starttime ( pid : u32 ) -> Option < u64 > {
670691 #[ cfg( target_os = "linux" ) ]
@@ -683,19 +704,22 @@ fn process_starttime(pid: u32) -> Option<u64> {
683704 }
684705}
685706
686- /// True when `pid` still looks like a stitch bot we spawned: exe/cmdline match
687- /// `stitch_bin`, and on Linux the persisted starttime still matches.
707+ /// True when `pid` still looks like a stitch bot we spawned.
708+ ///
709+ /// On Linux, a matching persisted `starttime` is sufficient: that pair is what
710+ /// we wrote at spawn, so it is the pid-reuse guard. Requiring exe/cmdline as
711+ /// well caused restore to skip kills under CI load when `/proc/<pid>/exe` was
712+ /// briefly unreadable, leaving the orphan running beside the respawn.
713+ /// Exe/cmdline checks apply when starttime was never recorded (non-Linux or
714+ /// older state files).
688715fn pid_is_our_stitch ( pid : u32 , starttime : Option < u64 > , stitch_bin : & Path ) -> bool {
689716 if !process_alive ( pid) {
690717 return false ;
691718 }
692719 #[ cfg( target_os = "linux" ) ]
693720 {
694721 if let Some ( expected) = starttime {
695- match process_starttime ( pid) {
696- Some ( actual) if actual == expected => { }
697- _ => return false ,
698- }
722+ return matches ! ( process_starttime( pid) , Some ( actual) if actual == expected) ;
699723 }
700724 let want_name = stitch_bin. file_name ( ) ;
701725 if let Ok ( exe) = std:: fs:: read_link ( format ! ( "/proc/{pid}/exe" ) ) {
@@ -784,40 +808,50 @@ fn terminate_managed_pid(pid: u32, starttime: Option<u64>, stitch_bin: &Path, gr
784808}
785809
786810fn terminate_pid ( pid : u32 , grace_secs : i64 ) {
787- if !process_alive ( pid) {
788- return ;
789- }
790811 #[ cfg( unix) ]
791812 {
792813 let Ok ( pid_i) = i32:: try_from ( pid) else {
793814 return ;
794815 } ;
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 ) ;
816+ if process_alive ( pid) {
817+ // libc::kill — same path as setup::terminate. Shelling out to kill(1)
818+ // can fail silently (PATH / wrapper) and leave orphans running.
819+ if grace_secs > 0 {
820+ // SAFETY: pid came from a process we spawned or persisted; signal is SIGTERM.
821+ unsafe {
822+ libc:: kill ( pid_i, libc:: SIGTERM ) ;
823+ }
824+ let deadline = Instant :: now ( ) + Duration :: from_secs ( grace_secs as u64 ) ;
825+ while Instant :: now ( ) < deadline {
826+ if !process_alive ( pid) {
827+ break ;
828+ }
829+ std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
830+ }
801831 }
802- let deadline = Instant :: now ( ) + Duration :: from_secs ( grace_secs as u64 ) ;
803- while Instant :: now ( ) < deadline {
804- if !process_alive ( pid) {
805- return ;
832+ if process_alive ( pid) {
833+ // SAFETY: last-resort SIGKILL for a pid that survived SIGTERM (or grace 0).
834+ unsafe {
835+ libc:: kill ( pid_i, libc:: SIGKILL ) ;
836+ }
837+ let deadline = Instant :: now ( ) + Duration :: from_secs ( 2 ) ;
838+ while Instant :: now ( ) < deadline && process_alive ( pid) {
839+ std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
806840 }
807- std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
808841 }
809842 }
810- // SAFETY: last-resort SIGKILL for a pid that survived SIGTERM (or grace 0).
843+ // If this pid is still our child (e.g. test `mem::forget` orphans), reap
844+ // the zombie so later (pid, starttime) probes don't see a leftover entry.
845+ // SAFETY: WNOHANG waitpid on a concrete pid; ECHILD/ESRCH are ignored.
811846 unsafe {
812- libc:: kill ( pid_i, libc:: SIGKILL ) ;
813- }
814- let deadline = Instant :: now ( ) + Duration :: from_secs ( 2 ) ;
815- while Instant :: now ( ) < deadline && process_alive ( pid) {
816- std:: thread:: sleep ( Duration :: from_millis ( 50 ) ) ;
847+ libc:: waitpid ( pid_i, std:: ptr:: null_mut ( ) , libc:: WNOHANG ) ;
817848 }
818849 }
819850 #[ cfg( windows) ]
820851 {
852+ if !process_alive ( pid) {
853+ return ;
854+ }
821855 let _ = Command :: new ( "taskkill" )
822856 . args ( [ "/PID" , & pid. to_string ( ) , "/T" , "/F" ] )
823857 . stdout ( Stdio :: null ( ) )
@@ -1382,16 +1416,24 @@ mod tests {
13821416 . spawn ( )
13831417 . unwrap ( ) ;
13841418 let orphan_pid = orphan. id ( ) ;
1385- let orphan_start = process_starttime ( orphan_pid) ;
1419+ // Spawn returns before execve finishes; poll until /proc identity is stable.
1420+ let orphan_start = wait_process_starttime ( orphan_pid) ;
1421+ let mut identity_ok = false ;
1422+ for _ in 0 ..50 {
1423+ if pid_is_our_stitch ( orphan_pid, orphan_start, & stitch_bin) {
1424+ identity_ok = true ;
1425+ break ;
1426+ }
1427+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
1428+ }
13861429 assert ! (
13871430 process_alive( orphan_pid) ,
13881431 "precondition: orphan must be running"
13891432 ) ;
1390- // Identity must accept this orphan before we forget the Child — otherwise
1391- // restore would skip the kill and the assertion below is meaningless.
13921433 assert ! (
1393- pid_is_our_stitch( orphan_pid, orphan_start, & stitch_bin) ,
1394- "precondition: orphan must match stitch identity checks"
1434+ identity_ok,
1435+ "precondition: orphan must match stitch identity checks \
1436+ (pid={orphan_pid}, starttime={orphan_start:?})"
13951437 ) ;
13961438 std:: mem:: forget ( orphan) ;
13971439 let record = PersistedBot {
@@ -1642,14 +1684,40 @@ mod tests {
16421684 . spawn ( )
16431685 . unwrap ( ) ;
16441686 let pid = orphan. id ( ) ;
1645- let start = process_starttime ( pid) ;
16461687 std:: mem:: forget ( orphan) ;
1647- // Claim this sleep pid belongs to `true` — identity check must refuse .
1648- terminate_managed_pid ( pid, start , & true_bin, 2 ) ;
1688+ // No starttime + wrong binary name → must refuse (pid-reuse safe path) .
1689+ terminate_managed_pid ( pid, None , & true_bin, 2 ) ;
16491690 assert ! (
16501691 process_alive( pid) ,
16511692 "must not kill a live process that isn't our stitch binary"
16521693 ) ;
16531694 terminate_pid ( pid, 2 ) ; // cleanup
16541695 }
1696+
1697+ #[ test]
1698+ #[ cfg( target_os = "linux" ) ]
1699+ fn matching_starttime_is_enough_to_own_a_pid ( ) {
1700+ // Regression: restore used to also require /proc/exe to match, and skipped
1701+ // the kill when that read flaked under parallel CI.
1702+ let sleep_bin = which_sleep ( ) ;
1703+ let orphan = Command :: new ( & sleep_bin)
1704+ . arg ( "30" )
1705+ . stdout ( Stdio :: null ( ) )
1706+ . stderr ( Stdio :: null ( ) )
1707+ . spawn ( )
1708+ . unwrap ( ) ;
1709+ let pid = orphan. id ( ) ;
1710+ let start = wait_process_starttime ( pid) ;
1711+ std:: mem:: forget ( orphan) ;
1712+ let unrelated = PathBuf :: from ( "/nonexistent/stitch-not-this-path" ) ;
1713+ assert ! (
1714+ start. is_some( ) && pid_is_our_stitch( pid, start, & unrelated) ,
1715+ "starttime match alone must identify the persisted orphan"
1716+ ) ;
1717+ terminate_managed_pid ( pid, start, & unrelated, 2 ) ;
1718+ assert ! (
1719+ !process_alive( pid) ,
1720+ "matching starttime must be enough to terminate on restore"
1721+ ) ;
1722+ }
16551723}
0 commit comments