@@ -52,12 +52,18 @@ use std::path::PathBuf;
5252use std:: process:: { Command , Output } ;
5353use std:: sync:: atomic:: { AtomicBool , AtomicU32 , Ordering } ;
5454use std:: sync:: Mutex ;
55- use std:: time:: Instant ;
55+ use std:: time:: { Duration , Instant } ;
5656
5757/// Default per-step cap on captured stdout/stderr — bounds in-memory [`Report`]
5858/// size when fanning hundreds of forks out. Override via [`WarmBase::max_output`].
5959const DEFAULT_MAX_OUTPUT : usize = 1 << 20 ; // 1 MiB
6060
61+ /// Default number of times to retry a fork that hits an *infrastructure* failure
62+ /// (restore/start/boot/exec-spawn). The step's command never ran, so re-forking is
63+ /// safe and idempotent; this absorbs the transient boot hiccups that surface under
64+ /// sustained, highly-concurrent churn. Override via [`WarmBase::infra_retries`].
65+ const DEFAULT_INFRA_RETRIES : usize = 2 ;
66+
6167/// `exec_step` returns this exit code when the step never ran because of an
6268/// *infrastructure* failure (restore/start/exec), distinct from any real exit
6369/// code (a host process killed by a signal surfaces as -1, a guest `exit -1` as
@@ -319,6 +325,7 @@ pub struct WarmBase<'a> {
319325 env : BTreeMap < String , String > ,
320326 cache : Option < & ' a FileCache > ,
321327 max_output : usize ,
328+ infra_retries : usize ,
322329}
323330
324331impl < ' a > WarmBase < ' a > {
@@ -329,6 +336,7 @@ impl<'a> WarmBase<'a> {
329336 env : BTreeMap :: new ( ) ,
330337 cache : None ,
331338 max_output : DEFAULT_MAX_OUTPUT ,
339+ infra_retries : DEFAULT_INFRA_RETRIES ,
332340 }
333341 }
334342 pub fn env ( mut self , k : impl Into < String > , v : impl Into < String > ) -> Self {
@@ -344,6 +352,12 @@ impl<'a> WarmBase<'a> {
344352 self . max_output = bytes;
345353 self
346354 }
355+ /// Retries for a fork that hits an infrastructure failure (default 2). The
356+ /// step's command never ran on such a failure, so re-forking is idempotent.
357+ pub fn infra_retries ( mut self , n : usize ) -> Self {
358+ self . infra_retries = n;
359+ self
360+ }
347361}
348362
349363/// A pipeline step: a command run in its own kernel forked from the base.
@@ -468,6 +482,7 @@ pub struct Base<'a> {
468482 snapshot_id : String ,
469483 cache : Option < & ' a FileCache > ,
470484 max_output : usize ,
485+ infra_retries : usize ,
471486 n : AtomicU32 ,
472487 disposed : AtomicBool ,
473488}
@@ -543,10 +558,29 @@ impl Base<'_> {
543558 } )
544559 }
545560
561+ /// `exec_step` with bounded retry on *infrastructure* failure (the step's
562+ /// command never ran, so re-forking is idempotent). A non-zero step exit is
563+ /// returned as `Ok` and never retried — only a failed fork is.
564+ fn exec_step_retrying ( & self , step : & Step ) -> Result < StepResult > {
565+ let mut last: Option < PipelineError > = None ;
566+ for attempt in 0 ..=self . infra_retries {
567+ match self . exec_step ( step) {
568+ Ok ( r) => return Ok ( r) ,
569+ Err ( e) => {
570+ last = Some ( e) ;
571+ if attempt < self . infra_retries {
572+ std:: thread:: sleep ( Duration :: from_millis ( 150 * ( attempt as u64 + 1 ) ) ) ;
573+ }
574+ }
575+ }
576+ }
577+ Err ( last. expect ( "loop runs at least once" ) )
578+ }
579+
546580 /// Run one step, **fail-fast**: a non-zero exit returns `Err(StepFailed)`
547581 /// unless `Step::allow_failure` was set. Use for a sequential pipeline.
548582 pub fn step ( & self , step : Step ) -> Result < StepResult > {
549- let r = self . exec_step ( & step) ?;
583+ let r = self . exec_step_retrying ( & step) ?;
550584 if r. exit_code != 0 && !step. allow_failure {
551585 return Err ( PipelineError :: StepFailed {
552586 name : r. name . clone ( ) ,
@@ -561,7 +595,7 @@ impl Base<'_> {
561595 /// `StepResult{ exit_code: `[`INFRA_FAILURE`]`, stderr: <error> }` so one bad
562596 /// fork can't abort the batch.
563597 fn run_one ( & self , step : Step ) -> StepResult {
564- match self . exec_step ( & step) {
598+ match self . exec_step_retrying ( & step) {
565599 Ok ( r) => r,
566600 Err ( e) => StepResult {
567601 name : step. name . clone ( ) ,
@@ -701,11 +735,75 @@ pub fn warm_base(spec: WarmBase<'_>) -> Result<Base<'_>> {
701735 snapshot_id : prepared?,
702736 cache : spec. cache ,
703737 max_output : spec. max_output ,
738+ infra_retries : spec. infra_retries ,
704739 n : AtomicU32 :: new ( 0 ) ,
705740 disposed : AtomicBool :: new ( false ) ,
706741 } )
707742}
708743
744+ /// The owner pid embedded in a `ci-base-<key>-<pid>-<seq>...` resource name.
745+ fn parse_owner_pid ( name : & str ) -> Option < u32 > {
746+ let rest = name. strip_prefix ( "ci-base-" ) ?;
747+ let mut it = rest. split ( '-' ) ;
748+ it. next ( ) ?; // key hash
749+ it. next ( ) ?. parse ( ) . ok ( )
750+ }
751+
752+ /// `Some(true/false)` where pid-liveness is knowable (Linux `/proc`); `None`
753+ /// otherwise — so a sweep never reclaims resources whose owner it can't confirm dead.
754+ fn pid_alive ( pid : u32 ) -> Option < bool > {
755+ if !std:: path:: Path :: new ( "/proc" ) . is_dir ( ) {
756+ return None ;
757+ }
758+ Some ( std:: path:: Path :: new ( & format ! ( "/proc/{pid}" ) ) . exists ( ) )
759+ }
760+
761+ /// If `name` is a ci-base resource owned by a confirmed-DEAD pid other than `me`,
762+ /// return that pid (it is an orphan safe to reclaim).
763+ fn orphan_pid ( name : & str , me : u32 ) -> Option < u32 > {
764+ let pid = parse_owner_pid ( name) ?;
765+ if pid == me {
766+ return None ; // ours — never sweep a live self
767+ }
768+ match pid_alive ( pid) {
769+ Some ( false ) => Some ( pid) , // confirmed dead
770+ _ => None , // alive or unknowable — leave it
771+ }
772+ }
773+
774+ /// Reclaim `ci-base-*` boxes and snapshots leaked by a **crashed** pipeline
775+ /// process. `Drop`/guards clean up graceful exits, but `SIGKILL`/OOM/power-loss
776+ /// skip them; resource names embed the owner pid, so a resource whose pid is no
777+ /// longer alive (and isn't this process) is removed. Returns the names reclaimed.
778+ /// Safe to call concurrently with live pipelines: it only touches dead-pid orphans.
779+ pub fn sweep_orphans ( ) -> Vec < String > {
780+ let me = std:: process:: id ( ) ;
781+ let mut removed = Vec :: new ( ) ;
782+
783+ if let Ok ( out) = box_run ( & [ "ps" , "-a" , "--format" , "{{.Names}}" ] ) {
784+ for name in String :: from_utf8_lossy ( & out. stdout ) . lines ( ) {
785+ let name = name. trim ( ) ;
786+ if orphan_pid ( name, me) . is_some ( ) {
787+ box_cleanup ( & [ "rm" , "-f" , name] ) ;
788+ removed. push ( name. to_string ( ) ) ;
789+ }
790+ }
791+ }
792+ if let Ok ( out) = box_run ( & [ "snapshot" , "ls" ] ) {
793+ let text = String :: from_utf8_lossy ( & out. stdout ) ;
794+ for line in text. lines ( ) {
795+ let mut cols = line. split_whitespace ( ) ;
796+ if let ( Some ( id) , Some ( name) ) = ( cols. next ( ) , cols. next ( ) ) {
797+ if orphan_pid ( name, me) . is_some ( ) {
798+ box_cleanup ( & [ "snapshot" , "rm" , "--force" , id] ) ;
799+ removed. push ( name. to_string ( ) ) ;
800+ }
801+ }
802+ }
803+ }
804+ removed
805+ }
806+
709807#[ cfg( test) ]
710808mod tests {
711809 use super :: * ;
@@ -866,6 +964,31 @@ mod tests {
866964 ) ;
867965 }
868966
967+ #[ test]
968+ fn parse_owner_pid_and_orphan_detection ( ) {
969+ assert_eq ! ( parse_owner_pid( "ci-base-deadbeef-4242-0-snap" ) , Some ( 4242 ) ) ;
970+ assert_eq ! (
971+ parse_owner_pid( "ci-base-deadbeef-4242-7-snap-job3-make" ) ,
972+ Some ( 4242 )
973+ ) ;
974+ assert_eq ! ( parse_owner_pid( "other-box" ) , None ) ;
975+ assert_eq ! ( parse_owner_pid( "ci-base-onlykey" ) , None ) ;
976+ // this process's own resources are never orphans, regardless of liveness.
977+ let me = std:: process:: id ( ) ;
978+ let mine = format ! ( "ci-base-deadbeef-{me}-0-snap" ) ;
979+ assert_eq ! ( orphan_pid( & mine, me) , None ) ;
980+ // a confirmed-dead pid is an orphan — but only where liveness is knowable
981+ // (Linux /proc); on hosts where it isn't, sweep must leave it untouched.
982+ if pid_alive ( 4_000_000_000 ) . is_some ( ) {
983+ assert_eq ! (
984+ orphan_pid( "ci-base-deadbeef-4000000000-0-snap" , me) ,
985+ Some ( 4_000_000_000 )
986+ ) ;
987+ } else {
988+ assert_eq ! ( orphan_pid( "ci-base-deadbeef-4000000000-0-snap" , me) , None ) ;
989+ }
990+ }
991+
869992 #[ test]
870993 fn ci_error_display_covers_each_variant ( ) {
871994 let io = PipelineError :: from ( std:: io:: Error :: other ( "boom" ) ) ;
@@ -906,7 +1029,14 @@ case "$1" in
9061029 case "$2" in
9071030 create) name=""; while [ "$#" -gt 0 ]; do [ "$1" = "--name" ] && name="$2"; shift; done
9081031 printf '%s %s\n' "snap-fake1" "$name" >> "$state"; printf 'snap-fake1\n'; exit 0 ;;
909- restore) for a in "$@"; do case "$a" in *infrafail*) exit 1 ;; esac; done; exit 0 ;;
1032+ restore)
1033+ for a in "$@"; do
1034+ case "$a" in
1035+ *infrafail*) exit 1 ;;
1036+ *flaky*) c="$(dirname "$0")/.flaky"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; [ "$n" -le 2 ] && exit 1 || exit 0 ;;
1037+ esac
1038+ done
1039+ exit 0 ;;
9101040 ls) printf 'ID NAME SRC\n'; cat "$state" 2>/dev/null; exit 0 ;;
9111041 *) exit 0 ;;
9121042 esac ;;
@@ -972,12 +1102,23 @@ esac
9721102 ) ;
9731103 assert ! ( rep2. passed, "allow_failure step must not fail the batch" ) ;
9741104
975- // an infra failure (restore refuses) surfaces as INFRA_FAILURE, not a panic.
1105+ // an infra failure (restore refuses) surfaces as INFRA_FAILURE, not a panic
1106+ // (it is retried infra_retries times first, then gives up).
9761107 let rep3 = base. run_parallel ( vec ! [ Step :: new( "infrafail" , "make" ) ] , 1 ) ;
9771108 assert_eq ! ( rep3. steps[ 0 ] . exit_code, INFRA_FAILURE ) ;
9781109 assert ! ( !rep3. steps[ 0 ] . stderr. is_empty( ) ) ;
9791110 assert ! ( !rep3. passed) ;
9801111
1112+ // a TRANSIENT infra failure is retried: "flaky" restore fails twice then
1113+ // succeeds, so with the default 2 retries (3 attempts) the step recovers.
1114+ let _ = std:: fs:: remove_file ( dir. join ( ".flaky" ) ) ;
1115+ let okr = base. run_parallel ( vec ! [ Step :: new( "flaky" , "make" ) ] , 1 ) ;
1116+ assert_eq ! (
1117+ okr. steps[ 0 ] . exit_code, 0 ,
1118+ "transient infra failure should be retried to success"
1119+ ) ;
1120+ assert ! ( okr. passed) ;
1121+
9811122 // empty batch is trivially passed.
9821123 assert ! ( base. run_parallel( Vec :: new( ) , 4 ) . passed) ;
9831124
@@ -1028,7 +1169,8 @@ esac
10281169 let _ = WarmBase :: new ( "img" , "setup" )
10291170 . env ( "A" , "1" )
10301171 . cache ( & c)
1031- . max_output ( 4096 ) ;
1172+ . max_output ( 4096 )
1173+ . infra_retries ( 1 ) ;
10321174 let _ = Step :: new ( "n" , "cmd" )
10331175 . input ( "x" )
10341176 . env ( "K" , "V" )
0 commit comments