Skip to content

Commit e487260

Browse files
committed
test: add entry value recovery coverage
1 parent f989f39 commit e487260

10 files changed

Lines changed: 523 additions & 1 deletion

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
mod common;
2+
3+
use common::{fixture_compiler_available, init, runner::GhostscopeRunner, FixtureCompiler};
4+
use ghostscope_dwarf::{ComputeStep, MemoryAccessSize};
5+
use regex::Regex;
6+
use std::collections::HashMap;
7+
use std::path::{Path, PathBuf};
8+
use std::process::{Command as StdCommand, Stdio};
9+
use std::sync::OnceLock;
10+
use std::time::Duration;
11+
use tokio::io::AsyncReadExt;
12+
use tokio::process::{Child, Command as TokioCommand};
13+
use tokio::task::JoinHandle;
14+
15+
const FIXTURE_NAME: &str = "entry_value_recovery_program";
16+
const FIXTURE_SOURCE: &str = "entry_value_recovery_program.c";
17+
const POST_CALL_TRACE_LINE: u32 = 21;
18+
19+
static CLANG_FIXTURE: OnceLock<Result<PathBuf, String>> = OnceLock::new();
20+
21+
struct LoggedTarget {
22+
child: Child,
23+
pid: u32,
24+
stdout_task: JoinHandle<anyhow::Result<String>>,
25+
stderr_task: JoinHandle<anyhow::Result<String>>,
26+
}
27+
28+
impl LoggedTarget {
29+
async fn terminate_and_collect(mut self) -> anyhow::Result<(String, String)> {
30+
let _ = self.child.start_kill();
31+
let _ = self.child.wait().await;
32+
let stdout = self
33+
.stdout_task
34+
.await
35+
.map_err(|e| anyhow::anyhow!("stdout task panicked: {e}"))??;
36+
let stderr = self
37+
.stderr_task
38+
.await
39+
.map_err(|e| anyhow::anyhow!("stderr task panicked: {e}"))??;
40+
Ok((stdout, stderr))
41+
}
42+
}
43+
44+
fn should_skip_for_ebpf_env(exit_code: i32, stderr: &str) -> bool {
45+
exit_code != 0
46+
&& (stderr.contains("BPF_PROG_LOAD")
47+
|| stderr.contains("needs elevated privileges")
48+
|| stderr.contains("cap_bpf"))
49+
}
50+
51+
fn compile_entry_value_recovery_program_clang() -> anyhow::Result<PathBuf> {
52+
let result = CLANG_FIXTURE.get_or_init(|| {
53+
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
54+
.join("tests/fixtures")
55+
.join(FIXTURE_NAME);
56+
let binary = base.join("entry_value_recovery_program_clang_dwarf5");
57+
let output = StdCommand::new("make")
58+
.arg("clean")
59+
.current_dir(&base)
60+
.output();
61+
if let Err(e) = output {
62+
return Err(format!("failed to run make clean for {FIXTURE_NAME}: {e}"));
63+
}
64+
65+
let output = std::process::Command::new("make")
66+
.arg("all")
67+
.arg("CC=clang")
68+
.arg("CFLAGS=-Wall -Wextra -gdwarf-5 -O3")
69+
.arg("BINARY=entry_value_recovery_program_clang_dwarf5")
70+
.arg("OBJ=entry_value_recovery_program_clang_dwarf5.o")
71+
.current_dir(&base)
72+
.output();
73+
74+
match output {
75+
Ok(output) if output.status.success() => Ok(binary),
76+
Ok(output) => Err(format!(
77+
"failed to compile {FIXTURE_NAME} clang fixture: {}",
78+
String::from_utf8_lossy(&output.stderr)
79+
)),
80+
Err(e) => Err(format!("failed to run make for {FIXTURE_NAME}: {e}")),
81+
}
82+
});
83+
84+
result.clone().map_err(|e| anyhow::anyhow!(e))
85+
}
86+
87+
async fn spawn_logged_target(binary_path: &Path) -> anyhow::Result<LoggedTarget> {
88+
let base = binary_path
89+
.parent()
90+
.ok_or_else(|| anyhow::anyhow!("fixture binary has no parent dir"))?;
91+
let mut child = TokioCommand::new(binary_path)
92+
.current_dir(base)
93+
.stdout(Stdio::piped())
94+
.stderr(Stdio::piped())
95+
.spawn()?;
96+
let pid = child
97+
.id()
98+
.ok_or_else(|| anyhow::anyhow!("spawned fixture had no pid"))?;
99+
let mut stdout = child
100+
.stdout
101+
.take()
102+
.ok_or_else(|| anyhow::anyhow!("fixture stdout was not piped"))?;
103+
let mut stderr = child
104+
.stderr
105+
.take()
106+
.ok_or_else(|| anyhow::anyhow!("fixture stderr was not piped"))?;
107+
let stdout_task = tokio::spawn(async move {
108+
let mut buf = String::new();
109+
stdout.read_to_string(&mut buf).await?;
110+
Ok(buf)
111+
});
112+
let stderr_task = tokio::spawn(async move {
113+
let mut buf = String::new();
114+
stderr.read_to_string(&mut buf).await?;
115+
Ok(buf)
116+
});
117+
tokio::time::sleep(Duration::from_millis(500)).await;
118+
Ok(LoggedTarget {
119+
child,
120+
pid,
121+
stdout_task,
122+
stderr_task,
123+
})
124+
}
125+
126+
#[tokio::test]
127+
async fn test_post_call_entry_value_recovers_state_members_at_runtime() -> anyhow::Result<()> {
128+
init();
129+
if !fixture_compiler_available(FixtureCompiler::ClangDwarf5) {
130+
eprintln!("Skipping entry_value runtime test because clang is unavailable");
131+
return Ok(());
132+
}
133+
134+
let binary_path = compile_entry_value_recovery_program_clang()?;
135+
let analyzer = ghostscope_dwarf::DwarfAnalyzer::from_exec_path(&binary_path).await?;
136+
let addrs = analyzer.lookup_addresses_by_source_line(FIXTURE_SOURCE, POST_CALL_TRACE_LINE);
137+
anyhow::ensure!(
138+
!addrs.is_empty(),
139+
"No DWARF addresses found for {FIXTURE_SOURCE}:{POST_CALL_TRACE_LINE}"
140+
);
141+
142+
let target = spawn_logged_target(&binary_path).await?;
143+
let script = format!(
144+
"trace {FIXTURE_SOURCE}:{POST_CALL_TRACE_LINE} {{\n print \"POSTCALL:{{}}:{{}}\", state.total_bytes, state.stream_id;\n}}\n"
145+
);
146+
let (exit_code, ghostscope_stdout, ghostscope_stderr) = GhostscopeRunner::new()
147+
.with_script(&script)
148+
.with_pid(target.pid)
149+
.timeout_secs(4)
150+
.enable_sysmon_shared_lib(false)
151+
.run()
152+
.await?;
153+
let (target_stdout, target_stderr) = target.terminate_and_collect().await?;
154+
155+
if should_skip_for_ebpf_env(exit_code, &ghostscope_stderr) {
156+
return Ok(());
157+
}
158+
159+
assert_eq!(
160+
exit_code, 0,
161+
"ghostscope stderr={ghostscope_stderr} ghostscope stdout={ghostscope_stdout} target stderr={target_stderr}"
162+
);
163+
assert!(
164+
!ghostscope_stdout.contains("ExprError"),
165+
"Expected exact post-call entry_value recovery. STDOUT: {ghostscope_stdout}\nSTDERR: {ghostscope_stderr}"
166+
);
167+
assert!(
168+
!ghostscope_stdout.contains("<optimized out>"),
169+
"Post-call entry_value should not be optimized out. STDOUT: {ghostscope_stdout}\nSTDERR: {ghostscope_stderr}"
170+
);
171+
172+
let actual_re = Regex::new(r"ACTUAL:([0-9-]+):([0-9-]+):([0-9-]+):([0-9-]+)")?;
173+
let mut actual_by_seed = HashMap::new();
174+
for caps in actual_re.captures_iter(&target_stdout) {
175+
actual_by_seed.insert(
176+
caps[1].parse::<i64>()?,
177+
(
178+
caps[2].parse::<i64>()?,
179+
caps[3].parse::<i64>()?,
180+
caps[4].parse::<i64>()?,
181+
),
182+
);
183+
}
184+
anyhow::ensure!(
185+
!actual_by_seed.is_empty(),
186+
"fixture stdout did not contain ACTUAL lines: {target_stdout}"
187+
);
188+
189+
let trace_re = Regex::new(r"POSTCALL:([0-9-]+):([0-9-]+)")?;
190+
let mut seen = 0;
191+
for caps in trace_re.captures_iter(&ghostscope_stdout) {
192+
let total_bytes = caps[1].parse::<i64>()?;
193+
let stream_id = caps[2].parse::<i64>()?;
194+
assert!(
195+
actual_by_seed
196+
.values()
197+
.any(|actual| actual.0 == total_bytes && actual.1 == stream_id),
198+
"missing ACTUAL record for total_bytes={total_bytes}, stream_id={stream_id}; target stdout={target_stdout}"
199+
);
200+
seen += 1;
201+
}
202+
203+
assert!(
204+
seen >= 2,
205+
"Expected multiple post-call trace events. GhostScope STDOUT: {ghostscope_stdout}\nTarget STDOUT: {target_stdout}"
206+
);
207+
Ok(())
208+
}
209+
210+
#[tokio::test]
211+
async fn test_recover_caller_frame_exposes_pc_and_callee_saved_steps() -> anyhow::Result<()> {
212+
init();
213+
if !fixture_compiler_available(FixtureCompiler::ClangDwarf5) {
214+
eprintln!("Skipping caller-frame recovery test because clang is unavailable");
215+
return Ok(());
216+
}
217+
218+
let binary_path = compile_entry_value_recovery_program_clang()?;
219+
let analyzer = ghostscope_dwarf::DwarfAnalyzer::from_exec_path(&binary_path).await?;
220+
let addrs = analyzer.lookup_addresses_by_source_line(FIXTURE_SOURCE, POST_CALL_TRACE_LINE);
221+
anyhow::ensure!(
222+
!addrs.is_empty(),
223+
"No DWARF addresses found for {FIXTURE_SOURCE}:{POST_CALL_TRACE_LINE}"
224+
);
225+
226+
let recovery = analyzer
227+
.recover_caller_frame(&addrs[0], &[3, 16])?
228+
.ok_or_else(|| anyhow::anyhow!("no caller-frame recovery returned"))?;
229+
230+
assert_eq!(recovery.return_address_register, 16);
231+
assert!(
232+
recovery.caller_pc_steps.iter().any(|step| matches!(
233+
step,
234+
ComputeStep::Dereference {
235+
size: MemoryAccessSize::U64
236+
}
237+
)),
238+
"caller_pc_steps should load the caller PC from memory: {:?}",
239+
recovery.caller_pc_steps
240+
);
241+
assert!(
242+
recovery
243+
.caller_pc_steps
244+
.iter()
245+
.any(|step| matches!(step, ComputeStep::PushConstant(_))),
246+
"caller_pc_steps should include a CFA-relative offset: {:?}",
247+
recovery.caller_pc_steps
248+
);
249+
let rbx_steps = recovery
250+
.register_recovery_steps
251+
.get(&3)
252+
.ok_or_else(|| anyhow::anyhow!("missing rbx recovery steps"))?;
253+
assert!(
254+
rbx_steps.iter().any(|step| matches!(
255+
step,
256+
ComputeStep::Dereference {
257+
size: MemoryAccessSize::U64
258+
}
259+
)),
260+
"rbx should recover from the caller stack slot at the post-call PC: {rbx_steps:?}"
261+
);
262+
assert_eq!(
263+
recovery.register_recovery_steps.get(&16),
264+
Some(&recovery.caller_pc_steps),
265+
"requesting the return-address register should reuse caller_pc_steps"
266+
);
267+
268+
Ok(())
269+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
CC ?= gcc
2+
BASE_CFLAGS = -Wall -Wextra -g
3+
CFLAGS ?= $(BASE_CFLAGS) -O3
4+
BINARY ?= entry_value_recovery_program
5+
OBJ ?= $(BINARY).o
6+
7+
all: $(BINARY)
8+
9+
$(BINARY): $(OBJ)
10+
$(CC) $(CFLAGS) -o $@ $(OBJ)
11+
12+
$(OBJ): entry_value_recovery_program.c
13+
$(CC) $(CFLAGS) -c -o $@ $<
14+
15+
clean:
16+
rm -f *.o entry_value_recovery_program entry_value_recovery_program_clang_dwarf5
17+
18+
.PHONY: all clean
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#include <stdio.h>
2+
#include <unistd.h>
3+
4+
struct EntryState {
5+
int total_bytes;
6+
int stream_id;
7+
};
8+
9+
static struct EntryState g_states[2] = {
10+
{10, 8},
11+
{20, 9},
12+
};
13+
14+
__attribute__((noinline)) static int touch(int x, struct EntryState *state) {
15+
asm volatile("" : : "r"(state) : "memory");
16+
return x * 3;
17+
}
18+
19+
__attribute__((noinline)) static int wrapper_state(int seed, struct EntryState *state) {
20+
int combined = touch(seed, state);
21+
int after_call = state->total_bytes + combined;
22+
if (after_call & 1) {
23+
after_call += state->stream_id;
24+
}
25+
asm volatile("" ::: "rbx", "memory");
26+
asm volatile("" : "+r"(after_call) :: "memory");
27+
return after_call;
28+
}
29+
30+
int main(void) {
31+
volatile int sink = 0;
32+
int i = 1;
33+
34+
setvbuf(stdout, NULL, _IONBF, 0);
35+
36+
while (i < 20000) {
37+
struct EntryState *state = &g_states[i & 1];
38+
int result = wrapper_state(i, state);
39+
printf("ACTUAL:%d:%d:%d:%d\n", i, state->total_bytes, state->stream_id, result);
40+
sink = result;
41+
i++;
42+
usleep(10000);
43+
}
44+
45+
return sink == 42 ? 0 : 0;
46+
}
Binary file not shown.

0 commit comments

Comments
 (0)