@@ -174,10 +174,11 @@ impl ProcessRuntime {
174174 Ok ( e) => e,
175175 Err ( _) => return Ok ( ( ) ) ,
176176 } ;
177- // Collect first so orphan stops can run concurrently. Sequential waits
178- // would be N × STOP_GRACE_SECS on a multi-bot restore.
177+ // Phase 1: parse every record before mutating any of them. If a later
178+ // file is corrupt, `?` must not leave earlier bots with their pid wiped
179+ // from disk while the orphan is still running (no ownership left to
180+ // reclaim on the next start → duplicate market makers).
179181 let mut pending: Vec < ( String , LiveBot , bool ) > = Vec :: new ( ) ;
180- let mut orphans: Vec < ( u32 , Option < u64 > ) > = Vec :: new ( ) ;
181182 for entry in entries. filter_map ( |e| e. ok ( ) ) {
182183 let path = entry. path ( ) ;
183184 if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "json" ) {
@@ -190,28 +191,32 @@ impl ProcessRuntime {
190191 let name = record. name . clone ( ) ;
191192 let log_path = self . log_path_for ( & name) ;
192193 let wanted = record. wanted_up && record. restart_unless_stopped ;
193- let mut live = LiveBot {
194- record,
195- child : None ,
196- log_path,
197- restart_after : None ,
198- restart_failures : 0 ,
199- } ;
200- // Only signal the pid when it still looks like our stitch binary
201- // (and, on Linux, the starttime matches) so a recycled PID is never
202- // killed. Clear the persisted pid before waiting so a crash mid-
203- // restore doesn't leave a stale claim.
194+ pending. push ( (
195+ name,
196+ LiveBot {
197+ record,
198+ child : None ,
199+ log_path,
200+ restart_after : None ,
201+ restart_failures : 0 ,
202+ } ,
203+ wanted,
204+ ) ) ;
205+ }
206+
207+ // Phase 2: claim orphans (clear persisted pid) then stop them. Only
208+ // signal when the pid still looks like our stitch binary (and, on
209+ // Linux, the starttime matches) so a recycled PID is never killed.
210+ // Full STOP_GRACE_SECS so a mid-tick bot can finish after SIGTERM;
211+ // parallelize so fleet size doesn't multiply cold-start latency.
212+ let mut orphans: Vec < ( u32 , Option < u64 > ) > = Vec :: new ( ) ;
213+ for ( _, live, _) in & mut pending {
204214 if let Some ( pid) = live. record . pid . take ( ) {
205215 let starttime = live. record . pid_starttime . take ( ) ;
206216 orphans. push ( ( pid, starttime) ) ;
207217 let _ = persist_record ( & self . state_dir , & live. record ) ;
208218 }
209- pending. push ( ( name, live, wanted) ) ;
210219 }
211-
212- // Full STOP_GRACE_SECS so a mid-tick bot can finish after SIGTERM —
213- // a 2s grace would SIGKILL during that window. Parallelize so fleet
214- // size doesn't multiply cold-start latency.
215220 let stitch_bin = & self . stitch_bin ;
216221 std:: thread:: scope ( |scope| {
217222 for ( pid, starttime) in orphans {
@@ -1513,6 +1518,81 @@ mod tests {
15131518 let _ = std:: fs:: remove_dir_all ( root) ;
15141519 }
15151520
1521+ #[ test]
1522+ fn load_persisted_keeps_pids_when_a_later_record_is_corrupt ( ) {
1523+ // Regression: clearing pid from early records before a later parse
1524+ // failure left orphans running with no ownership on disk.
1525+ let root = temp_root ( "corrupt-record" ) ;
1526+ let bots = root. join ( "bots" ) ;
1527+ let state = bots. join ( ".process-runtime" ) ;
1528+ std:: fs:: create_dir_all ( & state) . unwrap ( ) ;
1529+ let host = bots. join ( "bot-a" ) ;
1530+ std:: fs:: create_dir_all ( & host) . unwrap ( ) ;
1531+
1532+ let stitch_bin = root. join ( "stitch" ) ;
1533+ std:: fs:: copy ( which_sleep ( ) , & stitch_bin) . unwrap ( ) ;
1534+ #[ cfg( unix) ]
1535+ {
1536+ use std:: os:: unix:: fs:: PermissionsExt ;
1537+ let mut perms = std:: fs:: metadata ( & stitch_bin) . unwrap ( ) . permissions ( ) ;
1538+ perms. set_mode ( 0o755 ) ;
1539+ std:: fs:: set_permissions ( & stitch_bin, perms) . unwrap ( ) ;
1540+ }
1541+
1542+ let orphan = Command :: new ( & stitch_bin)
1543+ . arg ( "60" )
1544+ . stdout ( Stdio :: null ( ) )
1545+ . stderr ( Stdio :: null ( ) )
1546+ . spawn ( )
1547+ . unwrap ( ) ;
1548+ let orphan_pid = orphan. id ( ) ;
1549+ let orphan_start = wait_process_starttime ( orphan_pid) ;
1550+ std:: mem:: forget ( orphan) ;
1551+
1552+ let good = PersistedBot {
1553+ id : "proc-stitch-bot-a" . into ( ) ,
1554+ name : "stitch-bot-a" . into ( ) ,
1555+ image : BUNDLED_IMAGE . into ( ) ,
1556+ labels : HashMap :: new ( ) ,
1557+ env : vec ! [ ] ,
1558+ binds : vec ! [ PersistedBind :: from( & BindSpec :: rw( & host, RUN_DIR ) ) ] ,
1559+ cmd : Some ( vec ! [ "60" . into( ) ] ) ,
1560+ restart_unless_stopped : true ,
1561+ wanted_up : true ,
1562+ created_unix : now_unix ( ) ,
1563+ pid : Some ( orphan_pid) ,
1564+ pid_starttime : orphan_start,
1565+ } ;
1566+ persist_record ( & state, & good) . unwrap ( ) ;
1567+ // Lexically after stitch-bot-a.json so parse reaches the good record first.
1568+ std:: fs:: write ( state. join ( "stitch-bot-z-corrupt.json" ) , "{not-json" ) . unwrap ( ) ;
1569+
1570+ match ProcessRuntime :: new ( stitch_bin, & bots) {
1571+ Ok ( _) => panic ! ( "expected parse failure from corrupt sibling record" ) ,
1572+ Err ( err) => assert ! (
1573+ format!( "{err:#}" ) . contains( "parsing" ) ,
1574+ "expected parse failure, got: {err:#}"
1575+ ) ,
1576+ }
1577+
1578+ let on_disk: PersistedBot = serde_json:: from_str (
1579+ & std:: fs:: read_to_string ( state. join ( "stitch-bot-a.json" ) ) . unwrap ( ) ,
1580+ )
1581+ . unwrap ( ) ;
1582+ assert_eq ! (
1583+ on_disk. pid,
1584+ Some ( orphan_pid) ,
1585+ "good record pid must survive a later corrupt sibling"
1586+ ) ;
1587+ assert_eq ! ( on_disk. pid_starttime, orphan_start) ;
1588+ assert ! (
1589+ process_alive( orphan_pid) ,
1590+ "orphan must still be running so ownership can be reclaimed"
1591+ ) ;
1592+ terminate_pid ( orphan_pid, 2 ) ;
1593+ let _ = std:: fs:: remove_dir_all ( root) ;
1594+ }
1595+
15161596 #[ tokio:: test]
15171597 async fn abandoned_one_shot_terminates_the_child ( ) {
15181598 let sleep_bin = which_sleep ( ) ;
0 commit comments