Skip to content

Commit 04394ec

Browse files
gyuheon0hgleocadie
andauthored
chore(ci): run crashtracking ffi example tests in CI (#1687)
# What does this PR do? Previously, we couldn't run crashtracking FFI examples in CI because the C example had paths like /tmp/libdatadog/bin/libdatadog-crashtracking-receiver and /tmp/crashreports/ baked in, which is variable in CI. Also, the test runner had no way to handle intentional crashes. This PR 1. Enables the crashtracking FFI example to run in CI instead of being skipped 2. Adds ExpectedCrash support to the FFI test runner to validate tests that intentionally crash `examples/ffi/crashtracking.c` - Make receiver binary and output directory configurable via DDOG_CRASHT_TEST_RECEIVER and DDOG_CRASHT_TEST_OUTPUT_DIR env vars (falls back to hardcoded defaults for manual use) - Forward LD_LIBRARY_PATH/DYLD_LIBRARY_PATH to the receiver process so it can find shared libraries - Remove the custom signal(SIGSEGV, ...) handler - I also ran the clang formatter here `tools/src/bin/ffi_test.rs` - Remove crashtracking from the skip list - Add ExpectedCrash concept: validates the process dies with the expected signal (SIGSEGV) and that a crash report file was produced - Extract shared find_receiver_paths() and library_search_path_env() helpers used by both crashtracking and crashtracking_unhandled_exception tests # Motivation Although FFI examples are not meant to increase test coverage, running the crashtracking example in CI is a QOL improvement that makes sure our public FFI surface is buildable and usable in real-world scenarios. # How to test the change? crashtracking FFI tests [now run in CI](https://github.com/DataDog/libdatadog/actions/runs/22782590070/job/66091853014?pr=1687) <img width="290" height="215" alt="Screenshot 2026-03-06 at 4 46 48 PM" src="https://github.com/user-attachments/assets/2dbc51a1-723c-4d5b-ad19-76699abedb2c" /> Co-authored-by: gregory.leocadie <gregory.leocadie@datadoghq.com>
1 parent f8900a6 commit 04394ec

2 files changed

Lines changed: 174 additions & 63 deletions

File tree

examples/ffi/crashtracking.c

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@
66
#include <signal.h>
77
#include <stdio.h>
88
#include <stdlib.h>
9+
#include <string.h>
910

10-
#define INIT_FROM_SLICE(s) {.ptr = s.ptr, .len = s.len}
11+
#define INIT_FROM_SLICE(s) \
12+
{ .ptr = s.ptr, .len = s.len }
1113

12-
void example_segfault_handler(int signal) {
13-
printf("Segmentation fault caught. Signal number: %d\n", signal);
14-
exit(-1);
15-
}
14+
static ddog_CharSlice slice(const char *s) { return (ddog_CharSlice){.ptr = s, .len = strlen(s)}; }
1615

1716
void handle_result(ddog_VoidResult result) {
1817
if (result.tag == DDOG_VOID_RESULT_ERR) {
@@ -34,30 +33,58 @@ uintptr_t handle_uintptr_t_result(ddog_crasht_Result_Usize result) {
3433
}
3534

3635
int main(int argc, char **argv) {
37-
if (signal(SIGSEGV, example_segfault_handler) == SIG_ERR) {
38-
perror("Error setting up signal handler");
39-
return -1;
36+
// Receiver binary path: CLI arg > env var > hardcoded default
37+
const char *receiver_path = NULL;
38+
if (argc >= 2) {
39+
receiver_path = argv[1];
40+
} else {
41+
receiver_path = getenv("DDOG_CRASHT_TEST_RECEIVER");
42+
}
43+
if (!receiver_path || receiver_path[0] == '\0') {
44+
receiver_path = "/tmp/libdatadog/bin/libdatadog-crashtracking-receiver";
45+
}
46+
47+
// Output directory: env var > hardcoded default
48+
const char *output_dir = getenv("DDOG_CRASHT_TEST_OUTPUT_DIR");
49+
if (!output_dir || output_dir[0] == '\0') {
50+
output_dir = "/tmp/crashreports";
51+
}
52+
53+
// Build output file paths
54+
char report_path[512];
55+
char stderr_path[512];
56+
char stdout_path[512];
57+
snprintf(report_path, sizeof(report_path), "%s/crashreport.json", output_dir);
58+
snprintf(stderr_path, sizeof(stderr_path), "%s/stderr.txt", output_dir);
59+
snprintf(stdout_path, sizeof(stdout_path), "%s/stdout.txt", output_dir);
60+
61+
// Forward the dynamic-linker search path to the receiver process.
62+
#ifdef __APPLE__
63+
const char *ld_search_path_var = "DYLD_LIBRARY_PATH";
64+
#else
65+
const char *ld_search_path_var = "LD_LIBRARY_PATH";
66+
#endif
67+
const char *ld_library_path = getenv(ld_search_path_var);
68+
ddog_crasht_EnvVar env_vars[1];
69+
ddog_crasht_Slice_EnvVar env_slice = {.ptr = NULL, .len = 0};
70+
if (ld_library_path && ld_library_path[0] != '\0') {
71+
env_vars[0].key = slice(ld_search_path_var);
72+
env_vars[0].val = slice(ld_library_path);
73+
env_slice.ptr = env_vars;
74+
env_slice.len = 1;
4075
}
4176

4277
ddog_crasht_ReceiverConfig receiver_config = {
4378
.args = {},
44-
.env = {},
45-
//.path_to_receiver_binary = DDOG_CHARSLICE_C("SET ME TO THE ACTUAL PATH ON YOUR MACHINE"),
46-
// E.g. on my machine, where I run ./build-profiling-ffi.sh /tmp/libdatadog
47-
.path_to_receiver_binary =
48-
DDOG_CHARSLICE_C("/tmp/libdatadog/bin/libdatadog-crashtracking-receiver"),
49-
.optional_stderr_filename = DDOG_CHARSLICE_C("/tmp/crashreports/stderr.txt"),
50-
.optional_stdout_filename = DDOG_CHARSLICE_C("/tmp/crashreports/stdout.txt"),
79+
.env = env_slice,
80+
.path_to_receiver_binary = slice(receiver_path),
81+
.optional_stderr_filename = slice(stderr_path),
82+
.optional_stdout_filename = slice(stdout_path),
5183
};
5284

53-
struct ddog_Endpoint *endpoint =
54-
ddog_endpoint_from_filename(DDOG_CHARSLICE_C("/tmp/crashreports/crashreport.json"));
55-
// Alternatively:
56-
// struct ddog_Endpoint * endpoint =
57-
// ddog_endpoint_from_url(DDOG_CHARSLICE_C("http://localhost:8126"));
85+
struct ddog_Endpoint *endpoint = ddog_endpoint_from_filename(slice(report_path));
5886

5987
// Get the default signals and explicitly use them.
60-
// We could also pass an empty list here, which would also use the default signals.
6188
struct ddog_crasht_Slice_CInt signals = ddog_crasht_default_signals();
6289
ddog_crasht_Config config = {
6390
.create_alt_stack = false,
@@ -79,8 +106,10 @@ int main(int argc, char **argv) {
79106
handle_result(ddog_crasht_begin_op(DDOG_CRASHT_OP_TYPES_PROFILER_COLLECTING_SAMPLE));
80107
handle_uintptr_t_result(ddog_crasht_insert_span_id(0, 42));
81108
handle_uintptr_t_result(ddog_crasht_insert_trace_id(1, 1));
82-
handle_uintptr_t_result(ddog_crasht_insert_additional_tag(DDOG_CHARSLICE_C("This is a very informative extra bit of info")));
83-
handle_uintptr_t_result(ddog_crasht_insert_additional_tag(DDOG_CHARSLICE_C("This message will for sure help us debug the crash")));
109+
handle_uintptr_t_result(ddog_crasht_insert_additional_tag(
110+
DDOG_CHARSLICE_C("This is a very informative extra bit of info")));
111+
handle_uintptr_t_result(ddog_crasht_insert_additional_tag(
112+
DDOG_CHARSLICE_C("This message will for sure help us debug the crash")));
84113

85114
#ifdef EXPLICIT_RAISE_SEGV
86115
// Test raising SEGV explicitly, to ensure chaining works
@@ -91,8 +120,8 @@ int main(int argc, char **argv) {
91120
char *bug = NULL;
92121
*bug = 42;
93122

94-
// At this point, we expect the following files to be written into /tmp/crashreports
95-
// foo.txt foo.txt.telemetry stderr.txt stdout.txt
123+
// The crash handler should intercept the SIGSEGV, invoke the receiver,
124+
// and write the crash report to output_dir before the process terminates.
96125
return 0;
97126
}
98127

tools/src/bin/ffi_test.rs

Lines changed: 120 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -121,58 +121,102 @@ fn skip_examples() -> &'static HashMap<&'static str, &'static str> {
121121
static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
122122
MAP.get_or_init(|| {
123123
HashMap::from([
124-
("crashtracking", "intentionally crashes"),
125124
("exporter", "requires CLI arguments"),
126125
("exporter_manager", "Flaky because SIGPIPE thing"),
127126
])
128127
})
129128
}
130129

130+
struct ExpectedCrash {
131+
/// Expected Unix signal number (ex 11 for SIGSEGV).
132+
#[cfg(unix)]
133+
signal: i32,
134+
/// File that should exist in the work directory after the crash,
135+
/// confirming the crash handler ran successfully
136+
output_file: &'static str,
137+
}
138+
139+
fn expected_crashes() -> &'static HashMap<&'static str, ExpectedCrash> {
140+
static MAP: OnceLock<HashMap<&'static str, ExpectedCrash>> = OnceLock::new();
141+
MAP.get_or_init(|| {
142+
HashMap::from([(
143+
"crashtracking",
144+
ExpectedCrash {
145+
#[cfg(unix)]
146+
signal: 11, // SIGSEGV
147+
output_file: "crashreport.json",
148+
},
149+
)])
150+
})
151+
}
152+
153+
/// Locate the crashtracking receiver binary and library directory.
154+
///
155+
/// They may live in either "release/" (local/ffi_test build) or "artifacts/"
156+
/// (CI pre-built). Returns `(receiver_binary_path, lib_dir)`
157+
fn find_receiver_paths(project_root: &Path) -> (PathBuf, PathBuf) {
158+
let make_paths = |dir: &str| {
159+
let base = project_root.join(dir);
160+
(
161+
base.join("bin").join("libdatadog-crashtracking-receiver"),
162+
base.join("lib"),
163+
)
164+
};
165+
["release", "artifacts"]
166+
.iter()
167+
.map(|dir| make_paths(dir))
168+
.find(|(bin, _)| bin.exists())
169+
.unwrap_or_else(|| make_paths("release"))
170+
}
171+
172+
/// Build the library search-path env var for the receiver process.
173+
///
174+
/// The C test binary is dynamically linked against libdatadog_profiling.{so,dylib}
175+
/// which is not on the system library path. Returns `(var_name, value)`
176+
fn library_search_path_env(lib_dir: &Path) -> (String, String) {
177+
#[cfg(target_os = "macos")]
178+
let search_path_var = "DYLD_LIBRARY_PATH";
179+
#[cfg(not(target_os = "macos"))]
180+
let search_path_var = "LD_LIBRARY_PATH";
181+
182+
let lib_path = match std::env::var(search_path_var) {
183+
Ok(existing) if !existing.is_empty() => {
184+
format!("{}:{}", lib_dir.display(), existing)
185+
}
186+
_ => lib_dir.display().to_string(),
187+
};
188+
(search_path_var.to_string(), lib_path)
189+
}
190+
131191
/// Per-test environment variables. The runner sets these before spawning
132192
/// the test executable so that tests which need external resources (e.g. the
133193
/// receiver binary) can find them without hard-coding paths.
134-
fn per_test_env(name: &str, project_root: &Path) -> Vec<(String, String)> {
194+
fn per_test_env(name: &str, project_root: &Path, work_dir: &Path) -> Vec<(String, String)> {
135195
match name {
136-
"crashtracking_unhandled_exception" => {
137-
// The receiver binary and shared library may live in either
138-
// "release/" (local/ffi_test build) or "artifacts/" (CI pre-built).
139-
// Check both and use whichever exists.
140-
let make_paths = |dir: &str| {
141-
let base = project_root.join(dir);
196+
"crashtracking" => {
197+
let (receiver, lib_dir) = find_receiver_paths(project_root);
198+
let (search_var, search_val) = library_search_path_env(&lib_dir);
199+
vec![
142200
(
143-
base.join("bin").join("libdatadog-crashtracking-receiver"),
144-
base.join("lib"),
145-
)
146-
};
147-
let (receiver, lib_dir) = ["release", "artifacts"]
148-
.iter()
149-
.map(|dir| make_paths(dir))
150-
.find(|(bin, _)| bin.exists())
151-
.unwrap_or_else(|| make_paths("release"));
152-
153-
// The C test binary is dynamically linked against libdatadog_profiling.{so,dylib}
154-
// which is not on the system library path. Set the platform-specific linker
155-
// search path so the binary can load, and the C test forwards it via getenv()
156-
// into the receiver's explicit execve environment.
157-
// Linux → LD_LIBRARY_PATH
158-
// macOS → DYLD_LIBRARY_PATH
159-
#[cfg(target_os = "macos")]
160-
let search_path_var = "DYLD_LIBRARY_PATH";
161-
#[cfg(not(target_os = "macos"))]
162-
let search_path_var = "LD_LIBRARY_PATH";
163-
164-
let lib_path = match std::env::var(search_path_var) {
165-
Ok(existing) if !existing.is_empty() => {
166-
format!("{}:{}", lib_dir.display(), existing)
167-
}
168-
_ => lib_dir.display().to_string(),
169-
};
201+
"DDOG_CRASHT_TEST_RECEIVER".to_string(),
202+
receiver.display().to_string(),
203+
),
204+
(
205+
"DDOG_CRASHT_TEST_OUTPUT_DIR".to_string(),
206+
work_dir.display().to_string(),
207+
),
208+
(search_var, search_val),
209+
]
210+
}
211+
"crashtracking_unhandled_exception" => {
212+
let (receiver, lib_dir) = find_receiver_paths(project_root);
213+
let (search_var, search_val) = library_search_path_env(&lib_dir);
170214
vec![
171215
(
172216
"DDOG_CRASHT_TEST_RECEIVER".to_string(),
173217
receiver.display().to_string(),
174218
),
175-
(search_path_var.to_string(), lib_path),
219+
(search_var, search_val),
176220
]
177221
}
178222
_ => vec![],
@@ -428,9 +472,46 @@ fn format_exit_status(status: &std::process::ExitStatus) -> String {
428472
fn determine_status(
429473
exit_status: Option<std::process::ExitStatus>,
430474
is_expected_failure: bool,
475+
expected_crash: Option<&ExpectedCrash>,
476+
work_dir: &Path,
431477
) -> TestStatus {
432478
match exit_status {
433479
Some(status) => {
480+
// Check for expected crash first
481+
if let Some(crash) = expected_crash {
482+
let crashed_with_expected_signal = {
483+
#[cfg(unix)]
484+
{
485+
use std::os::unix::process::ExitStatusExt;
486+
status.signal() == Some(crash.signal)
487+
}
488+
#[cfg(not(unix))]
489+
{
490+
!status.success()
491+
}
492+
};
493+
494+
if crashed_with_expected_signal {
495+
let output_path = work_dir.join(crash.output_file);
496+
if output_path.exists() {
497+
return TestStatus::Passed;
498+
}
499+
return TestStatus::Failed(format!(
500+
"crashed as expected but output file '{}' not found",
501+
crash.output_file
502+
));
503+
}
504+
if status.success() {
505+
return TestStatus::Failed(
506+
"expected crash but process exited successfully".to_string(),
507+
);
508+
}
509+
return TestStatus::Failed(format!(
510+
"expected crash signal but got {}",
511+
format_exit_status(&status)
512+
));
513+
}
514+
434515
let success = status.success();
435516
match (success, is_expected_failure) {
436517
(true, false) => TestStatus::Passed,
@@ -451,7 +532,8 @@ fn run_test(
451532
timeout: Duration,
452533
) -> TestResult {
453534
let is_expected_failure = expected_failures().contains_key(name);
454-
let env_vars = per_test_env(name, project_root);
535+
let expected_crash = expected_crashes().get(name);
536+
let env_vars = per_test_env(name, project_root, work_dir);
455537
let start = Instant::now();
456538

457539
let child = match spawn_test(exe_path, work_dir, &env_vars) {
@@ -467,7 +549,7 @@ fn run_test(
467549
};
468550

469551
let (exit_status, output) = wait_with_output(child, timeout);
470-
let status = determine_status(exit_status, is_expected_failure);
552+
let status = determine_status(exit_status, is_expected_failure, expected_crash, work_dir);
471553

472554
TestResult {
473555
name: name.to_string(),

0 commit comments

Comments
 (0)