Skip to main content

running_process_probe/snapshot/
mod.rs

1//! Cooperative all-thread stack capture (#635, S6).
2//!
3//! # Cooperative, not external
4//!
5//! The probe thread lives *inside* the target process and walks its own
6//! sibling threads. There is no ptrace, no debugger attach, and no OS
7//! capability grant — those belong to the later `--force` external tier.
8//!
9//! # The suspend window is the whole design
10//!
11//! A suspended thread may hold *any* lock, including the allocator's. So while
12//! a thread is suspended this code does exactly two things: read its registers
13//! and `memcpy` a bounded slice of its stack into a **preallocated** buffer.
14//! Then it resumes immediately.
15//!
16//! Nothing else happens in that window — no allocation, no symbolization, no
17//! logging, no lock acquisition. Unwinding and symbolization run afterward,
18//! against the copied bytes, when every thread is running again. Violating
19//! this is how a stack profiler deadlocks the process it is profiling: suspend
20//! a thread inside `malloc`, then call `malloc` yourself.
21//!
22//! Windows, Linux, and macOS capture x86_64/aarch64 sibling stacks. Each
23//! backend resumes before deferred PE/ELF/Mach-O unwinding. Other platforms
24//! return [`SnapshotError::Unsupported`] rather than an empty snapshot.
25
26// Both Windows architectures are supported. The register names and the
27// unwinder differ per arch (see `windows.rs` / `unwind.rs`); everything else --
28// enumeration, suspend/resume sequencing, stack-copy bounds -- is shared.
29// Attribution needs a per-object-format module inventory.
30#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
31pub mod attribute;
32#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
33pub mod modules;
34
35#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
36pub mod unwind;
37
38#[cfg(target_os = "linux")]
39mod linux;
40#[cfg(target_os = "macos")]
41mod macos;
42#[cfg(windows)]
43mod windows;
44
45// Deliberately not platform-gated: the sink is pure Rust, so every CI lane
46// exercises the backpressure contract rather than only Windows.
47pub mod stream;
48
49use std::time::Duration;
50
51/// Upper bound on the stack bytes copied per thread.
52///
53/// Bounded because the copy happens with the thread suspended: an unbounded
54/// read would extend the window in proportion to stack depth. 256 KiB covers
55/// realistic call depths while keeping the window short and the buffer
56/// preallocatable.
57pub const MAX_STACK_BYTES: usize = 256 * 1024;
58
59/// How the capture was obtained, and what remains to be done to it.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum CaptureKind {
62    /// Registers plus raw stack bytes. Not yet unwound into return addresses.
63    RawContext,
64}
65
66/// One thread's captured state.
67#[derive(Clone, Debug)]
68pub struct ThreadSample {
69    /// OS thread id.
70    pub os_tid: u64,
71    /// Stack pointer at capture time.
72    pub stack_pointer: u64,
73    /// Instruction pointer at capture time.
74    pub instruction_pointer: u64,
75    /// Frame pointer at capture time.
76    pub frame_pointer: u64,
77    /// Link register, on architectures that have one (aarch64).
78    ///
79    /// Load-bearing there: a leaf frame's return address lives in LR rather
80    /// than on the stack, so unwinding without it loses the first frame.
81    /// `None` on x86_64, which has no such register.
82    pub link_register: Option<u64>,
83    /// Bytes copied from the stack, starting at `stack_pointer`.
84    pub stack_bytes: Vec<u8>,
85    /// True when the stack was longer than [`MAX_STACK_BYTES`], so the copy is
86    /// a prefix. A consumer must not read a truncated capture as a complete
87    /// one.
88    pub truncated: bool,
89    /// What stage this sample is at.
90    pub kind: CaptureKind,
91    /// Return addresses, once unwinding has run. Empty until then — check
92    /// [`Snapshot::frames_resolved`] rather than inferring from emptiness,
93    /// since a thread with an unwalkable stack also yields none.
94    pub frames: Vec<u64>,
95}
96
97/// What a capture cost and covered.
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
99pub struct SnapshotStats {
100    /// Sibling threads observed during enumeration.
101    pub threads_total: u32,
102    /// Threads successfully captured.
103    pub threads_captured: u32,
104    /// Threads that could not be captured (exited mid-capture, access denied).
105    ///
106    /// Non-zero means the snapshot is partial.
107    pub threads_dropped: u32,
108    /// Total time any thread spent suspended. The cost imposed on the target.
109    pub pause_nanos: u64,
110}
111
112/// The result of one capture.
113#[derive(Clone, Debug, Default)]
114pub struct Snapshot {
115    /// Per-thread samples.
116    pub threads: Vec<ThreadSample>,
117    /// Coverage and cost.
118    pub stats: SnapshotStats,
119    /// Whether `frames` have been resolved from the raw captures.
120    ///
121    /// False for now on every platform — unwinding lands separately. Exposed
122    /// so a consumer cannot mistake raw register/stack captures for symbolized
123    /// or even unwound frames.
124    pub frames_resolved: bool,
125}
126
127impl Snapshot {
128    /// Whether every enumerated thread was captured.
129    pub fn is_complete(&self) -> bool {
130        self.stats.threads_dropped == 0
131    }
132
133    /// Total time threads spent suspended.
134    pub fn pause(&self) -> Duration {
135        Duration::from_nanos(self.stats.pause_nanos)
136    }
137}
138
139/// Knobs for a capture.
140#[derive(Clone, Copy, Debug)]
141pub struct SnapshotConfig {
142    /// Per-thread stack copy limit.
143    pub max_stack_bytes: usize,
144}
145
146impl Default for SnapshotConfig {
147    fn default() -> Self {
148        Self {
149            max_stack_bytes: MAX_STACK_BYTES,
150        }
151    }
152}
153
154/// Why a capture could not run at all.
155#[derive(Debug, thiserror::Error)]
156pub enum SnapshotError {
157    /// No capture backend for this platform yet.
158    #[error("cooperative snapshot is not implemented for this platform yet")]
159    Unsupported,
160    /// The OS refused an enumeration or capture call.
161    #[error("snapshot failed: {0}")]
162    Os(#[from] std::io::Error),
163    /// A macOS Mach kernel operation failed.
164    #[error("Mach operation {operation} failed with kernel code {code}")]
165    Mach {
166        /// Operation that failed.
167        operation: &'static str,
168        /// `kern_return_t` value.
169        code: i32,
170    },
171}
172
173/// Capture every sibling thread of the calling thread.
174///
175/// The calling thread is deliberately excluded: suspending yourself is an
176/// immediate deadlock, and its stack is available directly anyway.
177///
178/// Returns [`SnapshotError::Unsupported`] on platforms whose backend has not
179/// landed, rather than silently returning an empty snapshot that would read as
180/// "this process has no threads".
181pub fn capture_all_threads(config: &SnapshotConfig) -> Result<Snapshot, SnapshotError> {
182    #[cfg(windows)]
183    {
184        windows::capture(config)
185    }
186    #[cfg(target_os = "linux")]
187    {
188        linux::capture(config)
189    }
190    #[cfg(target_os = "macos")]
191    {
192        macos::capture(config)
193    }
194    #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
195    {
196        let _ = config;
197        Err(SnapshotError::Unsupported)
198    }
199}
200
201/// Capture every sibling thread and resolve each capture to return addresses.
202///
203/// The two halves are separate functions because they have opposite
204/// constraints — capture runs with threads suspended and must do almost
205/// nothing, while unwinding runs afterwards and may allocate freely. Callers
206/// that just want frames should not have to know that, or to remember that
207/// resolving requires a module inventory taken from the same process.
208///
209/// Returns [`SnapshotError::Unsupported`] wherever [`capture_all_threads`]
210/// does, so an unsupported platform is never mistaken for a thread-less
211/// process.
212pub fn capture_and_resolve(config: &SnapshotConfig) -> Result<Snapshot, SnapshotError> {
213    #[cfg(windows)]
214    {
215        let mut snapshot = capture_all_threads(config)?;
216        let modules = modules::enumerate_modules()?;
217        unwind::resolve_frames(&mut snapshot, &modules);
218        Ok(snapshot)
219    }
220    #[cfg(any(target_os = "linux", target_os = "macos"))]
221    {
222        let mut snapshot = capture_all_threads(config)?;
223        unwind::resolve_frames_for_current_process(&mut snapshot)?;
224        Ok(snapshot)
225    }
226    #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
227    {
228        let _ = config;
229        Err(SnapshotError::Unsupported)
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn default_config_uses_the_documented_cap() {
239        assert_eq!(SnapshotConfig::default().max_stack_bytes, MAX_STACK_BYTES);
240    }
241
242    /// The combined entry point must resolve, not just capture.
243    ///
244    /// `capture_all_threads` alone leaves `frames` empty and
245    /// `frames_resolved` false; this is the difference between the two.
246    #[cfg(windows)]
247    #[test]
248    fn capture_and_resolve_produces_resolved_frames() {
249        let snapshot = capture_and_resolve(&SnapshotConfig::default()).expect("capture");
250        assert!(
251            snapshot.frames_resolved,
252            "the combined path must run the unwinder"
253        );
254        assert!(
255            snapshot.threads.iter().any(|t| !t.frames.is_empty()),
256            "at least one captured thread should yield frames"
257        );
258    }
259
260    #[test]
261    fn a_snapshot_with_drops_is_not_complete() {
262        let mut snap = Snapshot::default();
263        assert!(snap.is_complete());
264        snap.stats.threads_dropped = 1;
265        assert!(
266            !snap.is_complete(),
267            "a dropped thread must make the snapshot partial"
268        );
269    }
270
271    /// Raw captures must never be mistaken for unwound frames.
272    #[test]
273    fn raw_captures_report_frames_unresolved() {
274        let snap = Snapshot::default();
275        assert!(!snap.frames_resolved);
276    }
277
278    #[test]
279    fn pause_is_reported_in_wall_clock_terms() {
280        let snap = Snapshot {
281            stats: SnapshotStats {
282                pause_nanos: 1_500_000,
283                ..Default::default()
284            },
285            ..Default::default()
286        };
287        assert_eq!(snap.pause(), Duration::from_micros(1500));
288    }
289
290    /// #635 known-stack acceptance: the named blocked function must survive
291    /// capture and deferred unwinding as a raw return address.
292    #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
293    #[test]
294    fn known_blocked_stack_contains_marker_frame() {
295        use std::sync::atomic::{AtomicBool, Ordering};
296        use std::sync::mpsc;
297        use std::sync::Arc;
298
299        #[cfg(windows)]
300        #[allow(unsafe_code)]
301        fn current_tid() -> u64 {
302            u64::from(unsafe { winapi::um::processthreadsapi::GetCurrentThreadId() })
303        }
304        #[cfg(target_os = "linux")]
305        #[allow(unsafe_code)]
306        fn current_tid() -> u64 {
307            unsafe { libc::syscall(libc::SYS_gettid) as u64 }
308        }
309        #[cfg(target_os = "macos")]
310        #[allow(unsafe_code)]
311        fn current_tid() -> u64 {
312            let mut tid = 0u64;
313            let result = unsafe { libc::pthread_threadid_np(0, &mut tid) };
314            assert_eq!(result, 0, "pthread_threadid_np");
315            tid
316        }
317
318        #[cfg(not(target_arch = "x86_64"))]
319        #[inline(never)]
320        fn blocked_leaf(ready: &AtomicBool, stop: &AtomicBool) -> bool {
321            ready.store(true, Ordering::Release);
322            while !stop.load(Ordering::Acquire) {
323                std::hint::spin_loop();
324                std::hint::black_box(());
325            }
326            stop.load(Ordering::Acquire)
327        }
328
329        /// Keep every post-ready sample point inside this function on Windows.
330        ///
331        /// In an unoptimized MSVC build, `AtomicBool::load` may be emitted as
332        /// an out-of-line helper. Sampling in that helper makes the fixture
333        /// depend on unwinding compiler support code before it can find the
334        /// marker. The single-byte load is atomic on x86_64, and x86 loads
335        /// already have acquire ordering.
336        #[cfg(all(target_arch = "x86_64", windows))]
337        #[inline(never)]
338        #[allow(unsafe_code)]
339        fn blocked_leaf(ready: &AtomicBool, stop: &AtomicBool) -> bool {
340            ready.store(true, Ordering::Release);
341            let observed: u8;
342            unsafe {
343                std::arch::asm!(
344                    "2:",
345                    "mov {observed}, byte ptr [{stop}]",
346                    "test {observed}, {observed}",
347                    "je 2b",
348                    stop = in(reg) stop.as_ptr(),
349                    observed = out(reg_byte) observed,
350                    options(nostack),
351                );
352            }
353            observed != 0
354        }
355
356        /// A fixed SysV frame gives the unwinder a stable metadata and
357        /// frame-pointer fallback case.
358        ///
359        /// Coverage instrumentation can otherwise add CFI-sensitive wrapper
360        /// code around even a deterministic inline-assembly loop.
361        #[cfg(all(target_arch = "x86_64", not(windows)))]
362        #[unsafe(naked)]
363        #[allow(unsafe_code)]
364        extern "C" fn blocked_leaf(_ready: &AtomicBool, _stop: &AtomicBool) -> bool {
365            std::arch::naked_asm!(
366                ".cfi_startproc",
367                "push rbp",
368                ".cfi_def_cfa_offset 16",
369                ".cfi_offset rbp, -16",
370                "mov rbp, rsp",
371                ".cfi_def_cfa_register rbp",
372                "mov byte ptr [rdi], 1",
373                "2:",
374                "mov al, byte ptr [rsi]",
375                "test al, al",
376                "je 2b",
377                "pop rbp",
378                ".cfi_def_cfa rsp, 8",
379                "ret",
380                ".cfi_endproc",
381            );
382        }
383
384        #[inline(never)]
385        fn blocked_marker(ready: &AtomicBool, stop: &AtomicBool) {
386            let observed = blocked_leaf(ready, stop);
387            // This observable store depends on the leaf's return value, so it
388            // cannot be hoisted or removed. That makes blocked_marker a real
389            // caller frame even with coverage instrumentation.
390            ready.store(observed, Ordering::Release);
391        }
392
393        let ready = Arc::new(AtomicBool::new(false));
394        let stop = Arc::new(AtomicBool::new(false));
395        let (tx, rx) = mpsc::sync_channel(1);
396        let worker = {
397            let ready = Arc::clone(&ready);
398            let stop = Arc::clone(&stop);
399            std::thread::Builder::new()
400                .name("blocked_marker".into())
401                .spawn(move || {
402                    tx.send(current_tid()).unwrap();
403                    blocked_marker(&ready, &stop);
404                })
405                .unwrap()
406        };
407        let tid = rx.recv().unwrap();
408        while !ready.load(Ordering::Acquire) {
409            std::thread::yield_now();
410        }
411
412        let snapshot = capture_and_resolve(&SnapshotConfig::default()).expect("capture + unwind");
413        stop.store(true, Ordering::Release);
414        worker.join().unwrap();
415
416        let sample = snapshot
417            .threads
418            .iter()
419            .find(|sample| sample.os_tid == tid)
420            .unwrap_or_else(|| panic!("named worker {tid} absent from snapshot"));
421        let marker = blocked_marker as *const () as usize as u64;
422        assert!(
423            sample
424                .frames
425                .iter()
426                .skip(1)
427                .any(|frame| frame.abs_diff(marker) < 4096),
428            "unwound caller frames did not contain blocked_marker near {marker:#x} \
429             (captured ip={:#x}): {:?}",
430            sample.instruction_pointer,
431            sample.frames,
432        );
433    }
434
435    #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
436    #[test]
437    fn unimplemented_platforms_report_unsupported_not_empty() {
438        // An empty Ok(Snapshot) would read as "no threads", which is a very
439        // different claim from "not implemented here".
440        assert!(matches!(
441            capture_all_threads(&SnapshotConfig::default()),
442            Err(SnapshotError::Unsupported)
443        ));
444    }
445}