Skip to main content

libdd_crashtracker/
runtime_callback.rs

1// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! Runtime callback registration system for enhanced crash tracing
5//!
6//! This module provides APIs for runtime languages (Ruby, Python, PHP, etc.) to register
7//! callbacks that can provide runtime-specific stack traces during crash handling.
8
9use crate::crash_info::StackFrame;
10use core::ffi::c_char;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14#[cfg(unix)]
15use core::{
16    ptr,
17    sync::atomic::{AtomicPtr, Ordering},
18};
19use thiserror::Error;
20
21#[cfg(unix)]
22static FRAME_CSTR: &core::ffi::CStr = c"frame";
23#[cfg(unix)]
24static STACKTRACE_STRING_CSTR: &core::ffi::CStr = c"stacktrace_string";
25
26#[cfg(unix)]
27#[derive(Debug)]
28pub enum CallbackData {
29    Frame(RuntimeFrameCallback),
30    StacktraceString(RuntimeStacktraceStringCallback),
31}
32
33/// Global storage for the runtime callback
34#[cfg(unix)]
35static RUNTIME_CALLBACK: AtomicPtr<CallbackData> = AtomicPtr::new(ptr::null_mut());
36
37#[derive(Debug, Clone)]
38pub struct RuntimeStackFrame<'a> {
39    /// Line number in source file (0 if unknown)
40    pub line: u32,
41    /// Column number in source file (0 if unknown)
42    pub column: u32,
43    /// Function name (fully qualified if possible)
44    pub function: &'a [u8],
45    /// Source file name
46    pub file: &'a [u8],
47    /// Type name (class/module/namespace/etc.)
48    pub type_name: &'a [u8],
49}
50
51/// Function signature for runtime frame collection callbacks
52///
53/// This callback is invoked during crash handling in a signal context, so it must be signal-safe:
54///
55/// # Parameters
56/// - `emit_frame`: Function to call for each runtime frame (takes frame pointer)
57///
58/// # Safety
59/// The callback function is marked unsafe because:
60/// - It receives function pointers that take raw pointers as parameters
61/// - The callback must ensure any pointers it passes to these functions are valid
62pub type RuntimeFrameCallback =
63    unsafe extern "C" fn(emit_frame: unsafe extern "C" fn(&RuntimeStackFrame));
64
65/// Function signature for runtime stacktrace string collection callbacks
66///
67/// This callback is invoked during crash handling in a signal context, so it must be signal-safe:
68///
69/// # Parameters
70/// - `emit_stacktrace_string`: Function to call for complete stacktrace string (takes C string)
71///
72/// # Safety
73/// The callback function is marked unsafe because:
74/// - It receives function pointers that take raw pointers as parameters
75/// - All C strings passed must be null-terminated and remain valid for the call duration
76pub type RuntimeStacktraceStringCallback =
77    unsafe extern "C" fn(emit_stacktrace_string: unsafe extern "C" fn(*const c_char));
78
79/// Runtime stack representation for JSON serialization
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
81pub struct RuntimeStack {
82    pub format: String,
83    /// Array of runtime-specific stack frames (optional, mutually exclusive with
84    /// stacktrace_string)
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub frames: Vec<StackFrame>,
87    /// Raw stacktrace string (optional, mutually exclusive with frames)
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub stacktrace_string: Option<String>,
90}
91
92#[derive(Debug, Error)]
93pub enum CallbackError {
94    #[error("Null callback function provided")]
95    NullCallback,
96}
97
98#[cfg(unix)]
99pub fn register_runtime_frame_callback(
100    callback: RuntimeFrameCallback,
101) -> Result<(), CallbackError> {
102    if callback as usize == 0 {
103        return Err(CallbackError::NullCallback);
104    }
105
106    let callback_data = Box::into_raw(Box::new(CallbackData::Frame(callback)));
107    let previous = RUNTIME_CALLBACK.swap(callback_data, Ordering::SeqCst);
108
109    if !previous.is_null() {
110        // Safety: previous was returned by Box::into_raw() above,
111        // so it's guaranteed to be a valid Box pointer. We reconstruct the Box to drop it.
112        let _ = unsafe { Box::from_raw(previous) };
113    }
114
115    Ok(())
116}
117
118#[cfg(unix)]
119pub fn register_runtime_stacktrace_string_callback(
120    callback: RuntimeStacktraceStringCallback,
121) -> Result<(), CallbackError> {
122    if callback as usize == 0 {
123        return Err(CallbackError::NullCallback);
124    }
125
126    let callback_data = Box::into_raw(Box::new(CallbackData::StacktraceString(callback)));
127    let previous = RUNTIME_CALLBACK.swap(callback_data, Ordering::SeqCst);
128
129    if !previous.is_null() {
130        // Safety: previous was returned by Box::into_raw() above,
131        // so it's guaranteed to be a valid Box pointer. We reconstruct the Box to drop it.
132        let _ = unsafe { Box::from_raw(previous) };
133    }
134
135    Ok(())
136}
137
138/// Returns true if a callback is registered, false otherwise
139#[cfg(unix)]
140pub fn is_runtime_callback_registered() -> bool {
141    !RUNTIME_CALLBACK.load(Ordering::SeqCst).is_null()
142}
143
144/// Internal function to get the callback
145///
146/// # Safety
147/// This function loads from an atomic pointer and dereferences it.
148/// The caller must ensure that no other thread is calling `clear_runtime_callback`
149/// or registration functions concurrently, as those could invalidate
150/// the pointer between the null check and dereferencing.
151#[cfg(all(unix, feature = "collector"))]
152pub(crate) unsafe fn get_registered_callback() -> Option<CallbackData> {
153    let callback_ptr = RUNTIME_CALLBACK.load(Ordering::SeqCst);
154    if callback_ptr.is_null() {
155        return None;
156    }
157
158    // Safety: callback_ptr was checked to be non-null above, and was created by
159    // Box::into_raw() in registration functions, so it's a valid pointer
160    // to a properly aligned, initialized CallbackData.
161    Some(callback_ptr.read())
162}
163
164/// Get the callback type C string pointer from the currently registered callback
165///
166/// # Safety
167/// This function loads from an atomic pointer and dereferences it.
168/// The caller must ensure that no other thread is calling `clear_runtime_callback`
169/// or registration functions concurrently, as those could invalidate
170/// the pointer between the null check and dereferencing.
171#[cfg(unix)]
172pub unsafe fn get_registered_callback_type_ptr() -> *const core::ffi::c_char {
173    let callback_ptr = RUNTIME_CALLBACK.load(Ordering::SeqCst);
174    if callback_ptr.is_null() {
175        return core::ptr::null();
176    }
177
178    // Safety: callback_ptr was checked to be non-null above, and was created by
179    // Box::into_raw() in registration functions, so it's a valid pointer
180    // to a properly aligned, initialized CallbackData. The returned C string pointer
181    // points to static string literals, so it's always valid.
182    let callback_data = &*callback_ptr;
183    match callback_data {
184        CallbackData::Frame(_) => FRAME_CSTR.as_ptr(),
185        CallbackData::StacktraceString(_) => STACKTRACE_STRING_CSTR.as_ptr(),
186    }
187}
188
189/// Clear the registered runtime callback
190///
191/// # Safety
192/// This function should only be called when it's safe to clear the callback,
193/// like during testing or application shutdown. The caller must ensure:
194/// - No other thread is concurrently calling functions that dereference the callback pointer
195/// - No signal handlers are currently executing that might invoke the callback
196/// - The callback is not being used in any other way
197#[cfg(unix)]
198pub unsafe fn clear_runtime_callback() {
199    let old_ptr = RUNTIME_CALLBACK.swap(core::ptr::null_mut(), Ordering::SeqCst);
200    if !old_ptr.is_null() {
201        // Safety: old_ptr was created by Box::into_raw() in register_runtime_stack_callback(),
202        // so it's a valid Box pointer. We reconstruct the Box to properly drop the tuple.
203        let _ = Box::from_raw(old_ptr);
204    }
205}
206
207/// Internal function to invoke the registered runtime callback with direct pipe writing
208///
209/// # Safety
210/// This function is intended to be called from signal handlers and must maintain
211/// signal safety. It does not perform any dynamic allocation. The caller must ensure:
212/// - No other thread is calling `clear_runtime_callback` concurrently
213/// - The registered callback function is signal-safe
214/// - The writer parameter remains valid for the duration of the call
215#[cfg(all(unix, feature = "collector"))]
216pub(crate) unsafe fn invoke_runtime_callback_with_writer<W: std::io::Write>(
217    writer: &mut W,
218) -> Result<(), std::io::Error> {
219    static mut CURRENT_WRITER: Option<&'static mut dyn std::io::Write> = None;
220
221    let callback_ptr = RUNTIME_CALLBACK.load(Ordering::SeqCst);
222    if callback_ptr.is_null() {
223        return Err(std::io::Error::other("No runtime callback registered"));
224    }
225    let callback_data = &*callback_ptr;
226
227    CURRENT_WRITER = Some(core::mem::transmute::<
228        &mut dyn std::io::Write,
229        &'static mut dyn std::io::Write,
230    >(writer));
231
232    unsafe extern "C" fn emit_frame_collector(frame: &RuntimeStackFrame) {
233        if let Some(ref mut writer) = CURRENT_WRITER {
234            let _ = emit_frame_as_json(writer, frame);
235            let _ = writer.flush();
236        }
237    }
238
239    unsafe extern "C" fn emit_stacktrace_string_collector(stacktrace_string: *const c_char) {
240        if stacktrace_string.is_null() {
241            return;
242        }
243
244        if let Some(ref mut writer) = CURRENT_WRITER {
245            // SAFETY: the runtime guarantees a valid, null-terminated C string.
246            let cstr = core::ffi::CStr::from_ptr(stacktrace_string);
247            let bytes = cstr.to_bytes();
248            let _ = writer.write_all(bytes);
249            let _ = writeln!(writer);
250            let _ = writer.flush();
251        }
252    }
253
254    match callback_data {
255        CallbackData::Frame(cb) => cb(emit_frame_collector),
256        CallbackData::StacktraceString(cb) => cb(emit_stacktrace_string_collector),
257    }
258
259    CURRENT_WRITER = None;
260
261    Ok(())
262}
263
264/// Emit a single runtime frame as JSON to the writer
265///
266/// # Safety
267/// The caller must ensure that `frame` is either null or points to a valid, properly
268/// initialized RuntimeStackFrame. All C string pointers within the frame must be either
269/// null or point to valid, null-terminated C strings.
270#[cfg(all(unix, feature = "collector"))]
271unsafe fn emit_frame_as_json(
272    writer: &mut dyn std::io::Write,
273    frame: &RuntimeStackFrame,
274) -> std::io::Result<()> {
275    // `function`, `type_name`, `file` fields can have invalid utf8 characters
276    // Converting them to str might error, and we can't use from_utf8_lossy because
277    // it's not signal safe. So we just write the raw bytes and convert on the
278    // receiver side
279    write!(writer, "{{")?;
280
281    let mut first_field = true;
282
283    if !frame.function.is_empty() {
284        if !first_field {
285            write!(writer, ", ")?;
286        }
287        write!(writer, "\"function\": {:?}", frame.function)?;
288        first_field = false;
289    }
290
291    if !frame.type_name.is_empty() {
292        if !first_field {
293            write!(writer, ", ")?;
294        }
295        write!(writer, "\"type_name\": {:?}", frame.type_name)?;
296        first_field = false;
297    }
298
299    if !frame.file.is_empty() {
300        if !first_field {
301            write!(writer, ", ")?;
302        }
303        write!(writer, "\"file\": {:?}", frame.file)?;
304        first_field = false;
305    }
306
307    if frame.line != 0 {
308        if !first_field {
309            write!(writer, ", ")?;
310        }
311        write!(writer, "\"line\": {}", frame.line)?;
312        first_field = false;
313    }
314
315    if frame.column != 0 {
316        if !first_field {
317            write!(writer, ", ")?;
318        }
319        write!(writer, "\"column\": {}", frame.column)?;
320    }
321
322    writeln!(writer, "}}")?;
323    Ok(())
324}
325
326#[cfg(all(test, unix))]
327mod tests {
328    use super::*;
329    use std::sync::Mutex;
330
331    // So we don't have race conditions with global static variable
332    static TEST_MUTEX: Mutex<()> = Mutex::new(());
333
334    unsafe extern "C" fn test_emit_frame_callback(
335        emit_frame: unsafe extern "C" fn(&RuntimeStackFrame),
336    ) {
337        let type_name = "TestModule.TestClass";
338        let function_name = "test_function";
339        let file_name = "test.rb";
340
341        let frame = RuntimeStackFrame {
342            type_name: type_name.as_bytes(),
343            function: function_name.as_bytes(),
344            file: file_name.as_bytes(),
345            line: 42,
346            column: 10,
347        };
348
349        emit_frame(&frame);
350    }
351
352    #[cfg(feature = "collector")]
353    unsafe extern "C" fn test_emit_stacktrace_string_callback(
354        emit_stacktrace_string: unsafe extern "C" fn(*const c_char),
355    ) {
356        let stacktrace_string = alloc::ffi::CString::new("test_stacktrace_string").unwrap();
357
358        emit_stacktrace_string(stacktrace_string.as_ptr());
359    }
360
361    fn ensure_callback_cleared() {
362        let old_ptr = RUNTIME_CALLBACK.swap(ptr::null_mut(), Ordering::SeqCst);
363        if !old_ptr.is_null() {
364            let _ = unsafe { Box::from_raw(old_ptr) };
365        }
366    }
367
368    #[test]
369    fn test_callback_registration() {
370        let _guard = TEST_MUTEX.lock().unwrap();
371        ensure_callback_cleared();
372
373        let result = register_runtime_frame_callback(test_emit_frame_callback);
374        assert!(result.is_ok(), "Failed to register callback: {:?}", result);
375
376        let result = register_runtime_frame_callback(test_emit_frame_callback);
377        assert!(
378            result.is_ok(),
379            "Failed to re-register callback: {:?}",
380            result
381        );
382    }
383
384    #[test]
385    #[cfg_attr(miri, ignore)]
386    #[cfg(feature = "collector")]
387    fn test_frame_collection() {
388        let _guard = TEST_MUTEX.lock().unwrap();
389        ensure_callback_cleared();
390
391        let result = register_runtime_frame_callback(test_emit_frame_callback);
392        assert!(result.is_ok(), "Failed to register callback: {:?}", result);
393
394        let mut buffer = Vec::new();
395        let invocation_result = unsafe { invoke_runtime_callback_with_writer(&mut buffer) };
396        assert!(
397            invocation_result.is_ok(),
398            "Failed to invoke callback with writer"
399        );
400
401        let json_output = String::from_utf8(buffer).expect("Invalid UTF-8 in output");
402
403        // Should contain the frame data as JSON with string fields as UTF-8 byte arrays
404        assert!(
405            json_output.contains("\"function\""),
406            "Missing function field"
407        );
408
409        let function_bytes = format!("{:?}", "test_function".as_bytes());
410        assert!(
411            json_output.contains(&function_bytes),
412            "Missing function name as byte array"
413        );
414
415        assert!(
416            json_output.contains("\"type_name\""),
417            "Missing type_name field"
418        );
419
420        let type_name_bytes = format!("{:?}", "TestModule.TestClass".as_bytes());
421        assert!(
422            json_output.contains(&type_name_bytes),
423            "Missing type_name as byte array"
424        );
425
426        assert!(json_output.contains("\"file\""), "Missing file field");
427
428        let file_bytes = format!("{:?}", "test.rb".as_bytes());
429        assert!(
430            json_output.contains(&file_bytes),
431            "Missing file name as byte array"
432        );
433        assert!(json_output.contains("\"line\": 42"), "Missing line number");
434        assert!(
435            json_output.contains("\"column\": 10"),
436            "Missing column number"
437        );
438    }
439
440    #[test]
441    #[cfg_attr(miri, ignore)]
442    #[cfg(feature = "collector")]
443    fn test_stacktrace_string_collection() {
444        let _guard = TEST_MUTEX.lock().unwrap();
445        ensure_callback_cleared();
446
447        let result =
448            register_runtime_stacktrace_string_callback(test_emit_stacktrace_string_callback);
449        assert!(result.is_ok(), "Failed to register callback: {:?}", result);
450
451        let mut buffer = Vec::new();
452        let invocation_result = unsafe { invoke_runtime_callback_with_writer(&mut buffer) };
453        assert!(
454            invocation_result.is_ok(),
455            "Failed to invoke callback with writer"
456        );
457
458        let json_output = String::from_utf8(buffer).expect("Invalid UTF-8 in output");
459        // Should contain the stacktrace string
460        assert!(
461            json_output.contains("test_stacktrace_string"),
462            "Missing stacktrace string"
463        );
464    }
465
466    #[test]
467    #[cfg(feature = "collector")]
468    fn test_no_callback_registered() {
469        let _guard = TEST_MUTEX.lock().unwrap();
470        ensure_callback_cleared();
471
472        // Test that invoking callback returns 0 frames
473        let mut buffer = Vec::new();
474        let invocation_result = unsafe { invoke_runtime_callback_with_writer(&mut buffer) };
475
476        #[allow(clippy::std_instead_of_core)]
477        // Clippy tries to make us import from core::io which is nightly only
478        {
479            assert_eq!(
480                invocation_result.unwrap_err().kind(),
481                std::io::ErrorKind::Other,
482                "Expected Other error when no callback registered"
483            );
484        }
485
486        assert!(
487            buffer.is_empty(),
488            "Expected empty buffer when no callback registered"
489        );
490    }
491
492    #[test]
493    #[cfg_attr(miri, ignore)]
494    #[cfg(feature = "collector")]
495    fn test_direct_pipe_writing() {
496        let _guard = TEST_MUTEX.lock().unwrap();
497        ensure_callback_cleared();
498
499        let result = register_runtime_frame_callback(test_emit_frame_callback);
500        assert!(result.is_ok(), "Failed to register callback: {:?}", result);
501
502        // Test writing directly to a buffer
503        let mut buffer = Vec::new();
504        let invocation_result = unsafe { invoke_runtime_callback_with_writer(&mut buffer) };
505        assert!(
506            invocation_result.is_ok(),
507            "Failed to invoke callback with writer"
508        );
509
510        // Convert buffer to string and check JSON format with string fields as UTF-8 byte arrays
511        let json_output = String::from_utf8(buffer).expect("Invalid UTF-8 in output");
512
513        assert!(
514            json_output.contains("\"function\""),
515            "Missing function field"
516        );
517
518        let function_bytes = format!("{:?}", "test_function".as_bytes());
519        assert!(
520            json_output.contains(&function_bytes),
521            "Missing function name as byte array"
522        );
523
524        assert!(
525            json_output.contains("\"type_name\""),
526            "Missing type_name field"
527        );
528
529        let type_name_bytes = format!("{:?}", "TestModule.TestClass".as_bytes());
530        assert!(
531            json_output.contains(&type_name_bytes),
532            "Missing type name as byte array"
533        );
534
535        assert!(json_output.contains("\"file\""), "Missing file field");
536
537        let file_bytes = format!("{:?}", "test.rb".as_bytes());
538        assert!(
539            json_output.contains(&file_bytes),
540            "Missing file name as byte array"
541        );
542        assert!(json_output.contains("\"line\": 42"), "Missing line number");
543        assert!(
544            json_output.contains("\"column\": 10"),
545            "Missing column number"
546        );
547    }
548}