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}