Skip to content

Commit 9bea3b4

Browse files
committed
test: stabilize callgrind topology snapshots
1 parent a85aee4 commit 9bea3b4

13 files changed

Lines changed: 449 additions & 619 deletions

callgrind-utils/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ pub mod error;
22
pub mod model;
33
mod normalize;
44
pub mod parser;
5+
mod redact;
56
pub mod serialize;

callgrind-utils/src/redact.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
use super::model::{CallGraph, Node};
2+
3+
const UNKNOWN: &str = "???";
4+
5+
impl CallGraph {
6+
/// Redact host-specific node identity and rebuild the canonical graph.
7+
pub fn redact(self) -> CallGraph {
8+
let CallGraph { nodes, edges } = self;
9+
let mut nodes = nodes;
10+
let mut edges = edges;
11+
12+
for node in &mut nodes {
13+
redact_node(node);
14+
}
15+
16+
for edge in &mut edges {
17+
redact_node(&mut edge.caller);
18+
redact_node(&mut edge.callee);
19+
}
20+
21+
CallGraph::from_parts(nodes, edges)
22+
}
23+
}
24+
25+
fn redact_node(node: &mut Node) {
26+
node.object = redact_object(&node.object);
27+
28+
if is_runtime_object(&node.object) {
29+
node.function = UNKNOWN.to_string();
30+
node.file = UNKNOWN.to_string();
31+
return;
32+
}
33+
34+
node.function = redact_function(&node.function);
35+
}
36+
37+
fn redact_function(function: &str) -> String {
38+
let function = strip_symbol_version(function);
39+
if is_hex_address(function) {
40+
return "<unsymbolicated>".to_string();
41+
}
42+
function.to_string()
43+
}
44+
45+
fn strip_symbol_version(function: &str) -> &str {
46+
for marker in ["@@", "@"] {
47+
let Some(index) = function.find(marker) else {
48+
continue;
49+
};
50+
let version = &function[index + marker.len()..];
51+
if is_symbol_version(version) {
52+
return &function[..index];
53+
}
54+
}
55+
function
56+
}
57+
58+
fn is_symbol_version(version: &str) -> bool {
59+
let Some(first) = version.chars().next() else {
60+
return false;
61+
};
62+
(first.is_ascii_alphanumeric() || first == '_')
63+
&& version
64+
.chars()
65+
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
66+
}
67+
68+
fn is_hex_address(function: &str) -> bool {
69+
let Some(hex) = function.strip_prefix("0x") else {
70+
return false;
71+
};
72+
!hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit())
73+
}
74+
75+
fn redact_object(object: &str) -> String {
76+
if is_loader_soname(object) {
77+
return "ld-linux".to_string();
78+
}
79+
if let Some(module) = cpython_extension_module(object) {
80+
return format!("{module}.cpython.so");
81+
}
82+
if is_libffi_soname(object) {
83+
return "libffi.so".to_string();
84+
}
85+
object.to_string()
86+
}
87+
88+
fn is_runtime_object(object: &str) -> bool {
89+
object == "ld-linux" || is_libc_soname(object)
90+
}
91+
92+
fn is_libc_soname(object: &str) -> bool {
93+
let Some(version) = object.strip_prefix("libc.so.") else {
94+
return false;
95+
};
96+
!version.is_empty() && version.chars().all(|c| c.is_ascii_digit())
97+
}
98+
99+
fn cpython_extension_module(object: &str) -> Option<&str> {
100+
let (module, suffix) = object.split_once(".cpython-")?;
101+
let abi = suffix.strip_suffix(".so")?;
102+
if module.is_empty() || abi.is_empty() {
103+
return None;
104+
}
105+
Some(module)
106+
}
107+
108+
fn is_libffi_soname(object: &str) -> bool {
109+
let Some(version) = object.strip_prefix("libffi.so.") else {
110+
return false;
111+
};
112+
!version.is_empty()
113+
&& version.chars().all(|c| c.is_ascii_digit() || c == '.')
114+
&& version.chars().any(|c| c.is_ascii_digit())
115+
}
116+
fn is_loader_soname(object: &str) -> bool {
117+
let Some(rest) = object.strip_prefix("ld-") else {
118+
return false;
119+
};
120+
let Some(index) = rest.find(".so.") else {
121+
return false;
122+
};
123+
124+
let loader_name = &rest[..index];
125+
let soname_version = &rest[index + ".so.".len()..];
126+
!loader_name.is_empty()
127+
&& loader_name
128+
.chars()
129+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
130+
&& !soname_version.is_empty()
131+
&& soname_version.chars().all(|c| c.is_ascii_digit())
132+
}
Lines changed: 52 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
//! Structural test over the Python fixture (`testdata/recursion.py`).
1+
//! Topology-only snapshot of the Python fixture's call graph.
22
//!
3-
//! Unlike the C snapshot tests, a live Python run can't be a golden snapshot:
4-
//! Callgrind sees the CPython interpreter's C frames (version- and
5-
//! platform-specific), never the Python functions. The fixture drives Callgrind
6-
//! the way pytest-codspeed does: at runtime it adds the Python runtime objects
7-
//! (libpython + the python executable) to the obj-skip list via the
8-
//! `CALLGRIND_ADD_OBJ_SKIP` client request, so the interpreter's own frames are
9-
//! folded into their callers and don't obfuscate the graph. This test profiles
10-
//! the fixture live and asserts structural properties: instrumentation started
11-
//! at the shim, and the runtime obj-skip removed the interpreter.
3+
//! Mirrors `snapshot.rs` for the C fixtures: profile `testdata/recursion.py`
4+
//! live under the in-repo Callgrind, parse, and snapshot a topology-only
5+
//! view (nodes + caller/callee indices, no `call_count`; see below).
6+
//!
7+
//! Callgrind records the CPython interpreter's C frames, not the Python
8+
//! functions: the interpreter loop is obj-skipped at runtime via the `clgctl`
9+
//! shim's `CALLGRIND_ADD_OBJ_SKIP`, so what remains is the ctypes/libffi/libc
10+
//! C-residual around the `clg_start`/`clg_stop` shim. The graph shape
11+
//! (nodes + caller/callee indices) is stable after `CallGraph::redact()`: libc/ld
12+
//! debug-derived fields collapse to `???`, and CPython extension / libffi object
13+
//! suffixes are normalized. `call_count` on the seed-reconstructed residual edges
14+
//! drifts run-to-run (loader/PLT timing), so it is stripped and the snapshot is
15+
//! topology-only.
1216
//!
1317
//! Requires a built `./vg-in-place` at the repo root and `cc`. Silently skips
1418
//! when `python3` is not on PATH (mirrors the `.vgtest` `prereq` guards).
1519
use std::io::Cursor;
1620
use std::path::{Path, PathBuf};
1721
use std::process::Command;
1822

19-
use callgrind_utils::model::{CallGraph, ParseOptions};
23+
use callgrind_utils::model::{CallGraph, Node, ParseOptions};
2024

2125
/// Repo root: this crate lives at `<repo>/callgrind-utils`.
2226
fn repo_root() -> PathBuf {
@@ -44,34 +48,6 @@ fn have_python3() -> bool {
4448
.unwrap_or(false)
4549
}
4650

47-
/// The basenames of the objects `recursion.py` adds to the obj-skip list:
48-
/// libpython and the python executable, resolved exactly as the fixture does
49-
/// (realpath, then basename, matching Callgrind's normalized object names).
50-
fn skipped_runtime_objects() -> Vec<String> {
51-
let script = "\
52-
import os, sys, sysconfig
53-
ld = sysconfig.get_config_var('LDLIBRARY')
54-
libdir = sysconfig.get_config_var('LIBDIR')
55-
cands = [os.path.join(libdir, ld) if ld and libdir else None,
56-
os.path.join(sys.prefix, 'lib', ld) if ld else None]
57-
lp = next((p for p in cands if p and os.path.exists(p)), None)
58-
for p in (lp, sys.executable):
59-
if p:
60-
print(os.path.basename(os.path.realpath(p)))
61-
";
62-
let out = Command::new("python3")
63-
.arg("-c")
64-
.arg(script)
65-
.output()
66-
.expect("run python3 to resolve runtime objects");
67-
assert!(out.status.success(), "python3 obj resolution failed");
68-
String::from_utf8(out.stdout)
69-
.expect("utf8")
70-
.lines()
71-
.map(str::to_owned)
72-
.collect()
73-
}
74-
7551
/// Compile the Callgrind client-request shim the Python fixture loads via
7652
/// `ctypes`, as a shared library against the in-repo `callgrind.h`.
7753
fn compile_clgctl() -> PathBuf {
@@ -116,59 +92,54 @@ fn run_python(clgctl: &Path) -> String {
11692
.status()
11793
.unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}"));
11894
assert!(status.success(), "vg-in-place exited with {status}");
119-
std::fs::read_to_string(&out_file).unwrap_or_else(|e| panic!("read {}: {e}", out_file.display()))
95+
std::fs::read_to_string(&out_file)
96+
.unwrap_or_else(|e| panic!("read {}: {e}", out_file.display()))
97+
}
98+
99+
/// Topology-only JSON view: `nodes` then `edges` by index, no `call_count`.
100+
#[derive(serde::Serialize)]
101+
struct TopologyEdge {
102+
caller: usize,
103+
callee: usize,
104+
}
105+
106+
#[derive(serde::Serialize)]
107+
struct TopologyGraph<'a> {
108+
nodes: &'a [Node],
109+
edges: Vec<TopologyEdge>,
120110
}
121111

122112
#[test]
123-
fn python_runtime_is_obj_skipped() {
113+
fn python_topology_json() {
124114
if !have_python3() {
125-
eprintln!("skipping python_runtime_is_obj_skipped: python3 not on PATH");
115+
eprintln!("skipping python_topology_json: python3 not on PATH");
126116
return;
127117
}
128118

129-
let skipped = skipped_runtime_objects();
130-
assert!(
131-
!skipped.is_empty(),
132-
"expected at least the python executable to be skipped"
133-
);
134-
135119
let clgctl = compile_clgctl();
136120
let raw = run_python(&clgctl);
137121
let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default())
138-
.expect("parse python callgrind output");
139-
140-
assert!(!graph.nodes().is_empty(), "expected a non-empty node set");
141-
assert!(!graph.edges().is_empty(), "expected a non-empty edge set");
142-
143-
// The shim that fired START is captured, so instrumentation began exactly
144-
// where the fixture asked, and it is wired into the graph (not dropped as
145-
// an orphan root): the seeder reconstructed the native stack at the OFF->ON
146-
// transition.
147-
assert!(
148-
graph.nodes().iter().any(|n| n.function == "clg_start"),
149-
"clg_start (instrumentation shim) missing from graph"
150-
);
151-
assert!(
152-
graph
122+
.unwrap_or_else(|e| panic!("parse python callgrind output: {e:?}"))
123+
.redact();
124+
let nodes = graph.nodes();
125+
let topology = TopologyGraph {
126+
nodes,
127+
edges: graph
153128
.edges()
154129
.iter()
155-
.any(|e| e.caller.function == "clg_start" || e.callee.function == "clg_start"),
156-
"clg_start has no edges - START frame was not captured"
157-
);
130+
.map(|e| TopologyEdge {
131+
caller: nodes
132+
.iter()
133+
.position(|x| x == &e.caller)
134+
.expect("caller node present"),
135+
callee: nodes
136+
.iter()
137+
.position(|x| x == &e.callee)
138+
.expect("callee node present"),
139+
})
140+
.collect(),
141+
};
142+
let json = serde_json::to_string_pretty(&topology).expect("serialize");
158143

159-
// The runtime obj-skip folded the Python runtime out: no node belongs to
160-
// libpython or the python executable, and the interpreter loop is gone.
161-
for obj in &skipped {
162-
assert!(
163-
graph.nodes().iter().all(|n| &n.object != obj),
164-
"obj-skip failed: {obj} still present as a node object"
165-
);
166-
}
167-
assert!(
168-
!graph
169-
.nodes()
170-
.iter()
171-
.any(|n| n.function.starts_with("_PyEval_EvalFrameDefault")),
172-
"interpreter loop _PyEval_EvalFrameDefault should have been obj-skipped"
173-
);
144+
insta::assert_snapshot!("recursion_py__topology_json", json);
174145
}

callgrind-utils/tests/snapshot.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ fn fixture_canonical_json(#[case] stem: &str) {
8989
let bin = compile_fixture(stem);
9090
let raw = run_callgrind(&bin);
9191
let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default())
92-
.unwrap_or_else(|e| panic!("parse {stem} callgrind output: {e:?}"));
92+
.unwrap_or_else(|e| panic!("parse {stem} callgrind output: {e:?}"))
93+
.redact();
9394
let json = graph.to_json().expect("to_json");
9495

9596
insta::assert_snapshot!(format!("{stem}__json"), json);
@@ -129,7 +130,8 @@ fn fixture_full_trace(#[case] stem: &str) {
129130
let bin = compile_fixture(stem);
130131
let raw = run_callgrind_full(&bin);
131132
let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default())
132-
.unwrap_or_else(|e| panic!("parse {stem} full callgrind output: {e:?}"));
133+
.unwrap_or_else(|e| panic!("parse {stem} full callgrind output: {e:?}"))
134+
.redact();
133135
let json = graph.to_json().expect("to_json");
134136

135137
insta::assert_snapshot!(format!("{stem}_full__json"), json);

0 commit comments

Comments
 (0)