@@ -173,10 +173,15 @@ fn attach_thread(tid: libc::pid_t, stop_deadline: Instant) -> Result<(), PtraceE
173173 // On older kernels, the register state may not be
174174 // immediately readable after waitpid reports the stop. Spin briefly
175175 // until PEEKUSER returns a non-zero IP, proving registers are committed.
176- if !wait_for_registers ( tid, stop_deadline) {
177- let _ = detach_thread ( tid) ;
178- return Err ( PtraceError :: Attach ( tid, libc:: ETIMEDOUT ) ) ;
179- }
176+ //
177+ // If the deadline expires before we see a non-zero IP, proceed anyway:
178+ // libunwind uses PTRACE_GETREGSET which may succeed even when PEEKUSER
179+ // returns zero. If registers are truly uncommitted, unwind_remote_thread
180+ // will return 0 frames and capture_with_retry will retry without needing
181+ // a costly detach/re-attach cycle (which can fail with EPERM under CPU
182+ // pressure because the kernel hasn't fully released the prior ptrace
183+ // state).
184+ let _ = wait_for_registers ( tid, stop_deadline) ;
180185
181186 Ok ( ( ) )
182187}
@@ -245,6 +250,14 @@ fn detach_thread(tid: libc::pid_t) -> Result<(), PtraceError> {
245250 return Err ( PtraceError :: Detach ( tid, errno) ) ;
246251 }
247252 }
253+
254+ // Drain any pending waitpid event so the kernel fully releases the thread.
255+ // Without this, a rapid re-attach (PTRACE_SEIZE) can fail with EPERM under
256+ // CPU pressure because the kernel hasn't finished processing the detach.
257+ unsafe {
258+ libc:: waitpid ( tid, ptr:: null_mut ( ) , libc:: __WALL | libc:: WNOHANG ) ;
259+ }
260+
248261 Ok ( ( ) )
249262}
250263
@@ -390,58 +403,71 @@ pub fn capture_thread_context(
390403}
391404
392405/// Maximum time to wait for a single thread to enter ptrace-stop.
393- const STOP_TIMEOUT_PER_THREAD : Duration = Duration :: from_millis ( 50 ) ;
406+ ///
407+ /// 200ms accommodates CI environments with heavy CPU contention where the thread
408+ /// may take tens of milliseconds to be scheduled and enter ptrace-stop after
409+ /// PTRACE_INTERRUPT. On unloaded machines the stop typically arrives in <1ms.
410+ const STOP_TIMEOUT_PER_THREAD : Duration = Duration :: from_millis ( 200 ) ;
394411
395- /// Delay between retry attempts.
396- const RETRY_DELAY : Duration = Duration :: from_millis ( 5 ) ;
412+ /// Delay between retry attempts. Uses an exponential back-off starting from
413+ /// this value (10ms, 20ms, 40ms).
414+ const RETRY_BASE_DELAY : Duration = Duration :: from_millis ( 10 ) ;
415+
416+ /// Maximum number of retry attempts per thread.
417+ const MAX_RETRIES : u32 = 3 ;
397418
398419/// Returns true if err is worth retrying.
399420///
400- /// Only ETIMEDOUT (register-wait deadline expired before IP became readable)
401- /// should be retired.
402- ///
403421/// EPERM (Yama denial / missing PR_SET_PTRACER) and ESRCH (thread exited)
404422/// are permanent for the receiver's lifetime and are not retried.
405423fn is_transient_ptrace_error ( err : & PtraceError ) -> bool {
406424 matches ! ( err, PtraceError :: Attach ( _, libc:: ETIMEDOUT ) )
407425}
408426
409- /// Attempt to capture a thread context, retrying once on transient failures.
427+ /// Attempt to capture a thread context, retrying on transient failures.
410428///
411- /// Uses a single per-thread deadline across attempts so that a slow thread
412- /// cannot consume more than STOP_TIMEOUT_PER_THREAD total.
429+ /// Each attempt gets its own `STOP_TIMEOUT_PER_THREAD` budget (capped at the
430+ /// overall deadline) so that a retry after a timeout-induced failure actually
431+ /// has enough time to succeed. On older kernels (e.g. CentOS 7 / kernel 3.10)
432+ /// the first attempt can consume its entire budget waiting for registers to
433+ /// become readable; reusing that exhausted deadline would make the retry a
434+ /// no-op.
413435///
414436/// A capture that succeeds but produces zero frames is also retried: on a
415437/// running thread with a confirmed non-zero IP, empty frames indicates a
416438/// transient libunwind issue (e.g. stale address-space cache state) rather
417- /// than a permanent problem.
439+ /// than a permanent problem. Retries use exponential back-off to give the
440+ /// kernel time to fully commit thread state between attempts.
418441fn capture_with_retry (
419442 tid : libc:: pid_t ,
420443 resolve_frames : crate :: StacktraceCollection ,
421444 addr_space : UnwAddrSpaceT ,
422445 overall_deadline : Instant ,
423446) -> Option < CapturedThreadContext > {
424- let thread_deadline = ( Instant :: now ( ) + STOP_TIMEOUT_PER_THREAD ) . min ( overall_deadline) ;
447+ for attempt in 0 ..=MAX_RETRIES {
448+ let thread_deadline = ( Instant :: now ( ) + STOP_TIMEOUT_PER_THREAD ) . min ( overall_deadline) ;
425449
426- let should_retry =
427450 match capture_thread_context ( tid, resolve_frames, addr_space, thread_deadline) {
428451 Ok ( ctx) if !ctx. stack_trace . frames . is_empty ( ) => return Some ( ctx) ,
429- Ok ( _) => true ,
430- Err ( ref e) if is_transient_ptrace_error ( e) => true ,
431- Err ( _) => false ,
432- } ;
452+ Ok ( _) => { } // 0 frames -- retry
453+ Err ( ref e) if is_transient_ptrace_error ( e) => { } // ETIMEDOUT -- retry
454+ Err ( _) => return None , // permanent error
455+ }
433456
434- if !should_retry {
435- return None ;
436- }
457+ if attempt == MAX_RETRIES {
458+ break ;
459+ }
437460
438- let remaining = thread_deadline. saturating_duration_since ( Instant :: now ( ) ) ;
439- if remaining <= RETRY_DELAY {
440- return None ;
461+ let delay = RETRY_BASE_DELAY * 2u32 . saturating_pow ( attempt) ;
462+ if Instant :: now ( ) + delay >= overall_deadline {
463+ break ;
464+ }
465+ std:: thread:: sleep ( delay) ;
441466 }
442- std:: thread:: sleep ( RETRY_DELAY ) ;
443467
444- capture_thread_context ( tid, resolve_frames, addr_space, thread_deadline) . ok ( )
468+ // All attempts produced 0 frames or timed out; return None so the caller
469+ // records the thread with an incomplete stack rather than frames: [].
470+ None
445471}
446472
447473/// Stream thread contexts to a callback one at a time.
0 commit comments