Skip to main content

running_process_probe/snapshot/
attribute.rs

1//! Attributing captured addresses to their loaded module (#725).
2//!
3//! # Why absolute addresses cannot leave the process
4//!
5//! A capture yields absolute return addresses, which are only meaningful
6//! inside the process that produced them and only until it exits: the same
7//! build loads at a different base next time. Symbolization therefore consumes
8//! `(module, offset)` — stable against ASLR, and re-resolvable against the
9//! same binary long afterwards.
10//!
11//! This is the conversion, and it has to happen here, in the capturing
12//! process, because that is the only place the module bases exist.
13//!
14//! # Getting this wrong is worse than not doing it
15//!
16//! An address attributed to the wrong module produces an offset that is
17//! meaningless in that module — and a later, entirely correct symbol lookup
18//! will turn it into a confident, wrong function name. Nothing downstream can
19//! detect that. So an address that falls in no known module is reported as
20//! unattributed rather than being assigned to the nearest one.
21
22use super::modules::LoadedModule;
23use super::Snapshot;
24
25/// A module referenced by an attributed capture.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct AttributedModule {
28    /// File name, e.g. `_native.pyd`.
29    pub name: String,
30    /// Full path on disk, when known — the symbol file is found beside it.
31    pub path: Option<String>,
32    /// Exact symbol identity captured from the loaded module.
33    pub debug_id: Option<String>,
34    /// Sanitized native symbol filename captured from the loaded module.
35    pub debug_file: Option<String>,
36    /// Base the module was loaded at. Recorded for provenance only; the
37    /// offsets below are already relative, so nothing downstream needs it.
38    pub base: u64,
39}
40
41/// One frame, expressed relative to a module.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct AttributedFrame {
44    /// Index into [`AttributedCapture::modules`], or `None` when the address
45    /// fell outside every loaded module.
46    pub module_index: Option<u32>,
47    /// Offset from the module's base, or the raw address when unattributed.
48    ///
49    /// Never empty: an offset without a module is still evidence, and
50    /// discarding it would lose the only trace of that frame.
51    pub relative_address: u64,
52}
53
54/// One thread's attributed frames.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct AttributedThread {
57    /// OS thread id, carried through so the mixed-mode pairing survives.
58    pub os_tid: u64,
59    /// Frames, innermost first.
60    pub frames: Vec<AttributedFrame>,
61}
62
63/// A capture with every address expressed as module + offset.
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub struct AttributedCapture {
66    /// Modules actually referenced, in first-reference order.
67    ///
68    /// Only referenced modules are listed. A process maps hundreds; carrying
69    /// all of them would make the payload mostly noise.
70    pub modules: Vec<AttributedModule>,
71    /// Attributed threads, in capture order.
72    pub threads: Vec<AttributedThread>,
73}
74
75impl AttributedCapture {
76    /// How many frames could not be attributed to any module.
77    ///
78    /// Reported so a consumer can tell a sparse symbolization from a broken
79    /// one: many unattributed frames mean the module inventory did not match
80    /// the capture, not that symbols were missing.
81    pub fn unattributed_frames(&self) -> usize {
82        self.threads
83            .iter()
84            .flat_map(|t| &t.frames)
85            .filter(|f| f.module_index.is_none())
86            .count()
87    }
88}
89
90/// Express every frame in `snapshot` relative to its module.
91///
92/// `modules` must come from the same process and the same moment as the
93/// capture; bases from anywhere else describe a different address space.
94pub fn attribute(snapshot: &Snapshot, modules: &[LoadedModule]) -> AttributedCapture {
95    let mut out = AttributedCapture::default();
96    // Maps a module's base to its index in `out.modules`, so a module
97    // referenced by many frames is listed once.
98    let mut index_by_base: std::collections::HashMap<u64, u32> = std::collections::HashMap::new();
99
100    for sample in &snapshot.threads {
101        let mut frames = Vec::with_capacity(sample.frames.len());
102        for &address in &sample.frames {
103            match modules.iter().find(|m| m.contains(address)) {
104                Some(module) => {
105                    let next = u32::try_from(out.modules.len()).unwrap_or(u32::MAX);
106                    let index = *index_by_base.entry(module.base).or_insert_with(|| {
107                        out.modules.push(AttributedModule {
108                            name: module_name(module),
109                            path: module.path.clone(),
110                            debug_id: module.debug_id.clone(),
111                            debug_file: module.debug_file.clone(),
112                            base: module.base,
113                        });
114                        next
115                    });
116                    frames.push(AttributedFrame {
117                        module_index: Some(index),
118                        relative_address: address - module.base,
119                    });
120                }
121                // Outside every known module. Keep the address rather than
122                // guessing an owner — a wrong attribution becomes a wrong
123                // function name that nothing downstream can catch.
124                None => frames.push(AttributedFrame {
125                    module_index: None,
126                    relative_address: address,
127                }),
128            }
129        }
130        out.threads.push(AttributedThread {
131            os_tid: sample.os_tid,
132            frames,
133        });
134    }
135    out
136}
137
138fn module_name(module: &LoadedModule) -> String {
139    module
140        .path
141        .as_deref()
142        // Captures can be decoded or tested on a different host OS, so accept
143        // both native separator styles instead of delegating to host `Path`.
144        .and_then(|path| path.rsplit(['/', '\\']).find(|part| !part.is_empty()))
145        .map(str::to_owned)
146        .unwrap_or_else(|| format!("{:#x}", module.base))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::snapshot::modules::Section;
153    use crate::snapshot::{CaptureKind, ThreadSample};
154
155    fn module(base: u64, size: u64, path: Option<&str>) -> LoadedModule {
156        LoadedModule {
157            base,
158            size,
159            mapped_ranges: Vec::new(),
160            executable_ranges: Vec::new(),
161            path: path.map(str::to_owned),
162            debug_id: None,
163            debug_file: None,
164            sections: Vec::<Section>::new(),
165        }
166    }
167
168    fn snapshot_with(threads: Vec<(u64, Vec<u64>)>) -> Snapshot {
169        Snapshot {
170            threads: threads
171                .into_iter()
172                .map(|(os_tid, frames)| ThreadSample {
173                    os_tid,
174                    stack_pointer: 0,
175                    instruction_pointer: 0,
176                    frame_pointer: 0,
177                    link_register: None,
178                    stack_bytes: Vec::new(),
179                    truncated: false,
180                    kind: CaptureKind::RawContext,
181                    frames,
182                })
183                .collect(),
184            ..Default::default()
185        }
186    }
187
188    #[test]
189    fn an_address_becomes_an_offset_from_its_module() {
190        let modules = vec![module(0x1000, 0x1000, Some(r"C:\app\a.dll"))];
191        let capture = attribute(&snapshot_with(vec![(7, vec![0x1234])]), &modules);
192
193        assert_eq!(capture.modules.len(), 1);
194        assert_eq!(capture.modules[0].name, "a.dll");
195        assert_eq!(capture.threads[0].frames[0].module_index, Some(0));
196        assert_eq!(capture.threads[0].frames[0].relative_address, 0x234);
197    }
198
199    /// The failure that matters: an address outside every module must not be
200    /// assigned to one.
201    #[test]
202    fn an_address_outside_every_module_is_left_unattributed() {
203        let modules = vec![module(0x1000, 0x1000, Some("a.dll"))];
204        let capture = attribute(&snapshot_with(vec![(7, vec![0x9999])]), &modules);
205
206        assert!(capture.modules.is_empty(), "no module was referenced");
207        assert_eq!(capture.threads[0].frames[0].module_index, None);
208        assert_eq!(
209            capture.threads[0].frames[0].relative_address, 0x9999,
210            "the raw address must survive so the frame is not lost"
211        );
212        assert_eq!(capture.unattributed_frames(), 1);
213    }
214
215    /// Picking the wrong module of several is the silent-wrong-name failure.
216    #[test]
217    fn each_address_lands_in_its_own_module() {
218        let modules = vec![
219            module(0x1000, 0x1000, Some("a.dll")),
220            module(0x8000, 0x1000, Some("b.dll")),
221        ];
222        let capture = attribute(&snapshot_with(vec![(7, vec![0x8100, 0x1100])]), &modules);
223
224        let by_name: Vec<_> = capture.threads[0]
225            .frames
226            .iter()
227            .map(|f| {
228                (
229                    capture.modules[f.module_index.unwrap() as usize]
230                        .name
231                        .as_str(),
232                    f.relative_address,
233                )
234            })
235            .collect();
236        assert_eq!(by_name, vec![("b.dll", 0x100), ("a.dll", 0x100)]);
237    }
238
239    #[test]
240    fn a_module_referenced_twice_is_listed_once() {
241        let modules = vec![module(0x1000, 0x1000, Some("a.dll"))];
242        let capture = attribute(
243            &snapshot_with(vec![(7, vec![0x1100, 0x1200, 0x1300])]),
244            &modules,
245        );
246
247        assert_eq!(capture.modules.len(), 1, "one module, three frames");
248        for frame in &capture.threads[0].frames {
249            assert_eq!(frame.module_index, Some(0));
250        }
251    }
252
253    /// Unreferenced modules must not be carried: a process maps hundreds.
254    #[test]
255    fn only_referenced_modules_are_listed() {
256        let modules = vec![
257            module(0x1000, 0x1000, Some("used.dll")),
258            module(0x8000, 0x1000, Some("unused.dll")),
259        ];
260        let capture = attribute(&snapshot_with(vec![(7, vec![0x1100])]), &modules);
261
262        assert_eq!(capture.modules.len(), 1);
263        assert_eq!(capture.modules[0].name, "used.dll");
264    }
265
266    #[test]
267    fn thread_identity_and_order_survive() {
268        let modules = vec![module(0x1000, 0x1000, Some("a.dll"))];
269        let capture = attribute(
270            &snapshot_with(vec![(100, vec![0x1100]), (200, vec![0x1200])]),
271            &modules,
272        );
273        assert_eq!(capture.threads[0].os_tid, 100);
274        assert_eq!(capture.threads[1].os_tid, 200);
275    }
276
277    /// A module with no path still needs a name a report can print.
278    #[test]
279    fn a_pathless_module_is_named_by_its_base() {
280        let modules = vec![module(0x4000, 0x1000, None)];
281        let capture = attribute(&snapshot_with(vec![(7, vec![0x4010])]), &modules);
282        assert_eq!(capture.modules[0].name, "0x4000");
283        assert_eq!(capture.modules[0].path, None);
284    }
285
286    /// End to end against this process: real capture, real modules.
287    #[cfg(windows)]
288    #[test]
289    fn a_real_capture_attributes_most_of_its_frames() {
290        use crate::snapshot::modules::enumerate_modules;
291        use crate::snapshot::{capture_and_resolve, SnapshotConfig};
292
293        let snapshot = capture_and_resolve(&SnapshotConfig::default()).expect("capture");
294        let modules = enumerate_modules().expect("modules");
295        let capture = attribute(&snapshot, &modules);
296
297        let total: usize = capture.threads.iter().map(|t| t.frames.len()).sum();
298        if total == 0 {
299            // No sibling threads were captured, so there is nothing to
300            // attribute. Locally that is a legitimate outcome; under CI it
301            // would mean this test asserts nothing while reporting green,
302            // which is worse than a failure.
303            assert!(
304                std::env::var_os("GITHUB_ACTIONS").is_none(),
305                "captured no frames during a CI run; this test would assert nothing"
306            );
307            return;
308        }
309        // Assert the INVARIANT, not a coverage ratio. What must hold is that
310        // every attributed frame's offset lies inside the module it was
311        // attributed to — that is what a wrong attribution would violate, and
312        // it is what makes a later symbol lookup trustworthy.
313        //
314        // A ratio would be the wrong assertion: the proportion of frames
315        // landing in a known module depends on how deep the walks went and
316        // whether any ended in a truncated tail, which varies with what else
317        // the test binary is doing. An earlier version of this test asserted
318        // ">= 90% attributed", passed when run alone (4 frames), and failed in
319        // the full suite (9 of 28 unattributed) — measuring the environment,
320        // not the code.
321        for thread in &capture.threads {
322            for frame in &thread.frames {
323                let Some(index) = frame.module_index else {
324                    continue;
325                };
326                let module = &capture.modules[index as usize];
327                let size = modules
328                    .iter()
329                    .find(|m| m.base == module.base)
330                    .map(|m| m.size)
331                    .expect("attributed module must come from the inventory");
332                assert!(
333                    frame.relative_address < size,
334                    "offset {:#x} exceeds {}'s size {size:#x}; the frame was                      attributed to the wrong module",
335                    frame.relative_address,
336                    module.name
337                );
338            }
339        }
340
341        // And something must actually have been attributed, or the loop above
342        // is vacuous.
343        let attributed = total - capture.unattributed_frames();
344        assert!(
345            attributed > 0,
346            "no frame of {total} matched any module; attribution is not working"
347        );
348        assert!(
349            !capture.modules.is_empty(),
350            "attributed frames but listed no modules"
351        );
352    }
353}