Skip to main content

running_process_probe/crash/
mod.rs

1//! Default-on crash capture (#636).
2//!
3//! Calling [`install`] is the only arming point. Linking this crate installs
4//! no constructor and touches no signal or exception state. Once armed, a
5//! normal sampler thread keeps a bounded all-thread snapshot ready. The fatal
6//! callback copies only the raw platform context into that preallocated
7//! record and emits it with one OS write before returning `Handled(false)` so
8//! the previously installed application handler still runs.
9
10#![allow(unsafe_code)]
11
12pub mod spool;
13
14use std::cell::UnsafeCell;
15use std::collections::BTreeMap;
16use std::fs::File;
17use std::io;
18use std::path::PathBuf;
19use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
20use std::sync::{Arc, Mutex, OnceLock, Weak};
21use std::thread::JoinHandle;
22use std::time::Duration;
23
24use crash_handler::{CrashContext, CrashEventResult, CrashHandler};
25
26use self::spool::{CrashFrame, CrashMetadata, CrashModule, CrashThread, RECORD_SIZE};
27
28/// Crash interception policy. Calling `install` arms it by default.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum CrashPolicy {
31    /// Install native crash interception.
32    #[default]
33    On,
34    /// Leave all native handlers untouched.
35    Off,
36}
37
38/// Environment opt-out checked before any crash state is created.
39pub const NO_CRASH_HANDLER_ENV: &str = "RUNNING_PROCESS_PROBE_NO_CRASH_HANDLER";
40
41/// Local failure while arming crash capture.
42#[derive(Debug, thiserror::Error)]
43pub enum InstallError {
44    /// The owner-private spool could not be prepared.
45    #[error("cannot prepare crash spool: {0}")]
46    Spool(#[source] io::Error),
47    /// The platform crash handler could not be attached.
48    #[error("cannot attach native crash handler: {0}")]
49    Handler(#[source] crash_handler::Error),
50    /// The all-thread sampler could not be started.
51    #[error("cannot start crash snapshot sampler: {0}")]
52    Sampler(#[source] io::Error),
53    /// The platform SIGABRT predecessor chain could not be installed.
54    #[cfg(any(windows, target_os = "macos"))]
55    #[error("cannot chain the platform abort handler: {0}")]
56    AbortChain(#[source] io::Error),
57    /// A post-fork child must exec before installing its own crash runtime.
58    #[error("crash capture was inherited across fork; exec before reinstalling")]
59    ForkedProcess,
60}
61
62/// Keeps the native handler and sampler armed.
63pub struct CrashGuard {
64    runtime: Option<Arc<Runtime>>,
65    registration_id: Option<u64>,
66}
67
68impl CrashGuard {
69    /// An inert guard used by both opt-out paths.
70    pub fn inert() -> Self {
71        Self {
72            runtime: None,
73            registration_id: None,
74        }
75    }
76
77    /// Whether this guard contributes to an armed handler.
78    pub fn is_armed(&self) -> bool {
79        self.runtime.as_ref().is_some_and(|runtime| {
80            runtime.pid == std::process::id() && runtime.handler_armed.load(Ordering::Acquire)
81        })
82    }
83
84    /// Whether the background sampler has produced at least one snapshot.
85    pub fn sample_ready(&self) -> bool {
86        self.runtime
87            .as_ref()
88            .is_some_and(|runtime| runtime.shared.sample_ready.load(Ordering::Acquire))
89    }
90
91    /// Thread count in the latest bounded pre-crash sample.
92    pub fn sample_thread_count(&self) -> usize {
93        self.runtime.as_ref().map_or(0, |runtime| {
94            runtime.shared.sample_thread_count.load(Ordering::Acquire)
95        })
96    }
97
98    /// Pending spool path, exposed for diagnostic tests and operators.
99    pub fn spool_path(&self) -> Option<&std::path::Path> {
100        self.runtime.as_ref().map(|runtime| runtime.path.as_path())
101    }
102}
103
104impl Drop for CrashGuard {
105    fn drop(&mut self) {
106        if let (Some(runtime), Some(id)) = (&self.runtime, self.registration_id.take()) {
107            runtime.remove_registration(id);
108        }
109    }
110}
111
112static RUNTIME: OnceLock<Mutex<Weak<Runtime>>> = OnceLock::new();
113static OWNER_PID: AtomicU32 = AtomicU32::new(0);
114static HANDLER_TRANSITION: Mutex<()> = Mutex::new(());
115#[cfg(test)]
116static TEST_PAUSE_RESUME: AtomicBool = AtomicBool::new(false);
117#[cfg(test)]
118static TEST_RESUME_ENTERED: AtomicBool = AtomicBool::new(false);
119#[cfg(test)]
120static TEST_RELEASE_RESUME: AtomicBool = AtomicBool::new(false);
121
122/// Arm native crash capture unless policy or environment opts out.
123pub fn install(policy: CrashPolicy, metadata: CrashMetadata) -> Result<CrashGuard, InstallError> {
124    if policy == CrashPolicy::Off || env_opted_out() {
125        return Ok(CrashGuard::inert());
126    }
127
128    let pid = std::process::id();
129    match OWNER_PID.compare_exchange(0, pid, Ordering::AcqRel, Ordering::Acquire) {
130        Ok(_) => {}
131        Err(owner) if owner == pid => {}
132        Err(_) => {
133            // Do not touch a possibly locked mutex inherited from a vanished
134            // thread. The inherited callback is PID-gated and therefore inert.
135            return Err(InstallError::ForkedProcess);
136        }
137    }
138
139    let _transition = match HANDLER_TRANSITION.lock() {
140        Ok(transition) => transition,
141        Err(poisoned) => poisoned.into_inner(),
142    };
143    let slot = RUNTIME.get_or_init(|| Mutex::new(Weak::new()));
144    let mut weak = match slot.lock() {
145        Ok(guard) => guard,
146        Err(poisoned) => poisoned.into_inner(),
147    };
148    if let Some(runtime) = weak.upgrade() {
149        if let Err(error) = runtime.resume_handler() {
150            // This local upgrade can be the final Arc if the pre-existing last
151            // guard drops concurrently. Release both normal-context gates
152            // before dropping it, since Runtime::drop joins HANDLER_TRANSITION
153            // to serialize teardown against a new first attachment.
154            drop(weak);
155            drop(_transition);
156            drop(runtime);
157            return Err(error);
158        }
159        let registration_id = runtime.add_registration(metadata);
160        return Ok(CrashGuard {
161            runtime: Some(runtime),
162            registration_id: Some(registration_id),
163        });
164    }
165
166    let (runtime, registration_id) = Runtime::new(metadata)?;
167    *weak = Arc::downgrade(&runtime);
168    Ok(CrashGuard {
169        runtime: Some(runtime),
170        registration_id: Some(registration_id),
171    })
172}
173
174fn env_opted_out() -> bool {
175    std::env::var_os(NO_CRASH_HANDLER_ENV).is_some_and(|value| {
176        let text = value.to_string_lossy();
177        text == "1" || text.eq_ignore_ascii_case("true") || text.eq_ignore_ascii_case("yes")
178    })
179}
180
181struct Runtime {
182    shared: Arc<Shared>,
183    handler: Mutex<Option<CrashHandler>>,
184    handler_armed: AtomicBool,
185    sampler: Option<JoinHandle<()>>,
186    path: PathBuf,
187    pid: u32,
188    registrations: Mutex<RegistrationState>,
189}
190
191impl Runtime {
192    fn new(metadata: CrashMetadata) -> Result<(Arc<Self>, u64), InstallError> {
193        let (file, path, template) = spool::create_sink(&metadata).map_err(InstallError::Spool)?;
194        let shared = Arc::new(Shared::new(file, template));
195
196        let handler = match attach_handler(&shared) {
197            Ok(handler) => handler,
198            Err(error) => {
199                // Arming failed before ownership could move into `Runtime`.
200                // Close the pre-opened sink before removing it so Windows can
201                // delete it too; otherwise the daemon would retain an empty
202                // pending record forever.
203                drop(shared);
204                let _ = std::fs::remove_file(&path);
205                return Err(error);
206            }
207        };
208
209        let sampler_state = Arc::clone(&shared);
210        let sampler = match std::thread::Builder::new()
211            .name("rp-crash-sampler".into())
212            .spawn(move || sampler_loop(sampler_state))
213        {
214            Ok(sampler) => sampler,
215            Err(error) => {
216                #[cfg(windows)]
217                uninstall_windows_abort_chain();
218                #[cfg(target_os = "macos")]
219                uninstall_macos_abort_chain();
220                drop(handler);
221                drop(shared);
222                let _ = std::fs::remove_file(&path);
223                return Err(InstallError::Sampler(error));
224            }
225        };
226
227        let registration_id = 1;
228        let mut entries = BTreeMap::new();
229        entries.insert(registration_id, metadata);
230        Ok((
231            Arc::new(Self {
232                shared,
233                handler: Mutex::new(Some(handler)),
234                handler_armed: AtomicBool::new(true),
235                sampler: Some(sampler),
236                path,
237                pid: std::process::id(),
238                registrations: Mutex::new(RegistrationState {
239                    next_id: registration_id + 1,
240                    generation: 1,
241                    entries,
242                }),
243            }),
244            registration_id,
245        ))
246    }
247
248    fn add_registration(&self, metadata: CrashMetadata) -> u64 {
249        let mut state = match self.registrations.lock() {
250            Ok(state) => state,
251            Err(poisoned) => poisoned.into_inner(),
252        };
253        let id = state.next_id;
254        state.next_id = state.next_id.wrapping_add(1).max(1);
255        state.entries.insert(id, metadata);
256        id
257    }
258
259    fn remove_registration(&self, id: u64) {
260        let selected = {
261            let mut state = match self.registrations.lock() {
262                Ok(state) => state,
263                Err(poisoned) => poisoned.into_inner(),
264            };
265            let was_selected = state.entries.first_key_value().map(|(key, _)| *key) == Some(id);
266            state.entries.remove(&id);
267            if was_selected {
268                state.generation = state.generation.wrapping_add(1).max(1);
269                state
270                    .entries
271                    .first_key_value()
272                    .map(|(_, value)| (state.generation, value.clone()))
273            } else {
274                None
275            }
276        };
277        if let Some((generation, metadata)) = selected {
278            self.shared.set_metadata(generation, &metadata);
279        }
280    }
281
282    fn suspend_handler(&self) -> bool {
283        let mut handler = match self.handler.lock() {
284            Ok(handler) => handler,
285            Err(poisoned) => poisoned.into_inner(),
286        };
287        if handler.is_none() {
288            return false;
289        }
290        self.handler_armed.store(false, Ordering::Release);
291        #[cfg(windows)]
292        uninstall_windows_abort_chain();
293        #[cfg(target_os = "macos")]
294        uninstall_macos_abort_chain();
295        handler.take();
296        true
297    }
298
299    fn resume_handler(&self) -> Result<(), InstallError> {
300        let mut handler = match self.handler.lock() {
301            Ok(handler) => handler,
302            Err(poisoned) => poisoned.into_inner(),
303        };
304        #[cfg(test)]
305        if TEST_PAUSE_RESUME.load(Ordering::Acquire) {
306            TEST_RESUME_ENTERED.store(true, Ordering::Release);
307            while !TEST_RELEASE_RESUME.load(Ordering::Acquire) {
308                std::thread::yield_now();
309            }
310            TEST_PAUSE_RESUME.store(false, Ordering::Release);
311        }
312        if handler.is_none() {
313            *handler = Some(attach_handler(&self.shared)?);
314            self.handler_armed.store(true, Ordering::Release);
315        }
316        Ok(())
317    }
318}
319
320struct RegistrationState {
321    next_id: u64,
322    generation: u64,
323    entries: BTreeMap<u64, CrashMetadata>,
324}
325
326impl Drop for Runtime {
327    fn drop(&mut self) {
328        let handler = match self.handler.get_mut() {
329            Ok(handler) => handler,
330            Err(poisoned) => poisoned.into_inner(),
331        };
332        if self.pid != std::process::id() {
333            // A fork copied JoinHandle/handler bookkeeping but not the sampler
334            // thread. Joining or detaching here could deadlock on locks held by
335            // vanished threads. Leak those process-local registrations until
336            // the child execs/exits; callbacks are PID-gated and inert.
337            if let Some(handler) = handler.take() {
338                std::mem::forget(handler);
339            }
340            if let Some(sampler) = self.sampler.take() {
341                std::mem::forget(sampler);
342            }
343            return;
344        }
345        let _transition = match HANDLER_TRANSITION.lock() {
346            Ok(transition) => transition,
347            Err(poisoned) => poisoned.into_inner(),
348        };
349        // Uninstall first, so no callback can begin while the sampler and sink
350        // are being torn down.
351        #[cfg(windows)]
352        uninstall_windows_abort_chain();
353        #[cfg(target_os = "macos")]
354        uninstall_macos_abort_chain();
355        self.handler_armed.store(false, Ordering::Release);
356        handler.take();
357        self.shared.stop.store(true, Ordering::Release);
358        if let Some(handle) = self.sampler.take() {
359            let _ = handle.join();
360        }
361        // A cleanly dropped process never wrote the pre-opened file.
362        let _ = std::fs::remove_file(&self.path);
363    }
364}
365
366fn attach_handler(shared: &Arc<Shared>) -> Result<CrashHandler, InstallError> {
367    #[cfg(windows)]
368    let previous_abort = windows_previous_abort_handler().map_err(InstallError::AbortChain)?;
369    #[cfg(target_os = "macos")]
370    let previous_abort = macos_previous_abort_action().map_err(InstallError::AbortChain)?;
371
372    let callback_state = Arc::clone(shared);
373    // SAFETY: `handle_crash` performs only atomic operations, raw copies,
374    // clock_gettime/GetSystemTimeAsFileTime, and one OS write. It neither
375    // allocates nor locks.
376    let event = unsafe {
377        crash_handler::make_crash_event(move |context| {
378            callback_state.handle_crash(context);
379            CrashEventResult::Handled(false)
380        })
381    };
382    let handler = CrashHandler::attach(event).map_err(InstallError::Handler)?;
383
384    #[cfg(windows)]
385    if let Err(error) = install_windows_abort_chain(shared, previous_abort) {
386        drop(handler);
387        return Err(InstallError::AbortChain(error));
388    }
389    #[cfg(target_os = "macos")]
390    if let Err(error) = install_macos_abort_chain(shared, previous_abort) {
391        drop(handler);
392        return Err(InstallError::AbortChain(error));
393    }
394
395    Ok(handler)
396}
397
398/// Run a normal-context handler installation beneath the native crash layer.
399///
400/// Some runtimes install their own fatal handlers lazily. Temporarily
401/// detaching here lets the external handler become our new predecessor, so
402/// later native teardown restores it instead of the older process default.
403/// The native handler is re-armed before this function returns, even if the
404/// callback unwinds.
405pub fn with_handler_suspended<R>(install: impl FnOnce() -> R) -> Result<R, InstallError> {
406    let pid = std::process::id();
407    let owner_before_lock = OWNER_PID.load(Ordering::Acquire);
408    if owner_before_lock != 0 && owner_before_lock != pid {
409        return Err(InstallError::ForkedProcess);
410    }
411
412    // Declared before the transition guard so an unwind releases the gate
413    // before dropping the final possible Runtime Arc. Runtime::drop joins the
414    // same gate to serialize teardown against a new first attachment.
415    let runtime: Option<Arc<Runtime>>;
416    let transition = match HANDLER_TRANSITION.lock() {
417        Ok(transition) => transition,
418        Err(poisoned) => poisoned.into_inner(),
419    };
420    let owner = OWNER_PID.load(Ordering::Acquire);
421    if owner != 0 && owner != pid {
422        return Err(InstallError::ForkedProcess);
423    }
424    runtime = if owner == 0 {
425        None
426    } else {
427        let slot = RUNTIME.get_or_init(|| Mutex::new(Weak::new()));
428        let weak = match slot.lock() {
429            Ok(weak) => weak,
430            Err(poisoned) => poisoned.into_inner(),
431        };
432        weak.upgrade()
433    };
434
435    let Some(runtime) = runtime.as_ref() else {
436        return Ok(install());
437    };
438    runtime.resume_handler()?;
439    let suspended = runtime.suspend_handler();
440    let mut resume = ResumeHandler {
441        runtime: Arc::clone(runtime),
442        suspended,
443    };
444    let result = install();
445    let resumed = resume.finish();
446    drop(resume);
447    drop(transition);
448    resumed?;
449    Ok(result)
450}
451
452struct ResumeHandler {
453    runtime: Arc<Runtime>,
454    suspended: bool,
455}
456
457impl ResumeHandler {
458    fn finish(&mut self) -> Result<(), InstallError> {
459        if self.suspended {
460            self.runtime.resume_handler()?;
461            self.suspended = false;
462        }
463        Ok(())
464    }
465}
466
467impl Drop for ResumeHandler {
468    fn drop(&mut self) {
469        if self.suspended {
470            let _ = self.runtime.resume_handler();
471        }
472    }
473}
474
475struct Shared {
476    buffers: UnsafeCell<[[u8; RECORD_SIZE]; 2]>,
477    template: Mutex<[u8; RECORD_SIZE]>,
478    publish: Mutex<()>,
479    metadata_generation: AtomicU64,
480    active: AtomicUsize,
481    reading: AtomicBool,
482    in_handler: AtomicBool,
483    stop: AtomicBool,
484    sample_ready: AtomicBool,
485    sample_thread_count: AtomicUsize,
486    file: File,
487    pid: u32,
488}
489
490// The sampler is the sole normal writer. The handler sets `reading` before
491// touching the active buffer, and the sampler never overwrites either buffer
492// while that flag is set. The active index is published with release/acquire.
493unsafe impl Sync for Shared {}
494
495impl Shared {
496    fn new(file: File, template: [u8; RECORD_SIZE]) -> Self {
497        Self {
498            buffers: UnsafeCell::new([template, template]),
499            template: Mutex::new(template),
500            publish: Mutex::new(()),
501            metadata_generation: AtomicU64::new(1),
502            active: AtomicUsize::new(0),
503            reading: AtomicBool::new(false),
504            in_handler: AtomicBool::new(false),
505            stop: AtomicBool::new(false),
506            sample_ready: AtomicBool::new(false),
507            sample_thread_count: AtomicUsize::new(0),
508            file,
509            pid: std::process::id(),
510        }
511    }
512
513    fn set_metadata(&self, generation: u64, metadata: &CrashMetadata) {
514        let _publish = match self.publish.lock() {
515            Ok(publish) => publish,
516            Err(poisoned) => poisoned.into_inner(),
517        };
518        if generation <= self.metadata_generation.load(Ordering::Acquire) {
519            return;
520        }
521        let mut template = match self.template.lock() {
522            Ok(template) => template,
523            Err(poisoned) => poisoned.into_inner(),
524        };
525        spool::put_metadata(&mut template, metadata);
526        let inactive = 1 - self.active.load(Ordering::Acquire);
527        // SAFETY: `publish` serializes normal writers. The callback can only
528        // read the currently active buffer, while this writes the inactive
529        // one and publishes it afterward.
530        let target = unsafe { &mut (*self.buffers.get())[inactive] };
531        *target = *template;
532        self.metadata_generation
533            .store(generation, Ordering::Release);
534        self.active.store(inactive, Ordering::Release);
535    }
536
537    fn handle_crash(&self, context: &CrashContext) {
538        if self.pid != std::process::id() {
539            return;
540        }
541        if self
542            .in_handler
543            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
544            .is_err()
545        {
546            return;
547        }
548        self.reading.store(true, Ordering::SeqCst);
549        let index = self.active.load(Ordering::Acquire);
550        // SAFETY: `reading` excludes the sampler from this active buffer.
551        let record = unsafe { (*self.buffers.get())[index].as_mut_ptr() };
552        with_platform_fields(context, |fields| {
553            // SAFETY: the context lives for this callback, and `record` names
554            // the exclusive active fixed buffer.
555            unsafe {
556                spool::put_crash(
557                    record,
558                    fields.tid,
559                    fields.code,
560                    fields.address,
561                    fields.raw,
562                    fields.raw_len,
563                );
564                write_once(&self.file, record, RECORD_SIZE);
565            }
566        });
567        self.reading.store(false, Ordering::Release);
568        self.in_handler.store(false, Ordering::Release);
569    }
570
571    #[cfg(any(windows, target_os = "macos"))]
572    fn handle_abort(&self, tid: u64, code: i64) {
573        if self.pid != std::process::id() {
574            return;
575        }
576        if self
577            .in_handler
578            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
579            .is_err()
580        {
581            return;
582        }
583        self.reading.store(true, Ordering::SeqCst);
584        let index = self.active.load(Ordering::Acquire);
585        // SAFETY: `reading` excludes the sampler from this active buffer.
586        let record = unsafe { (*self.buffers.get())[index].as_mut_ptr() };
587        // SAFETY: fixed active buffer; the CRT abort path supplies no
588        // EXCEPTION_POINTERS, so the bounded all-thread sample is the register
589        // evidence for this synthetic fatal event.
590        unsafe {
591            spool::put_crash(record, tid, code, 0, std::ptr::null(), 0);
592            write_once(&self.file, record, RECORD_SIZE);
593        }
594        self.reading.store(false, Ordering::Release);
595        self.in_handler.store(false, Ordering::Release);
596    }
597}
598
599#[cfg(windows)]
600static WINDOWS_ABORT_SHARED: std::sync::atomic::AtomicPtr<Shared> =
601    std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
602#[cfg(windows)]
603static WINDOWS_PREVIOUS_ABORT: AtomicUsize = AtomicUsize::new(0);
604#[cfg(windows)]
605static WINDOWS_ABORT_READERS: AtomicUsize = AtomicUsize::new(0);
606
607#[cfg(windows)]
608fn windows_previous_abort_handler() -> io::Result<usize> {
609    // `signal` has no read-only query. Swap to default and immediately restore
610    // in ordinary install context, before crash-handler attaches.
611    let previous = unsafe { libc::signal(libc::SIGABRT, libc::SIG_DFL) };
612    if previous == usize::MAX {
613        return Err(io::Error::last_os_error());
614    }
615    unsafe {
616        libc::signal(libc::SIGABRT, previous);
617    }
618    Ok(previous)
619}
620
621#[cfg(windows)]
622fn install_windows_abort_chain(shared: &Arc<Shared>, previous: usize) -> io::Result<()> {
623    // crash-handler captures the pre-existing abort handler but its Windows
624    // CRT shim does not invoke it for `Handled(false)`. Replace only that shim
625    // with a tiny wrapper that writes our record and then calls the handler
626    // that existed before crash-handler attached (notably Python faulthandler).
627    WINDOWS_PREVIOUS_ABORT.store(previous, Ordering::Release);
628    WINDOWS_ABORT_SHARED.store(Arc::as_ptr(shared).cast_mut(), Ordering::Release);
629    let replaced =
630        unsafe { libc::signal(libc::SIGABRT, windows_abort_handler as *const () as usize) };
631    if replaced == usize::MAX {
632        WINDOWS_ABORT_SHARED.store(std::ptr::null_mut(), Ordering::Release);
633        return Err(io::Error::last_os_error());
634    }
635    Ok(())
636}
637
638#[cfg(windows)]
639fn uninstall_windows_abort_chain() {
640    WINDOWS_ABORT_SHARED.store(std::ptr::null_mut(), Ordering::SeqCst);
641    // A callback that acquired the old pointer announces itself before the
642    // load. Wait in normal teardown context until it has finished using
643    // `Shared`; callbacks arriving after the null publication never deref it.
644    while WINDOWS_ABORT_READERS.load(Ordering::SeqCst) != 0 {
645        std::hint::spin_loop();
646    }
647    let previous = WINDOWS_PREVIOUS_ABORT.swap(0, Ordering::AcqRel);
648    if previous != 0 {
649        unsafe {
650            libc::signal(libc::SIGABRT, previous);
651        }
652    }
653}
654
655#[cfg(windows)]
656unsafe extern "C" fn windows_abort_handler(signal: i32, _subcode: i32) {
657    use windows_sys::Win32::System::Threading::GetCurrentThreadId;
658
659    WINDOWS_ABORT_READERS.fetch_add(1, Ordering::SeqCst);
660    let shared = WINDOWS_ABORT_SHARED.load(Ordering::SeqCst);
661    if let Some(shared) = unsafe { shared.as_ref() } {
662        shared.handle_abort(
663            u64::from(GetCurrentThreadId()),
664            i64::from(crash_handler::ExceptionCode::Abort as i32),
665        );
666    }
667    WINDOWS_ABORT_READERS.fetch_sub(1, Ordering::SeqCst);
668
669    let previous = WINDOWS_PREVIOUS_ABORT.load(Ordering::Acquire);
670    // Let the CRT invoke the predecessor using its exact private ABI. Calling
671    // CPython faulthandler's pointer directly is not equivalent on Windows and
672    // turns an abort into a secondary access violation.
673    unsafe {
674        libc::signal(libc::SIGABRT, previous);
675        libc::raise(signal);
676        libc::signal(libc::SIGABRT, windows_abort_handler as *const () as usize);
677    }
678}
679
680#[cfg(target_os = "macos")]
681static MACOS_PREVIOUS_ABORT: std::sync::atomic::AtomicPtr<libc::sigaction> =
682    std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
683#[cfg(target_os = "macos")]
684static MACOS_ABORT_SHARED: std::sync::atomic::AtomicPtr<Shared> =
685    std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
686#[cfg(target_os = "macos")]
687static MACOS_ABORT_READERS: AtomicUsize = AtomicUsize::new(0);
688
689#[cfg(target_os = "macos")]
690fn macos_previous_abort_action() -> io::Result<libc::sigaction> {
691    let mut previous = std::mem::MaybeUninit::uninit();
692    let result = unsafe { libc::sigaction(libc::SIGABRT, std::ptr::null(), previous.as_mut_ptr()) };
693    if result == 0 {
694        Ok(unsafe { previous.assume_init() })
695    } else {
696        Err(io::Error::last_os_error())
697    }
698}
699
700#[cfg(target_os = "macos")]
701fn install_macos_abort_chain(shared: &Arc<Shared>, previous: libc::sigaction) -> io::Result<()> {
702    // crash-handler's macOS SIGABRT shim reports the synthetic Mach event but
703    // does not dispatch the predecessor when our callback returns
704    // `Handled(false)`. Replace only that shim so Python faulthandler and
705    // application-owned abort actions retain their exact sigaction ABI.
706    //
707    // A signal delivery can select this wrapper before teardown restores the
708    // predecessor, yet enter after the reader count was observed at zero.
709    // Publish the new immutable generation before retiring the old one. The
710    // callback increments its hazard count before loading this pointer, so a
711    // delayed entry loads the new generation while every callback that could
712    // have loaded the old one is included in the drain below.
713    let previous = Box::into_raw(Box::new(previous));
714    let retired = MACOS_PREVIOUS_ABORT.swap(previous, Ordering::SeqCst);
715    while MACOS_ABORT_READERS.load(Ordering::SeqCst) != 0 {
716        std::hint::spin_loop();
717    }
718    if !retired.is_null() {
719        unsafe {
720            drop(Box::from_raw(retired));
721        }
722    }
723    MACOS_ABORT_SHARED.store(Arc::as_ptr(shared).cast_mut(), Ordering::Release);
724
725    let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
726    unsafe {
727        libc::sigemptyset(&mut action.sa_mask);
728        libc::sigaddset(&mut action.sa_mask, libc::SIGABRT);
729    }
730    action.sa_sigaction = macos_abort_handler as *const () as usize;
731    action.sa_flags = libc::SA_SIGINFO;
732    if unsafe { libc::sigaction(libc::SIGABRT, &action, std::ptr::null_mut()) } != 0 {
733        let error = io::Error::last_os_error();
734        // A delayed callback from the retired wrapper can have observed the
735        // newly published Shared. Clear it and drain before Runtime::new is
736        // allowed to drop the final Arc on this error path.
737        MACOS_ABORT_SHARED.store(std::ptr::null_mut(), Ordering::SeqCst);
738        while MACOS_ABORT_READERS.load(Ordering::SeqCst) != 0 {
739            std::hint::spin_loop();
740        }
741        return Err(error);
742    }
743    Ok(())
744}
745
746#[cfg(target_os = "macos")]
747fn uninstall_macos_abort_chain() {
748    // Close handler entry before checking the reader hazard count. Leaving the
749    // wrapper installed until after a zero read would allow a fresh callback
750    // to race the next generation's predecessor publication.
751    unsafe {
752        let previous = MACOS_PREVIOUS_ABORT.load(Ordering::Acquire);
753        debug_assert!(!previous.is_null());
754        libc::sigaction(libc::SIGABRT, previous, std::ptr::null_mut());
755    }
756    MACOS_ABORT_SHARED.store(std::ptr::null_mut(), Ordering::SeqCst);
757    while MACOS_ABORT_READERS.load(Ordering::SeqCst) != 0 {
758        std::hint::spin_loop();
759    }
760}
761
762#[cfg(target_os = "macos")]
763unsafe extern "C" fn macos_abort_handler(
764    signal: i32,
765    _info: *mut libc::siginfo_t,
766    _context: *mut std::ffi::c_void,
767) {
768    MACOS_ABORT_READERS.fetch_add(1, Ordering::SeqCst);
769    let previous = MACOS_PREVIOUS_ABORT.load(Ordering::SeqCst);
770    let shared = MACOS_ABORT_SHARED.load(Ordering::SeqCst);
771    if let Some(shared) = unsafe { shared.as_ref() } {
772        // Avoid pthread introspection in signal context; the already-published
773        // all-thread sample carries the platform thread identifiers.
774        shared.handle_abort(0, i64::from(signal));
775    }
776
777    // Restore and re-raise instead of calling the function pointer directly:
778    // this preserves SA_SIGINFO, SA_RESETHAND, masks, and default/ignore
779    // actions for CPython and arbitrary application predecessors.
780    unsafe {
781        libc::sigaction(signal, previous, std::ptr::null_mut());
782    }
783    MACOS_ABORT_READERS.fetch_sub(1, Ordering::SeqCst);
784    unsafe {
785        libc::raise(signal);
786    }
787}
788
789struct PlatformFields {
790    tid: u64,
791    code: i64,
792    address: u64,
793    raw: *const u8,
794    raw_len: usize,
795}
796
797#[cfg(any(target_os = "linux", target_os = "android"))]
798fn with_platform_fields<R>(
799    context: &CrashContext,
800    callback: impl FnOnce(PlatformFields) -> R,
801) -> R {
802    callback(PlatformFields {
803        tid: context.tid as u64,
804        code: i64::from(context.siginfo.ssi_signo),
805        address: context.siginfo.ssi_addr,
806        raw: context.as_bytes().as_ptr(),
807        raw_len: context.as_bytes().len(),
808    })
809}
810
811#[cfg(windows)]
812fn with_platform_fields<R>(
813    context: &CrashContext,
814    callback: impl FnOnce(PlatformFields) -> R,
815) -> R {
816    let mut address = 0u64;
817    let mut raw = (context as *const CrashContext).cast::<u8>();
818    let mut raw_len = std::mem::size_of::<CrashContext>();
819    // SAFETY: crash-handler supplies live EXCEPTION_POINTERS for the duration
820    // of the callback. Prefer the full register CONTEXT over the pointer-only
821    // wrapper.
822    unsafe {
823        if let Some(pointers) = context.exception_pointers.as_ref() {
824            if let Some(exception) = pointers.ExceptionRecord.as_ref() {
825                address = exception.ExceptionAddress as usize as u64;
826            }
827            if let Some(registers) = pointers.ContextRecord.as_ref() {
828                raw = std::ptr::from_ref(registers).cast::<u8>();
829                raw_len = std::mem::size_of_val(registers);
830            }
831        }
832    }
833    callback(PlatformFields {
834        tid: u64::from(context.thread_id),
835        code: i64::from(context.exception_code),
836        address,
837        raw,
838        raw_len,
839    })
840}
841
842#[cfg(target_os = "macos")]
843fn with_platform_fields<R>(
844    context: &CrashContext,
845    callback: impl FnOnce(PlatformFields) -> R,
846) -> R {
847    use mach2::kern_return::KERN_SUCCESS;
848    use mach2::thread_act::thread_get_state;
849
850    let mut identifier: libc::thread_identifier_info_data_t = unsafe { std::mem::zeroed() };
851    let mut identifier_count = libc::THREAD_IDENTIFIER_INFO_COUNT;
852    // SAFETY: the exception context supplies a live Mach thread port, and the
853    // flavor/count pair matches thread_identifier_info_data_t.
854    let identifier_result = unsafe {
855        libc::thread_info(
856            context.thread,
857            libc::THREAD_IDENTIFIER_INFO as libc::thread_flavor_t,
858            (&raw mut identifier).cast(),
859            &raw mut identifier_count,
860        )
861    };
862    let tid = if identifier_result == KERN_SUCCESS {
863        identifier.thread_id
864    } else {
865        u64::from(context.thread)
866    };
867    let code = context
868        .exception
869        .map(|exception| i64::from(exception.kind))
870        .unwrap_or(0);
871    let address = context
872        .exception
873        .and_then(|exception| exception.subcode)
874        .unwrap_or(0);
875
876    #[cfg(target_arch = "x86_64")]
877    {
878        use mach2::structs::x86_thread_state64_t;
879        use mach2::thread_status::x86_THREAD_STATE64;
880        let mut state = x86_thread_state64_t::new();
881        let mut count = x86_thread_state64_t::count();
882        // SAFETY: crash-handler supplies a live suspended Mach thread port,
883        // and the state/count pair matches x86_THREAD_STATE64.
884        let result = unsafe {
885            thread_get_state(
886                context.thread,
887                x86_THREAD_STATE64,
888                (&raw mut state).cast(),
889                &raw mut count,
890            )
891        };
892        if result == KERN_SUCCESS && count >= x86_thread_state64_t::count() {
893            return callback(PlatformFields {
894                tid,
895                code,
896                address,
897                raw: std::ptr::from_ref(&state).cast(),
898                raw_len: std::mem::size_of_val(&state),
899            });
900        }
901    }
902    #[cfg(target_arch = "aarch64")]
903    {
904        use mach2::structs::arm_thread_state64_t;
905        use mach2::thread_status::ARM_THREAD_STATE64;
906        let mut state = arm_thread_state64_t::new();
907        let mut count = arm_thread_state64_t::count();
908        // SAFETY: crash-handler supplies a live suspended Mach thread port,
909        // and the state/count pair matches ARM_THREAD_STATE64.
910        let result = unsafe {
911            thread_get_state(
912                context.thread,
913                ARM_THREAD_STATE64,
914                (&raw mut state).cast(),
915                &raw mut count,
916            )
917        };
918        if result == KERN_SUCCESS && count >= arm_thread_state64_t::count() {
919            return callback(PlatformFields {
920                tid,
921                code,
922                address,
923                raw: std::ptr::from_ref(&state).cast(),
924                raw_len: std::mem::size_of_val(&state),
925            });
926        }
927    }
928
929    // Unsupported architecture or a failed Mach state read still preserves
930    // the exception metadata, but never mistakes pointer-only data for a
931    // register context.
932    callback(PlatformFields {
933        tid,
934        code,
935        address,
936        raw: std::ptr::null(),
937        raw_len: 0,
938    })
939}
940
941fn sampler_loop(shared: Arc<Shared>) {
942    while !shared.stop.load(Ordering::Acquire) {
943        if !shared.reading.load(Ordering::Acquire) {
944            let sample = capture_sample();
945            // Recheck after the allocating capture: the callback may have
946            // started while capture was in progress.
947            if let Some(sample) = sample {
948                if !shared.reading.load(Ordering::Acquire) {
949                    let _publish = match shared.publish.lock() {
950                        Ok(publish) => publish,
951                        Err(poisoned) => poisoned.into_inner(),
952                    };
953                    let mut template = match shared.template.lock() {
954                        Ok(template) => template,
955                        Err(poisoned) => poisoned.into_inner(),
956                    };
957                    spool::put_sample(&mut template, &sample.modules, &sample.threads);
958                    let inactive = 1 - shared.active.load(Ordering::Acquire);
959                    // SAFETY: sampler is the only normal writer and writes
960                    // only the inactive buffer while holding `publish`.
961                    let target = unsafe { &mut (*shared.buffers.get())[inactive] };
962                    *target = *template;
963                    shared.active.store(inactive, Ordering::Release);
964                    shared
965                        .sample_thread_count
966                        .store(sample.threads.len(), Ordering::Release);
967                    shared.sample_ready.store(true, Ordering::Release);
968                }
969            }
970        }
971        std::thread::sleep(Duration::from_millis(50));
972    }
973}
974
975#[cfg(all(
976    any(windows, target_os = "linux", target_os = "macos"),
977    any(target_arch = "x86_64", target_arch = "aarch64")
978))]
979fn capture_sample() -> Option<CrashSample> {
980    use crate::snapshot::attribute::attribute;
981    use crate::snapshot::modules::enumerate_modules;
982    use crate::snapshot::{capture_and_resolve, SnapshotConfig};
983
984    let Ok(snapshot) = capture_and_resolve(&SnapshotConfig::default()) else {
985        return None;
986    };
987    let Ok(loaded) = enumerate_modules() else {
988        return None;
989    };
990    let attributed = attribute(&snapshot, &loaded);
991    Some(CrashSample {
992        modules: attributed
993            .modules
994            .into_iter()
995            .map(|module| CrashModule {
996                identity: module.path.unwrap_or(module.name),
997            })
998            .collect(),
999        threads: attributed
1000            .threads
1001            .into_iter()
1002            .map(|thread| CrashThread {
1003                os_tid: thread.os_tid,
1004                frames: thread
1005                    .frames
1006                    .into_iter()
1007                    .map(|frame| CrashFrame {
1008                        module_index: frame.module_index,
1009                        relative_address: frame.relative_address,
1010                    })
1011                    .collect(),
1012            })
1013            .collect(),
1014    })
1015}
1016
1017#[cfg(not(all(
1018    any(windows, target_os = "linux", target_os = "macos"),
1019    any(target_arch = "x86_64", target_arch = "aarch64")
1020)))]
1021fn capture_sample() -> Option<CrashSample> {
1022    None
1023}
1024
1025#[derive(Default)]
1026struct CrashSample {
1027    modules: Vec<CrashModule>,
1028    threads: Vec<CrashThread>,
1029}
1030
1031/// One bounded file write. Partial writes remain parse-invalid and are left
1032/// for forensic inspection rather than retried from a compromised context.
1033///
1034/// # Safety
1035///
1036/// `record` must name `length` readable bytes.
1037#[cfg(unix)]
1038unsafe fn write_once(file: &File, record: *const u8, length: usize) {
1039    use std::os::fd::AsRawFd as _;
1040    // SAFETY: caller contract; `write` is async-signal-safe.
1041    let _ = unsafe { libc::write(file.as_raw_fd(), record.cast(), length) };
1042}
1043
1044/// One bounded Windows kernel write.
1045///
1046/// # Safety
1047///
1048/// `record` must name `length` readable bytes.
1049#[cfg(windows)]
1050unsafe fn write_once(file: &File, record: *const u8, length: usize) {
1051    use std::os::windows::io::AsRawHandle as _;
1052    use windows_sys::Win32::Storage::FileSystem::WriteFile;
1053    let mut written = 0u32;
1054    // SAFETY: caller contract and a live pre-opened file handle.
1055    let _ = unsafe {
1056        WriteFile(
1057            file.as_raw_handle() as _,
1058            record.cast(),
1059            length as u32,
1060            &raw mut written,
1061            std::ptr::null_mut(),
1062        )
1063    };
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069
1070    static PROCESS_STATE: Mutex<()> = Mutex::new(());
1071
1072    fn process_state() -> std::sync::MutexGuard<'static, ()> {
1073        match PROCESS_STATE.lock() {
1074            Ok(guard) => guard,
1075            Err(poisoned) => poisoned.into_inner(),
1076        }
1077    }
1078
1079    #[test]
1080    fn off_policy_is_inert() {
1081        let _state = process_state();
1082        let guard = install(
1083            CrashPolicy::Off,
1084            CrashMetadata {
1085                app_class: "a".into(),
1086                app_name: "a".into(),
1087                app_version: "1".into(),
1088                instance_name: String::new(),
1089                creation_time_ms: 1,
1090                cwd: "/test".into(),
1091            },
1092        )
1093        .unwrap();
1094        assert!(!guard.is_armed());
1095        assert!(guard.spool_path().is_none());
1096    }
1097
1098    #[test]
1099    fn opt_out_values_are_strict_and_documented() {
1100        let _state = process_state();
1101        assert!(!matches!(CrashPolicy::default(), CrashPolicy::Off));
1102    }
1103
1104    fn metadata(name: &str) -> CrashMetadata {
1105        CrashMetadata {
1106            app_class: name.into(),
1107            app_name: name.into(),
1108            app_version: "1".into(),
1109            instance_name: String::new(),
1110            creation_time_ms: 1,
1111            cwd: "/test".into(),
1112        }
1113    }
1114
1115    #[test]
1116    fn independent_guards_reselect_oldest_live_metadata() {
1117        let _state = process_state();
1118        let first = install(CrashPolicy::On, metadata("first")).unwrap();
1119        let second = install(CrashPolicy::On, metadata("second")).unwrap();
1120        assert!(first.is_armed());
1121        assert!(second.is_armed());
1122        drop(first);
1123        let template = match second.runtime.as_ref().unwrap().shared.template.lock() {
1124            Ok(template) => *template,
1125            Err(poisoned) => *poisoned.into_inner(),
1126        };
1127        assert_eq!(
1128            spool::parse(&template).unwrap().metadata.app_class,
1129            "second"
1130        );
1131        drop(second);
1132    }
1133
1134    #[test]
1135    fn external_handler_transitions_are_serialized_and_rearm() {
1136        let _state = process_state();
1137        let guard = install(CrashPolicy::On, metadata("transition-owner")).unwrap();
1138        let start = Arc::new(std::sync::Barrier::new(3));
1139        let inside = Arc::new(AtomicUsize::new(0));
1140        let overlapped = Arc::new(AtomicBool::new(false));
1141        let mut threads = Vec::new();
1142        for _ in 0..2 {
1143            let start = Arc::clone(&start);
1144            let inside = Arc::clone(&inside);
1145            let overlapped = Arc::clone(&overlapped);
1146            threads.push(std::thread::spawn(move || {
1147                start.wait();
1148                with_handler_suspended(|| {
1149                    if inside.fetch_add(1, Ordering::AcqRel) != 0 {
1150                        overlapped.store(true, Ordering::Release);
1151                    }
1152                    std::thread::sleep(Duration::from_millis(20));
1153                    inside.fetch_sub(1, Ordering::AcqRel);
1154                })
1155                .unwrap();
1156            }));
1157        }
1158        start.wait();
1159        for thread in threads {
1160            thread.join().unwrap();
1161        }
1162        assert!(!overlapped.load(Ordering::Acquire));
1163        assert!(guard.is_armed());
1164    }
1165
1166    #[test]
1167    fn install_waits_for_external_handler_transition() {
1168        let _state = process_state();
1169        let live_runtime = RUNTIME
1170            .get()
1171            .and_then(|slot| slot.lock().ok())
1172            .and_then(|weak| weak.upgrade());
1173        assert!(
1174            live_runtime.is_none(),
1175            "the first-attach race requires no existing runtime"
1176        );
1177        let (entered_tx, entered_rx) = std::sync::mpsc::channel();
1178        let (release_tx, release_rx) = std::sync::mpsc::channel();
1179        let transition = std::thread::spawn(move || {
1180            with_handler_suspended(|| {
1181                entered_tx.send(()).unwrap();
1182                release_rx.recv().unwrap();
1183            })
1184            .unwrap();
1185        });
1186        entered_rx.recv().unwrap();
1187
1188        let (installed_tx, installed_rx) = std::sync::mpsc::channel();
1189        let installer = std::thread::spawn(move || {
1190            let guard = install(CrashPolicy::On, metadata("transition-first")).unwrap();
1191            installed_tx.send(guard).unwrap();
1192        });
1193        assert!(
1194            installed_rx
1195                .recv_timeout(Duration::from_millis(30))
1196                .is_err(),
1197            "install returned while native interception was suspended"
1198        );
1199        release_tx.send(()).unwrap();
1200        transition.join().unwrap();
1201        let first = installed_rx.recv_timeout(Duration::from_secs(2)).unwrap();
1202        installer.join().unwrap();
1203        assert!(first.is_armed());
1204    }
1205
1206    #[test]
1207    fn failed_external_handler_rearm_is_reported_unarmed() {
1208        let _state = process_state();
1209        let guard = install(CrashPolicy::On, metadata("failed-rearm")).unwrap();
1210        let foreign = Arc::new(Mutex::new(None));
1211        let foreign_slot = Arc::clone(&foreign);
1212        let result = with_handler_suspended(|| {
1213            // SAFETY: the test callback is allocation-free and intentionally
1214            // does nothing; this handler only occupies crash-handler's global
1215            // slot so the runtime's reattach attempt has a stable failure.
1216            let event =
1217                unsafe { crash_handler::make_crash_event(|_| CrashEventResult::Handled(false)) };
1218            let handler = CrashHandler::attach(event).unwrap();
1219            match foreign_slot.lock() {
1220                Ok(mut slot) => *slot = Some(handler),
1221                Err(poisoned) => *poisoned.into_inner() = Some(handler),
1222            }
1223        });
1224        assert!(matches!(result, Err(InstallError::Handler(_))));
1225        assert!(!guard.is_armed());
1226
1227        // Release the deliberately competing global handler before this
1228        // runtime is dropped, leaving subsequent tests a clean process.
1229        match foreign.lock() {
1230            Ok(mut slot) => slot.take(),
1231            Err(poisoned) => poisoned.into_inner().take(),
1232        };
1233
1234        with_handler_suspended(|| {}).unwrap();
1235        assert!(
1236            guard.is_armed(),
1237            "the next transition must retry a failed reattach"
1238        );
1239
1240        let foreign_slot = Arc::clone(&foreign);
1241        let second_failure = with_handler_suspended(|| {
1242            // SAFETY: same inert test callback as above.
1243            let event =
1244                unsafe { crash_handler::make_crash_event(|_| CrashEventResult::Handled(false)) };
1245            let handler = CrashHandler::attach(event).unwrap();
1246            match foreign_slot.lock() {
1247                Ok(mut slot) => *slot = Some(handler),
1248                Err(poisoned) => *poisoned.into_inner() = Some(handler),
1249            }
1250        });
1251        assert!(matches!(second_failure, Err(InstallError::Handler(_))));
1252        match foreign.lock() {
1253            Ok(mut slot) => slot.take(),
1254            Err(poisoned) => poisoned.into_inner().take(),
1255        };
1256        let second = install(CrashPolicy::On, metadata("retried-install")).unwrap();
1257        assert!(
1258            guard.is_armed() && second.is_armed(),
1259            "an ordinary install must retry a failed reattach"
1260        );
1261        drop(second);
1262        drop(guard);
1263    }
1264
1265    #[test]
1266    fn failed_install_rearm_drops_last_runtime_after_transition_gate() {
1267        let _state = process_state();
1268        let guard = install(CrashPolicy::On, metadata("last-guard")).unwrap();
1269        let foreign = Arc::new(Mutex::new(None));
1270        let foreign_slot = Arc::clone(&foreign);
1271        let failure = with_handler_suspended(|| {
1272            // SAFETY: inert callback used only to occupy the global handler.
1273            let event =
1274                unsafe { crash_handler::make_crash_event(|_| CrashEventResult::Handled(false)) };
1275            let handler = CrashHandler::attach(event).unwrap();
1276            match foreign_slot.lock() {
1277                Ok(mut slot) => *slot = Some(handler),
1278                Err(poisoned) => *poisoned.into_inner() = Some(handler),
1279            }
1280        });
1281        assert!(matches!(failure, Err(InstallError::Handler(_))));
1282        assert!(!guard.is_armed());
1283
1284        TEST_RESUME_ENTERED.store(false, Ordering::Release);
1285        TEST_RELEASE_RESUME.store(false, Ordering::Release);
1286        TEST_PAUSE_RESUME.store(true, Ordering::Release);
1287        let (result_tx, result_rx) = std::sync::mpsc::channel();
1288        let installer = std::thread::spawn(move || {
1289            result_tx
1290                .send(install(CrashPolicy::On, metadata("racer")))
1291                .unwrap();
1292        });
1293        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1294        while !TEST_RESUME_ENTERED.load(Ordering::Acquire) {
1295            if std::time::Instant::now() >= deadline {
1296                TEST_RELEASE_RESUME.store(true, Ordering::Release);
1297                panic!("install never reached the forced reattach point");
1298            }
1299            std::thread::yield_now();
1300        }
1301
1302        // The installer now owns the only other Runtime Arc and is paused
1303        // while holding both the transition and handler locks.
1304        drop(guard);
1305        TEST_RELEASE_RESUME.store(true, Ordering::Release);
1306        let result = result_rx
1307            .recv_timeout(Duration::from_secs(2))
1308            .expect("failed reattach deadlocked while dropping the final Runtime Arc");
1309        assert!(matches!(result, Err(InstallError::Handler(_))));
1310        installer.join().unwrap();
1311
1312        match foreign.lock() {
1313            Ok(mut slot) => slot.take(),
1314            Err(poisoned) => poisoned.into_inner().take(),
1315        };
1316    }
1317
1318    #[cfg(unix)]
1319    #[test]
1320    fn post_fork_child_is_detected_before_touching_inherited_locks() {
1321        let _state = process_state();
1322        let guard = install(CrashPolicy::On, metadata("fork-owner")).unwrap();
1323        // SAFETY: the child performs only the PID-gated install check and
1324        // `_exit`; it never runs inherited Rust destructors.
1325        let child = unsafe { libc::fork() };
1326        assert!(child >= 0, "fork failed");
1327        if child == 0 {
1328            let rejected = matches!(
1329                install(CrashPolicy::On, metadata("fork-owner")),
1330                Err(InstallError::ForkedProcess)
1331            );
1332            unsafe { libc::_exit(i32::from(!rejected)) };
1333        }
1334        let mut status = 0;
1335        // SAFETY: `child` is the live pid returned by fork.
1336        assert_eq!(unsafe { libc::waitpid(child, &raw mut status, 0) }, child);
1337        assert_eq!(status, 0, "child touched inherited crash state");
1338        drop(guard);
1339    }
1340}