diff --git a/.github/workflows/crashtracker-signal-safe.yml b/.github/workflows/crashtracker-signal-safe.yml new file mode 100644 index 0000000000..3699cb9eb9 --- /dev/null +++ b/.github/workflows/crashtracker-signal-safe.yml @@ -0,0 +1,64 @@ +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +name: Crashtracker signal-safe + +on: + pull_request: + paths: + - "libdd-crashtracker/**" + - "libdd-crashtracker-ffi/**" + - "libdd-profiling-ffi/**" + - "builder/**" + - "tools/check_signal_safe_symbols.sh" + - ".github/workflows/crashtracker-signal-safe.yml" + - "Cargo.toml" + - "Cargo.lock" + push: + branches: + - main + +jobs: + signal-safe: + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + - name: Install Rust toolchain + run: | + rustup set profile minimal + rustup toolchain install stable + rustup component add clippy --toolchain stable + rustup target add aarch64-unknown-linux-gnu + - name: Install cargo nextest + uses: taiki-e/install-action@2c41309d51ede152b6f2ee6bf3b71e6dc9a8b7df # 2.49.27 + with: + tool: nextest@0.9.96 + - name: Check signal-safe collector + run: cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe + - name: Check aarch64 signal-safe collector + run: cargo check -p libdd-crashtracker --target aarch64-unknown-linux-gnu --no-default-features --features collector_signal-safe + - name: Clippy signal-safe collector + run: cargo +stable clippy -p libdd-crashtracker --no-default-features --features collector_signal-safe --all-targets -- -D warnings + - name: Test signal-safe collector + run: cargo nextest run -p libdd-crashtracker --no-default-features --features collector_signal-safe --no-fail-fast + - name: Test signal-safe receiver compatibility + run: cargo nextest run -p libdd-crashtracker --features "collector_signal-safe,receiver" --no-fail-fast + - name: Symbol guard + run: bash tools/check_signal_safe_symbols.sh + + signal-safe-macos: + runs-on: macos-latest + steps: + - name: Checkout sources + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + - name: Install Rust toolchain + run: | + rustup set profile minimal + rustup toolchain install stable + - name: Install cargo nextest + uses: taiki-e/install-action@2c41309d51ede152b6f2ee6bf3b71e6dc9a8b7df # 2.49.27 + with: + tool: nextest@0.9.96 + - name: Test signal-safe collector fallback paths + run: cargo nextest run -p libdd-crashtracker --no-default-features --features collector_signal-safe --no-fail-fast diff --git a/AGENTS.md b/AGENTS.md index f6a3a58835..8c3d881a99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Iterate fastest with `cargo check -p ` while editing; the full validation ### Per-crate test notes -- **crashtracker**: needs `--features libdd-crashtracker/generate-unit-test-files` for unit tests. +- **crashtracker**: needs `--features libdd-crashtracker/generate-unit-test-files` for its unit tests. For the signal-safe collector, validate with `cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe`, `cargo +stable clippy -p libdd-crashtracker --no-default-features --features collector_signal-safe --all-targets -- -D warnings`, `cargo nextest run -p libdd-crashtracker --no-default-features --features collector_signal-safe --no-fail-fast`, `cargo nextest run -p libdd-crashtracker --features "collector_signal-safe,receiver" --no-fail-fast`, and `bash tools/check_signal_safe_symbols.sh`. - **http-client**: ships two alternative backend features (`reqwest-backend` is the default, `hyper-backend` is the alternative). Cargo does not enforce exclusivity, but each backend must be exercised independently when this crate is touched: ```bash # Default (reqwest) backend — covered by the workspace test run diff --git a/Cargo.lock b/Cargo.lock index 7b6e9fbe57..0b9318e4a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2169,6 +2169,15 @@ dependencies = [ "serde", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2244,6 +2253,17 @@ dependencies = [ "http", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "serde", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2813,9 +2833,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdd-agent-client" @@ -2941,25 +2961,26 @@ dependencies = [ "cxx-build", "errno", "goblin", + "heapless", "http", "libc", "libdd-common", "libdd-libunwind-sys", "libdd-telemetry", "nix 0.29.0", - "num-derive", - "num-traits", "os_info", "page_size", "portable-atomic", "rand 0.8.5", + "rustix 1.1.4", "schemars", "serde", + "serde-json-core", "serde_json", "symbolic-common", "symbolic-demangle", "tempfile", - "thiserror 1.0.68", + "thiserror 2.0.17", "tokio", "uuid", "windows 0.59.0", @@ -3569,9 +3590,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3859,17 +3880,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.87", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -4908,14 +4918,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5179,6 +5189,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-json-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b81787e655bd59cecadc91f7b6b8651330b2be6c33246039a65e5cd6f4e0828" +dependencies = [ + "ryu", + "serde", +] + [[package]] name = "serde-transcode" version = "1.1.1" @@ -5757,7 +5777,7 @@ dependencies = [ "fastrand", "getrandom 0.3.2", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 0e794bc021..ef84ef658f 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -167,10 +167,12 @@ gloo-timers,https://github.com/rustwasm/gloo/tree/master/crates/timers,MIT OR Ap h2,https://github.com/hyperium/h2,MIT,"Carl Lerche , Sean McArthur " half,https://github.com/starkat99/half-rs,MIT OR Apache-2.0,Kathryn Long halfbrown,https://github.com/Licenser/halfbrown,Apache-2.0 OR MIT,Heinz N. Gies +hash32,https://github.com/japaric/hash32,MIT OR Apache-2.0,Jorge Aparicio hashbrown,https://github.com/rust-lang/hashbrown,MIT OR Apache-2.0,Amanieu d'Antras hdrhistogram,https://github.com/HdrHistogram/HdrHistogram_rust,MIT OR Apache-2.0,"Jon Gjengset , Marshall Pierce " headers,https://github.com/hyperium/headers,MIT,Sean McArthur headers-core,https://github.com/hyperium/headers,MIT,Sean McArthur +heapless,https://github.com/rust-embedded/heapless,MIT OR Apache-2.0,"Jorge Aparicio , Per Lindgren , Emil Fresk " heck,https://github.com/withoutboats/heck,MIT OR Apache-2.0,The heck Authors heck,https://github.com/withoutboats/heck,MIT OR Apache-2.0,Without Boats hermit-abi,https://github.com/hermit-os/hermit-rs,MIT OR Apache-2.0,Stefan Lankes @@ -255,7 +257,6 @@ nom,https://github.com/Geal/nom,MIT,contact@geoffroycouprie.com ntapi,https://github.com/MSxDOS/ntapi,Apache-2.0 OR MIT,MSxDOS nu-ansi-term,https://github.com/nushell/nu-ansi-term,MIT,"ogham@bsago.me, Ryan Scheel (Havvy) , Josh Triplett , The Nushell Project Developers" num-conv,https://github.com/jhpratt/num-conv,MIT OR Apache-2.0,Jacob Pratt -num-derive,https://github.com/rust-num/num-derive,MIT OR Apache-2.0,The Rust Project Developers num-traits,https://github.com/rust-num/num-traits,MIT OR Apache-2.0,The Rust Project Developers objc2,https://github.com/madsmtm/objc2,MIT,Mads Marquart objc2-cloud-kit,https://github.com/madsmtm/objc2,Zlib OR Apache-2.0 OR MIT,The objc2-cloud-kit Authors @@ -375,6 +376,7 @@ semver,https://github.com/dtolnay/semver,MIT OR Apache-2.0,David Tolnay , Bernardo Meurer , Léo Gaspard " serde,https://github.com/serde-rs/serde,MIT OR Apache-2.0,"Erick Tryzelaar , David Tolnay " serde-bool,https://github.com/x52dev/serde-utils,MIT OR Apache-2.0,Rob Ede +serde-json-core,https://github.com/rust-embedded-community/serde-json-core,MIT OR Apache-2.0,"Jorge Aparicio , Ryan Summers , Robert Jördens , Mathias Koch " serde-transcode,https://github.com/sfackler/serde-transcode,MIT OR Apache-2.0,Steven Fackler serde_bytes,https://github.com/serde-rs/bytes,MIT OR Apache-2.0,David Tolnay serde_core,https://github.com/serde-rs/serde,MIT OR Apache-2.0,"Erick Tryzelaar , David Tolnay " diff --git a/docs/RFCs/artifacts/crashtracker-unified-runtime-stack-schema-v1_8.json b/docs/RFCs/artifacts/crashtracker-unified-runtime-stack-schema-v1_8.json index 913e431e0b..437f76bd16 100644 --- a/docs/RFCs/artifacts/crashtracker-unified-runtime-stack-schema-v1_8.json +++ b/docs/RFCs/artifacts/crashtracker-unified-runtime-stack-schema-v1_8.json @@ -302,7 +302,7 @@ } }, "SiCodes": { - "description": "See https://man7.org/linux/man-pages/man2/sigaction.2.html MUST REMAIN IN SYNC WITH THE ENUM IN emit_sigcodes.c", + "description": "See https://man7.org/linux/man-pages/man2/sigaction.2.html", "type": "string", "enum": [ "BUS_ADRALN", @@ -310,6 +310,14 @@ "BUS_MCEERR_AO", "BUS_MCEERR_AR", "BUS_OBJERR", + "FPE_FLTDIV", + "FPE_FLTINV", + "FPE_FLTOVF", + "FPE_FLTRES", + "FPE_FLTSUB", + "FPE_FLTUND", + "FPE_INTDIV", + "FPE_INTOVF", "ILL_BADSTK", "ILL_COPROC", "ILL_ILLADR", diff --git a/examples/ffi/crashtracking.c b/examples/ffi/crashtracking.c index 03d612a298..da66ea84be 100644 --- a/examples/ffi/crashtracking.c +++ b/examples/ffi/crashtracking.c @@ -115,10 +115,16 @@ int main(int argc, char **argv) { // Test raising SEGV explicitly, to ensure chaining works // properly in this case raise(SIGSEGV); +#elif defined(__APPLE__) && defined(__aarch64__) + // Optimized arm64 macOS builds may lower the undefined null write below to + // a trap instruction, which terminates with SIGTRAP instead of SIGSEGV. + raise(SIGSEGV); #endif +#if !defined(EXPLICIT_RAISE_SEGV) && !(defined(__APPLE__) && defined(__aarch64__)) char *bug = NULL; *bug = 42; +#endif // The crash handler should intercept the SIGSEGV, invoke the receiver, // and write the crash report to output_dir before the process terminates. diff --git a/libdd-crashtracker-ffi/Cargo.toml b/libdd-crashtracker-ffi/Cargo.toml index 47d07a28e1..9864fb24a9 100644 --- a/libdd-crashtracker-ffi/Cargo.toml +++ b/libdd-crashtracker-ffi/Cargo.toml @@ -20,15 +20,16 @@ bench = false required-features = ["collector_windows"] [features] -default = ["cbindgen", "collector", "demangler", "receiver"] +default = ["std", "cbindgen", "collector", "demangler", "receiver"] +std = ["libdd-crashtracker/std"] cbindgen = ["build_common/cbindgen"] regex-lite = ["libdd-common-ffi/regex-lite"] # Enables the in-process collection of crash-info -collector = [] -collector_windows = [] -demangler = ["dep:symbolic-demangle", "dep:symbolic-common"] +collector = ["std", "libdd-crashtracker/collector"] +collector_windows = ["std", "libdd-crashtracker/collector_windows"] +demangler = ["std", "dep:symbolic-demangle", "dep:symbolic-common"] # Enables the use of this library to receiver crash-info from a suitable collector -receiver = [] +receiver = ["std", "libdd-crashtracker/receiver"] [target.'cfg(windows)'.features] default = ["collector_windows"] @@ -38,7 +39,7 @@ build_common = { path = "../build-common" } [dependencies] anyhow = "1.0" -libdd-crashtracker = { path = "../libdd-crashtracker" } +libdd-crashtracker = { path = "../libdd-crashtracker", default-features = false } libdd-common = { path = "../libdd-common" } libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false } symbolic-demangle = { version = "12.8.0", default-features = false, features = ["rust", "cpp", "msvc"], optional = true } diff --git a/libdd-crashtracker-ffi/src/lib.rs b/libdd-crashtracker-ffi/src/lib.rs index a4aa8a0e53..e9f012b48f 100644 --- a/libdd-crashtracker-ffi/src/lib.rs +++ b/libdd-crashtracker-ffi/src/lib.rs @@ -11,19 +11,23 @@ mod collector; #[cfg(all(windows, feature = "collector_windows"))] mod collector_windows; +#[cfg(feature = "std")] mod crash_info; #[cfg(feature = "demangler")] mod demangler; #[cfg(all(unix, feature = "receiver"))] mod receiver; +#[cfg(feature = "std")] mod runtime_callback; #[cfg(all(unix, feature = "collector"))] pub use collector::*; #[cfg(all(windows, feature = "collector_windows"))] pub use collector_windows::api::ddog_crasht_init_windows; +#[cfg(feature = "std")] pub use crash_info::*; #[cfg(feature = "demangler")] pub use demangler::*; #[cfg(all(unix, feature = "receiver"))] pub use receiver::*; +#[cfg(feature = "std")] pub use runtime_callback::*; diff --git a/libdd-crashtracker/Cargo.toml b/libdd-crashtracker/Cargo.toml index ce3e6729f7..8a34b5dec4 100644 --- a/libdd-crashtracker/Cargo.toml +++ b/libdd-crashtracker/Cargo.toml @@ -10,71 +10,104 @@ license.workspace = true autobenches = false [lib] -crate-type = ["lib", "staticlib"] +crate-type = ["lib"] bench = false [[bin]] name = "crashtracker-receiver" path = "src/bin/crashtracker_receiver.rs" bench = false +required-features = ["std", "receiver"] [[bench]] name = "main" harness = false path = "benches/main.rs" +required-features = ["std", "benchmarking"] [features] -default = ["collector", "receiver", "collector_windows"] +default = ["std", "collector", "receiver", "collector_windows"] +std = [ + "dep:anyhow", + "dep:blazesym", + "dep:chrono", + "dep:errno", + "dep:http", + "dep:libc", + "dep:libdd-common", + "dep:libdd-libunwind-sys", + "dep:libdd-telemetry", + "dep:nix", + "dep:os_info", + "dep:page_size", + "dep:portable-atomic", + "dep:rand", + "dep:schemars", + "dep:serde", + "dep:serde_json", + "dep:symbolic-common", + "dep:symbolic-demangle", + "dep:thiserror", + "thiserror/std", + "dep:tokio", + "dep:uuid", + "dep:windows", + "serde/std", +] # Enables the in-process collection of crash-info -collector = [] +collector = ["std"] +# Enables the signal-safe in-process collection of crash-info +collector_signal-safe = ["dep:heapless", "dep:libc", "dep:rustix", "dep:serde", "dep:serde-json-core", "dep:thiserror"] # Enables the use of this library to receiver crash-info from a suitable collector -receiver = [] +receiver = ["std"] # Enables the collection of crash-info on Windows -collector_windows = [] +collector_windows = ["std"] generate-unit-test-files = [] # Enables benchmark functionality -benchmarking = ["generate-unit-test-files"] +benchmarking = ["std", "generate-unit-test-files"] # Enables C++ bindings via cxx -cxx = ["dep:cxx", "dep:cxx-build"] +cxx = ["std", "dep:cxx", "dep:cxx-build"] [target.'cfg(unix)'.dependencies] # Should be kept in sync with the libdatadog symbolizer crate (also using blasesym) -blazesym = "=0.2.3" +blazesym = { version = "=0.2.3", optional = true } [target.'cfg(target_os = "linux")'.dependencies] -libdd-libunwind-sys = { version = "1.0.2" } +libdd-libunwind-sys = { version = "1.0.2", optional = true } [dependencies] -anyhow = "1.0" -chrono = {version = "0.4", default-features = false, features = ["std", "clock", "serde"]} +anyhow = { version = "1.0", optional = true } +chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"], optional = true } cxx = { version = "1.0", optional = true } -errno = "0.3" -libdd-common = { version = "5.0.0", path = "../libdd-common" } -libdd-telemetry = { version = "5.0.1", path = "../libdd-telemetry" } -http = "1.1" -libc = "0.2" -nix = { version = "0.29", features = ["poll", "signal", "socket"] } -num-derive = "0.4.2" -num-traits = "0.2.19" -os_info = "3.14.0" -page_size = "0.6.0" -portable-atomic = { version = "1.6.0", features = ["serde"] } -rand = "0.8.5" -schemars = "0.8.21" -serde = {version = "1.0", features = ["derive"]} -serde_json = {version = "1.0"} -symbolic-demangle = { version = "12.8.0", default-features = false, features = ["rust", "cpp", "msvc"] } -symbolic-common = { version = "12.8.0", default-features = false } -tokio = { version = "1.23", features = ["rt", "macros", "io-std", "io-util"] } -uuid = { version = "1.4.1", features = ["v4", "serde"] } -thiserror = "1.0" +errno = { version = "0.3", optional = true } +heapless = { version = "0.8", default-features = false, features = ["serde"], optional = true } +libdd-common = { version = "5.0.0", path = "../libdd-common", optional = true } +libdd-telemetry = { version = "5.0.1", path = "../libdd-telemetry", optional = true } +http = { version = "1.1", optional = true } +libc = { version = "0.2", default-features = false, optional = true } +nix = { version = "0.29", features = ["poll", "signal", "socket"], optional = true } +os_info = { version = "3.14.0", optional = true } +page_size = { version = "0.6.0", optional = true } +portable-atomic = { version = "1.6.0", features = ["serde"], optional = true } +rand = { version = "0.8.5", optional = true } +rustix = { version = "=1.1.4", default-features = false, features = ["event", "fs", "mm", "pipe", "process", "runtime", "stdio", "thread", "time"], optional = true } +schemars = { version = "0.8.21", optional = true } +serde = { version = "1.0", default-features = false, features = ["derive"], optional = true } +serde-json-core = { version = "0.6", default-features = false, optional = true } +serde_json = { version = "1.0", optional = true } +symbolic-demangle = { version = "12.8.0", default-features = false, features = ["rust", "cpp", "msvc"], optional = true } +symbolic-common = { version = "12.8.0", default-features = false, optional = true } +tokio = { version = "1.23", features = ["rt", "macros", "io-std", "io-util"], optional = true } +uuid = { version = "1.4.1", features = ["v4", "serde"], optional = true } +thiserror = { version = "2", default-features = false, optional = true } [target.'cfg(windows)'.dependencies] -windows = { version = "0.59.0", features = ["Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ErrorReporting", "Win32_System_Kernel", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_Security"] } +windows = { version = "0.59.0", features = ["Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ErrorReporting", "Win32_System_Kernel", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_Security"], optional = true } [dev-dependencies] criterion = { version = "0.5.1" } goblin = "0.9.3" +serde_json = "1.0" tempfile = { version = "3.13" } [build-dependencies] diff --git a/libdd-crashtracker/README.md b/libdd-crashtracker/README.md index 742db44210..f414d26d3b 100644 --- a/libdd-crashtracker/README.md +++ b/libdd-crashtracker/README.md @@ -31,6 +31,7 @@ This ensures crash reports are sent even if the main process is corrupted. - `collector` (default): Enable in-process crash collection - `receiver` (default): Enable crash receiver functionality - `collector_windows` (default): Windows crash collection +- `collector_signal-safe`: Enable the opt-in Unix signal-safe crash collector - `benchmarking`: Enable benchmark functionality ## Example Usage @@ -55,4 +56,3 @@ use libdd_crashtracker; ## Receiver Binary The crate includes a `crashtracker-receiver` binary that runs as a separate process to ensure crash reports are sent even when the main process crashes. - diff --git a/libdd-crashtracker/build.rs b/libdd-crashtracker/build.rs index ba44902fe0..b08230c2f3 100644 --- a/libdd-crashtracker/build.rs +++ b/libdd-crashtracker/build.rs @@ -119,10 +119,6 @@ fn main() { std::env::var("TARGET").unwrap() ); - cc::Build::new() - .file("src/crash_info/emit_sicodes.c") - .compile("emit_sicodes"); - // Build CXX bridge if feature is enabled #[cfg(feature = "cxx")] build_cxx_bridge(); diff --git a/libdd-crashtracker/src/collector/additional_tags.rs b/libdd-crashtracker/src/collector/additional_tags.rs index 07acfde4ac..a3d32d14f5 100644 --- a/libdd-crashtracker/src/collector/additional_tags.rs +++ b/libdd-crashtracker/src/collector/additional_tags.rs @@ -12,9 +12,15 @@ pub fn clear_additional_tags() -> Result<(), AtomicSetError> { pub fn consume_and_emit_additional_tags(w: &mut impl Write) -> Result<(), AtomicSetError> { use crate::shared::constants::*; - writeln!(w, "{DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS}")?; - ADDITIONAL_TAGS.consume_and_emit(w, true)?; - writeln!(w, "{DD_CRASHTRACK_END_ADDITIONAL_TAGS}")?; + crate::protocol::section::<_, AtomicSetError>( + w, + DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS, + DD_CRASHTRACK_END_ADDITIONAL_TAGS, + |w| { + ADDITIONAL_TAGS.consume_and_emit(w, true)?; + Ok(()) + }, + )?; w.flush()?; Ok(()) } diff --git a/libdd-crashtracker/src/collector/api.rs b/libdd-crashtracker/src/collector/api.rs index 59d82dc824..54c34b7f5a 100644 --- a/libdd-crashtracker/src/collector/api.rs +++ b/libdd-crashtracker/src/collector/api.rs @@ -4,14 +4,17 @@ use super::{crash_handler::enable, receiver_manager::Receiver}; use crate::{ - clear_spans, clear_traces, collector::crash_handler::register_panic_hook, - collector::signal_handler_manager::register_crash_handlers, crash_info::Metadata, - reset_counters, shared::configuration::CrashtrackerReceiverConfig, update_config, - update_metadata, CrashtrackerConfiguration, + clear_spans, clear_traces, + collector::crash_handler::register_panic_hook, + collector::signal_handler_manager::register_crash_handlers, + crash_info::Metadata, + reset_counters, + shared::{configuration::CrashtrackerReceiverConfig, signals::LEGACY_DEFAULT_SIGNALS}, + signal_owner::{self, SignalOwner}, + update_config, update_metadata, CrashtrackerConfiguration, }; -pub static DEFAULT_SYMBOLS: [libc::c_int; 4] = - [libc::SIGBUS, libc::SIGABRT, libc::SIGSEGV, libc::SIGILL]; +pub static DEFAULT_SYMBOLS: [libc::c_int; 4] = LEGACY_DEFAULT_SIGNALS; pub fn default_signals() -> Vec { Vec::from(DEFAULT_SYMBOLS) @@ -81,13 +84,23 @@ pub fn init( receiver_config: CrashtrackerReceiverConfig, metadata: Metadata, ) -> anyhow::Result<()> { - update_metadata(metadata)?; - update_config(config.clone())?; - Receiver::update_stored_config(receiver_config)?; - register_crash_handlers(&config)?; - register_panic_hook()?; - enable(); - Ok(()) + if !signal_owner::acquire(SignalOwner::StdCollector) { + anyhow::bail!("another crashtracker collector already owns the crash signal handlers"); + } + + let result = (|| { + update_metadata(metadata)?; + update_config(config.clone())?; + Receiver::update_stored_config(receiver_config)?; + register_crash_handlers(&config)?; + register_panic_hook()?; + enable(); + Ok(()) + })(); + if result.is_err() { + signal_owner::release(SignalOwner::StdCollector); + } + result } /// Reconfigure the crash-tracking infrastructure. diff --git a/libdd-crashtracker/src/collector/counters.rs b/libdd-crashtracker/src/collector/counters.rs index d7dab3645b..936092769c 100644 --- a/libdd-crashtracker/src/collector/counters.rs +++ b/libdd-crashtracker/src/collector/counters.rs @@ -98,16 +98,22 @@ pub fn end_op(op: OpTypes) -> Result<(), CounterError> { pub fn emit_counters(w: &mut impl Write) -> Result<(), CounterError> { use crate::shared::constants::*; - writeln!(w, "{DD_CRASHTRACK_BEGIN_COUNTERS}")?; - for (i, c) in OP_COUNTERS.iter().enumerate() { - writeln!( - w, - "{{\"{}\": {}}}", - OpTypes::name(i)?, - c.load(Ordering::Relaxed) - )?; - } - writeln!(w, "{DD_CRASHTRACK_END_COUNTERS}")?; + crate::protocol::section::<_, CounterError>( + w, + DD_CRASHTRACK_BEGIN_COUNTERS, + DD_CRASHTRACK_END_COUNTERS, + |w| { + for (i, c) in OP_COUNTERS.iter().enumerate() { + writeln!( + w, + "{{\"{}\": {}}}", + OpTypes::name(i)?, + c.load(Ordering::Relaxed) + )?; + } + Ok(()) + }, + )?; w.flush()?; Ok(()) } diff --git a/libdd-crashtracker/src/collector/emitters.rs b/libdd-crashtracker/src/collector/emitters.rs index 530b0f12a7..1b76801261 100644 --- a/libdd-crashtracker/src/collector/emitters.rs +++ b/libdd-crashtracker/src/collector/emitters.rs @@ -4,6 +4,7 @@ use crate::collector::additional_tags::consume_and_emit_additional_tags; use crate::collector::counters::emit_counters; use crate::collector::spans::{emit_spans, emit_traces}; +use crate::protocol; use crate::runtime_callback::{ get_registered_callback, invoke_runtime_callback_with_writer, is_runtime_callback_registered, CallbackData, @@ -38,6 +39,17 @@ pub enum EmitterError { SerializationError(#[from] serde_json::Error), } +fn emit_section( + w: &mut W, + begin: &str, + end: &str, + body: impl FnOnce(&mut W) -> Result<(), EmitterError>, +) -> Result<(), EmitterError> { + protocol::section(w, begin, end, body)?; + w.flush()?; + Ok(()) +} + /// Crash-kind-specific data passed to `emit_crashreport`. /// /// Each variant carries exactly the fields that are meaningful for that crash @@ -124,7 +136,7 @@ pub(crate) fn emit_crashreport( } } - writeln!(pipe, "{DD_CRASHTRACK_DONE}")?; + protocol::marker_line::<_, EmitterError>(pipe, DD_CRASHTRACK_DONE)?; pipe.flush()?; Ok(()) } @@ -141,38 +153,41 @@ unsafe fn emit_backtrace_by_frames( resolve_frames: StacktraceCollection, ucontext: *const ucontext_t, ) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_STACKTRACE}")?; - - // On macOS, backtrace::trace_unsynchronized fails in forked children because - // macOS restricts many APIs after fork-without-exec. Walk the frame pointer - // chain directly from the saved ucontext registers instead. The parent's - // stack memory is still readable in the forked child. - #[cfg(target_os = "macos")] - { - let _ = resolve_frames; - // SAFETY: `ucontext` originates from the signal handler and points to - // the kernel-saved register snapshot. The caller guarantees we are in a - // crash-handling context where the parent's stack is still readable - // (copy-on-write after fork) - unsafe { emit_macos_backtrace_from_ucontext(w, ucontext)? }; - } + emit_section( + w, + DD_CRASHTRACK_BEGIN_STACKTRACE, + DD_CRASHTRACK_END_STACKTRACE, + |w| { + // On macOS, backtrace::trace_unsynchronized fails in forked children because + // macOS restricts many APIs after fork-without-exec. Walk the frame pointer + // chain directly from the saved ucontext registers instead. The parent's + // stack memory is still readable in the forked child. + #[cfg(target_os = "macos")] + { + let _ = resolve_frames; + // SAFETY: `ucontext` originates from the signal handler and points to + // the kernel-saved register snapshot. The caller guarantees we are in a + // crash-handling context where the parent's stack is still readable + // (copy-on-write after fork) + unsafe { emit_macos_backtrace_from_ucontext(w, ucontext)? }; + } - // On Linux, use the bundled libunwind. unw_init_local2(cursor, ucontext, 0) - // seeds the unwinder from the saved CPU context that the OS captured at the - // moment of the crash, so we start already past the signal frame at the - // actual faulting instruction. This is essential on musl libc (Alpine - // Linux), where the signal trampoline provides no DWARF unwind info and - // libgcc's unwinder cannot cross the signal frame boundary. - #[cfg(target_os = "linux")] - // SAFETY: `ucontext` originates from the signal handler and points to the - // kernel-saved register snapshot. The caller guarantees single-threaded, - // non-reentrant crash-handler execution - unsafe { - emit_backtrace_via_libunwind(w, resolve_frames, ucontext)? - }; - writeln!(w, "{DD_CRASHTRACK_END_STACKTRACE}")?; - w.flush()?; - Ok(()) + // On Linux, use the bundled libunwind. unw_init_local2(cursor, ucontext, 0) + // seeds the unwinder from the saved CPU context that the OS captured at the + // moment of the crash, so we start already past the signal frame at the + // actual faulting instruction. This is essential on musl libc (Alpine + // Linux), where the signal trampoline provides no DWARF unwind info and + // libgcc's unwinder cannot cross the signal frame boundary. + #[cfg(target_os = "linux")] + // SAFETY: `ucontext` originates from the signal handler and points to the + // kernel-saved register snapshot. The caller guarantees single-threaded, + // non-reentrant crash-handler execution + unsafe { + emit_backtrace_via_libunwind(w, resolve_frames, ucontext)? + }; + Ok(()) + }, + ) } /// Unwind the stack using the bundled libunwind, seeded from the OS-captured @@ -327,13 +342,10 @@ unsafe fn emit_macos_backtrace_from_ucontext( addr >= stack_bottom && end <= stack_top }; - // SAFETY: `mcontext` was checked non-null above and is the kernel-provided - // machine context from the signal handler's ucontext. - let ss = unsafe { &(*mcontext).__ss }; - #[cfg(target_arch = "aarch64")] - let (pc, mut fp) = (ss.__pc as usize, ss.__fp as usize); - #[cfg(target_arch = "x86_64")] - let (pc, mut fp) = (ss.__rip as usize, ss.__rbp as usize); + let Some(registers) = crate::shared::ucontext::ucontext_registers(unsafe { &*ucontext }) else { + return Ok(()); + }; + let (pc, mut fp) = (registers.ip, registers.fp); // SAFETY: `pc` is a valid code address from the kernel-saved register state. unsafe { emit_frame_with_dladdr(w, pc)? }; @@ -412,37 +424,48 @@ unsafe fn emit_whole_stacktrace( w: &mut impl Write, stacktrace: StackTrace, ) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE}")?; - let _ = serde_json::to_writer(&mut *w, &stacktrace); - writeln!(w)?; - writeln!(w, "{DD_CRASHTRACK_END_WHOLE_STACKTRACE}")?; - w.flush()?; - Ok(()) + emit_section( + w, + DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE, + DD_CRASHTRACK_END_WHOLE_STACKTRACE, + |w| { + serde_json::to_writer(&mut *w, &stacktrace)?; + writeln!(w)?; + Ok(()) + }, + ) } fn emit_config(w: &mut impl Write, config_str: &str) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_CONFIG}")?; - writeln!(w, "{config_str}")?; - writeln!(w, "{DD_CRASHTRACK_END_CONFIG}")?; - w.flush()?; - Ok(()) + emit_section( + w, + DD_CRASHTRACK_BEGIN_CONFIG, + DD_CRASHTRACK_END_CONFIG, + |w| { + writeln!(w, "{config_str}")?; + Ok(()) + }, + ) } fn emit_kind(w: &mut W, kind: &ErrorKind) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_KIND}")?; - let _ = serde_json::to_writer(&mut *w, kind); - writeln!(w)?; - writeln!(w, "{DD_CRASHTRACK_END_KIND}")?; - w.flush()?; - Ok(()) + emit_section(w, DD_CRASHTRACK_BEGIN_KIND, DD_CRASHTRACK_END_KIND, |w| { + serde_json::to_writer(&mut *w, kind)?; + writeln!(w)?; + Ok(()) + }) } fn emit_metadata(w: &mut impl Write, metadata_str: &str) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_METADATA}")?; - writeln!(w, "{metadata_str}")?; - writeln!(w, "{DD_CRASHTRACK_END_METADATA}")?; - w.flush()?; - Ok(()) + emit_section( + w, + DD_CRASHTRACK_BEGIN_METADATA, + DD_CRASHTRACK_END_METADATA, + |w| { + writeln!(w, "{metadata_str}")?; + Ok(()) + }, + ) } /// Write message content to the wire, escaping newlines and neutralizing sentinel @@ -493,21 +516,27 @@ fn emit_message(w: &mut impl Write, message_ptr: *mut String) -> Result<(), Emit if !message_ptr.is_null() { let message = unsafe { &*message_ptr }; if !message.trim().is_empty() { - writeln!(w, "{DD_CRASHTRACK_BEGIN_MESSAGE}")?; - write_sanitized_message_line(w, message)?; - writeln!(w, "{DD_CRASHTRACK_END_MESSAGE}")?; - w.flush()?; + emit_section( + w, + DD_CRASHTRACK_BEGIN_MESSAGE, + DD_CRASHTRACK_END_MESSAGE, + |w| write_sanitized_message_line(w, message), + )?; } } Ok(()) } fn emit_procinfo(w: &mut impl Write, pid: i32, tid: libc::pid_t) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_PROCINFO}")?; - writeln!(w, "{{\"pid\": {pid}, \"tid\": {tid} }}")?; - writeln!(w, "{DD_CRASHTRACK_END_PROCINFO}")?; - w.flush()?; - Ok(()) + emit_section( + w, + DD_CRASHTRACK_BEGIN_PROCINFO, + DD_CRASHTRACK_END_PROCINFO, + |w| { + writeln!(w, "{{\"pid\": {pid}, \"tid\": {tid} }}")?; + Ok(()) + }, + ) } #[cfg(target_os = "linux")] @@ -523,53 +552,57 @@ fn emit_ucontext(w: &mut impl Write, ucontext: *const ucontext_t) -> Result<(), if ucontext.is_null() { return Err(EmitterError::NullUcontext); } - writeln!(w, "{DD_CRASHTRACK_BEGIN_UCONTEXT}")?; - // SAFETY: the pointer is given to us by the signal handler, and is non-null. - let uc = unsafe { &*ucontext }; + emit_section( + w, + DD_CRASHTRACK_BEGIN_UCONTEXT, + DD_CRASHTRACK_END_UCONTEXT, + |w| { + // SAFETY: the pointer is given to us by the signal handler, and is non-null. + let uc = unsafe { &*ucontext }; - #[cfg(target_arch = "x86_64")] - { - let gregs = &uc.uc_mcontext.gregs; - write!(w, "{{\"arch\": \"x86_64\", \"registers\": {{")?; - write!(w, "\"rip\": \"0x{:016x}\"", gregs[libc::REG_RIP as usize])?; - write!(w, ", \"rsp\": \"0x{:016x}\"", gregs[libc::REG_RSP as usize])?; - write!(w, ", \"rbp\": \"0x{:016x}\"", gregs[libc::REG_RBP as usize])?; - write!(w, ", \"rax\": \"0x{:016x}\"", gregs[libc::REG_RAX as usize])?; - write!(w, ", \"rbx\": \"0x{:016x}\"", gregs[libc::REG_RBX as usize])?; - write!(w, ", \"rcx\": \"0x{:016x}\"", gregs[libc::REG_RCX as usize])?; - write!(w, ", \"rdx\": \"0x{:016x}\"", gregs[libc::REG_RDX as usize])?; - write!(w, ", \"rsi\": \"0x{:016x}\"", gregs[libc::REG_RSI as usize])?; - write!(w, ", \"rdi\": \"0x{:016x}\"", gregs[libc::REG_RDI as usize])?; - write!(w, ", \"r8\": \"0x{:016x}\"", gregs[libc::REG_R8 as usize])?; - write!(w, ", \"r9\": \"0x{:016x}\"", gregs[libc::REG_R9 as usize])?; - write!(w, ", \"r10\": \"0x{:016x}\"", gregs[libc::REG_R10 as usize])?; - write!(w, ", \"r11\": \"0x{:016x}\"", gregs[libc::REG_R11 as usize])?; - write!(w, ", \"r12\": \"0x{:016x}\"", gregs[libc::REG_R12 as usize])?; - write!(w, ", \"r13\": \"0x{:016x}\"", gregs[libc::REG_R13 as usize])?; - write!(w, ", \"r14\": \"0x{:016x}\"", gregs[libc::REG_R14 as usize])?; - write!(w, ", \"r15\": \"0x{:016x}\"", gregs[libc::REG_R15 as usize])?; - // Preserve the full ucontext as a raw Debug string so that FPU state, - // signal mask, and alternate-stack info are not lost. - write!(w, "}}, \"raw\": \"{:?}\"", uc)?; - writeln!(w, "}}")?; - } + #[cfg(target_arch = "x86_64")] + { + let gregs = &uc.uc_mcontext.gregs; + write!(w, "{{\"arch\": \"x86_64\", \"registers\": {{")?; + write!(w, "\"rip\": \"0x{:016x}\"", gregs[libc::REG_RIP as usize])?; + write!(w, ", \"rsp\": \"0x{:016x}\"", gregs[libc::REG_RSP as usize])?; + write!(w, ", \"rbp\": \"0x{:016x}\"", gregs[libc::REG_RBP as usize])?; + write!(w, ", \"rax\": \"0x{:016x}\"", gregs[libc::REG_RAX as usize])?; + write!(w, ", \"rbx\": \"0x{:016x}\"", gregs[libc::REG_RBX as usize])?; + write!(w, ", \"rcx\": \"0x{:016x}\"", gregs[libc::REG_RCX as usize])?; + write!(w, ", \"rdx\": \"0x{:016x}\"", gregs[libc::REG_RDX as usize])?; + write!(w, ", \"rsi\": \"0x{:016x}\"", gregs[libc::REG_RSI as usize])?; + write!(w, ", \"rdi\": \"0x{:016x}\"", gregs[libc::REG_RDI as usize])?; + write!(w, ", \"r8\": \"0x{:016x}\"", gregs[libc::REG_R8 as usize])?; + write!(w, ", \"r9\": \"0x{:016x}\"", gregs[libc::REG_R9 as usize])?; + write!(w, ", \"r10\": \"0x{:016x}\"", gregs[libc::REG_R10 as usize])?; + write!(w, ", \"r11\": \"0x{:016x}\"", gregs[libc::REG_R11 as usize])?; + write!(w, ", \"r12\": \"0x{:016x}\"", gregs[libc::REG_R12 as usize])?; + write!(w, ", \"r13\": \"0x{:016x}\"", gregs[libc::REG_R13 as usize])?; + write!(w, ", \"r14\": \"0x{:016x}\"", gregs[libc::REG_R14 as usize])?; + write!(w, ", \"r15\": \"0x{:016x}\"", gregs[libc::REG_R15 as usize])?; + // Preserve the full ucontext as a raw Debug string so that FPU state, + // signal mask, and alternate-stack info are not lost. + write!(w, "}}, \"raw\": \"{:?}\"", uc)?; + writeln!(w, "}}")?; + } - #[cfg(target_arch = "aarch64")] - { - let mc = &uc.uc_mcontext; - write!(w, "{{\"arch\": \"aarch64\", \"registers\": {{")?; - write!(w, "\"pc\": \"0x{:016x}\"", mc.pc)?; - write!(w, ", \"sp\": \"0x{:016x}\"", mc.sp)?; - for i in 0..31 { - write!(w, ", \"x{}\": \"0x{:016x}\"", i, mc.regs[i])?; - } - write!(w, "}}, \"raw\": \"{:?}\"", uc)?; - writeln!(w, "}}")?; - } + #[cfg(target_arch = "aarch64")] + { + let mc = &uc.uc_mcontext; + write!(w, "{{\"arch\": \"aarch64\", \"registers\": {{")?; + write!(w, "\"pc\": \"0x{:016x}\"", mc.pc)?; + write!(w, ", \"sp\": \"0x{:016x}\"", mc.sp)?; + for i in 0..31 { + write!(w, ", \"x{}\": \"0x{:016x}\"", i, mc.regs[i])?; + } + write!(w, "}}, \"raw\": \"{:?}\"", uc)?; + writeln!(w, "}}")?; + } - writeln!(w, "{DD_CRASHTRACK_END_UCONTEXT}")?; - w.flush()?; - Ok(()) + Ok(()) + }, + ) } /// Emit runtime stack frames collected from registered runtime callback @@ -605,24 +638,32 @@ fn emit_runtime_stack(w: &mut impl Write) -> Result<(), EmitterError> { } fn emit_runtime_stack_by_frames(w: &mut impl Write) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME}")?; - // SAFETY: The runtime callback was registered during initialization and - // must be signal-safe per its API contract. The crash handler's - // non-reentrant model ensures no concurrent invocation. - unsafe { invoke_runtime_callback_with_writer(w)? }; - writeln!(w, "{DD_CRASHTRACK_END_RUNTIME_STACK_FRAME}")?; - w.flush()?; - Ok(()) + emit_section( + w, + DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME, + DD_CRASHTRACK_END_RUNTIME_STACK_FRAME, + |w| { + // SAFETY: The runtime callback was registered during initialization and + // must be signal-safe per its API contract. The crash handler's + // non-reentrant model ensures no concurrent invocation. + unsafe { invoke_runtime_callback_with_writer(w)? }; + Ok(()) + }, + ) } fn emit_runtime_stack_by_stacktrace_string(w: &mut impl Write) -> Result<(), EmitterError> { - writeln!(w, "{DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING}")?; - // SAFETY: Same contract as emit_runtime_stack_by_frames — the callback - // was registered at init time and the crash handler runs non-reentrantly. - unsafe { invoke_runtime_callback_with_writer(w)? }; - writeln!(w, "{DD_CRASHTRACK_END_RUNTIME_STACK_STRING}")?; - w.flush()?; - Ok(()) + emit_section( + w, + DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING, + DD_CRASHTRACK_END_RUNTIME_STACK_STRING, + |w| { + // SAFETY: Same contract as emit_runtime_stack_by_frames — the callback + // was registered at init time and the crash handler runs non-reentrantly. + unsafe { invoke_runtime_callback_with_writer(w)? }; + Ok(()) + }, + ) } #[cfg(target_os = "macos")] @@ -634,65 +675,68 @@ fn emit_ucontext(w: &mut impl Write, ucontext: *const ucontext_t) -> Result<(), // SAFETY: the pointer is given to us by the signal handler, and is non-null. let uc = unsafe { &*ucontext }; let mcontext = uc.uc_mcontext; - writeln!(w, "{DD_CRASHTRACK_BEGIN_UCONTEXT}")?; - - if mcontext.is_null() { - // Fall back to raw Debug output if mcontext pointer is null. - write!(w, "{{\"arch\": \"")?; - #[cfg(target_arch = "x86_64")] - write!(w, "x86_64")?; - #[cfg(target_arch = "aarch64")] - write!(w, "aarch64")?; - write!(w, "\", \"registers\": {{}}")?; - write!(w, ", \"raw\": \"{:?}\"", uc)?; - writeln!(w, "}}")?; - } else { - // SAFETY: mcontext is non-null, provided by the signal handler. - let mc = unsafe { &*mcontext }; - let ss = &mc.__ss; - - #[cfg(target_arch = "x86_64")] - { - write!(w, "{{\"arch\": \"x86_64\", \"registers\": {{")?; - write!(w, "\"rip\": \"0x{:016x}\"", ss.__rip)?; - write!(w, ", \"rsp\": \"0x{:016x}\"", ss.__rsp)?; - write!(w, ", \"rbp\": \"0x{:016x}\"", ss.__rbp)?; - write!(w, ", \"rax\": \"0x{:016x}\"", ss.__rax)?; - write!(w, ", \"rbx\": \"0x{:016x}\"", ss.__rbx)?; - write!(w, ", \"rcx\": \"0x{:016x}\"", ss.__rcx)?; - write!(w, ", \"rdx\": \"0x{:016x}\"", ss.__rdx)?; - write!(w, ", \"rsi\": \"0x{:016x}\"", ss.__rsi)?; - write!(w, ", \"rdi\": \"0x{:016x}\"", ss.__rdi)?; - write!(w, ", \"r8\": \"0x{:016x}\"", ss.__r8)?; - write!(w, ", \"r9\": \"0x{:016x}\"", ss.__r9)?; - write!(w, ", \"r10\": \"0x{:016x}\"", ss.__r10)?; - write!(w, ", \"r11\": \"0x{:016x}\"", ss.__r11)?; - write!(w, ", \"r12\": \"0x{:016x}\"", ss.__r12)?; - write!(w, ", \"r13\": \"0x{:016x}\"", ss.__r13)?; - write!(w, ", \"r14\": \"0x{:016x}\"", ss.__r14)?; - write!(w, ", \"r15\": \"0x{:016x}\"", ss.__r15)?; - write!(w, "}}, \"raw\": \"{:?}, {:?}\"", uc, mc)?; - writeln!(w, "}}")?; - } + emit_section( + w, + DD_CRASHTRACK_BEGIN_UCONTEXT, + DD_CRASHTRACK_END_UCONTEXT, + |w| { + if mcontext.is_null() { + // Fall back to raw Debug output if mcontext pointer is null. + write!(w, "{{\"arch\": \"")?; + #[cfg(target_arch = "x86_64")] + write!(w, "x86_64")?; + #[cfg(target_arch = "aarch64")] + write!(w, "aarch64")?; + write!(w, "\", \"registers\": {{}}")?; + write!(w, ", \"raw\": \"{:?}\"", uc)?; + writeln!(w, "}}")?; + } else { + // SAFETY: mcontext is non-null, provided by the signal handler. + let mc = unsafe { &*mcontext }; + let ss = &mc.__ss; + + #[cfg(target_arch = "x86_64")] + { + write!(w, "{{\"arch\": \"x86_64\", \"registers\": {{")?; + write!(w, "\"rip\": \"0x{:016x}\"", ss.__rip)?; + write!(w, ", \"rsp\": \"0x{:016x}\"", ss.__rsp)?; + write!(w, ", \"rbp\": \"0x{:016x}\"", ss.__rbp)?; + write!(w, ", \"rax\": \"0x{:016x}\"", ss.__rax)?; + write!(w, ", \"rbx\": \"0x{:016x}\"", ss.__rbx)?; + write!(w, ", \"rcx\": \"0x{:016x}\"", ss.__rcx)?; + write!(w, ", \"rdx\": \"0x{:016x}\"", ss.__rdx)?; + write!(w, ", \"rsi\": \"0x{:016x}\"", ss.__rsi)?; + write!(w, ", \"rdi\": \"0x{:016x}\"", ss.__rdi)?; + write!(w, ", \"r8\": \"0x{:016x}\"", ss.__r8)?; + write!(w, ", \"r9\": \"0x{:016x}\"", ss.__r9)?; + write!(w, ", \"r10\": \"0x{:016x}\"", ss.__r10)?; + write!(w, ", \"r11\": \"0x{:016x}\"", ss.__r11)?; + write!(w, ", \"r12\": \"0x{:016x}\"", ss.__r12)?; + write!(w, ", \"r13\": \"0x{:016x}\"", ss.__r13)?; + write!(w, ", \"r14\": \"0x{:016x}\"", ss.__r14)?; + write!(w, ", \"r15\": \"0x{:016x}\"", ss.__r15)?; + write!(w, "}}, \"raw\": \"{:?}, {:?}\"", uc, mc)?; + writeln!(w, "}}")?; + } - #[cfg(target_arch = "aarch64")] - { - write!(w, "{{\"arch\": \"aarch64\", \"registers\": {{")?; - write!(w, "\"pc\": \"0x{:016x}\"", ss.__pc)?; - write!(w, ", \"sp\": \"0x{:016x}\"", ss.__sp)?; - write!(w, ", \"fp\": \"0x{:016x}\"", ss.__fp)?; - write!(w, ", \"lr\": \"0x{:016x}\"", ss.__lr)?; - for i in 0..29 { - write!(w, ", \"x{}\": \"0x{:016x}\"", i, ss.__x[i])?; + #[cfg(target_arch = "aarch64")] + { + write!(w, "{{\"arch\": \"aarch64\", \"registers\": {{")?; + write!(w, "\"pc\": \"0x{:016x}\"", ss.__pc)?; + write!(w, ", \"sp\": \"0x{:016x}\"", ss.__sp)?; + write!(w, ", \"fp\": \"0x{:016x}\"", ss.__fp)?; + write!(w, ", \"lr\": \"0x{:016x}\"", ss.__lr)?; + for i in 0..29 { + write!(w, ", \"x{}\": \"0x{:016x}\"", i, ss.__x[i])?; + } + write!(w, "}}, \"raw\": \"{:?}, {:?}\"", uc, mc)?; + writeln!(w, "}}")?; + } } - write!(w, "}}, \"raw\": \"{:?}, {:?}\"", uc, mc)?; - writeln!(w, "}}")?; - } - } - writeln!(w, "{DD_CRASHTRACK_END_UCONTEXT}")?; - w.flush()?; - Ok(()) + Ok(()) + }, + ) } fn emit_siginfo(w: &mut impl Write, sig_info: *const siginfo_t) -> Result<(), EmitterError> { @@ -708,38 +752,41 @@ fn emit_siginfo(w: &mut impl Write, sig_info: *const siginfo_t) -> Result<(), Em // Derive the faulting address from `sig_info` // https://man7.org/linux/man-pages/man2/sigaction.2.html // SIGILL, SIGFPE, SIGSEGV, SIGBUS, and SIGTRAP fill in si_addr with the address of the fault. - let si_addr: Option = match si_signo { - libc::SIGILL | libc::SIGFPE | libc::SIGSEGV | libc::SIGBUS | libc::SIGTRAP => { - // SAFETY: for these signal types, si_addr is defined and valid - // per sigaction(2). `sig_info` was checked non-null above. - Some(unsafe { (*sig_info).si_addr() as usize }) - } - _ => None, + let si_addr: Option = if crate::shared::signal_names::signal_has_address(si_signo) { + // SAFETY: for these signal types, si_addr is defined and valid + // per sigaction(2). `sig_info` was checked non-null above. + Some(unsafe { (*sig_info).si_addr() as usize }) + } else { + None }; // SAFETY: `sig_info` was checked non-null and points to valid kernel data. let si_code = unsafe { (*sig_info).si_code }; let si_code_human_readable = translate_si_code(si_signo, si_code); - writeln!(w, "{DD_CRASHTRACK_BEGIN_SIGINFO}")?; - write!(w, "{{")?; - write!(w, "\"si_code\": {si_code}")?; - write!( + emit_section( w, - ", \"si_code_human_readable\": \"{si_code_human_readable:?}\"" - )?; - write!(w, ", \"si_signo\": {si_signo}")?; - write!( - w, - ", \"si_signo_human_readable\": \"{si_signo_human_readable:?}\"" - )?; - if let Some(si_addr) = si_addr { - write!(w, ", \"si_addr\": \"{si_addr:#018x}\"")?; - } - writeln!(w, "}}")?; - writeln!(w, "{DD_CRASHTRACK_END_SIGINFO}")?; - w.flush()?; - Ok(()) + DD_CRASHTRACK_BEGIN_SIGINFO, + DD_CRASHTRACK_END_SIGINFO, + |w| { + write!(w, "{{")?; + write!(w, "\"si_code\": {si_code}")?; + write!( + w, + ", \"si_code_human_readable\": \"{si_code_human_readable:?}\"" + )?; + write!(w, ", \"si_signo\": {si_signo}")?; + write!( + w, + ", \"si_signo_human_readable\": \"{si_signo_human_readable:?}\"" + )?; + if let Some(si_addr) = si_addr { + write!(w, ", \"si_addr\": \"{si_addr:#018x}\"")?; + } + writeln!(w, "}}")?; + Ok(()) + }, + ) } /// Emit a file onto the given handle. diff --git a/libdd-crashtracker/src/collector/spans.rs b/libdd-crashtracker/src/collector/spans.rs index 3f9442bec0..c40ad7ff04 100644 --- a/libdd-crashtracker/src/collector/spans.rs +++ b/libdd-crashtracker/src/collector/spans.rs @@ -15,9 +15,15 @@ pub fn clear_spans() -> Result<(), AtomicSetError> { #[allow(dead_code)] pub fn emit_spans(w: &mut impl Write) -> Result<(), AtomicSetError> { use crate::shared::constants::*; - writeln!(w, "{DD_CRASHTRACK_BEGIN_SPAN_IDS}")?; - ACTIVE_SPANS.consume_and_emit(w, true)?; - writeln!(w, "{DD_CRASHTRACK_END_SPAN_IDS}")?; + crate::protocol::section::<_, AtomicSetError>( + w, + DD_CRASHTRACK_BEGIN_SPAN_IDS, + DD_CRASHTRACK_END_SPAN_IDS, + |w| { + ACTIVE_SPANS.consume_and_emit(w, true)?; + Ok(()) + }, + )?; w.flush()?; Ok(()) } @@ -38,9 +44,15 @@ pub fn clear_traces() -> Result<(), AtomicSetError> { #[allow(dead_code)] pub fn emit_traces(w: &mut impl Write) -> Result<(), AtomicSetError> { use crate::shared::constants::*; - writeln!(w, "{DD_CRASHTRACK_BEGIN_TRACE_IDS}")?; - ACTIVE_TRACES.consume_and_emit(w, true)?; - writeln!(w, "{DD_CRASHTRACK_END_TRACE_IDS}")?; + crate::protocol::section::<_, AtomicSetError>( + w, + DD_CRASHTRACK_BEGIN_TRACE_IDS, + DD_CRASHTRACK_END_TRACE_IDS, + |w| { + ACTIVE_TRACES.consume_and_emit(w, true)?; + Ok(()) + }, + )?; w.flush()?; Ok(()) } diff --git a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs new file mode 100644 index 0000000000..4a31a1d90a --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs @@ -0,0 +1,177 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::c_void; + +use super::sys; +use crate::shared::ucontext::ucontext_registers; + +const MMAP_MIN_ADDR: usize = 0x10000; +const MAX_FRAME_SIZE: usize = 0x100000; +const WORD: usize = core::mem::size_of::(); + +#[inline] +fn aligned_8(p: usize) -> bool { + p & 0b111 == 0 +} + +#[inline] +fn word_at(rec: &[u8; 2 * WORD], i: usize) -> usize { + let mut b = [0u8; WORD]; + b.copy_from_slice(&rec[i * WORD..(i + 1) * WORD]); + usize::from_ne_bytes(b) +} + +fn walk_fp(out: &mut [usize], mut n: usize, self_pid: i32, mut fp: usize) -> usize { + while n < out.len() && fp != 0 { + if !aligned_8(fp) || fp < MMAP_MIN_ADDR { + break; + } + + let mut rec = [0u8; 2 * WORD]; + if !sys::read_own_mem(self_pid, fp, &mut rec) { + break; + } + + let prev_fp = word_at(&rec, 0); + let ret = word_at(&rec, 1); + if ret <= MMAP_MIN_ADDR { + break; + } + + out[n] = ret; + n += 1; + + if prev_fp <= fp || prev_fp - fp > MAX_FRAME_SIZE { + break; + } + fp = prev_fp; + } + n +} + +fn arch_seed(uc: &libc::ucontext_t, out: &mut [usize]) -> (usize, usize) { + let Some(registers) = ucontext_registers(uc) else { + return (0, 0); + }; + let mut n = 0; + if registers.ip != 0 { + out[0] = registers.ip; + n = 1; + } + + let fp_walkable = registers.fp != 0 && aligned_8(registers.fp) && registers.fp >= MMAP_MIN_ADDR; + if !fp_walkable && registers.link != 0 && n < out.len() { + out[n] = registers.link; + n += 1; + } + (n, registers.fp) +} + +pub fn backtrace_from_ucontext( + out: &mut [usize], + ucontext: *const c_void, + self_pid: i32, + allow_memory_read: bool, +) -> usize { + if out.is_empty() || ucontext.is_null() { + return 0; + } + + let uc = unsafe { &*(ucontext as *const libc::ucontext_t) }; + let (n, fp) = arch_seed(uc, out); + if allow_memory_read { + walk_fp(out, n, self_pid, fp) + } else { + n + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + fn pid() -> i32 { + unsafe { libc::getpid() } + } + + #[test] + fn walks_a_synthetic_chain() { + let mut frames = [[0usize; 2]; 3]; + let base = frames.as_ptr() as usize; + let stride = core::mem::size_of::<[usize; 2]>(); + frames[0] = [base + stride, 0x401000]; + frames[1] = [base + 2 * stride, 0x402000]; + frames[2] = [0, 0x403000]; + // The frame array is read through `process_vm_readv`, which is opaque to lints. + core::hint::black_box(&frames); + + let mut out = [0usize; 8]; + let n = walk_fp(&mut out, 0, pid(), base); + assert_eq!(n, 3); + assert_eq!(&out[..3], &[0x401000, 0x402000, 0x403000]); + } + + #[test] + fn forked_child_walks_inherited_snapshot_with_self_pid() { + let mut frames = [[0usize; 2]; 3]; + let base = frames.as_ptr() as usize; + let stride = core::mem::size_of::<[usize; 2]>(); + frames[0] = [base + stride, 0x401000]; + frames[1] = [base + 2 * stride, 0x402000]; + frames[2] = [0, 0x403000]; + // The child reads this array through `process_vm_readv`, which is opaque to lints. + core::hint::black_box(&frames); + + let child = unsafe { sys::fork_raw() }; + if child == 0 { + let mut out = [0usize; 8]; + let n = walk_fp(&mut out, 0, sys::getpid(), base); + let ok = n == 3 && out[..3] == [0x401000, 0x402000, 0x403000]; + sys::exit_process(if ok { 0 } else { 1 }); + } + + assert!(child > 0, "fork failed: {child}"); + match sys::reap_child(child as i32, 1_000, 10, 100) { + sys::ChildReap::Reaped(status) => { + assert!(libc::WIFEXITED(status)); + assert_eq!(libc::WEXITSTATUS(status), 0); + } + sys::ChildReap::NoChild => panic!("child was already reaped"), + sys::ChildReap::WaitFailed(errno) => panic!("waitpid failed: {errno}"), + sys::ChildReap::TimedOut => panic!("child timed out"), + } + } + + #[test] + fn stops_on_unmapped_fp() { + let mut out = [0usize; 8]; + assert_eq!(walk_fp(&mut out, 0, pid(), 0x10000), 0); + } + + #[test] + fn honors_out_capacity() { + let mut frames = [[0usize; 2]; 3]; + let base = frames.as_ptr() as usize; + let stride = core::mem::size_of::<[usize; 2]>(); + frames[0] = [base + stride, 0x401000]; + frames[1] = [base + 2 * stride, 0x402000]; + frames[2] = [0, 0x403000]; + // The frame array is read through `process_vm_readv`, which is opaque to lints. + core::hint::black_box(&frames); + + let mut out = [0usize; 2]; + let n = walk_fp(&mut out, 0, pid(), base); + assert_eq!(n, 2); + assert_eq!(&out[..2], &[0x401000, 0x402000]); + } + + #[test] + fn read_own_mem_roundtrips_and_rejects_unmapped() { + let src = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let mut dst = [0u8; 8]; + assert!(sys::read_own_mem(pid(), src.as_ptr() as usize, &mut dst)); + assert_eq!(dst, src); + assert!(!sys::read_own_mem(pid(), 0x10000, &mut dst)); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs new file mode 100644 index 0000000000..d78bd742a3 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -0,0 +1,221 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::sync::atomic::{AtomicU32, Ordering}; + +use super::sys; + +/// Declares a `#[repr(transparent)]` u32 bitset newtype with the common flag +/// operations shared by [`Capabilities`] and [`Degradations`]. +macro_rules! bitset_u32 { + ($name:ident) => { + #[repr(transparent)] + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct $name(u32); + + impl $name { + pub const fn empty() -> Self { + Self(0) + } + + pub const fn from_bits(bits: u32) -> Self { + Self(bits) + } + + pub const fn bits(self) -> u32 { + self.0 + } + + pub const fn contains(self, flag: Self) -> bool { + self.0 & flag.0 != 0 + } + + fn insert(&mut self, flag: Self) { + self.0 |= flag.0; + } + } + }; +} + +bitset_u32!(Capabilities); +bitset_u32!(Degradations); + +impl Degradations { + pub const fn with(self, flag: Self) -> Self { + Self(self.0 | flag.0) + } +} + +pub const RECEIVER_OK: Capabilities = Capabilities::from_bits(1 << 0); +pub const PROC_VM_READV: Capabilities = Capabilities::from_bits(1 << 1); +pub const FORK_OK: Capabilities = Capabilities::from_bits(1 << 2); +pub const DEV_NULL: Capabilities = Capabilities::from_bits(1 << 3); +pub const PIPE_OK: Capabilities = Capabilities::from_bits(1 << 4); +pub const REPORT_FD_OK: Capabilities = Capabilities::from_bits(1 << 5); + +pub const DEGRADED_MISSING_RECEIVER: Degradations = Degradations::from_bits(1 << 0); +pub const DEGRADED_NO_PROC_VM_READV: Degradations = Degradations::from_bits(1 << 1); +pub const DEGRADED_NO_FORK: Degradations = Degradations::from_bits(1 << 2); +pub const DEGRADED_NO_DEV_NULL: Degradations = Degradations::from_bits(1 << 3); +pub const DEGRADED_NO_PIPE: Degradations = Degradations::from_bits(1 << 4); +pub const DEGRADED_PIPE_FAILED: Degradations = Degradations::from_bits(1 << 5); +pub const DEGRADED_FORK_FAILED: Degradations = Degradations::from_bits(1 << 6); +pub const DEGRADED_RECEIVER_UNAVAILABLE: Degradations = Degradations::from_bits(1 << 7); +pub const DEGRADED_REPORT_TO_FD: Degradations = Degradations::from_bits(1 << 8); +pub const DEGRADED_TRUNCATED: Degradations = Degradations::from_bits(1 << 9); +pub const DEGRADED_METADATA_TRUNCATED: Degradations = Degradations::from_bits(1 << 10); +pub const DEGRADED_APP_HANDLER_PRESENT: Degradations = Degradations::from_bits(1 << 11); +pub const DEGRADED_ALT_STACK_GUARD_UNAVAILABLE: Degradations = Degradations::from_bits(1 << 12); + +pub const DEGRADATION_REASONS: &[(Degradations, &str)] = &[ + (DEGRADED_MISSING_RECEIVER, "missing_receiver"), + (DEGRADED_NO_PROC_VM_READV, "no_process_vm_readv"), + (DEGRADED_NO_FORK, "no_fork"), + (DEGRADED_NO_DEV_NULL, "no_dev_null"), + (DEGRADED_NO_PIPE, "no_pipe"), + (DEGRADED_PIPE_FAILED, "pipe_failed"), + (DEGRADED_FORK_FAILED, "fork_failed"), + (DEGRADED_RECEIVER_UNAVAILABLE, "receiver_unavailable"), + (DEGRADED_REPORT_TO_FD, "report_to_fd"), + (DEGRADED_TRUNCATED, "truncated"), + (DEGRADED_METADATA_TRUNCATED, "metadata_truncated"), + (DEGRADED_APP_HANDLER_PRESENT, "app_handler_present"), + ( + DEGRADED_ALT_STACK_GUARD_UNAVAILABLE, + "alt_stack_guard_unavailable", + ), +]; + +static CAPABILITIES: AtomicU32 = AtomicU32::new(0); +static DEGRADATIONS: AtomicU32 = AtomicU32::new(0); + +const SECCOMP_PROBE_REAP_TIMEOUT_MS: i64 = 100; +const SECCOMP_PROBE_REAP_POLL_MS: i32 = 10; +const SECCOMP_PROBE_KILL_TIMEOUT_MS: i64 = 10; + +pub fn publish(receiver_path: &[u8], report_fd: i32, probe_seccomp: bool) { + let mut caps = Capabilities::empty(); + let mut degraded = Degradations::empty(); + + if sys::access_executable(receiver_path.as_ptr()) { + caps.insert(RECEIVER_OK); + } else { + degraded.insert(DEGRADED_MISSING_RECEIVER); + } + + if probe_process_vm_readv() && (!probe_seccomp || probe_process_vm_readv_in_child()) { + caps.insert(PROC_VM_READV); + } else { + degraded.insert(DEGRADED_NO_PROC_VM_READV); + } + + if sys::fork_supported() { + caps.insert(FORK_OK); + } else { + degraded.insert(DEGRADED_NO_FORK); + } + + let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); + if devnull >= 0 { + caps.insert(DEV_NULL); + sys::close(devnull); + } else { + degraded.insert(DEGRADED_NO_DEV_NULL); + } + + if let Some(pipe) = sys::pipe() { + caps.insert(PIPE_OK); + sys::close(pipe.read); + sys::close(pipe.write); + } else { + degraded.insert(DEGRADED_NO_PIPE); + } + + if sys::fd_valid(report_fd) { + caps.insert(REPORT_FD_OK); + } + + CAPABILITIES.store(caps.bits(), Ordering::Release); + DEGRADATIONS.store(degraded.bits(), Ordering::Release); +} + +pub fn get() -> Capabilities { + Capabilities::from_bits(CAPABILITIES.load(Ordering::Acquire)) +} + +pub fn has(capability: Capabilities) -> bool { + get().contains(capability) +} + +pub fn degradations() -> Degradations { + Degradations::from_bits(DEGRADATIONS.load(Ordering::Acquire)) +} + +pub fn note_degraded(reason: Degradations) { + DEGRADATIONS.fetch_or(reason.bits(), Ordering::AcqRel); +} + +fn probe_process_vm_readv() -> bool { + let src = 0x5au8; + let mut dst = [0u8; 1]; + sys::read_own_mem(sys::getpid(), (&src as *const u8) as usize, &mut dst) && dst[0] == src +} + +fn probe_process_vm_readv_in_child() -> bool { + if !sys::fork_supported() { + return true; + } + + let child = unsafe { sys::fork_raw() }; + if child == 0 { + sys::exit_process(if probe_process_vm_readv() { 0 } else { 1 }); + } + if child < 0 { + return true; + } + + match sys::reap_child( + child as i32, + SECCOMP_PROBE_REAP_TIMEOUT_MS, + SECCOMP_PROBE_REAP_POLL_MS, + SECCOMP_PROBE_KILL_TIMEOUT_MS, + ) { + sys::ChildReap::Reaped(status) => libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, + sys::ChildReap::NoChild | sys::ChildReap::WaitFailed(_) | sys::ChildReap::TimedOut => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::fd::AsRawFd; + + #[test] + fn publish_reports_missing_receiver_degradation() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + + publish(b"/definitely/missing-signal-safe-receiver\0", -1, false); + + assert!(!get().contains(RECEIVER_OK)); + assert!(degradations().contains(DEGRADED_MISSING_RECEIVER)); + assert!(!get().contains(REPORT_FD_OK)); + } + + #[test] + fn publish_marks_valid_report_fd() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + let file = tempfile::tempfile().expect("tempfile"); + + publish( + b"/definitely/missing-signal-safe-receiver\0", + file.as_raw_fd(), + false, + ); + + assert!(get().contains(REPORT_FD_OK)); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs new file mode 100644 index 0000000000..6ad84997f3 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -0,0 +1,455 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::sync::atomic::Ordering::Relaxed; + +use heapless::String as HeaplessString; +use serde::Serialize; +use thiserror::Error; + +use super::{capabilities, state}; +use crate::shared::{ + defaults::DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS, signals::SIGNAL_SAFE_CRASH_SIGNALS, + stacktrace_collection::StacktraceCollection, +}; + +// Default metadata used only when the caller leaves an `InitConfig` field empty. The +// library reads no environment (neither at build time nor at runtime): a consumer populates the +// config struct with its own identity. These neutral placeholders keep any single consumer's +// name out of the shared crate. +pub const DEFAULT_LIBRARY_NAME: &str = "unknown-library"; +pub const DEFAULT_LIBRARY_VERSION: &str = "unknown"; +pub const DEFAULT_LIBRARY_FAMILY: &str = "native"; +pub const DEFAULT_SERVICE: &str = "unknown-service"; +pub const DEFAULT_RUNTIME_ID: &str = "00000000-0000-0000-0000-000000000000"; + +/// Capacity for signal-safe filesystem path buffers (PATH_MAX + trailing NUL). +pub const PATH_CAPACITY: usize = 513; + +pub const RECEIVER_TIMEOUT_SECS_DEFAULT: u32 = DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS; +pub const RECEIVER_TIMEOUT_SECS_MAX: u32 = 60; +pub const COLLECTOR_REAP_MS_DEFAULT: i32 = 500; +pub const RECEIVER_REAP_GRACE_MS: i32 = 1000; +pub const RECEIVER_REAP_MS_DEFAULT: i32 = + RECEIVER_TIMEOUT_SECS_DEFAULT as i32 * 1000 + RECEIVER_REAP_GRACE_MS; +pub const BACKTRACE_LEVELS_DEFAULT: usize = 32; +pub const BACKTRACE_LEVELS_MAX: usize = 64; + +pub const CRASH_SIGNALS: [i32; 5] = SIGNAL_SAFE_CRASH_SIGNALS; + +pub const CONFIG_JSON_BUF_SIZE: usize = 2048; + +#[derive(Clone, Copy, Debug)] +pub struct InitConfig<'a> { + /// Receiver executable path. When empty, no receiver is spawned and collection degrades to + /// the `report_fd` fallback. + pub receiver_path: &'a [u8], + /// Service name emitted in crash report metadata and tags. + pub service: &'a [u8], + /// Deployment environment emitted in crash report metadata and tags. + pub env: &'a [u8], + /// Application version emitted in crash report metadata and tags. + pub app_version: &'a [u8], + /// Runtime identifier emitted in crash report metadata. + pub runtime_id: &'a [u8], + /// Platform name emitted in crash report metadata. + pub platform: &'a [u8], + /// Library name emitted in crash report metadata. + pub library_name: &'a [u8], + /// Library version emitted in crash report metadata. + pub library_version: &'a [u8], + /// Library family emitted in crash report metadata. + pub family: &'a [u8], + /// Service fallback emitted when `service` is empty. + pub default_service: &'a [u8], + /// Install, probe, and immediately shut down once `bootstrap_complete` is called. + pub only_bootstrap: bool, + /// Emit minimal async-signal-safe handler diagnostics to stderr. + pub debug_logging: bool, + /// Install the built-in alternate signal stack on the init thread. + /// + /// This stack is per-thread kernel state. Other threads must install their own alternate + /// stack before `use_alt_stack` can protect stack-overflow crashes on those threads. + pub create_alt_stack: bool, + /// Register crash handlers with `SA_ONSTACK`. + /// + /// This may be used with `create_alt_stack` or with a caller-provided alternate stack already + /// installed on the current thread. + pub use_alt_stack: bool, + /// Add all managed crash signals to the signal mask while the handler runs. + pub block_signals: bool, + /// Reset the crashing signal's disposition to `SIG_DFL` immediately on handler entry. + pub disarm_on_entry: bool, + /// File descriptor used for degraded-mode report emission. + pub report_fd: i32, + /// Milliseconds to wait for the collector child before killing it. + pub collector_reap_ms: i32, + /// Receiver timeout, in seconds, passed through the receiver config and used for parent reap. + pub receiver_timeout_secs: u32, + /// Maximum number of stack frames to emit. + pub max_frames: usize, + /// Close non-stdio descriptors before the receiver `execv`. + pub close_fds_on_receiver: bool, + /// Probe `process_vm_readv` in a forked child to detect seccomp denial. + pub probe_seccomp: bool, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum PrepareError { + #[error("invalid signal-safe crashtracker configuration")] + InvalidConfig, + #[error("failed to prepare signal-safe crashtracker configuration")] + Failed, +} + +impl<'a> Default for InitConfig<'a> { + fn default() -> Self { + Self { + receiver_path: &[], + service: &[], + env: &[], + app_version: &[], + runtime_id: &[], + platform: &[], + library_name: DEFAULT_LIBRARY_NAME.as_bytes(), + library_version: DEFAULT_LIBRARY_VERSION.as_bytes(), + family: DEFAULT_LIBRARY_FAMILY.as_bytes(), + default_service: DEFAULT_SERVICE.as_bytes(), + only_bootstrap: false, + debug_logging: false, + create_alt_stack: false, + use_alt_stack: false, + block_signals: true, + disarm_on_entry: false, + report_fd: -1, + collector_reap_ms: COLLECTOR_REAP_MS_DEFAULT, + receiver_timeout_secs: RECEIVER_TIMEOUT_SECS_DEFAULT, + max_frames: BACKTRACE_LEVELS_DEFAULT, + close_fds_on_receiver: true, + probe_seccomp: false, + } + } +} + +#[derive(Serialize)] +struct WireConfig<'a> { + additional_files: [&'a str; 0], + create_alt_stack: bool, + use_alt_stack: bool, + demangle_names: bool, + endpoint: Option<()>, + resolve_frames: StacktraceCollection, + signals: &'a [i32], + timeout: WireTimeout, + unix_socket_path: Option<()>, +} + +#[derive(Serialize)] +struct WireTimeout { + secs: u32, + nanos: u32, +} + +pub fn build_config_json( + out: &mut HeaplessString, + config: &InitConfig<'_>, +) -> bool { + out.clear(); + let wire = WireConfig { + additional_files: [], + create_alt_stack: config.create_alt_stack, + use_alt_stack: config.use_alt_stack, + demangle_names: true, + endpoint: None, + resolve_frames: StacktraceCollection::EnabledWithSymbolsInReceiver, + signals: &CRASH_SIGNALS, + timeout: WireTimeout { + secs: normalized_receiver_timeout_secs(config.receiver_timeout_secs), + nanos: 0, + }, + unix_socket_path: None, + }; + + let mut buf = [0u8; CONFIG_JSON_BUF_SIZE]; + let Ok(len) = serde_json_core::to_slice(&wire, &mut buf) else { + return false; + }; + let Ok(json) = core::str::from_utf8(&buf[..len]) else { + return false; + }; + out.push_str(json).is_ok() && out.push('\n').is_ok() +} + +pub fn apply(config: &InitConfig<'_>, m: &mut state::Meta) -> Result<(), PrepareError> { + validate(config)?; + + if !build_config_json(&mut m.config_json, config) { + return Err(PrepareError::Failed); + } + + let mut metadata_truncated = false; + metadata_truncated |= !set_str(&mut m.service, config.service); + metadata_truncated |= !set_str(&mut m.env, config.env); + metadata_truncated |= !set_str(&mut m.app_version, config.app_version); + metadata_truncated |= !set_str_or( + &mut m.runtime_id, + config.runtime_id, + DEFAULT_RUNTIME_ID.as_bytes(), + ); + metadata_truncated |= !set_str(&mut m.platform, config.platform); + if m.platform.is_empty() { + metadata_truncated |= !set_str(&mut m.platform, b"host"); + } + metadata_truncated |= !set_str_or( + &mut m.library_name, + config.library_name, + DEFAULT_LIBRARY_NAME.as_bytes(), + ); + metadata_truncated |= !set_str_or( + &mut m.library_version, + config.library_version, + DEFAULT_LIBRARY_VERSION.as_bytes(), + ); + metadata_truncated |= !set_str_or( + &mut m.family, + config.family, + DEFAULT_LIBRARY_FAMILY.as_bytes(), + ); + metadata_truncated |= !set_str_or( + &mut m.default_service, + config.default_service, + DEFAULT_SERVICE.as_bytes(), + ); + + if !set_receiver_path(&mut m.process_path, config.receiver_path) { + return Err(PrepareError::InvalidConfig); + } + + state::SETTINGS + .only_bootstrap + .store(config.only_bootstrap, Relaxed); + state::SETTINGS + .debug_log + .store(config.debug_logging, Relaxed); + state::SETTINGS + .create_alt_stack + .store(config.create_alt_stack, Relaxed); + state::SETTINGS + .use_alt_stack + .store(config.use_alt_stack, Relaxed); + state::SETTINGS + .block_signals + .store(config.block_signals, Relaxed); + state::SETTINGS + .disarm_on_entry + .store(config.disarm_on_entry, Relaxed); + state::SETTINGS + .close_fds_on_receiver + .store(config.close_fds_on_receiver, Relaxed); + state::SETTINGS.report_fd.store(config.report_fd, Relaxed); + state::SETTINGS.collector_reap_ms.store( + normalized_collector_reap_ms(config.collector_reap_ms), + Relaxed, + ); + state::SETTINGS.receiver_reap_ms.store( + normalized_receiver_timeout_secs(config.receiver_timeout_secs) as i32 * 1000 + + RECEIVER_REAP_GRACE_MS, + Relaxed, + ); + state::SETTINGS + .max_frames + .store(normalized_max_frames(config.max_frames), Relaxed); + capabilities::publish( + m.process_path.as_slice(), + config.report_fd, + config.probe_seccomp, + ); + if metadata_truncated { + capabilities::note_degraded(capabilities::DEGRADED_METADATA_TRUNCATED); + } + Ok(()) +} + +fn normalized_receiver_timeout_secs(value: u32) -> u32 { + if value == 0 { + RECEIVER_TIMEOUT_SECS_DEFAULT + } else if value > RECEIVER_TIMEOUT_SECS_MAX { + RECEIVER_TIMEOUT_SECS_MAX + } else { + value + } +} + +fn normalized_collector_reap_ms(value: i32) -> i32 { + if value <= 0 { + COLLECTOR_REAP_MS_DEFAULT + } else { + value + } +} + +fn normalized_max_frames(value: usize) -> usize { + if value == 0 { + BACKTRACE_LEVELS_DEFAULT + } else if value > BACKTRACE_LEVELS_MAX { + BACKTRACE_LEVELS_MAX + } else { + value + } +} + +fn set_str(dst: &mut HeaplessString, src: &[u8]) -> bool { + dst.clear(); + if let Ok(s) = core::str::from_utf8(src) { + for ch in s.chars() { + if dst.push(ch).is_err() { + return false; + } + } + } + true +} + +fn set_str_or(dst: &mut HeaplessString, src: &[u8], default: &[u8]) -> bool { + if src.is_empty() { + set_str(dst, default) + } else { + set_str(dst, src) + } +} + +fn set_receiver_path(dst: &mut heapless::Vec, path: &[u8]) -> bool { + dst.clear(); + if path.len() >= dst.capacity() { + return false; + } + dst.extend_from_slice(path).is_ok() && dst.push(0).is_ok() +} + +fn validate(config: &InitConfig<'_>) -> Result<(), PrepareError> { + if config.create_alt_stack && !config.use_alt_stack { + return Err(PrepareError::InvalidConfig); + } + + if config.receiver_path.len() >= PATH_CAPACITY { + return Err(PrepareError::InvalidConfig); + } + + // `report_fd` validity is probed by `capabilities::publish`, which records its absence as a + // degradation rather than failing init: the fd is only the degraded-mode fallback, so a bad + // one must not take down the primary fork/receiver path. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + use alloc::vec::Vec; + use std::format; + + #[test] + fn config_json_contains_receiver_contract() { + let mut out = HeaplessString::::new(); + assert!(build_config_json(&mut out, &InitConfig::default())); + let signals = CRASH_SIGNALS + .iter() + .map(i32::to_string) + .collect::>() + .join(","); + assert_eq!( + out.as_str(), + format!( + "{{\"additional_files\":[],\"create_alt_stack\":false,\"use_alt_stack\":false,\ + \"demangle_names\":true,\"endpoint\":null,\ + \"resolve_frames\":\"EnabledWithSymbolsInReceiver\",\ + \"signals\":[{signals}],\"timeout\":{{\"secs\":5,\"nanos\":0}},\ + \"unix_socket_path\":null}}\n" + ) + ); + assert!(CRASH_SIGNALS.contains(&libc::SIGSEGV)); + assert!(CRASH_SIGNALS.contains(&libc::SIGABRT)); + assert!(CRASH_SIGNALS.contains(&libc::SIGBUS)); + assert!(CRASH_SIGNALS.contains(&libc::SIGILL)); + assert!(CRASH_SIGNALS.contains(&libc::SIGFPE)); + } + + #[test] + fn timeout_seconds_are_clamped() { + assert_eq!( + normalized_receiver_timeout_secs(0), + RECEIVER_TIMEOUT_SECS_DEFAULT + ); + assert_eq!(normalized_receiver_timeout_secs(1), 1); + assert_eq!( + normalized_receiver_timeout_secs(RECEIVER_TIMEOUT_SECS_MAX + 1), + RECEIVER_TIMEOUT_SECS_MAX + ); + } + + #[test] + fn validate_rejects_pointless_alt_stack_configuration() { + assert_eq!( + validate(&InitConfig { + create_alt_stack: true, + use_alt_stack: false, + ..InitConfig::default() + }), + Err(PrepareError::InvalidConfig) + ); + } + + #[test] + fn prepare_caches_fixed_metadata() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + + let mut meta = state::Meta::new(); + assert!(apply( + &InitConfig { + receiver_path: b"/tmp/receiver", + service: b"svc", + env: b"prod", + app_version: b"1.2.3", + runtime_id: b"rid", + platform: b"host", + only_bootstrap: true, + debug_logging: true, + ..InitConfig::default() + }, + &mut meta + ) + .is_ok()); + + assert_eq!(meta.service.as_str(), "svc"); + assert_eq!(meta.env.as_str(), "prod"); + assert_eq!(meta.app_version.as_str(), "1.2.3"); + assert_eq!(meta.runtime_id.as_str(), "rid"); + assert_eq!(meta.platform.as_str(), "host"); + assert_eq!(meta.process_path.as_slice(), b"/tmp/receiver\0"); + assert!(state::SETTINGS.only_bootstrap.load(Relaxed)); + assert!(state::SETTINGS.debug_log.load(Relaxed)); + } + + #[test] + fn prepare_marks_metadata_truncation_degraded() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + let oversized_service = "s".repeat(300); + + let mut meta = state::Meta::new(); + assert!(apply( + &InitConfig { + receiver_path: b"/definitely/missing-signal-safe-receiver", + service: oversized_service.as_bytes(), + ..InitConfig::default() + }, + &mut meta + ) + .is_ok()); + + assert!(capabilities::degradations().contains(capabilities::DEGRADED_METADATA_TRUNCATED)); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/emitter.rs b/libdd-crashtracker/src/collector_signal_safe/emitter.rs new file mode 100644 index 0000000000..c31142a332 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -0,0 +1,312 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use heapless::String as HeaplessString; +use serde::Serialize; + +use super::capabilities::{Capabilities, Degradations}; +use super::fmt::hex_u32; +use super::fmt::{write_i32, I32_BUF_CAPACITY}; +use super::report::{ + CrashContext, Frame, Metadata, ProcInfo, Report, Tag, Tags, MESSAGE_CAPACITY, + SECTION_BUF_CAPACITY, +}; +use super::{capabilities, config, state}; +use crate::protocol; +use crate::shared::tag_keys; + +pub trait Sink: protocol::ByteSink { + fn put(&mut self, bytes: &[u8]) -> bool { + self.write_bytes(bytes).is_ok() + } +} + +impl> Sink for T {} + +#[cfg(test)] +pub struct SliceSink<'a> { + buf: &'a mut [u8], + len: usize, +} + +#[cfg(test)] +impl<'a> SliceSink<'a> { + pub fn new(buf: &'a mut [u8]) -> Self { + Self { buf, len: 0 } + } + + pub fn as_slice(&self) -> &[u8] { + &self.buf[..self.len] + } +} + +#[cfg(test)] +impl protocol::ByteSink for SliceSink<'_> { + type Error = (); + + fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error> { + let Some(end) = self.len.checked_add(bytes.len()) else { + return Err(()); + }; + if end > self.buf.len() { + return Err(()); + } + self.buf[self.len..end].copy_from_slice(bytes); + self.len = end; + Ok(()) + } +} + +pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { + if value.is_empty() { + return true; + } + + let mut tag = super::report::Tag::new(); + tag.push_str(key).is_ok() + && tag.push(':').is_ok() + && tag.push_str(value).is_ok() + && tags.push(tag).is_ok() +} + +pub fn emit_report(sink: &mut impl Sink, report: &Report<'_>, context: &CrashContext<'_>) -> bool { + if !emit_report_sections(sink, report, context) { + return emit_truncated_tail(sink, report, context); + } + + emit_message(sink, &context.signal) && emit_done(sink) +} + +fn emit_report_sections( + sink: &mut impl Sink, + report: &Report<'_>, + context: &CrashContext<'_>, +) -> bool { + emit_config(sink, report.config_json) + && emit_metadata(sink, report) + && emit_additional_tags( + sink, + report.stackwalk_method, + report.capabilities, + report.degradations, + ) + && emit_kind(sink) + && emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_SIGINFO, + &context.signal, + protocol::DD_CRASHTRACK_END_SIGINFO, + ) + && emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_PROCINFO, + &ProcInfo { + pid: context.pid, + tid: context.tid, + }, + protocol::DD_CRASHTRACK_END_PROCINFO, + ) + && emit_stacktrace(sink, context.frames) +} + +pub fn emit_json_section( + sink: &mut impl Sink, + begin: &str, + value: &T, + end: &str, +) -> bool { + let mut buf = [0u8; SECTION_BUF_CAPACITY]; + protocol::section::<_, ()>(sink, begin, end, |sink| { + let len = serde_json_core::to_slice(value, &mut buf).map_err(|_| ())?; + sink.write_bytes(&buf[..len])?; + sink.write_bytes(b"\n") + }) + .is_ok() +} + +fn emit_config(sink: &mut impl Sink, config_json: &str) -> bool { + protocol::section::<_, ()>( + sink, + protocol::DD_CRASHTRACK_BEGIN_CONFIG, + protocol::DD_CRASHTRACK_END_CONFIG, + |sink| { + sink.write_bytes(config_json.as_bytes())?; + if config_json.ends_with('\n') { + Ok(()) + } else { + sink.write_bytes(b"\n") + } + }, + ) + .is_ok() +} + +fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { + let service = if report.service.is_empty() { + report.default_service + } else { + report.service + }; + + let mut metadata = Metadata::new(report.library_name, report.library_version, report.family); + push_tag(&mut metadata.tags, tag_keys::LANGUAGE, "native") + && push_tag(&mut metadata.tags, tag_keys::RUNTIME, "native") + && push_tag(&mut metadata.tags, tag_keys::IS_CRASH, "true") + && push_tag(&mut metadata.tags, tag_keys::SEVERITY, "crash") + && push_tag(&mut metadata.tags, tag_keys::SERVICE, service) + && push_tag(&mut metadata.tags, tag_keys::ENV, report.env) + && push_tag(&mut metadata.tags, tag_keys::VERSION, report.app_version) + && push_tag(&mut metadata.tags, tag_keys::RUNTIME_ID, report.runtime_id) + && push_tag( + &mut metadata.tags, + tag_keys::RUNTIME_VERSION, + report.library_version, + ) + && push_tag( + &mut metadata.tags, + tag_keys::LIBRARY_VERSION, + report.library_version, + ) + && push_tag(&mut metadata.tags, tag_keys::PLATFORM, report.platform) + && push_tag( + &mut metadata.tags, + tag_keys::INJECTOR_VERSION, + report.library_version, + ) + && emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_METADATA, + &metadata, + protocol::DD_CRASHTRACK_END_METADATA, + ) +} + +fn emit_additional_tags( + sink: &mut impl Sink, + stackwalk_method: &str, + capability_bits: Capabilities, + degradation_bits: Degradations, +) -> bool { + let mut tags = Tags::new(); + if !push_tag(&mut tags, tag_keys::STACKWALK_METHOD, stackwalk_method) { + return false; + } + let capabilities = hex_u32(capability_bits.bits()); + if !push_tag(&mut tags, tag_keys::CAPABILITIES, capabilities.as_str()) { + return false; + } + let degradations = hex_u32(degradation_bits.bits()); + if !push_tag(&mut tags, tag_keys::DEGRADATIONS, degradations.as_str()) { + return false; + } + for &(bit, reason) in capabilities::DEGRADATION_REASONS { + if degradation_bits.contains(bit) && !push_tag(&mut tags, tag_keys::REPORT_DEGRADED, reason) + { + return false; + } + } + if degradation_bits.contains(capabilities::DEGRADED_APP_HANDLER_PRESENT) + && !push_app_handler_present_tags(&mut tags) + { + return false; + } + emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS, + &tags, + protocol::DD_CRASHTRACK_END_ADDITIONAL_TAGS, + ) +} + +fn push_app_handler_present_tags(tags: &mut Tags) -> bool { + for &sig in &config::CRASH_SIGNALS { + if !state::app_handler_present(sig) { + continue; + } + let mut value = Tag::new(); + if value.push_str("app_handler_present:").is_err() { + return false; + } + let mut buf = [0u8; I32_BUF_CAPACITY]; + let written = write_i32(sig, &mut buf); + let Ok(sig) = core::str::from_utf8(&buf[..written]) else { + return false; + }; + if value.push_str(sig).is_err() { + return false; + } + if !push_tag(tags, tag_keys::REPORT_DEGRADED, value.as_str()) { + return false; + } + } + true +} + +fn emit_kind(sink: &mut impl Sink) -> bool { + protocol::section::<_, ()>( + sink, + protocol::DD_CRASHTRACK_BEGIN_KIND, + protocol::DD_CRASHTRACK_END_KIND, + |sink| sink.write_bytes(b"\"UnixSignal\"\n"), + ) + .is_ok() +} + +fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { + protocol::section::<_, ()>( + sink, + protocol::DD_CRASHTRACK_BEGIN_STACKTRACE, + protocol::DD_CRASHTRACK_END_STACKTRACE, + |sink| { + let mut buf = [0u8; SECTION_BUF_CAPACITY]; + for ip in frames { + if *ip == 0 { + continue; + } + + let frame = Frame::from_ip(*ip); + let len = serde_json_core::to_slice(&frame, &mut buf).map_err(|_| ())?; + sink.write_bytes(&buf[..len])?; + sink.write_bytes(b"\n")?; + } + Ok::<(), ()>(()) + }, + ) + .is_ok() +} + +fn emit_message(sink: &mut impl Sink, signal: &super::report::SignalInfo) -> bool { + let mut message = HeaplessString::::new(); + message.push_str("Crash (").is_ok() + && message.push_str(signal.si_signo_human_readable).is_ok() + && message.push(')').is_ok() + && protocol::section::<_, ()>( + sink, + protocol::DD_CRASHTRACK_BEGIN_MESSAGE, + protocol::DD_CRASHTRACK_END_MESSAGE, + |sink| { + sink.write_bytes(message.as_bytes())?; + sink.write_bytes(b"\n") + }, + ) + .is_ok() +} + +fn emit_truncated_tail( + sink: &mut impl Sink, + report: &Report<'_>, + context: &CrashContext<'_>, +) -> bool { + capabilities::note_degraded(capabilities::DEGRADED_TRUNCATED); + let _ = emit_additional_tags( + sink, + report.stackwalk_method, + report.capabilities, + report.degradations.with(capabilities::DEGRADED_TRUNCATED), + ); + emit_message(sink, &context.signal) && emit_done(sink) +} + +fn emit_done(sink: &mut impl Sink) -> bool { + protocol::marker_line::<_, ()>(sink, protocol::DD_CRASHTRACK_DONE).is_ok() +} diff --git a/libdd-crashtracker/src/collector_signal_safe/fmt.rs b/libdd-crashtracker/src/collector_signal_safe/fmt.rs new file mode 100644 index 0000000000..06e100bfe8 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/fmt.rs @@ -0,0 +1,111 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use heapless::String as HeaplessString; + +use super::report::FRAME_IP_CAPACITY; + +pub const HEX_U32_CAPACITY: usize = 10; +pub const I32_BUF_CAPACITY: usize = 12; + +pub fn hex_addr(value: usize) -> HeaplessString { + hex(value as u64, core::mem::size_of::() * 2) +} + +pub fn hex_u32(value: u32) -> HeaplessString { + hex(value as u64, 8) +} + +fn hex(value: u64, digits: usize) -> HeaplessString { + let mut out = HeaplessString::new(); + let _ = out.push_str("0x"); + + for shift in (0..digits).rev() { + let nibble = ((value >> (shift * 4)) & 0xf) as u8; + let ch = if nibble < 10 { + b'0' + nibble + } else { + b'a' + (nibble - 10) + }; + let _ = out.push(ch as char); + } + + out +} + +pub fn write_i32(value: i32, out: &mut [u8; I32_BUF_CAPACITY]) -> usize { + let mut n = value as i64; + let negative = n < 0; + if negative { + n = n.wrapping_neg(); + } + + let mut tmp = [0u8; 11]; + let mut len = 0usize; + loop { + tmp[len] = b'0' + (n % 10) as u8; + len += 1; + n /= 10; + if n == 0 { + break; + } + } + + let mut off = 0usize; + if negative { + out[0] = b'-'; + off = 1; + } + let mut i = 0usize; + while i < len { + out[off + i] = tmp[len - i - 1]; + i += 1; + } + off + len +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_addr_covers_boundaries() { + let zero = hex_addr(0); + assert_eq!(zero.len(), FRAME_IP_CAPACITY); + assert!(zero + .as_str() + .strip_prefix("0x") + .unwrap() + .bytes() + .all(|b| b == b'0')); + + let max = hex_addr(usize::MAX); + assert_eq!(max.len(), FRAME_IP_CAPACITY); + assert!(max + .as_str() + .strip_prefix("0x") + .unwrap() + .bytes() + .all(|b| b == b'f')); + } + + #[test] + fn hex_u32_covers_boundaries() { + assert_eq!(hex_u32(0).as_str(), "0x00000000"); + assert_eq!(hex_u32(u32::MAX).as_str(), "0xffffffff"); + assert_eq!(hex_u32(0x1234abcd).as_str(), "0x1234abcd"); + } + + #[test] + fn integer_debug_writer_handles_sign() { + let mut buf = [0u8; I32_BUF_CAPACITY]; + let n = write_i32(-123, &mut buf); + assert_eq!(&buf[..n], b"-123"); + let n = write_i32(42, &mut buf); + assert_eq!(&buf[..n], b"42"); + let n = write_i32(0, &mut buf); + assert_eq!(&buf[..n], b"0"); + let n = write_i32(i32::MIN, &mut buf); + assert_eq!(&buf[..n], b"-2147483648"); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/child.rs b/libdd-crashtracker/src/collector_signal_safe/handler/child.rs new file mode 100644 index 0000000000..141d7d98a2 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/child.rs @@ -0,0 +1,151 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Forked-child setup; after fork the process is single-threaded, may inherit a corrupt heap, uses +//! only async-signal-safe syscalls, and every path ends in `exit_process`. + +use core::ffi::c_char; +use core::ptr::null_mut; +use core::sync::atomic::Ordering; + +use super::collect::{emit_report_to_fd, CrashEvent}; +use super::sigaction::reset_signals_to_default; +use super::EXIT_CODE_FAILURE; +use crate::collector_signal_safe::{capabilities, config, state, sys}; + +#[derive(Clone, Copy)] +enum StdioFallback { + CloseAll, + LeaveOpen, +} + +pub(super) fn receiver_child(read_fd: i32, write_fd: i32) -> ! { + sys::close(write_fd); + let read_fd = sanitize_forked_child(read_fd, StdioFallback::CloseAll); + if read_fd < 0 { + sys::exit_process(EXIT_CODE_FAILURE); + } + if read_fd != libc::STDIN_FILENO { + let _ = sys::dup2(read_fd, libc::STDIN_FILENO); + sys::close(read_fd); + } + if state::SETTINGS + .close_fds_on_receiver + .load(Ordering::Relaxed) + { + let _ = sys::close_range_from(libc::STDERR_FILENO + 1); + } + strip_loader_injection_env(); + + let path = state::meta().process_path.as_slice(); + if path.is_empty() || path[path.len() - 1] != 0 { + sys::exit_process(EXIT_CODE_FAILURE); + } + + let argv = [path.as_ptr() as *const c_char, null_mut()]; + unsafe { + libc::execv(path.as_ptr() as *const c_char, argv.as_ptr()); + } + sys::exit_process(EXIT_CODE_FAILURE); +} + +pub(super) fn collector_child(read_fd: i32, write_fd: i32, event: CrashEvent) -> ! { + sys::close(read_fd); + let write_fd = sanitize_forked_child(write_fd, StdioFallback::LeaveOpen); + if write_fd < 0 { + sys::exit_process(EXIT_CODE_FAILURE); + } + ignore_sigpipe(); + + let _ = emit_report_to_fd(write_fd, event); + sys::close(write_fd); + sys::exit_process(0); +} + +fn sanitize_forked_child(mut keep_fd: i32, fallback: StdioFallback) -> i32 { + if (libc::STDIN_FILENO..=libc::STDERR_FILENO).contains(&keep_fd) { + let relocated = sys::fcntl_dupfd(keep_fd, libc::STDERR_FILENO + 1); + if relocated < 0 { + return -1; + } + sys::close(keep_fd); + keep_fd = relocated; + } + + let _ = reset_signals_to_default(&config::CRASH_SIGNALS); + disable_alt_stack(); + + let devnull = if capabilities::has(capabilities::DEV_NULL) { + sys::open_readwrite(c"/dev/null".as_ptr().cast()) + } else { + -1 + }; + if devnull >= 0 { + let _ = sys::dup2(devnull, libc::STDIN_FILENO); + let _ = sys::dup2(devnull, libc::STDOUT_FILENO); + let _ = sys::dup2(devnull, libc::STDERR_FILENO); + if devnull > libc::STDERR_FILENO { + sys::close(devnull); + } + } else if matches!(fallback, StdioFallback::CloseAll) { + close_stdio(); + } + keep_fd +} + +fn close_stdio() { + sys::close(libc::STDIN_FILENO); + sys::close(libc::STDOUT_FILENO); + sys::close(libc::STDERR_FILENO); +} + +/// Unregister any alternate signal stack inherited by a forked child. +/// +/// The child resets its crash handlers to `SIG_DFL`, so the alt stack is no longer needed. Dropping +/// it explicitly means that even if some inherited disposition were re-armed, the child can never +/// run a handler on a stack region whose contents we no longer maintain. +fn disable_alt_stack() { + let stack = libc::stack_t { + ss_sp: null_mut(), + ss_flags: libc::SS_DISABLE, + ss_size: 0, + }; + let _ = unsafe { libc::sigaltstack(&stack, null_mut()) }; +} + +/// Ignore `SIGPIPE` in a collector child before it writes the report. +/// +/// The child inherits the crashing process' `SIGPIPE` disposition, which is often `SIG_DFL` +/// (terminate). If the receiver closed the read end, we want the write to fail with `EPIPE` -- +/// which [`FdSink`](crate::collector_signal_safe::sys::FdSink) already reports as an error -- +/// rather than a `SIGPIPE` killing us in the middle of the report. +fn ignore_sigpipe() { + let mut ign: libc::sigaction = unsafe { core::mem::zeroed() }; + ign.sa_sigaction = libc::SIG_IGN; + unsafe { + libc::sigemptyset(&mut ign.sa_mask); + let _ = libc::sigaction(libc::SIGPIPE, &ign, null_mut()); + } +} + +fn strip_loader_injection_env() { + let env = sys::environ_ptr(); + if env.is_null() { + return; + } + const PREFIXES: [&[u8]; 2] = [b"LD_PRELOAD=", b"LD_AUDIT="]; + unsafe { + let mut src = env; + let mut dst = env; + while !(*src).is_null() { + let entry = *src; + let injected = PREFIXES.iter().any(|p| sys::cstr_has_prefix(entry, p)); + if !injected { + *dst = entry; + dst = dst.add(1); + } + src = src.add(1); + } + *dst = null_mut(); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs b/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs new file mode 100644 index 0000000000..30515015ed --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs @@ -0,0 +1,245 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Crash-event capture, report emission, and child reaping. + +use core::ffi::{c_int, c_void}; +use core::sync::atomic::Ordering; + +use super::child::{collector_child, receiver_child}; +use super::crash_debug; +use super::sigaction::{siginfo_addr, siginfo_pid}; +use super::EXIT_CODE_FAILURE; +use crate::collector_signal_safe::sys::FdSink; +use crate::collector_signal_safe::{ + backtrace, capabilities, config, state, sys, CrashContext, Report, SignalInfo, +}; + +const REAP_KILL_TIMEOUT_MS: i64 = 500; +// This currently equals `config::COLLECTOR_REAP_MS_DEFAULT` by coincidence: it is the +// post-timeout SIGKILL wait, not the collector's configured reap budget. +const REAP_WAIT_INTERVAL_MS: i32 = 100; + +#[derive(Clone, Copy)] +pub(super) struct CrashEvent { + pub(super) sig: i32, + pub(super) si_code: i32, + pub(super) has_info: bool, + pub(super) si_addr: usize, + pub(super) si_pid: i32, + pub(super) pid: i32, + pub(super) tid: i32, + ucontext: *mut c_void, +} + +impl CrashEvent { + pub(super) fn from_signal( + sig: c_int, + info: *mut libc::siginfo_t, + ucontext: *mut c_void, + ) -> Self { + let has_info = !info.is_null(); + let si_code = if has_info { + unsafe { (*info).si_code } + } else { + 0 + }; + let si_addr = if has_info { + unsafe { siginfo_addr(info) } + } else { + 0 + }; + let si_pid = if has_info { + unsafe { siginfo_pid(info) } + } else { + 0 + }; + + Self { + sig, + si_code, + has_info, + si_addr, + si_pid, + pid: sys::getpid(), + tid: sys::gettid(), + ucontext, + } + } + + fn context<'a>(self, frames: &'a [usize]) -> CrashContext<'a> { + CrashContext { + signal: SignalInfo::new(self.sig, self.si_code, self.si_addr, self.has_info), + pid: self.pid, + tid: self.tid, + frames, + } + } +} + +pub(super) fn collect_crash(event: CrashEvent) { + // The receiver is forked first and execs the configured receiver binary with the read side of + // the pipe on stdin. Report generation runs in a second forked child so stack walking and + // formatting happen outside the crashing process' possibly-corrupt heap. The original process + // only forks, closes fds, and reaps. Receiver exec failure or abnormal death leaves the + // handler with a last-chance `report_fd` fallback. + let report_fd = state::SETTINGS.report_fd.load(Ordering::Relaxed); + let caps = capabilities::get(); + + if !caps.contains(capabilities::FORK_OK) { + crash_debug(b"fork unavailable", event.sig); + fallback_to_report_fd(event, caps, report_fd, capabilities::DEGRADED_NO_FORK); + return; + } + + if !caps.contains(capabilities::RECEIVER_OK) { + crash_debug(b"receiver unavailable", event.sig); + fallback_to_report_fd( + event, + caps, + report_fd, + capabilities::DEGRADED_RECEIVER_UNAVAILABLE, + ); + return; + } + + if !caps.contains(capabilities::PIPE_OK) { + crash_debug(b"pipe unavailable", event.sig); + fallback_to_report_fd(event, caps, report_fd, capabilities::DEGRADED_NO_PIPE); + return; + } + + let Some(pipe) = sys::pipe() else { + crash_debug(b"pipe failed", event.sig); + fallback_to_report_fd(event, caps, report_fd, capabilities::DEGRADED_PIPE_FAILED); + return; + }; + + let receiver = unsafe { sys::fork_raw() }; + if receiver == 0 { + receiver_child(pipe.read, pipe.write); + } + if receiver < 0 { + crash_debug(b"receiver fork failed", event.sig); + sys::close(pipe.read); + sys::close(pipe.write); + fallback_to_report_fd(event, caps, report_fd, capabilities::DEGRADED_FORK_FAILED); + return; + } + + let collector = unsafe { sys::fork_raw() }; + if collector == 0 { + collector_child(pipe.read, pipe.write, event); + } + + sys::close(pipe.read); + sys::close(pipe.write); + + if collector > 0 { + let _ = reap_or_kill( + collector as i32, + state::SETTINGS.collector_reap_ms.load(Ordering::Relaxed) as i64, + ); + } else { + crash_debug(b"collector fork failed", event.sig); + fallback_to_report_fd(event, caps, report_fd, capabilities::DEGRADED_FORK_FAILED); + } + + let receiver_status = reap_or_kill( + receiver as i32, + state::SETTINGS.receiver_reap_ms.load(Ordering::Relaxed) as i64, + ); + if receiver_status.is_some_and(receiver_unavailable) { + crash_debug(b"receiver unavailable", event.sig); + // A receiver killed by our reap escalation may have already consumed the pipe and written + // the report. Prefer a possible duplicate via `report_fd` over losing the crash report. + fallback_to_report_fd( + event, + caps, + report_fd, + capabilities::DEGRADED_RECEIVER_UNAVAILABLE, + ); + } +} + +fn fallback_to_report_fd( + event: CrashEvent, + caps: capabilities::Capabilities, + report_fd: i32, + reason: capabilities::Degradations, +) { + capabilities::note_degraded(reason); + if caps.contains(capabilities::REPORT_FD_OK) { + capabilities::note_degraded(capabilities::DEGRADED_REPORT_TO_FD); + // The fallback path does not own `report_fd`; the caller keeps it open. + let _ = emit_report_to_fd(report_fd, event); + } +} + +pub(super) fn emit_report_to_fd(write_fd: i32, event: CrashEvent) -> bool { + let mut frames = [0usize; config::BACKTRACE_LEVELS_MAX]; + let max_frames = state::SETTINGS + .max_frames + .load(Ordering::Relaxed) + .min(config::BACKTRACE_LEVELS_MAX); + let caps = capabilities::get(); + let can_walk = caps.contains(capabilities::PROC_VM_READV); + // Walk through a self-read in the calling process. In the forked collector child this reads + // the CoW crash snapshot and is Yama-exempt; in the in-process fallback it matches event.pid. + let n = backtrace::backtrace_from_ucontext( + &mut frames[..max_frames], + event.ucontext, + sys::getpid(), + can_walk, + ); + let stackwalk_method = if n == 0 { + "none" + } else if can_walk { + "fp_pvr" + } else { + "seed_only" + }; + + let meta = state::meta(); + let report = Report { + config_json: meta.config_json.as_str(), + library_name: meta.library_name.as_str(), + library_version: meta.library_version.as_str(), + family: meta.family.as_str(), + default_service: meta.default_service.as_str(), + service: meta.service.as_str(), + env: meta.env.as_str(), + app_version: meta.app_version.as_str(), + runtime_id: meta.runtime_id.as_str(), + platform: meta.platform.as_str(), + stackwalk_method, + capabilities: caps, + degradations: capabilities::degradations(), + }; + let context = event.context(&frames[..n]); + + let mut sink = FdSink::new(write_fd); + let emitted = crate::collector_signal_safe::emit_report(&mut sink, &report, &context); + // Flush the staged bytes before the fd is closed, otherwise the report is lost on close. + let flushed = sink.flush(); + emitted && flushed +} + +fn reap_or_kill(pid: i32, timeout_ms: i64) -> Option { + match sys::reap_child(pid, timeout_ms, REAP_WAIT_INTERVAL_MS, REAP_KILL_TIMEOUT_MS) { + sys::ChildReap::Reaped(status) => Some(status), + sys::ChildReap::WaitFailed(_) => { + crash_debug(b"waitpid failed", -1); + None + } + sys::ChildReap::NoChild | sys::ChildReap::TimedOut => None, + } +} + +fn exited_with(status: i32, code: i32) -> bool { + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == code +} + +fn receiver_unavailable(status: i32) -> bool { + exited_with(status, EXIT_CODE_FAILURE) || libc::WIFSIGNALED(status) +} diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/crash.rs b/libdd-crashtracker/src/collector_signal_safe/handler/crash.rs new file mode 100644 index 0000000000..7121d78066 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/crash.rs @@ -0,0 +1,73 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Crash-handler entry point and final default-signal dispatch. + +use core::ffi::{c_int, c_void}; +use core::sync::atomic::{AtomicBool, Ordering}; + +use super::collect::{collect_crash, CrashEvent}; +use super::sigaction::{effective_target, reset_signals_to_default, unblock_signal, Target}; +use super::{crash_debug, EXIT_CODE_FAILURE}; +use crate::collector_signal_safe::policy::{ + chain_action, disposition_of, is_genuine_fault, ChainAction, +}; +use crate::collector_signal_safe::state::{self, sig_index}; +use crate::collector_signal_safe::sys; + +/// Prevents recursive crash collection. Reset only during explicit shutdown/re-init lifecycle. +static COLLECTING: AtomicBool = AtomicBool::new(false); + +pub(super) fn reset_collecting() { + COLLECTING.store(false, Ordering::Relaxed); +} + +pub(super) extern "C" fn crash_handler( + sig: c_int, + info: *mut libc::siginfo_t, + ucontext: *mut c_void, +) { + if !state::HANDLERS_ENABLED.load(Ordering::Acquire) { + return; + } + + let saved_errno = sys::errno(); + crash_debug(b"handler entered", sig); + if state::SETTINGS.disarm_on_entry.load(Ordering::Relaxed) { + let _ = reset_signals_to_default(&[sig]); + } + + let idx = sig_index(sig); + let event = CrashEvent::from_signal(sig, info, ucontext); + + let target = match idx { + Some(i) => effective_target(i), + None => Target { + fn_ptr: core::ptr::null_mut(), + flags: 0, + }, + }; + + let genuine_fault = is_genuine_fault(event.has_info, event.si_code, event.si_pid, event.pid); + if genuine_fault && !COLLECTING.swap(true, Ordering::Relaxed) { + collect_crash(event); + } + + sys::set_errno(saved_errno); + + let action = chain_action(disposition_of(target.fn_ptr), event.has_info, event.si_code); + match action { + ChainAction::RestoreDefaultAndRefault | ChainAction::RestoreDefaultAndReraise => { + if !reset_signals_to_default(&[sig]) { + sys::exit_process(EXIT_CODE_FAILURE); + } + unsafe { + if let ChainAction::RestoreDefaultAndReraise = action { + unblock_signal(sig); + libc::raise(sig); + sys::exit_process(EXIT_CODE_FAILURE); + } + } + } + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/lifecycle.rs b/libdd-crashtracker/src/collector_signal_safe/handler/lifecycle.rs new file mode 100644 index 0000000000..c8bd4fa009 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/lifecycle.rs @@ -0,0 +1,194 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Public lifecycle API and alternate-signal-stack installation. + +use core::cell::UnsafeCell; +use core::ffi::c_void; +use core::ptr::null_mut; +use core::sync::atomic::Ordering; + +use super::crash; +use super::crash_debug; +use super::sigaction::{install_all_handlers, uninstall_all_handlers}; +use crate::signal_owner::{self, SignalOwner}; + +use crate::collector_signal_safe::config::{self, InitConfig, PrepareError}; +use crate::collector_signal_safe::state::{self, BeginInitError}; +use crate::collector_signal_safe::{capabilities, sys}; + +const ALT_STACK_SIZE: usize = 64 * 1024; +const ALT_STACK_GUARD_SIZE: usize = 4096; + +const _: () = assert!(crate::collector_signal_safe::SECTION_BUF_CAPACITY <= ALT_STACK_SIZE / 8); + +#[repr(C, align(4096))] +struct AltStackLayout { + guard: [u8; ALT_STACK_GUARD_SIZE], + usable: [u8; ALT_STACK_SIZE], +} + +struct AltStackStorage(UnsafeCell); + +unsafe impl Sync for AltStackStorage {} + +static ALT_STACK: AltStackStorage = AltStackStorage(UnsafeCell::new(AltStackLayout { + guard: [0; ALT_STACK_GUARD_SIZE], + usable: [0; ALT_STACK_SIZE], +})); + +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InitResult { + /// Handlers were installed and crash collection is enabled. + Enabled, + /// Initialization failed for a reason that cannot be represented more specifically. + Failed, + /// The signal-safe collector is already initialized. + AlreadyInitialized, + /// Another crash collector already owns process crash signals. + OwnerConflict, + /// The supplied init configuration is invalid. + InvalidConfig, +} + +pub fn init(config: &InitConfig<'_>) -> InitResult { + init_with_prepare(|session| config::apply(config, session.meta_mut())) +} + +fn init_with_prepare( + prepare: impl FnOnce(&mut state::InitSession) -> Result<(), PrepareError>, +) -> InitResult { + let mut session = match state::begin_init() { + Ok(session) => session, + Err(err) => return err.into(), + }; + + if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { + return InitResult::OwnerConflict; + } + + let prepared = (|| { + prepare(&mut session).map_err(InitResult::from)?; + if !install_alt_stack_if_requested() { + return Err(InitResult::Failed); + } + Ok(()) + })(); + if let Err(err) = prepared { + signal_owner::release(SignalOwner::SignalSafeCollector); + return err; + } + + install_all_handlers(); + session.finish(); + state::HANDLERS_ENABLED.store(true, Ordering::Release); + InitResult::Enabled +} + +/// Complete bootstrap-only mode. +/// +/// This is a no-op in normal mode. When `only_bootstrap` was set at init time, this performs a full +/// [`shutdown`] so the collector can validate setup without staying installed for later crashes. +pub fn bootstrap_complete() { + if state::SETTINGS.only_bootstrap.load(Ordering::Relaxed) { + shutdown(); + } +} + +pub fn shutdown() { + state::HANDLERS_ENABLED.store(false, Ordering::Release); + uninstall_all_handlers(); + crash::reset_collecting(); + signal_owner::release(SignalOwner::SignalSafeCollector); + state::reset_after_shutdown(); +} + +impl From for InitResult { + fn from(err: BeginInitError) -> Self { + match err { + BeginInitError::AlreadyInitialized => Self::AlreadyInitialized, + BeginInitError::Busy => Self::Failed, + } + } +} + +impl From for InitResult { + fn from(err: PrepareError) -> Self { + match err { + PrepareError::InvalidConfig => Self::InvalidConfig, + PrepareError::Failed => Self::Failed, + } + } +} + +fn install_alt_stack_if_requested() -> bool { + if !state::SETTINGS.create_alt_stack.load(Ordering::Relaxed) { + return true; + } + + install_alt_stack_with(sys::mprotect_none, install_sigaltstack) +} + +fn install_alt_stack_with( + mprotect_none: fn(*mut u8, usize) -> bool, + sigaltstack: fn(&libc::stack_t) -> bool, +) -> bool { + let layout = ALT_STACK.0.get(); + let guard = unsafe { core::ptr::addr_of_mut!((*layout).guard).cast::() }; + let usable = unsafe { core::ptr::addr_of_mut!((*layout).usable).cast::() }; + if !mprotect_none(guard, ALT_STACK_GUARD_SIZE) { + capabilities::note_degraded(capabilities::DEGRADED_ALT_STACK_GUARD_UNAVAILABLE); + crash_debug(b"alt stack guard unavailable", -1); + } + + let stack = libc::stack_t { + ss_sp: usable, + ss_flags: 0, + ss_size: ALT_STACK_SIZE, + }; + sigaltstack(&stack) +} + +fn install_sigaltstack(stack: &libc::stack_t) -> bool { + unsafe { libc::sigaltstack(stack, null_mut()) == 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alt_stack_guard_failure_is_degraded_but_not_fatal() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + + capabilities::publish(b"/definitely/missing-signal-safe-receiver\0", -1, false); + assert!(install_alt_stack_with(|_, _| false, |_| true)); + assert!(capabilities::degradations() + .contains(capabilities::DEGRADED_ALT_STACK_GUARD_UNAVAILABLE)); + } + + #[cfg(not(feature = "collector"))] + #[test] + fn lifecycle_can_install_and_shutdown() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + + let config = InitConfig { + receiver_path: b"/bin/cat", + ..InitConfig::default() + }; + assert_eq!(init(&config), InitResult::Enabled); + assert!(state::HANDLERS_ENABLED.load(Ordering::Acquire)); + assert_eq!(init(&config), InitResult::AlreadyInitialized); + shutdown(); + assert!(!state::HANDLERS_ENABLED.load(Ordering::Acquire)); + assert_eq!(init(&config), InitResult::Enabled); + assert!(state::HANDLERS_ENABLED.load(Ordering::Acquire)); + shutdown(); + assert!(!state::HANDLERS_ENABLED.load(Ordering::Acquire)); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/mod.rs b/libdd-crashtracker/src/collector_signal_safe/handler/mod.rs new file mode 100644 index 0000000000..fb77f406ed --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/mod.rs @@ -0,0 +1,42 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Signal handler lifecycle and crash-path orchestration. +//! +//! The installed handler records the signal event, starts a receiver child connected by a pipe, +//! starts a collector child to stack-walk and write the signal-safe wire report, then reaps both +//! children before chaining or refaulting. Any missing capability or crash-path failure falls back +//! to `report_fd` when the caller supplied a valid descriptor. + +mod child; +mod collect; +mod crash; +mod lifecycle; +mod sigaction; + +use core::sync::atomic::Ordering; + +use super::fmt::{write_i32, I32_BUF_CAPACITY}; +use super::state; +use super::sys::FdSink; + +pub use lifecycle::{bootstrap_complete, init, shutdown, InitResult}; + +// Used only by forked children; 125 matches the existing shell-like "cannot exec" convention. +pub(super) const EXIT_CODE_FAILURE: i32 = 125; + +fn crash_debug(msg: &[u8], sig: i32) { + if !state::SETTINGS.debug_log.load(Ordering::Relaxed) { + return; + } + let mut sink = FdSink::new(libc::STDERR_FILENO); + let _ = super::Sink::put(&mut sink, b"dd-crashtracker[signal-safe]: "); + let _ = super::Sink::put(&mut sink, msg); + if sig >= 0 { + let _ = super::Sink::put(&mut sink, b" "); + let mut buf = [0u8; I32_BUF_CAPACITY]; + let written = write_i32(sig, &mut buf); + let _ = super::Sink::put(&mut sink, &buf[..written]); + } + let _ = super::Sink::put(&mut sink, b"\n"); +} diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs b/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs new file mode 100644 index 0000000000..48f6ef799e --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs @@ -0,0 +1,184 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Signal disposition querying, installation, removal, and low-level signal helpers. + +use core::ffi::{c_int, c_void}; +use core::ptr::null_mut; +use core::sync::atomic::Ordering; + +use super::crash::crash_handler; +use super::crash_debug; +use crate::collector_signal_safe::capabilities; +use crate::collector_signal_safe::config; +use crate::collector_signal_safe::policy::app_handler_is_real; +use crate::collector_signal_safe::state::{self, sig_index}; + +#[derive(Clone, Copy)] +pub(super) struct Target { + pub(super) fn_ptr: *mut c_void, + pub(super) flags: i32, +} + +pub(super) fn effective_target(idx: usize) -> Target { + let (fn_ptr, flags) = state::signal_slot(idx).original_handler(); + Target { fn_ptr, flags } +} + +pub(super) fn query_sigaction(sig: c_int) -> Option { + let mut out: libc::sigaction = unsafe { core::mem::zeroed() }; + if unsafe { libc::sigaction(sig, null_mut(), &mut out) } == 0 { + Some(out) + } else { + None + } +} + +fn is_our_handler(sig: c_int) -> bool { + let Some(cur) = query_sigaction(sig) else { + return false; + }; + cur.sa_flags & libc::SA_SIGINFO != 0 && cur.sa_sigaction == crash_handler as *const () as usize +} + +fn build_crash_sigaction() -> libc::sigaction { + let mut sa: libc::sigaction = unsafe { core::mem::zeroed() }; + sa.sa_sigaction = crash_handler as *const () as usize; + sa.sa_flags = libc::SA_SIGINFO; + if state::SETTINGS.use_alt_stack.load(Ordering::Relaxed) { + sa.sa_flags |= libc::SA_ONSTACK; + } + unsafe { + libc::sigemptyset(&mut sa.sa_mask); + if state::SETTINGS.block_signals.load(Ordering::Relaxed) { + for &blocked in &config::CRASH_SIGNALS { + let _ = libc::sigaddset(&mut sa.sa_mask, blocked); + } + } + } + sa +} + +pub(super) fn reset_signals_to_default(signals: &[c_int]) -> bool { + let mut dfl: libc::sigaction = unsafe { core::mem::zeroed() }; + dfl.sa_sigaction = libc::SIG_DFL; + unsafe { + libc::sigemptyset(&mut dfl.sa_mask); + } + let mut ok = true; + for &sig in signals { + ok &= unsafe { libc::sigaction(sig, &dfl, null_mut()) == 0 }; + } + ok +} + +pub(super) unsafe fn unblock_signal(sig: c_int) { + let mut set: libc::sigset_t = core::mem::zeroed(); + libc::sigemptyset(&mut set); + libc::sigaddset(&mut set, sig); + libc::sigprocmask(libc::SIG_UNBLOCK, &set, null_mut()); +} + +fn install_crash_handler(sig: c_int) { + let Some(cur) = query_sigaction(sig) else { + return; + }; + if cur.sa_sigaction != libc::SIG_DFL { + if app_handler_is_real(cur.sa_sigaction as *mut c_void) { + if let Some(i) = sig_index(sig) { + state::signal_slot(i).set_app_handler_present(); + } + capabilities::note_degraded(capabilities::DEGRADED_APP_HANDLER_PRESENT); + crash_debug(b"app handler present", sig); + } + return; + } + + let sa = build_crash_sigaction(); + let mut old: libc::sigaction = unsafe { core::mem::zeroed() }; + if unsafe { libc::sigaction(sig, &sa, &mut old) } != 0 { + return; + } + + if let Some(i) = sig_index(sig) { + state::signal_slot(i).store_original_handler( + old.sa_sigaction as *mut c_void, + old.sa_flags, + &old.sa_mask, + ); + state::signal_slot(i).set_owned(true); + } +} + +fn uninstall_crash_handler(sig: c_int) { + if !is_our_handler(sig) { + return; + } + let Some(i) = sig_index(sig) else { + return; + }; + + let target = effective_target(i); + let mut restore: libc::sigaction = unsafe { core::mem::zeroed() }; + restore.sa_sigaction = target.fn_ptr as usize; + restore.sa_flags = target.flags; + unsafe { + state::signal_slot(i).load_original_mask(&mut restore.sa_mask); + if libc::sigaction(sig, &restore, null_mut()) == 0 { + state::signal_slot(i).set_owned(false); + } + } +} + +pub(super) fn install_all_handlers() { + state::clear_signal_state(); + for &sig in &config::CRASH_SIGNALS { + install_crash_handler(sig); + } +} + +pub(super) fn uninstall_all_handlers() { + for &sig in &config::CRASH_SIGNALS { + uninstall_crash_handler(sig); + } +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] +pub(super) unsafe fn siginfo_pid(info: *mut libc::siginfo_t) -> i32 { + (*info).si_pid() +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +)))] +pub(super) unsafe fn siginfo_pid(_info: *mut libc::siginfo_t) -> i32 { + i32::MIN +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] +pub(super) unsafe fn siginfo_addr(info: *mut libc::siginfo_t) -> usize { + (*info).si_addr() as usize +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +)))] +pub(super) unsafe fn siginfo_addr(_info: *mut libc::siginfo_t) -> usize { + 0 +} diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs new file mode 100644 index 0000000000..a095f5d4e7 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -0,0 +1,287 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Signal-safe Unix crash collection. +//! +//! `init` takes explicit caller-provided configuration. The collector reads no environment +//! variables of its own; the consumer populates [`InitConfig`] with the values it wants. +//! +//! Crash path: the handler captures signal metadata, opens a pipe, forks a receiver child that +//! `execv`s the configured receiver, then forks a collector child that stack-walks and writes the +//! wire report. The parent closes the pipe, reaps both children, and falls back to `report_fd` on +//! any unavailable capability or fork/exec/pipe failure. Degradations are recorded as +//! `report_degraded:*` tags and as bitsets. Lifecycle is `init` -> optional +//! [`bootstrap_complete`] -> [`shutdown`]. The wire format is pinned by +//! `tests/fixtures/signal_safe_report.golden`. +//! +//! Support matrix: +//! +//! | Target | fork collection | stackwalk | fallback | +//! | --- | --- | --- | --- | +//! | Linux x86_64/aarch64 | raw `clone(SIGCHLD)` | frame-pointer walk + `process_vm_readv` | `report_fd` | +//! | other Linux arches | no | no | `report_fd` | +//! | macOS/iOS | no | no | `report_fd` with siginfo-only minimal reports | +//! | non-Unix | inert | inert | inert | +//! +//! `create_alt_stack` installs the built-in alternate signal stack only for the init thread. +//! `use_alt_stack` may be used with a caller-installed per-thread alternate stack. Stack-overflow +//! crashes on threads without an alternate stack are collected on the faulting thread's stack. +//! When `block_signals` is enabled, managed crash signals are masked while this handler runs. + +mod backtrace; +pub(crate) mod capabilities; +mod config; +mod emitter; +mod fmt; +mod handler; +mod policy; +mod report; +mod state; +mod sys; + +use crate::shared::signal_names; + +#[cfg(test)] +pub(crate) static TEST_GLOBAL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +pub use config::InitConfig; +#[cfg(test)] +pub(crate) use emitter::SliceSink; +pub(crate) use emitter::{emit_report, Sink}; +#[cfg(test)] +pub(crate) use fmt::hex_addr; +pub use handler::{bootstrap_complete, init, shutdown, InitResult}; +pub(crate) use report::{CrashContext, Report, SignalInfo, SECTION_BUF_CAPACITY}; +#[cfg(test)] +pub(crate) use signal_names::*; + +/// Return signal-safe collection capability bits. +/// +/// Bit meanings are the `capabilities::*` constants and the value is emitted as a +/// `capabilities:0x...` report tag. +pub fn capability_bits() -> u32 { + capabilities::get().bits() +} + +/// Return signal-safe collection degradation bits. +/// +/// Bit meanings are the `capabilities::*` degradation constants and the value is emitted as a +/// `degradations:0x...` report tag. +pub fn degradation_bits() -> u32 { + capabilities::degradations().bits() +} + +/// Count crash signals currently owned by this collector. +pub fn owned_signal_count() -> u32 { + state::owned_signal_count() +} + +/// Return whether this collector currently owns `sig`. +pub fn owns_signal(sig: i32) -> bool { + state::owns_signal(sig) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::borrow::ToOwned; + use alloc::string::String; + use core::str; + + #[test] + fn slice_sink_reports_capacity_failure() { + let mut buf = [0u8; 3]; + let mut sink = SliceSink::new(&mut buf); + + assert!(sink.put(b"abc")); + assert!(!sink.put(b"d")); + assert_eq!(sink.as_slice(), b"abc"); + } + + #[test] + fn oversized_metadata_still_terminates_report_with_degradation() { + let signal = SignalInfo::new(libc::SIGSEGV, SEGV_ACCERR, 0x4321, true); + let frames = [0x10usize]; + let context = CrashContext { + signal, + pid: 123, + tid: 456, + frames: &frames, + }; + let oversized_library_name = "x".repeat(SECTION_BUF_CAPACITY); + let report = Report { + config_json: "{\"resolve_frames\":\"Disabled\"}", + library_name: &oversized_library_name, + library_version: "golden-1.0", + family: config::DEFAULT_LIBRARY_FAMILY, + default_service: config::DEFAULT_SERVICE, + service: "", + env: "prod", + app_version: "v1", + runtime_id: "rid", + platform: "linux", + stackwalk_method: "fp_pvr", + capabilities: capabilities::Capabilities::from_bits(0x21), + degradations: capabilities::Degradations::empty(), + }; + + let mut buf = [0u8; 4096]; + let mut sink = SliceSink::new(&mut buf); + assert!(emit_report(&mut sink, &report, &context)); + + let report = str::from_utf8(sink.as_slice()).unwrap(); + assert!(report.contains("\"report_degraded:truncated\"")); + assert!(report.contains("DD_CRASHTRACK_BEGIN_MESSAGE\n")); + assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); + } + + #[test] + fn full_report_emits_native_section_shape() { + let signal = SignalInfo::new(libc::SIGSEGV, SEGV_ACCERR, 0x4321, true); + let frames = [0x10usize, 0, 0x20usize]; + let context = CrashContext { + signal, + pid: 123, + tid: 456, + frames: &frames, + }; + let report = Report { + config_json: "{\"resolve_frames\":\"Disabled\"}", + library_name: config::DEFAULT_LIBRARY_NAME, + library_version: "golden-1.0", + family: config::DEFAULT_LIBRARY_FAMILY, + default_service: config::DEFAULT_SERVICE, + service: "", + env: "prod", + app_version: "v1", + runtime_id: "rid", + platform: "linux", + stackwalk_method: "fp_pvr", + capabilities: capabilities::Capabilities::from_bits(0x21), + degradations: capabilities::Degradations::empty(), + }; + + let mut buf = [0u8; 4096]; + let mut sink = SliceSink::new(&mut buf); + assert!(emit_report(&mut sink, &report, &context)); + + let report = str::from_utf8(sink.as_slice()).unwrap(); + let metadata = section( + report, + "DD_CRASHTRACK_BEGIN_METADATA\n", + "DD_CRASHTRACK_END_METADATA\n", + ); + let tags = section( + report, + "DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS\n", + "DD_CRASHTRACK_END_ADDITIONAL_TAGS\n", + ); + let kind = section( + report, + "DD_CRASHTRACK_BEGIN_KIND\n", + "DD_CRASHTRACK_END_KIND\n", + ); + let procinfo = section( + report, + "DD_CRASHTRACK_BEGIN_PROCESSINFO\n", + "DD_CRASHTRACK_END_PROCESSINFO\n", + ); + let stacktrace = section( + report, + "DD_CRASHTRACK_BEGIN_STACKTRACE\n", + "DD_CRASHTRACK_END_STACKTRACE\n", + ); + let message = section( + report, + "DD_CRASHTRACK_BEGIN_MESSAGE\n", + "DD_CRASHTRACK_END_MESSAGE\n", + ); + + let metadata: serde_json::Value = serde_json::from_str(metadata.trim()).unwrap(); + let tags: serde_json::Value = serde_json::from_str(tags.trim()).unwrap(); + let kind: serde_json::Value = serde_json::from_str(kind.trim()).unwrap(); + let procinfo: serde_json::Value = serde_json::from_str(procinfo.trim()).unwrap(); + + assert_eq!(metadata["library_name"], config::DEFAULT_LIBRARY_NAME); + assert_eq!(metadata["library_version"], "golden-1.0"); + assert_eq!(metadata["tags"][0], "language:native"); + assert_eq!( + metadata["tags"][4] + .as_str() + .unwrap() + .strip_prefix("service:"), + Some(config::DEFAULT_SERVICE) + ); + assert_eq!(metadata["tags"][5], "env:prod"); + assert_eq!(metadata["tags"][6], "version:v1"); + assert_eq!(tags[0], "stackwalk_method:fp_pvr"); + assert_eq!(tags[1], "capabilities:0x00000021"); + assert_eq!(tags[2], "degradations:0x00000000"); + assert_eq!(kind, "UnixSignal"); + assert_eq!(procinfo["pid"], 123); + assert_eq!(procinfo["tid"], 456); + assert!(stacktrace.contains(hex_addr(0x10).as_str())); + assert!(stacktrace.contains(hex_addr(0x20).as_str())); + assert!(!stacktrace.contains(hex_addr(0).as_str())); + assert_eq!(message.trim(), "Crash (SIGSEGV)"); + assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn emitted_wire_matches_golden_fixture() { + let emitted = golden_report(); + assert_eq!( + emitted, + include_str!("../../tests/fixtures/signal_safe_report.golden") + ); + } + + #[cfg(target_pointer_width = "64")] + #[test] + #[ignore = "regenerates the signal-safe emitted-wire golden fixture"] + fn regenerate_signal_safe_report_golden() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/signal_safe_report.golden"); + std::fs::write(path, golden_report()).expect("write signal-safe golden fixture"); + } + + #[cfg(target_pointer_width = "64")] + fn golden_report() -> String { + let signal = SignalInfo::new(libc::SIGSEGV, SEGV_MAPERR, 0x1234, true); + let frames = [0x10usize, 0x20usize]; + let context = CrashContext { + signal, + pid: 123, + tid: 456, + frames: &frames, + }; + let report = Report { + config_json: "{\"resolve_frames\":\"Disabled\"}", + library_name: "dd-test", + library_version: "1.2.3", + family: "native", + default_service: "default-service", + service: "svc", + env: "prod", + app_version: "v1", + runtime_id: "rid", + platform: "linux", + stackwalk_method: "fp_pvr", + capabilities: capabilities::Capabilities::from_bits(0x21), + degradations: capabilities::DEGRADED_REPORT_TO_FD, + }; + + let mut buf = [0u8; 8192]; + let mut sink = SliceSink::new(&mut buf); + assert!(emit_report(&mut sink, &report, &context)); + str::from_utf8(sink.as_slice()).unwrap().to_owned() + } + + fn section<'a>(report: &'a str, begin: &str, end: &str) -> &'a str { + let start = report.find(begin).unwrap() + begin.len(); + let remaining = &report[start..]; + let finish = remaining.find(end).unwrap(); + &remaining[..finish] + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/policy.rs b/libdd-crashtracker/src/collector_signal_safe/policy.rs new file mode 100644 index 0000000000..51bf8d6ff8 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/policy.rs @@ -0,0 +1,124 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::c_void; + +use super::signal_names::{SI_ASYNCIO, SI_MESGQ, SI_QUEUE, SI_SIGIO, SI_TIMER, SI_TKILL, SI_USER}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Disposition { + Default, + Ignore, + Handler, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChainAction { + RestoreDefaultAndRefault, + RestoreDefaultAndReraise, +} + +pub(super) fn disposition_of(handler: *mut c_void) -> Disposition { + match handler as usize { + value if value == libc::SIG_DFL => Disposition::Default, + value if value == libc::SIG_IGN => Disposition::Ignore, + _ => Disposition::Handler, + } +} + +pub(super) fn app_handler_is_real(handler: *mut c_void) -> bool { + matches!(disposition_of(handler), Disposition::Handler) +} + +pub(super) fn is_genuine_fault( + has_siginfo: bool, + si_code: i32, + si_pid: i32, + self_pid: i32, +) -> bool { + if !has_siginfo { + return false; + } + if si_code != SI_USER && si_code != SI_TKILL { + return true; + } + si_pid == self_pid +} + +pub(super) fn chain_action( + disposition: Disposition, + has_siginfo: bool, + si_code: i32, +) -> ChainAction { + // Owned signal slots are installed only over SIG_DFL today. If a non-default disposition ever + // appears here before app-handler chaining is implemented, keep terminal crash semantics + // instead of invoking unreachable app code. + match (disposition, should_refault(has_siginfo, si_code)) { + (_, true) => ChainAction::RestoreDefaultAndRefault, + (Disposition::Default | Disposition::Ignore | Disposition::Handler, false) => { + ChainAction::RestoreDefaultAndReraise + } + } +} + +fn should_refault(has_siginfo: bool, si_code: i32) -> bool { + has_siginfo && si_code > 0 && !is_async_si_code(si_code) +} + +fn is_async_si_code(si_code: i32) -> bool { + matches!( + si_code, + SI_USER | SI_QUEUE | SI_TIMER | SI_MESGQ | SI_ASYNCIO | SI_SIGIO | SI_TKILL + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::collector_signal_safe::signal_names::{SEGV_MAPERR, SI_USER}; + + #[test] + fn dispositions_match_sigaction_sentinels() { + let dfl = libc::SIG_DFL as *mut c_void; + let ign = libc::SIG_IGN as *mut c_void; + let handler = 0x1234usize as *mut c_void; + + assert_eq!(disposition_of(dfl), Disposition::Default); + assert_eq!(disposition_of(core::ptr::null_mut()), Disposition::Default); + assert_eq!(disposition_of(ign), Disposition::Ignore); + assert_eq!(disposition_of(handler), Disposition::Handler); + assert!(!app_handler_is_real(dfl)); + assert!(!app_handler_is_real(ign)); + assert!(app_handler_is_real(handler)); + } + + #[test] + fn genuine_fault_filter_ignores_external_async_signal() { + assert!(!is_genuine_fault(true, SI_USER, 7, 9)); + } + + #[test] + fn genuine_fault_filter_accepts_self_sent_async_signal() { + assert!(is_genuine_fault(true, SI_USER, 9, 9)); + } + + #[test] + fn chain_action_matches_default_signal_semantics() { + assert_eq!( + chain_action(Disposition::Default, true, SEGV_MAPERR), + ChainAction::RestoreDefaultAndRefault + ); + assert_eq!( + chain_action(Disposition::Default, true, SI_USER), + ChainAction::RestoreDefaultAndReraise + ); + assert_eq!( + chain_action(Disposition::Handler, true, SEGV_MAPERR), + ChainAction::RestoreDefaultAndRefault + ); + assert_eq!( + chain_action(Disposition::Ignore, true, SI_USER), + ChainAction::RestoreDefaultAndReraise + ); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/report.rs b/libdd-crashtracker/src/collector_signal_safe/report.rs new file mode 100644 index 0000000000..b702b51906 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/report.rs @@ -0,0 +1,105 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use heapless::{String as HeaplessString, Vec as HeaplessVec}; +use serde::Serialize; + +use super::capabilities::{Capabilities, Degradations}; +use super::fmt::hex_addr; +use super::signal_names::{rust_si_code_name, rust_signal_name, signal_has_address}; + +pub const SECTION_BUF_CAPACITY: usize = 4096; +pub const TAG_CAPACITY: usize = 288; +pub const MAX_TAGS: usize = 20; +pub const FRAME_IP_CAPACITY: usize = 2 + core::mem::size_of::() * 2; +pub const MESSAGE_CAPACITY: usize = 192; + +pub type Tag = HeaplessString; +pub type Tags = HeaplessVec; + +#[derive(Serialize)] +pub struct Metadata<'a> { + pub library_name: &'a str, + pub library_version: &'a str, + pub family: &'a str, + pub tags: Tags, +} + +impl<'a> Metadata<'a> { + pub fn new(library_name: &'a str, library_version: &'a str, family: &'a str) -> Self { + Self { + library_name, + library_version, + family, + tags: Tags::new(), + } + } +} + +#[derive(Serialize)] +pub struct SignalInfo { + pub si_signo: i32, + pub si_code: i32, + pub si_signo_human_readable: &'static str, + pub si_code_human_readable: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub si_addr: Option>, +} + +impl SignalInfo { + pub fn new(si_signo: i32, si_code: i32, si_addr: usize, has_siginfo: bool) -> Self { + let si_addr = if has_siginfo && signal_has_address(si_signo) { + Some(hex_addr(si_addr)) + } else { + None + }; + + Self { + si_signo, + si_code, + si_signo_human_readable: rust_signal_name(si_signo), + si_code_human_readable: rust_si_code_name(si_signo, si_code), + si_addr, + } + } +} + +#[derive(Serialize)] +pub struct ProcInfo { + pub pid: i32, + pub tid: i32, +} + +#[derive(Serialize)] +pub struct Frame { + pub ip: HeaplessString, +} + +impl Frame { + pub fn from_ip(ip: usize) -> Self { + Self { ip: hex_addr(ip) } + } +} + +pub struct CrashContext<'a> { + pub signal: SignalInfo, + pub pid: i32, + pub tid: i32, + pub frames: &'a [usize], +} + +pub struct Report<'a> { + pub config_json: &'a str, + pub library_name: &'a str, + pub library_version: &'a str, + pub family: &'a str, + pub default_service: &'a str, + pub service: &'a str, + pub env: &'a str, + pub app_version: &'a str, + pub runtime_id: &'a str, + pub platform: &'a str, + pub stackwalk_method: &'a str, + pub capabilities: Capabilities, + pub degradations: Degradations, +} diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs new file mode 100644 index 0000000000..cd0347bc7d --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -0,0 +1,262 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Global state publication for the signal-safe collector. +//! +//! Initialization is single-threaded behind [`InitSession`]. The initializing thread writes +//! metadata and settings first, then publishes handler readiness with the `Release` store to +//! [`HANDLERS_ENABLED`]. The signal handler `Acquire`-loads that flag before reading the rest of +//! the state, so per-setting and per-slot accesses can stay `Relaxed`. + +use core::cell::UnsafeCell; +use core::ffi::c_void; +use core::mem::MaybeUninit; +use core::ptr::null_mut; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, AtomicUsize, Ordering}; + +use heapless::{String as HeaplessString, Vec as HeaplessVec}; +use thiserror::Error; + +use super::config::{self, CONFIG_JSON_BUF_SIZE, PATH_CAPACITY}; + +// Raw signal numbers index these arrays. 128 covers Linux and BSD/macOS signal ranges used here. +pub const NSIG: usize = 128; + +#[inline] +pub fn sig_index(sig: i32) -> Option { + usize::try_from(sig).ok().filter(|&i| i < NSIG) +} + +pub struct Meta { + pub config_json: HeaplessString, + pub service: HeaplessString<256>, + pub env: HeaplessString<256>, + pub app_version: HeaplessString<256>, + pub platform: HeaplessString<256>, + pub runtime_id: HeaplessString<64>, + pub process_path: HeaplessVec, + pub library_name: HeaplessString<128>, + pub library_version: HeaplessString<128>, + pub family: HeaplessString<128>, + pub default_service: HeaplessString<128>, +} + +impl Meta { + pub(super) const fn new() -> Self { + Self { + config_json: HeaplessString::new(), + service: HeaplessString::new(), + env: HeaplessString::new(), + app_version: HeaplessString::new(), + platform: HeaplessString::new(), + runtime_id: HeaplessString::new(), + process_path: HeaplessVec::new(), + library_name: HeaplessString::new(), + library_version: HeaplessString::new(), + family: HeaplessString::new(), + default_service: HeaplessString::new(), + } + } +} + +struct StaticMeta(UnsafeCell); + +unsafe impl Sync for StaticMeta {} + +static META: StaticMeta = StaticMeta(UnsafeCell::new(Meta::new())); + +const INIT_UNINIT: i32 = 0; +const INIT_INITIALIZING: i32 = 1; +const INIT_READY: i32 = 2; + +static INIT_STATE: AtomicI32 = AtomicI32::new(INIT_UNINIT); + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum BeginInitError { + #[error("signal-safe crashtracker is already initialized")] + AlreadyInitialized, + #[error("signal-safe crashtracker initialization is already in progress")] + Busy, +} + +pub struct InitSession { + finished: bool, +} + +impl InitSession { + pub fn meta_mut(&mut self) -> &mut Meta { + unsafe { &mut *META.0.get() } + } + + pub fn finish(mut self) { + self.finished = true; + INIT_STATE.store(INIT_READY, Ordering::Release); + } +} + +impl Drop for InitSession { + fn drop(&mut self) { + if !self.finished { + INIT_STATE.store(INIT_UNINIT, Ordering::Release); + } + } +} + +pub fn begin_init() -> Result { + match INIT_STATE.compare_exchange( + INIT_UNINIT, + INIT_INITIALIZING, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => Ok(InitSession { finished: false }), + Err(INIT_READY) => Err(BeginInitError::AlreadyInitialized), + Err(_) => Err(BeginInitError::Busy), + } +} + +pub fn reset_after_shutdown() { + INIT_STATE.store(INIT_UNINIT, Ordering::Release); +} + +pub fn meta() -> &'static Meta { + unsafe { &*META.0.get() } +} + +pub(super) struct SignalSlot { + orig_fn: AtomicPtr, + orig_flags: AtomicI32, + own_signal: AtomicBool, + app_handler_present: AtomicBool, + orig_mask: UnsafeCell>, +} + +unsafe impl Sync for SignalSlot {} + +impl SignalSlot { + const fn new() -> Self { + Self { + orig_fn: AtomicPtr::new(null_mut()), + orig_flags: AtomicI32::new(0), + own_signal: AtomicBool::new(false), + app_handler_present: AtomicBool::new(false), + orig_mask: UnsafeCell::new(MaybeUninit::uninit()), + } + } + + pub(super) fn original_handler(&self) -> (*mut c_void, i32) { + ( + self.orig_fn.load(Ordering::Relaxed), + self.orig_flags.load(Ordering::Relaxed), + ) + } + + pub(super) fn store_original_handler( + &self, + fn_ptr: *mut c_void, + flags: i32, + mask: &libc::sigset_t, + ) { + self.orig_fn.store(fn_ptr, Ordering::Relaxed); + self.orig_flags.store(flags, Ordering::Relaxed); + unsafe { + (*self.orig_mask.get()).as_mut_ptr().write(*mask); + } + } + + pub(super) fn load_original_mask(&self, out: &mut libc::sigset_t) { + unsafe { + out.clone_from(&*(*self.orig_mask.get()).as_ptr()); + } + } + + pub(super) fn set_owned(&self, owned: bool) { + self.own_signal.store(owned, Ordering::Relaxed); + } + + pub(super) fn owns_signal(&self) -> bool { + self.own_signal.load(Ordering::Relaxed) + } + + pub(super) fn set_app_handler_present(&self) { + self.app_handler_present.store(true, Ordering::Relaxed); + } + + pub(super) fn app_handler_present(&self) -> bool { + self.app_handler_present.load(Ordering::Relaxed) + } + + fn clear(&self) { + self.orig_fn.store(null_mut(), Ordering::Relaxed); + self.orig_flags.store(0, Ordering::Relaxed); + self.own_signal.store(false, Ordering::Relaxed); + self.app_handler_present.store(false, Ordering::Relaxed); + } +} + +static SIGNAL_SLOTS: [SignalSlot; NSIG] = [const { SignalSlot::new() }; NSIG]; + +pub(super) fn signal_slot(idx: usize) -> &'static SignalSlot { + &SIGNAL_SLOTS[idx] +} + +pub static HANDLERS_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Runtime settings copied from the caller's init config. +/// +/// These are written before [`HANDLERS_ENABLED`] is published and are read from the crash path +/// after that publication has been observed. +pub struct Settings { + pub only_bootstrap: AtomicBool, + pub debug_log: AtomicBool, + pub create_alt_stack: AtomicBool, + pub use_alt_stack: AtomicBool, + pub block_signals: AtomicBool, + pub disarm_on_entry: AtomicBool, + pub close_fds_on_receiver: AtomicBool, + pub report_fd: AtomicI32, + pub collector_reap_ms: AtomicI32, + pub receiver_reap_ms: AtomicI32, + pub max_frames: AtomicUsize, +} + +impl Settings { + const fn new() -> Self { + Self { + only_bootstrap: AtomicBool::new(false), + debug_log: AtomicBool::new(false), + create_alt_stack: AtomicBool::new(false), + use_alt_stack: AtomicBool::new(false), + block_signals: AtomicBool::new(true), + disarm_on_entry: AtomicBool::new(false), + close_fds_on_receiver: AtomicBool::new(true), + report_fd: AtomicI32::new(-1), + collector_reap_ms: AtomicI32::new(config::COLLECTOR_REAP_MS_DEFAULT), + receiver_reap_ms: AtomicI32::new(config::RECEIVER_REAP_MS_DEFAULT), + max_frames: AtomicUsize::new(config::BACKTRACE_LEVELS_DEFAULT), + } + } +} + +pub static SETTINGS: Settings = Settings::new(); + +pub fn clear_signal_state() { + for slot in &SIGNAL_SLOTS { + slot.clear(); + } +} + +pub fn owns_signal(sig: i32) -> bool { + sig_index(sig).is_some_and(|i| signal_slot(i).owns_signal()) +} + +pub fn owned_signal_count() -> u32 { + SIGNAL_SLOTS + .iter() + .filter(|slot| slot.owns_signal()) + .count() as u32 +} + +pub fn app_handler_present(sig: i32) -> bool { + sig_index(sig).is_some_and(|i| signal_slot(i).app_handler_present()) +} diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs new file mode 100644 index 0000000000..5d9e1b72a1 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -0,0 +1,657 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::c_char; + +unsafe extern "C" { + static mut environ: *mut *mut c_char; +} + +/// Staging buffer size for `FdSink`. Report emission issues many tiny +/// `write_bytes` calls -- every marker, body, and trailing newline is a +/// separate call -- so staging coalesces a full report into a handful of +/// `write(2)` syscalls on the crash path instead of dozens. Sized to match the +/// section buffer so it inherits the same alt-stack budget (asserted in +/// `handler`); the two buffers can be live at once during emission. +const FD_SINK_BUF_CAPACITY: usize = super::report::SECTION_BUF_CAPACITY; + +/// Buffered writer over a raw fd. `write_bytes` stages into a fixed inline +/// buffer and only issues a syscall when the buffer would overflow; callers +/// must `flush` before the fd is closed to emit the remainder. Drop flushes as +/// a safety net so a forgotten `flush` never silently drops data. +pub struct FdSink { + fd: i32, + buf: [u8; FD_SINK_BUF_CAPACITY], + len: usize, +} + +impl FdSink { + pub fn new(fd: i32) -> Self { + Self { + fd, + buf: [0u8; FD_SINK_BUF_CAPACITY], + len: 0, + } + } + + /// Write everything currently staged to the fd and reset the buffer. + /// Returns `false` if the underlying write failed. + pub fn flush(&mut self) -> bool { + let ok = write_all(self.fd, &self.buf[..self.len]); + self.len = 0; + ok + } +} + +impl Drop for FdSink { + fn drop(&mut self) { + if self.len > 0 { + let _ = self.flush(); + } + } +} + +impl crate::protocol::ByteSink for FdSink { + type Error = (); + + fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error> { + // Bytes larger than the whole buffer can never be staged: flush what is + // already staged (to preserve ordering), then write them straight out. + if bytes.len() > self.buf.len() { + if self.len > 0 && !self.flush() { + return Err(()); + } + return if write_all(self.fd, bytes) { + Ok(()) + } else { + Err(()) + }; + } + // Otherwise stage them, flushing first if they would not fit alongside + // what is already buffered. + if self.len + bytes.len() > self.buf.len() && !self.flush() { + return Err(()); + } + self.buf[self.len..self.len + bytes.len()].copy_from_slice(bytes); + self.len += bytes.len(); + Ok(()) + } +} + +#[derive(Clone, Copy)] +pub struct Pipe { + pub read: i32, + pub write: i32, +} + +fn write_all(fd: i32, bytes: &[u8]) -> bool { + let mut off = 0usize; + while off < bytes.len() { + let n = write(fd, &bytes[off..]); + if n > 0 { + off += n as usize; + continue; + } + return false; + } + true +} + +mod raw_common { + use core::ffi::CStr; + use core::num::NonZeroI32; + use rustix::fd::{BorrowedFd, IntoRawFd}; + + use super::Pipe; + + #[inline] + fn neg_errno(err: rustix::io::Errno) -> i32 { + -err.raw_os_error() + } + + #[inline] + pub unsafe fn borrowed_fd(fd: i32) -> BorrowedFd<'static> { + BorrowedFd::borrow_raw(fd) + } + + pub fn write(fd: i32, bytes: &[u8]) -> isize { + match rustix::io::retry_on_intr(|| rustix::io::write(unsafe { borrowed_fd(fd) }, bytes)) { + Ok(n) => n as isize, + Err(err) => neg_errno(err) as isize, + } + } + + #[cfg(not(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") + )))] + pub fn close(fd: i32) { + unsafe { + rustix::io::close(fd); + } + } + + pub fn fcntl_dupfd(fd: i32, min_fd: i32) -> i32 { + match rustix::io::fcntl_dupfd_cloexec(unsafe { borrowed_fd(fd) }, min_fd) { + Ok(fd) => match rustix::io::fcntl_setfd(&fd, rustix::io::FdFlags::empty()) { + Ok(()) => fd.into_raw_fd(), + Err(err) => neg_errno(err), + }, + Err(err) => neg_errno(err), + } + } + + pub fn fd_valid(fd: i32) -> bool { + fd >= 0 && rustix::io::fcntl_getfd(unsafe { borrowed_fd(fd) }).is_ok() + } + + pub fn pipe() -> Option { + match rustix::pipe::pipe() { + Ok((read_fd, write_fd)) => Some(Pipe { + read: read_fd.into_raw_fd(), + write: write_fd.into_raw_fd(), + }), + Err(_) => None, + } + } + + pub fn open_readwrite(path: *const u8) -> i32 { + let path = unsafe { CStr::from_ptr(path.cast()) }; + match rustix::fs::openat( + rustix::fs::CWD, + path, + rustix::fs::OFlags::RDWR, + rustix::fs::Mode::empty(), + ) { + Ok(fd) => fd.into_raw_fd(), + Err(err) => neg_errno(err), + } + } + + pub fn access_executable(path: *const u8) -> bool { + let path = unsafe { CStr::from_ptr(path.cast()) }; + rustix::fs::accessat( + rustix::fs::CWD, + path, + rustix::fs::Access::EXEC_OK, + rustix::fs::AtFlags::empty(), + ) + .is_ok() + } + + pub fn mprotect_none(addr: *mut u8, len: usize) -> bool { + unsafe { rustix::mm::mprotect(addr.cast(), len, rustix::mm::MprotectFlags::empty()) } + .is_ok() + } + + pub fn getpid() -> i32 { + rustix::process::getpid().as_raw_pid() + } + + pub fn kill(pid: i32, sig: i32) -> i32 { + let Some(pid) = rustix::process::Pid::from_raw(pid) else { + return -libc::EINVAL; + }; + let Some(sig) = NonZeroI32::new(sig) else { + return -libc::EINVAL; + }; + let sig = unsafe { rustix::process::Signal::from_raw_nonzero_unchecked(sig) }; + match rustix::process::kill_process(pid, sig) { + Ok(()) => 0, + Err(err) => neg_errno(err), + } + } + + pub fn waitpid_nohang_status(pid: i32, status: &mut i32) -> i32 { + let Some(pid) = rustix::process::Pid::from_raw(pid) else { + return -libc::EINVAL; + }; + match rustix::process::waitpid(Some(pid), rustix::process::WaitOptions::NOHANG) { + Ok(Some((waited, wait_status))) => { + *status = wait_status.as_raw(); + waited.as_raw_pid() + } + Ok(None) => 0, + Err(err) => neg_errno(err), + } + } + + pub fn poll_sleep_ms(timeout_ms: i32) { + if timeout_ms <= 0 { + return; + } + let ts = rustix::thread::Timespec { + tv_sec: (timeout_ms / 1000) as i64, + tv_nsec: ((timeout_ms % 1000) as i64) * 1_000_000, + }; + let _ = rustix::thread::nanosleep(&ts); + } + + pub fn monotonic_nanos() -> i64 { + let ts = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic); + ts.tv_sec + .wrapping_mul(1_000_000_000) + .wrapping_add(ts.tv_nsec as i64) + } +} + +#[cfg(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") +))] +mod raw { + use core::arch::asm; + use core::ffi::c_void; + + pub use super::raw_common::{ + access_executable, fcntl_dupfd, fd_valid, getpid, kill, monotonic_nanos, mprotect_none, + open_readwrite, pipe, poll_sleep_ms, waitpid_nohang_status, write, + }; + + /// Upper bound on the descriptor scan in [`close_range_from`], so a very large (or unlimited) + /// `RLIMIT_NOFILE` can't turn the close loop into an unbounded number of syscalls. + const CLOSE_FD_SCAN_LIMIT: u64 = 65_536; + + #[cfg(target_arch = "x86_64")] + #[inline] + unsafe fn syscall3(nr: i64, a0: usize, a1: usize, a2: usize) -> isize { + let ret: isize; + asm!( + "syscall", + inlateout("rax") nr as isize => ret, + in("rdi") a0, + in("rsi") a1, + in("rdx") a2, + lateout("rcx") _, + lateout("r11") _, + options(nostack, preserves_flags), + ); + ret + } + + #[cfg(target_arch = "x86_64")] + #[inline] + unsafe fn syscall6( + nr: i64, + a0: usize, + a1: usize, + a2: usize, + a3: usize, + a4: usize, + a5: usize, + ) -> isize { + let ret: isize; + asm!( + "syscall", + inlateout("rax") nr as isize => ret, + in("rdi") a0, + in("rsi") a1, + in("rdx") a2, + in("r10") a3, + in("r8") a4, + in("r9") a5, + lateout("rcx") _, + lateout("r11") _, + options(nostack, preserves_flags), + ); + ret + } + + #[cfg(target_arch = "aarch64")] + #[inline] + unsafe fn syscall3(nr: i64, a0: usize, a1: usize, a2: usize) -> isize { + let ret: isize; + asm!( + "svc 0", + in("x8") nr, + inlateout("x0") a0 => ret, + in("x1") a1, + in("x2") a2, + options(nostack), + ); + ret + } + + #[cfg(target_arch = "aarch64")] + #[inline] + unsafe fn syscall6( + nr: i64, + a0: usize, + a1: usize, + a2: usize, + a3: usize, + a4: usize, + a5: usize, + ) -> isize { + let ret: isize; + asm!( + "svc 0", + in("x8") nr, + inlateout("x0") a0 => ret, + in("x1") a1, + in("x2") a2, + in("x3") a3, + in("x4") a4, + in("x5") a5, + options(nostack), + ); + ret + } + + pub fn dup2(oldfd: i32, newfd: i32) -> i32 { + if oldfd == newfd { + return newfd; + } + unsafe { syscall3(libc::SYS_dup3, oldfd as usize, newfd as usize, 0) as i32 } + } + + pub fn close(fd: i32) { + let _ = unsafe { syscall3(libc::SYS_close, fd as usize, 0, 0) }; + } + + pub fn close_range_from(first_fd: i32) -> bool { + if first_fd < 0 { + return false; + } + // `close_range(2)` only exists on Linux 5.9+, so we can't rely on it (e.g. CentOS 7 runs + // 3.10). Close descriptors individually up to the process' `RLIMIT_NOFILE` soft limit, + // capped so an unlimited/huge limit can't turn this into millions of syscalls. + let limit = rustix::process::getrlimit(rustix::process::Resource::Nofile) + .current + .map_or(CLOSE_FD_SCAN_LIMIT, |soft| soft.min(CLOSE_FD_SCAN_LIMIT)) + as i32; + let mut fd = first_fd; + while fd < limit { + close(fd); + fd += 1; + } + true + } + + pub fn fork_supported() -> bool { + true + } + + pub unsafe fn fork_raw() -> isize { + // `kernel_fork` issues a bare `clone(SIGCHLD)` syscall through rustix's linux_raw backend, + // so it never runs libc `pthread_atfork` handlers — the async-signal-safe fork we need on + // the crash path. Map its typed result back onto the fork(2) ABI the callers expect. + match rustix::runtime::kernel_fork() { + Ok(rustix::runtime::Fork::Child(_)) => 0, + Ok(rustix::runtime::Fork::ParentOf(pid)) => pid.as_raw_pid() as isize, + Err(err) => -(err.raw_os_error() as isize), + } + } + + pub fn exit_process(code: i32) -> ! { + rustix::runtime::exit_group(code) + } + + pub fn gettid() -> i32 { + rustix::thread::gettid().as_raw_pid() + } + + pub fn read_own_mem(pid: i32, src: usize, dst: &mut [u8]) -> bool { + let local = libc::iovec { + iov_base: dst.as_mut_ptr() as *mut c_void, + iov_len: dst.len(), + }; + let remote = libc::iovec { + iov_base: src as *mut c_void, + iov_len: dst.len(), + }; + let ret = unsafe { + syscall6( + libc::SYS_process_vm_readv, + pid as usize, + &local as *const libc::iovec as usize, + 1, + &remote as *const libc::iovec as usize, + 1, + 0, + ) + }; + ret == dst.len() as isize + } +} + +#[cfg(not(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") +)))] +mod raw { + pub use super::raw_common::{ + access_executable, close, fcntl_dupfd, fd_valid, getpid, kill, monotonic_nanos, + mprotect_none, open_readwrite, pipe, poll_sleep_ms, waitpid_nohang_status, write, + }; + + pub fn dup2(oldfd: i32, newfd: i32) -> i32 { + unsafe { libc::dup2(oldfd, newfd) } + } + + pub fn close_range_from(_first_fd: i32) -> bool { + false + } + + pub fn fork_supported() -> bool { + false + } + + pub unsafe fn fork_raw() -> isize { + -(libc::ENOSYS as isize) + } + + pub fn exit_process(code: i32) -> ! { + unsafe { + libc::_exit(code); + } + } + + pub fn gettid() -> i32 { + #[cfg(any(target_os = "macos", target_os = "ios"))] + { + let mut tid = 0u64; + unsafe { + let _ = libc::pthread_threadid_np(0 as libc::pthread_t, &mut tid); + } + tid as i32 + } + #[cfg(not(any(target_os = "macos", target_os = "ios")))] + { + unsafe { libc::getpid() } + } + } + + pub fn read_own_mem(_pid: i32, _src: usize, _dst: &mut [u8]) -> bool { + false + } +} + +pub use raw::{ + access_executable, close, close_range_from, dup2, exit_process, fcntl_dupfd, fd_valid, + fork_raw, fork_supported, getpid, gettid, mprotect_none, open_readwrite, pipe, read_own_mem, +}; +use raw::{kill, monotonic_nanos, poll_sleep_ms, waitpid_nohang_status, write}; + +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum ChildReap { + Reaped(i32), + NoChild, + WaitFailed(i32), + TimedOut, +} + +pub fn reap_child(pid: i32, timeout_ms: i64, poll_ms: i32, kill_timeout_ms: i64) -> ChildReap { + let mut remaining_timeout_ms = timeout_ms; + let mut should_kill = true; + loop { + match wait_child_until(pid, remaining_timeout_ms, poll_ms) { + ChildReap::TimedOut if should_kill => { + let _ = kill(pid, libc::SIGKILL); + remaining_timeout_ms = kill_timeout_ms; + should_kill = false; + } + result => return result, + } + } +} + +fn wait_child_until(pid: i32, timeout_ms: i64, poll_ms: i32) -> ChildReap { + let start = monotonic_nanos(); + loop { + let mut status = 0i32; + let waited = waitpid_nohang_status(pid, &mut status); + if waited == pid { + return ChildReap::Reaped(status); + } + if waited < 0 { + return if waited == -libc::ECHILD { + ChildReap::NoChild + } else { + ChildReap::WaitFailed(-waited) + }; + } + + poll_sleep_ms(poll_ms); + let elapsed_ms = (monotonic_nanos() - start) / 1_000_000; + if elapsed_ms >= timeout_ms { + return ChildReap::TimedOut; + } + } +} + +pub fn environ_ptr() -> *mut *mut c_char { + unsafe { environ } +} + +pub unsafe fn cstr_has_prefix(s: *const c_char, prefix: &[u8]) -> bool { + // Read as bytes so the comparison doesn't depend on `c_char`'s platform-varying signedness + // (`i8` on x86_64/macOS, `u8` on aarch64-linux). + let bytes = s.cast::(); + let mut i = 0usize; + while i < prefix.len() { + let c = *bytes.add(i); + if c == 0 || c != prefix[i] { + return false; + } + i += 1; + } + true +} + +pub fn errno() -> i32 { + unsafe { *errno_location() } +} + +pub fn set_errno(errno: i32) { + unsafe { + *errno_location() = errno; + } +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +unsafe fn errno_location() -> *mut i32 { + libc::__error() +} + +#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios"))))] +unsafe fn errno_location() -> *mut i32 { + libc::__errno_location() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::collector_signal_safe::Sink; + #[cfg(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") + ))] + use std::os::fd::IntoRawFd; + + #[test] + fn fd_sink_writes_to_pipe() { + let pipe = pipe().expect("pipe"); + let mut sink = FdSink::new(pipe.write); + assert!(sink.put(b"abc")); + assert!(sink.flush()); + close(pipe.write); + + let mut out = [0u8; 3]; + let n = unsafe { libc::read(pipe.read, out.as_mut_ptr().cast(), out.len()) }; + close(pipe.read); + assert_eq!(n, 3); + assert_eq!(&out, b"abc"); + } + + #[test] + fn fd_sink_coalesces_and_handles_overflow() { + let pipe = pipe().expect("pipe"); + let mut sink = FdSink::new(pipe.write); + + // Small writes that together exceed the staging buffer must auto-flush + // mid-stream; a single write larger than the buffer takes the direct + // path. Kept well under the pipe capacity so no writer blocks. + let small = [b'a'; 100]; + let small_total = FD_SINK_BUF_CAPACITY + 500; + let mut written = 0usize; + while written < small_total { + assert!(sink.put(&small)); + written += small.len(); + } + let big = [b'b'; FD_SINK_BUF_CAPACITY + 1]; + assert!(sink.put(&big)); + assert!(sink.flush()); + close(pipe.write); + + let expected = written + big.len(); + let mut out = [0u8; 4 * FD_SINK_BUF_CAPACITY]; + let mut got = 0usize; + loop { + let n = + unsafe { libc::read(pipe.read, out[got..].as_mut_ptr().cast(), out.len() - got) }; + if n <= 0 { + break; + } + got += n as usize; + } + close(pipe.read); + + assert_eq!(got, expected); + assert!(out[..written].iter().all(|&b| b == b'a')); + assert!(out[written..expected].iter().all(|&b| b == b'b')); + } + + #[cfg(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") + ))] + #[test] + fn close_tolerates_already_closed_fd() { + let fd = tempfile::tempfile().expect("tempfile").into_raw_fd(); + close(fd); + close(fd); + } + + #[cfg(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") + ))] + #[test] + fn close_range_from_tolerates_closed_fds() { + let child = unsafe { fork_raw() }; + if child == 0 { + close(10); + exit_process(if close_range_from(3) { 0 } else { 1 }); + } + assert!(child > 0, "fork failed: {child}"); + + match reap_child(child as i32, 1_000, 10, 100) { + ChildReap::Reaped(status) => { + assert!(libc::WIFEXITED(status)); + assert_eq!(libc::WEXITSTATUS(status), 0); + } + _ => panic!("unexpected child reap result"), + } + } +} diff --git a/libdd-crashtracker/src/crash_info/emit_sicodes.c b/libdd-crashtracker/src/crash_info/emit_sicodes.c deleted file mode 100644 index 806ebde81a..0000000000 --- a/libdd-crashtracker/src/crash_info/emit_sicodes.c +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -#include - -//! Different OSes have different values for si_code constants -//! https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/siginfo.h -//! https://github.com/apple/darwin-xnu/blob/main/bsd/sys/signal.h -//! Ideally we would use libc:: like we do for signum, but rust doesn't actually -//! export constants for si_code types. -//! As a workaround, link some C code which DOES have access to the types on the current platform -//! and use it to do the translation. -//! Also, OSX doesn't always set the value at all, which sucks -//! https://vorner.github.io/2021/01/03/dark-side-of-posix-apis.html - -// MUST REMAIN IN SYNC WITH THE ENUM IN SIG_INFO.RS -enum SiCodes { - SI_CODE_BUS_ADRALN, - SI_CODE_BUS_ADRERR, - SI_CODE_BUS_MCEERR_AO, - SI_CODE_BUS_MCEERR_AR, - SI_CODE_BUS_OBJERR, - SI_CODE_ILL_BADSTK, - SI_CODE_ILL_COPROC, - SI_CODE_ILL_ILLADR, - SI_CODE_ILL_ILLOPC, - SI_CODE_ILL_ILLOPN, - SI_CODE_ILL_ILLTRP, - SI_CODE_ILL_PRVOPC, - SI_CODE_ILL_PRVREG, - SI_CODE_SEGV_ACCERR, - SI_CODE_SEGV_BNDERR, - SI_CODE_SEGV_MAPERR, - SI_CODE_SEGV_PKUERR, - SI_CODE_SI_ASYNCIO, - SI_CODE_SI_KERNEL, - SI_CODE_SI_MESGQ, - SI_CODE_SI_QUEUE, - SI_CODE_SI_SIGIO, - SI_CODE_SI_TIMER, - SI_CODE_SI_TKILL, - SI_CODE_SI_USER, - SI_CODE_SYS_SECCOMP, - SI_CODE_UNKNOWN, -}; - -/// @brief A best effort attempt to translate si_codes into the enum crashtracker understands. -/// @param signum -/// @param si_code -/// @return The enum value of the si_code, given signum. UNKNOWN if unable to translate. -int translate_si_code_impl(int signum, int si_code) { - // TODO, handle ptrace events - - switch (si_code) { - case SI_USER: - return SI_CODE_SI_USER; -#ifdef SI_KERNEL - case SI_KERNEL: - return SI_CODE_SI_KERNEL; -#endif -#ifdef SI_TIMER - case SI_TIMER: - return SI_CODE_SI_TIMER; -#endif - case SI_QUEUE: - return SI_CODE_SI_QUEUE; - case SI_MESGQ: - return SI_CODE_SI_MESGQ; - case SI_ASYNCIO: - return SI_CODE_SI_ASYNCIO; -#ifdef SI_SIGIO - case SI_SIGIO: - return SI_CODE_SI_SIGIO; -#endif -#ifdef SI_TKILL - case SI_TKILL: - return SI_CODE_SI_TKILL; -#endif - } - - switch (signum) { - case SIGBUS: - switch (si_code) { - case BUS_ADRALN: - return SI_CODE_BUS_ADRALN; - case BUS_ADRERR: - return SI_CODE_BUS_ADRERR; -#ifdef BUS_MCEERR_AO - case BUS_MCEERR_AO: - return SI_CODE_BUS_MCEERR_AO; -#endif -#ifdef BUS_MCEERR_AR - case BUS_MCEERR_AR: - return SI_CODE_BUS_MCEERR_AR; -#endif - default: - return SI_CODE_UNKNOWN; - } - - case SIGSEGV: - switch (si_code) { - case SEGV_ACCERR: - return SI_CODE_SEGV_ACCERR; -#ifdef SEGV_BNDERR - case SEGV_BNDERR: - return SI_CODE_SEGV_BNDERR; -#endif - case SEGV_MAPERR: - return SI_CODE_SEGV_MAPERR; -#ifdef SEGV_PKUERR - case SEGV_PKUERR: - return SI_CODE_SEGV_PKUERR; -#endif - default: - return SI_CODE_UNKNOWN; - } - - default: - return SI_CODE_UNKNOWN; - } -} \ No newline at end of file diff --git a/libdd-crashtracker/src/crash_info/errors_intake.rs b/libdd-crashtracker/src/crash_info/errors_intake.rs index 55edc54800..f1152043cd 100644 --- a/libdd-crashtracker/src/crash_info/errors_intake.rs +++ b/libdd-crashtracker/src/crash_info/errors_intake.rs @@ -5,7 +5,7 @@ use alloc::borrow::Cow; use core::time::Duration; use std::time::SystemTime; -use crate::{OsInfo, SigInfo, Ucontext}; +use crate::{shared::tag_keys, OsInfo, SigInfo, Ucontext}; use super::{ telemetry::CrashPing, CrashInfo, Experimental, Metadata, ProcInfo, StackTrace, ThreadData, @@ -298,16 +298,16 @@ impl ExtractedMetadata { for tag in &metadata.tags { if let Some((key, value)) = tag.split_once(':') { match key { - "service" => result.service_name = value.to_string(), - "env" => result.env = Some(value.to_string()), - "version" | "service_version" => { + tag_keys::SERVICE => result.service_name = value.to_string(), + tag_keys::ENV => result.env = Some(value.to_string()), + tag_keys::VERSION | tag_keys::SERVICE_VERSION => { result.service_version = Some(value.to_string()) } - "language" => result.language_name = Some(value.to_string()), - "language_version" | "runtime_version" => { + tag_keys::LANGUAGE => result.language_name = Some(value.to_string()), + tag_keys::LANGUAGE_VERSION | tag_keys::RUNTIME_VERSION => { result.language_version = Some(value.to_string()) } - "library_version" | "profiler_version" => { + tag_keys::LIBRARY_VERSION | tag_keys::PROFILER_VERSION => { result.tracer_version = Some(value.to_string()) } _ => {} diff --git a/libdd-crashtracker/src/crash_info/sig_info.rs b/libdd-crashtracker/src/crash_info/sig_info.rs index 861de4ad39..199fd0efad 100644 --- a/libdd-crashtracker/src/crash_info/sig_info.rs +++ b/libdd-crashtracker/src/crash_info/sig_info.rs @@ -1,6 +1,5 @@ // Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use num_derive::{FromPrimitive, ToPrimitive}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -53,6 +52,45 @@ pub enum SignalNames { UNKNOWN, } +impl SignalNames { + pub(crate) fn from_name(name: &str) -> Self { + match name { + "SIGHUP" => Self::SIGHUP, + "SIGINT" => Self::SIGINT, + "SIGQUIT" => Self::SIGQUIT, + "SIGILL" => Self::SIGILL, + "SIGTRAP" => Self::SIGTRAP, + "SIGABRT" => Self::SIGABRT, + "SIGBUS" => Self::SIGBUS, + "SIGFPE" => Self::SIGFPE, + "SIGKILL" => Self::SIGKILL, + "SIGUSR1" => Self::SIGUSR1, + "SIGSEGV" => Self::SIGSEGV, + "SIGUSR2" => Self::SIGUSR2, + "SIGPIPE" => Self::SIGPIPE, + "SIGALRM" => Self::SIGALRM, + "SIGTERM" => Self::SIGTERM, + "SIGCHLD" => Self::SIGCHLD, + "SIGCONT" => Self::SIGCONT, + "SIGSTOP" => Self::SIGSTOP, + "SIGTSTP" => Self::SIGTSTP, + "SIGTTIN" => Self::SIGTTIN, + "SIGTTOU" => Self::SIGTTOU, + "SIGURG" => Self::SIGURG, + "SIGXCPU" => Self::SIGXCPU, + "SIGXFSZ" => Self::SIGXFSZ, + "SIGVTALRM" => Self::SIGVTALRM, + "SIGPROF" => Self::SIGPROF, + "SIGWINCH" => Self::SIGWINCH, + "SIGIO" => Self::SIGIO, + "SIGSYS" => Self::SIGSYS, + "SIGEMT" => Self::SIGEMT, + "SIGINFO" => Self::SIGINFO, + _ => Self::UNKNOWN, + } + } +} + #[cfg(unix)] pub use unix::*; @@ -62,81 +100,24 @@ mod unix { impl From for SignalNames { fn from(value: libc::c_int) -> Self { - match value { - libc::SIGHUP => SignalNames::SIGHUP, - libc::SIGINT => SignalNames::SIGINT, - libc::SIGQUIT => SignalNames::SIGQUIT, - libc::SIGILL => SignalNames::SIGILL, - libc::SIGTRAP => SignalNames::SIGTRAP, - libc::SIGABRT => SignalNames::SIGABRT, - libc::SIGBUS => SignalNames::SIGBUS, - libc::SIGFPE => SignalNames::SIGFPE, - libc::SIGKILL => SignalNames::SIGKILL, - libc::SIGUSR1 => SignalNames::SIGUSR1, - libc::SIGSEGV => SignalNames::SIGSEGV, - libc::SIGUSR2 => SignalNames::SIGUSR2, - libc::SIGPIPE => SignalNames::SIGPIPE, - libc::SIGALRM => SignalNames::SIGALRM, - libc::SIGTERM => SignalNames::SIGTERM, - libc::SIGCHLD => SignalNames::SIGCHLD, - libc::SIGCONT => SignalNames::SIGCONT, - libc::SIGSTOP => SignalNames::SIGSTOP, - libc::SIGTSTP => SignalNames::SIGTSTP, - libc::SIGTTIN => SignalNames::SIGTTIN, - libc::SIGTTOU => SignalNames::SIGTTOU, - libc::SIGURG => SignalNames::SIGURG, - libc::SIGXCPU => SignalNames::SIGXCPU, - libc::SIGXFSZ => SignalNames::SIGXFSZ, - libc::SIGVTALRM => SignalNames::SIGVTALRM, - libc::SIGPROF => SignalNames::SIGPROF, - libc::SIGWINCH => SignalNames::SIGWINCH, - libc::SIGIO => SignalNames::SIGIO, - libc::SIGSYS => SignalNames::SIGSYS, - #[cfg(not(any( - target_os = "android", - target_os = "emscripten", - target_os = "fuchsia", - target_os = "linux", - target_os = "redox", - target_os = "haiku" - )))] - libc::SIGEMT => SignalNames::SIGEMT, - #[cfg(not(any( - target_os = "android", - target_os = "emscripten", - target_os = "fuchsia", - target_os = "linux", - target_os = "redox", - target_os = "haiku", - target_os = "aix" - )))] - libc::SIGINFO => SignalNames::SIGINFO, - _ => SignalNames::UNKNOWN, - } + SignalNames::from_name(crate::shared::signal_names::rust_signal_name(value)) } } - extern "C" { - /// A bit of C code which can access the constants in . - /// See the file comment on emit_sicodes.c for full details. - fn translate_si_code_impl(signum: libc::c_int, si_code: libc::c_int) -> libc::c_int; - } - pub fn translate_si_code(signum: libc::c_int, si_code: libc::c_int) -> SiCodes { - use num_traits::FromPrimitive; - // SAFETY: this function has no safety requirements - let translated = unsafe { translate_si_code_impl(signum, si_code) }; - SiCodes::from_i32(translated).unwrap_or(SiCodes::UNKNOWN) + SiCodes::from_name(crate::shared::signal_names::rust_si_code_name( + signum, si_code, + )) } #[cfg(test)] #[cfg_attr(miri, ignore)] #[test] fn test_si_code() { - // standard values differ between oses, but it seems like segv match - // https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/siginfo.h - // https://github.com/apple/darwin-xnu/blob/main/bsd/sys/signal.h - assert_eq!(translate_si_code(libc::SIGSEGV, 2), SiCodes::SEGV_ACCERR); + assert_eq!( + translate_si_code(libc::SIGSEGV, crate::shared::signal_names::SEGV_ACCERR), + SiCodes::SEGV_ACCERR + ); // An invalid code should translate to UNKNOWN assert_eq!(translate_si_code(libc::SIGSEGV, 42), SiCodes::UNKNOWN); @@ -255,19 +236,24 @@ mod unix { } } -#[derive( - Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, FromPrimitive, ToPrimitive, -)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[allow(clippy::upper_case_acronyms, non_camel_case_types)] #[repr(C)] /// See https://man7.org/linux/man-pages/man2/sigaction.2.html -/// MUST REMAIN IN SYNC WITH THE ENUM IN emit_sigcodes.c pub enum SiCodes { BUS_ADRALN, BUS_ADRERR, BUS_MCEERR_AO, BUS_MCEERR_AR, BUS_OBJERR, + FPE_FLTDIV, + FPE_FLTINV, + FPE_FLTOVF, + FPE_FLTRES, + FPE_FLTSUB, + FPE_FLTUND, + FPE_INTDIV, + FPE_INTOVF, ILL_BADSTK, ILL_COPROC, ILL_ILLADR, @@ -292,6 +278,48 @@ pub enum SiCodes { UNKNOWN, } +impl SiCodes { + pub(crate) fn from_name(name: &str) -> Self { + match name { + "BUS_ADRALN" => Self::BUS_ADRALN, + "BUS_ADRERR" => Self::BUS_ADRERR, + "BUS_MCEERR_AO" => Self::BUS_MCEERR_AO, + "BUS_MCEERR_AR" => Self::BUS_MCEERR_AR, + "BUS_OBJERR" => Self::BUS_OBJERR, + "FPE_FLTDIV" => Self::FPE_FLTDIV, + "FPE_FLTINV" => Self::FPE_FLTINV, + "FPE_FLTOVF" => Self::FPE_FLTOVF, + "FPE_FLTRES" => Self::FPE_FLTRES, + "FPE_FLTSUB" => Self::FPE_FLTSUB, + "FPE_FLTUND" => Self::FPE_FLTUND, + "FPE_INTDIV" => Self::FPE_INTDIV, + "FPE_INTOVF" => Self::FPE_INTOVF, + "ILL_BADSTK" => Self::ILL_BADSTK, + "ILL_COPROC" => Self::ILL_COPROC, + "ILL_ILLADR" => Self::ILL_ILLADR, + "ILL_ILLOPC" => Self::ILL_ILLOPC, + "ILL_ILLOPN" => Self::ILL_ILLOPN, + "ILL_ILLTRP" => Self::ILL_ILLTRP, + "ILL_PRVOPC" => Self::ILL_PRVOPC, + "ILL_PRVREG" => Self::ILL_PRVREG, + "SEGV_ACCERR" => Self::SEGV_ACCERR, + "SEGV_BNDERR" => Self::SEGV_BNDERR, + "SEGV_MAPERR" => Self::SEGV_MAPERR, + "SEGV_PKUERR" => Self::SEGV_PKUERR, + "SI_ASYNCIO" => Self::SI_ASYNCIO, + "SI_KERNEL" => Self::SI_KERNEL, + "SI_MESGQ" => Self::SI_MESGQ, + "SI_QUEUE" => Self::SI_QUEUE, + "SI_SIGIO" => Self::SI_SIGIO, + "SI_TIMER" => Self::SI_TIMER, + "SI_TKILL" => Self::SI_TKILL, + "SI_USER" => Self::SI_USER, + "SYS_SECCOMP" => Self::SYS_SECCOMP, + _ => Self::UNKNOWN, + } + } +} + #[cfg(test)] impl SigInfo { pub fn test_instance(_seed: u64) -> Self { diff --git a/libdd-crashtracker/src/crash_info/telemetry.rs b/libdd-crashtracker/src/crash_info/telemetry.rs index e35aa46639..1042dbab1e 100644 --- a/libdd-crashtracker/src/crash_info/telemetry.rs +++ b/libdd-crashtracker/src/crash_info/telemetry.rs @@ -3,7 +3,7 @@ use core::fmt::Write; use std::time::SystemTime; -use crate::{ErrorKind, SigInfo}; +use crate::{shared::tag_keys, ErrorKind, SigInfo}; use super::{CrashInfo, Metadata, TARGET_TRIPLE}; use anyhow::Context; @@ -160,25 +160,6 @@ impl CrashPing { } } -macro_rules! parse_tags { - ( $tag_iterator:expr, - $($tag_name:literal => $var:ident),* $(,)?) => { - $( - let mut $var: Option<&str> = None; - )* - for tag in $tag_iterator { - let Some((name, value)) = tag.split_once(':') else { - continue; - }; - match name { - $($tag_name => {$var = Some(value);}, )* - _ => {}, - } - } - - }; -} - pub struct TelemetryCrashUploader { metadata: TelemetryMetadata, cfg: libdd_telemetry::config::Config, @@ -217,18 +198,32 @@ impl TelemetryCrashUploader { let _ = cfg.set_endpoint(telemetry_endpoint); } - parse_tags!( - crashtracker_metadata.tags.iter(), - "env" => env, - "language" => language_name, - "library_version" => library_version, - "profiler_version" => profiler_version, - "runtime_version" => language_version, - "runtime-id" => runtime_id, - "service_version" => service_version, - "service" => service_name, - "process_tags" => process_tags, - ); + let mut env: Option<&str> = None; + let mut language_name: Option<&str> = None; + let mut library_version: Option<&str> = None; + let mut profiler_version: Option<&str> = None; + let mut language_version: Option<&str> = None; + let mut runtime_id: Option<&str> = None; + let mut service_version: Option<&str> = None; + let mut service_name: Option<&str> = None; + let mut process_tags: Option<&str> = None; + for tag in &crashtracker_metadata.tags { + let Some((name, value)) = tag.split_once(':') else { + continue; + }; + match name { + tag_keys::ENV => env = Some(value), + tag_keys::LANGUAGE => language_name = Some(value), + tag_keys::LIBRARY_VERSION => library_version = Some(value), + tag_keys::PROFILER_VERSION => profiler_version = Some(value), + tag_keys::RUNTIME_VERSION => language_version = Some(value), + tag_keys::RUNTIME_ID_LEGACY | tag_keys::RUNTIME_ID => runtime_id = Some(value), + tag_keys::SERVICE_VERSION => service_version = Some(value), + tag_keys::SERVICE => service_name = Some(value), + tag_keys::PROCESS_TAGS => process_tags = Some(value), + _ => {} + } + } let application = Application { service_name: service_name.unwrap_or("unknown").to_owned(), diff --git a/libdd-crashtracker/src/lib.rs b/libdd-crashtracker/src/lib.rs index 71ae5a4fc7..39e86f20a7 100644 --- a/libdd-crashtracker/src/lib.rs +++ b/libdd-crashtracker/src/lib.rs @@ -28,6 +28,12 @@ //! and then exits. The signal handler must wait for the receiver in order to reap its exit //! status. //! +//! The default Unix collector remains `collector`. The opt-in `collector_signal_safe` collector +//! is a separate Unix-only implementation for consumers that need crash-path work to stay within a +//! signal-safe syscall subset. Both collectors use the shared `signal_owner` latch: the first +//! collector initialized owns process crash signals, and later attempts fail with an owner-conflict +//! error instead of partially installing handlers. +//! //! Data collected: //! 1. The data collected by the crash-handler includes: //! 1. The signal type leading to the crash @@ -40,40 +46,67 @@ //! 1. Metadata provided by the caller (e.g. library & profiler versions). //! 2. System info: OS version, /proc/cpuinfo /proc/meminfo, etc. //! 3. A timestamp and GUID for tracking the crash report. -//! -//! Handling of forks -//! Safety issues #![cfg_attr(not(test), deny(clippy::panic))] #![cfg_attr(not(test), deny(clippy::unwrap_used))] #![cfg_attr(not(test), deny(clippy::expect_used))] #![cfg_attr(not(test), deny(clippy::todo))] #![cfg_attr(not(test), deny(clippy::unimplemented))] +#![cfg_attr(not(feature = "std"), no_std)] extern crate alloc; +#[cfg(all(test, not(feature = "std")))] +extern crate std; + #[cfg(all(unix, feature = "collector"))] mod collector; -#[cfg(all(windows, feature = "collector_windows"))] +#[cfg(all(unix, feature = "collector_signal-safe"))] +pub mod collector_signal_safe; +#[cfg(all(windows, feature = "std", feature = "collector_windows"))] mod collector_windows; -#[cfg(unix)] +#[cfg(all(unix, feature = "std"))] mod common; +#[cfg(feature = "std")] mod crash_info; -#[cfg(all(unix, feature = "receiver"))] +#[cfg(all( + unix, + any( + feature = "collector", + feature = "receiver", + feature = "collector_signal-safe" + ) +))] +mod protocol; +#[cfg(all(unix, feature = "std", feature = "receiver"))] mod receiver; +#[cfg(feature = "std")] mod runtime_callback; +#[cfg(all(unix, any(feature = "collector", feature = "collector_signal-safe")))] +mod signal_owner; // Keep this module private to avoid exposing blazesym to users of the crate -#[cfg(all(unix, any(feature = "collector", feature = "receiver")))] +#[cfg(all( + unix, + any( + feature = "collector", + feature = "receiver", + feature = "collector_signal-safe" + ) +))] #[cfg(not(feature = "benchmarking"))] mod shared; // Make this module public when benchmarking is enabled to allow access to constants -#[cfg(all(unix, any(feature = "collector", feature = "receiver")))] +#[cfg(all( + unix, + feature = "std", + any(feature = "collector", feature = "receiver") +))] #[cfg(feature = "benchmarking")] pub mod shared; -#[cfg(all(unix, feature = "collector"))] +#[cfg(all(unix, feature = "std", feature = "collector"))] pub use collector::{ begin_op, clear_additional_tags, clear_spans, clear_traces, consume_and_emit_additional_tags, default_signals, disable, enable, end_op, get_expected_receiver_pid, init, @@ -82,26 +115,32 @@ pub use collector::{ set_expected_receiver_pid, update_config, update_metadata, OpTypes, DEFAULT_SYMBOLS, }; -#[cfg(all(windows, feature = "collector_windows"))] +#[cfg(all(windows, feature = "std", feature = "collector_windows"))] pub use collector_windows::api::{exception_event_callback, init_crashtracking_windows}; +#[cfg(feature = "std")] pub use crash_info::*; +#[cfg(feature = "std")] pub use runtime_callback::*; -#[cfg(all(unix, feature = "receiver"))] +#[cfg(all(unix, feature = "std", feature = "receiver"))] pub use receiver::{ async_receiver_entry_point_unix_listener, async_receiver_entry_point_unix_socket, get_receiver_unix_socket, receiver_entry_point_stdin, receiver_entry_point_unix_socket, }; -#[cfg(all(unix, any(feature = "collector", feature = "receiver")))] +#[cfg(all( + unix, + feature = "std", + any(feature = "collector", feature = "receiver") +))] pub use shared::configuration::{ default_max_threads, CrashtrackerConfiguration, CrashtrackerConfigurationBuilder, CrashtrackerReceiverConfig, StacktraceCollection, }; -#[cfg(all(unix, feature = "benchmarking"))] +#[cfg(all(unix, feature = "std", feature = "benchmarking"))] pub use receiver::benchmark; -#[cfg(unix)] +#[cfg(all(unix, feature = "std"))] pub use common::{get_tests_folder_path, SharedLibrary}; diff --git a/libdd-crashtracker/src/protocol.rs b/libdd-crashtracker/src/protocol.rs new file mode 100644 index 0000000000..e23373cc1a --- /dev/null +++ b/libdd-crashtracker/src/protocol.rs @@ -0,0 +1,80 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +// Some markers are only consumed by a subset of collector/receiver builds, so +// they can be unused depending on the enabled feature set. +#![allow(dead_code)] + +pub const DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS: &str = "DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS"; +pub const DD_CRASHTRACK_BEGIN_CONFIG: &str = "DD_CRASHTRACK_BEGIN_CONFIG"; +pub const DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE"; +pub const DD_CRASHTRACK_BEGIN_COUNTERS: &str = "DD_CRASHTRACK_BEGIN_COUNTERS"; +pub const DD_CRASHTRACK_BEGIN_FILE: &str = "DD_CRASHTRACK_BEGIN_FILE"; +pub const DD_CRASHTRACK_BEGIN_KIND: &str = "DD_CRASHTRACK_BEGIN_KIND"; +pub const DD_CRASHTRACK_BEGIN_METADATA: &str = "DD_CRASHTRACK_BEGIN_METADATA"; +pub const DD_CRASHTRACK_BEGIN_PROCINFO: &str = "DD_CRASHTRACK_BEGIN_PROCESSINFO"; +pub const DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME: &str = "DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME"; +pub const DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING: &str = + "DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING"; +pub const DD_CRASHTRACK_BEGIN_SIGINFO: &str = "DD_CRASHTRACK_BEGIN_SIGINFO"; +pub const DD_CRASHTRACK_BEGIN_SPAN_IDS: &str = "DD_CRASHTRACK_BEGIN_SPAN_IDS"; +pub const DD_CRASHTRACK_BEGIN_STACKTRACE: &str = "DD_CRASHTRACK_BEGIN_STACKTRACE"; +pub const DD_CRASHTRACK_BEGIN_TRACE_IDS: &str = "DD_CRASHTRACK_BEGIN_TRACE_IDS"; +pub const DD_CRASHTRACK_BEGIN_UCONTEXT: &str = "DD_CRASHTRACK_BEGIN_UCONTEXT"; +pub const DD_CRASHTRACK_BEGIN_MESSAGE: &str = "DD_CRASHTRACK_BEGIN_MESSAGE"; +pub const DD_CRASHTRACK_DONE: &str = "DD_CRASHTRACK_DONE"; +pub const DD_CRASHTRACK_END_ADDITIONAL_TAGS: &str = "DD_CRASHTRACK_END_ADDITIONAL_TAGS"; +pub const DD_CRASHTRACK_END_CONFIG: &str = "DD_CRASHTRACK_END_CONFIG"; +pub const DD_CRASHTRACK_END_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_END_WHOLE_STACKTRACE"; +pub const DD_CRASHTRACK_END_COUNTERS: &str = "DD_CRASHTRACK_END_COUNTERS"; +pub const DD_CRASHTRACK_END_FILE: &str = "DD_CRASHTRACK_END_FILE"; +pub const DD_CRASHTRACK_END_KIND: &str = "DD_CRASHTRACK_END_KIND"; +pub const DD_CRASHTRACK_END_METADATA: &str = "DD_CRASHTRACK_END_METADATA"; +pub const DD_CRASHTRACK_END_PROCINFO: &str = "DD_CRASHTRACK_END_PROCESSINFO"; +pub const DD_CRASHTRACK_END_RUNTIME_STACK_FRAME: &str = "DD_CRASHTRACK_END_RUNTIME_STACK_FRAME"; +pub const DD_CRASHTRACK_END_RUNTIME_STACK_STRING: &str = "DD_CRASHTRACK_END_RUNTIME_STACK_STRING"; +pub const DD_CRASHTRACK_END_SIGINFO: &str = "DD_CRASHTRACK_END_SIGINFO"; +pub const DD_CRASHTRACK_END_SPAN_IDS: &str = "DD_CRASHTRACK_END_SPAN_IDS"; +pub const DD_CRASHTRACK_END_STACKTRACE: &str = "DD_CRASHTRACK_END_STACKTRACE"; +pub const DD_CRASHTRACK_END_TRACE_IDS: &str = "DD_CRASHTRACK_END_TRACE_IDS"; +pub const DD_CRASHTRACK_END_UCONTEXT: &str = "DD_CRASHTRACK_END_UCONTEXT"; +pub const DD_CRASHTRACK_END_MESSAGE: &str = "DD_CRASHTRACK_END_MESSAGE"; + +pub trait ByteSink { + type Error; + + fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error>; +} + +#[cfg(feature = "std")] +impl ByteSink for W { + type Error = std::io::Error; + + fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error> { + self.write_all(bytes) + } +} + +pub fn marker_line(sink: &mut S, marker: &str) -> Result<(), E> +where + S: ByteSink + ?Sized, + E: From, +{ + sink.write_bytes(marker.as_bytes()).map_err(E::from)?; + sink.write_bytes(b"\n").map_err(E::from) +} + +pub fn section( + sink: &mut S, + begin: &str, + end: &str, + body: impl FnOnce(&mut S) -> Result<(), E>, +) -> Result<(), E> +where + S: ByteSink + ?Sized, + E: From, +{ + marker_line(sink, begin)?; + body(sink)?; + marker_line(sink, end) +} diff --git a/libdd-crashtracker/src/receiver/mod.rs b/libdd-crashtracker/src/receiver/mod.rs index 8b3f636726..83ea2d8dd2 100644 --- a/libdd-crashtracker/src/receiver/mod.rs +++ b/libdd-crashtracker/src/receiver/mod.rs @@ -106,4 +106,142 @@ mod tests { join_handle2.await??; Ok(()) } + + #[cfg(feature = "collector_signal-safe")] + #[tokio::test] + #[cfg_attr(miri, ignore)] + async fn signal_safe_emitted_report_round_trips_through_receiver_parser() -> anyhow::Result<()> + { + use crate::collector_signal_safe as signal_safe; + use signal_safe::capabilities::{Capabilities, Degradations}; + + let config = CrashtrackerConfiguration::builder() + .signals(default_signals()) + .timeout(Duration::from_secs(3)) + .build()?; + let config_json = serde_json::to_string(&config)?; + let signal = + signal_safe::SignalInfo::new(libc::SIGSEGV, signal_safe::SEGV_MAPERR, 0x1234, true); + let frames = [0x10usize, 0x20usize]; + let context = signal_safe::CrashContext { + signal, + pid: std::process::id() as i32, + tid: std::process::id() as i32, + frames: &frames, + }; + let report = signal_safe::Report { + config_json: &config_json, + library_name: "dd-test", + library_version: "1.2.3", + family: "native", + default_service: "default-service", + service: "svc", + env: "prod", + app_version: "v1", + runtime_id: "rid", + platform: "linux", + stackwalk_method: "fp_pvr", + capabilities: Capabilities::from_bits(0x21), + degradations: Degradations::from_bits(1 << 8), // DEGRADED_REPORT_TO_FD + }; + + let mut buf = [0u8; 8192]; + let mut sink = signal_safe::SliceSink::new(&mut buf); + assert!(signal_safe::emit_report(&mut sink, &report, &context)); + let emitted = sink.as_slice().to_vec(); + + let (mut sender, receiver) = tokio::net::UnixStream::pair()?; + let writer = tokio::spawn(async move { + sender.write_all(&emitted).await?; + sender.shutdown().await + }); + + let parsed = receive_report_from_stream(Duration::from_secs(2), BufReader::new(receiver)) + .await? + .expect("signal-safe report should parse"); + writer.await??; + + let (_config, crashinfo) = parsed; + assert_eq!(crashinfo.error.kind, ErrorKind::UnixSignal); + assert_eq!(crashinfo.metadata.library_name, "dd-test"); + assert_eq!(crashinfo.metadata.tags[4], "service:svc"); + + let sig_info = crashinfo.sig_info.expect("siginfo parsed"); + assert_eq!(sig_info.si_signo_human_readable, SignalNames::SIGSEGV); + assert_eq!(sig_info.si_code_human_readable, SiCodes::SEGV_MAPERR); + + let tags = crashinfo + .experimental + .expect("additional tags parsed") + .additional_tags; + assert!(tags.iter().any(|tag| tag == "stackwalk_method:fp_pvr")); + assert!(tags.iter().any(|tag| tag == "report_degraded:report_to_fd")); + + Ok(()) + } + + #[cfg(feature = "collector_signal-safe")] + #[test] + fn signal_safe_signal_names_stay_receiver_compatible() -> anyhow::Result<()> { + use crate::collector_signal_safe as signal_safe; + + let signals = [ + (libc::SIGSEGV, SignalNames::SIGSEGV), + (libc::SIGABRT, SignalNames::SIGABRT), + (libc::SIGBUS, SignalNames::SIGBUS), + (libc::SIGILL, SignalNames::SIGILL), + (libc::SIGFPE, SignalNames::SIGFPE), + ]; + for (signum, expected) in signals { + let siginfo = siginfo_from_signal_safe_names(signum, signal_safe::SI_USER)?; + assert_eq!(siginfo.si_signo_human_readable, expected); + assert_eq!(siginfo.si_code_human_readable, SiCodes::SI_USER); + } + + let sicodes = [ + ( + libc::SIGSEGV, + signal_safe::SEGV_MAPERR, + SiCodes::SEGV_MAPERR, + ), + ( + libc::SIGSEGV, + signal_safe::SEGV_ACCERR, + SiCodes::SEGV_ACCERR, + ), + (libc::SIGBUS, signal_safe::BUS_ADRALN, SiCodes::BUS_ADRALN), + (libc::SIGBUS, signal_safe::BUS_ADRERR, SiCodes::BUS_ADRERR), + (libc::SIGBUS, signal_safe::BUS_OBJERR, SiCodes::BUS_OBJERR), + (libc::SIGILL, signal_safe::ILL_ILLOPC, SiCodes::ILL_ILLOPC), + (libc::SIGILL, signal_safe::ILL_ILLOPN, SiCodes::ILL_ILLOPN), + (libc::SIGILL, signal_safe::ILL_ILLADR, SiCodes::ILL_ILLADR), + (libc::SIGILL, signal_safe::ILL_ILLTRP, SiCodes::ILL_ILLTRP), + (libc::SIGILL, signal_safe::ILL_PRVOPC, SiCodes::ILL_PRVOPC), + (libc::SIGILL, signal_safe::ILL_PRVREG, SiCodes::ILL_PRVREG), + (libc::SIGILL, signal_safe::ILL_COPROC, SiCodes::ILL_COPROC), + (libc::SIGILL, signal_safe::ILL_BADSTK, SiCodes::ILL_BADSTK), + (libc::SIGFPE, signal_safe::FPE_INTDIV, SiCodes::FPE_INTDIV), + ]; + for (signum, si_code, expected) in sicodes { + let siginfo = siginfo_from_signal_safe_names(signum, si_code)?; + assert_eq!(siginfo.si_code_human_readable, expected); + } + + let siginfo = siginfo_from_signal_safe_names(libc::SIGSEGV, 999)?; + assert_eq!(siginfo.si_code_human_readable, SiCodes::UNKNOWN); + Ok(()) + } + + #[cfg(feature = "collector_signal-safe")] + fn siginfo_from_signal_safe_names(signum: i32, si_code: i32) -> anyhow::Result { + use crate::collector_signal_safe as signal_safe; + + Ok(serde_json::from_value(serde_json::json!({ + "si_addr": null, + "si_code": si_code, + "si_code_human_readable": signal_safe::rust_si_code_name(signum, si_code), + "si_signo": signum, + "si_signo_human_readable": signal_safe::rust_signal_name(signum), + }))?) + } } diff --git a/libdd-crashtracker/src/shared/configuration/mod.rs b/libdd-crashtracker/src/shared/configuration/mod.rs index b5104cb626..c082f3699e 100644 --- a/libdd-crashtracker/src/shared/configuration/mod.rs +++ b/libdd-crashtracker/src/shared/configuration/mod.rs @@ -7,24 +7,7 @@ use core::time::Duration; use libdd_common::Endpoint; use serde::{Deserialize, Serialize}; -/// Stacktrace collection occurs in the context of a crashing process. -/// If the stack is sufficiently corruputed, it is possible (but unlikely), -/// for stack trace collection itself to crash. -/// We recommend fully enabling stacktrace collection, but having an environment -/// variable to allow downgrading the collector. -#[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum StacktraceCollection { - #[default] - Disabled, - WithoutSymbols, - /// This option uses `backtrace::resolve_frame_unsynchronized()` to gather symbol information - /// and also unwind inlined functions. Enabling this feature will not only provide symbolic - /// details, but may also yield additional or less stack frames compared to other - /// configurations. - EnabledWithInprocessSymbols, - EnabledWithSymbolsInReceiver, -} +pub use super::stacktrace_collection::StacktraceCollection; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CrashtrackerConfiguration { diff --git a/libdd-crashtracker/src/shared/constants.rs b/libdd-crashtracker/src/shared/constants.rs index 0883543fd7..5c2c9bf9e1 100644 --- a/libdd-crashtracker/src/shared/constants.rs +++ b/libdd-crashtracker/src/shared/constants.rs @@ -3,39 +3,9 @@ use core::time::Duration; -pub const DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS: &str = "DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS"; -pub const DD_CRASHTRACK_BEGIN_CONFIG: &str = "DD_CRASHTRACK_BEGIN_CONFIG"; -pub const DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE"; -pub const DD_CRASHTRACK_BEGIN_COUNTERS: &str = "DD_CRASHTRACK_BEGIN_COUNTERS"; -pub const DD_CRASHTRACK_BEGIN_FILE: &str = "DD_CRASHTRACK_BEGIN_FILE"; -pub const DD_CRASHTRACK_BEGIN_KIND: &str = "DD_CRASHTRACK_BEGIN_KIND"; -pub const DD_CRASHTRACK_BEGIN_METADATA: &str = "DD_CRASHTRACK_BEGIN_METADATA"; -pub const DD_CRASHTRACK_BEGIN_PROCINFO: &str = "DD_CRASHTRACK_BEGIN_PROCESSINFO"; -pub const DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME: &str = "DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME"; -pub const DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING: &str = - "DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING"; -pub const DD_CRASHTRACK_BEGIN_SIGINFO: &str = "DD_CRASHTRACK_BEGIN_SIGINFO"; -pub const DD_CRASHTRACK_BEGIN_SPAN_IDS: &str = "DD_CRASHTRACK_BEGIN_SPAN_IDS"; -pub const DD_CRASHTRACK_BEGIN_STACKTRACE: &str = "DD_CRASHTRACK_BEGIN_STACKTRACE"; -pub const DD_CRASHTRACK_BEGIN_TRACE_IDS: &str = "DD_CRASHTRACK_BEGIN_TRACE_IDS"; -pub const DD_CRASHTRACK_BEGIN_UCONTEXT: &str = "DD_CRASHTRACK_BEGIN_UCONTEXT"; -pub const DD_CRASHTRACK_BEGIN_MESSAGE: &str = "DD_CRASHTRACK_BEGIN_MESSAGE"; -pub const DD_CRASHTRACK_DONE: &str = "DD_CRASHTRACK_DONE"; -pub const DD_CRASHTRACK_END_ADDITIONAL_TAGS: &str = "DD_CRASHTRACK_END_ADDITIONAL_TAGS"; -pub const DD_CRASHTRACK_END_CONFIG: &str = "DD_CRASHTRACK_END_CONFIG"; -pub const DD_CRASHTRACK_END_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_END_WHOLE_STACKTRACE"; -pub const DD_CRASHTRACK_END_COUNTERS: &str = "DD_CRASHTRACK_END_COUNTERS"; -pub const DD_CRASHTRACK_END_FILE: &str = "DD_CRASHTRACK_END_FILE"; -pub const DD_CRASHTRACK_END_KIND: &str = "DD_CRASHTRACK_END_KIND"; -pub const DD_CRASHTRACK_END_METADATA: &str = "DD_CRASHTRACK_END_METADATA"; -pub const DD_CRASHTRACK_END_PROCINFO: &str = "DD_CRASHTRACK_END_PROCESSINFO"; -pub const DD_CRASHTRACK_END_RUNTIME_STACK_FRAME: &str = "DD_CRASHTRACK_END_RUNTIME_STACK_FRAME"; -pub const DD_CRASHTRACK_END_RUNTIME_STACK_STRING: &str = "DD_CRASHTRACK_END_RUNTIME_STACK_STRING"; -pub const DD_CRASHTRACK_END_SIGINFO: &str = "DD_CRASHTRACK_END_SIGINFO"; -pub const DD_CRASHTRACK_END_SPAN_IDS: &str = "DD_CRASHTRACK_END_SPAN_IDS"; -pub const DD_CRASHTRACK_END_STACKTRACE: &str = "DD_CRASHTRACK_END_STACKTRACE"; -pub const DD_CRASHTRACK_END_TRACE_IDS: &str = "DD_CRASHTRACK_END_TRACE_IDS"; -pub const DD_CRASHTRACK_END_UCONTEXT: &str = "DD_CRASHTRACK_END_UCONTEXT"; -pub const DD_CRASHTRACK_END_MESSAGE: &str = "DD_CRASHTRACK_END_MESSAGE"; +use super::defaults::DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS; -pub const DD_CRASHTRACK_DEFAULT_TIMEOUT: Duration = Duration::from_millis(5_000); +pub use crate::protocol::*; + +pub const DD_CRASHTRACK_DEFAULT_TIMEOUT: Duration = + Duration::from_secs(DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS as u64); diff --git a/libdd-crashtracker/src/shared/defaults.rs b/libdd-crashtracker/src/shared/defaults.rs new file mode 100644 index 0000000000..57d380ceed --- /dev/null +++ b/libdd-crashtracker/src/shared/defaults.rs @@ -0,0 +1,4 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +pub const DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS: u32 = 5; diff --git a/libdd-crashtracker/src/shared/mod.rs b/libdd-crashtracker/src/shared/mod.rs index 0628ecabf5..e2d0e10df7 100644 --- a/libdd-crashtracker/src/shared/mod.rs +++ b/libdd-crashtracker/src/shared/mod.rs @@ -3,10 +3,18 @@ //! This module holds constants/structures that are shared between the collector and receiver +pub(crate) mod defaults; +pub(crate) mod signal_names; +pub(crate) mod signals; +pub(crate) mod stacktrace_collection; +pub(crate) mod tag_keys; +pub(crate) mod ucontext; + +#[cfg(feature = "std")] pub(crate) mod configuration; -#[cfg(not(feature = "benchmarking"))] +#[cfg(all(feature = "std", not(feature = "benchmarking")))] pub(crate) mod constants; -#[cfg(feature = "benchmarking")] +#[cfg(all(feature = "std", feature = "benchmarking"))] pub mod constants; diff --git a/libdd-crashtracker/src/shared/signal_names.rs b/libdd-crashtracker/src/shared/signal_names.rs new file mode 100644 index 0000000000..56e9af2533 --- /dev/null +++ b/libdd-crashtracker/src/shared/signal_names.rs @@ -0,0 +1,297 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#[cfg_attr(not(feature = "collector_signal-safe"), allow(dead_code))] +pub fn rust_signal_name(signal: i32) -> &'static str { + match signal { + libc::SIGHUP => "SIGHUP", + libc::SIGINT => "SIGINT", + libc::SIGQUIT => "SIGQUIT", + libc::SIGILL => "SIGILL", + libc::SIGTRAP => "SIGTRAP", + libc::SIGABRT => "SIGABRT", + libc::SIGBUS => "SIGBUS", + libc::SIGFPE => "SIGFPE", + libc::SIGKILL => "SIGKILL", + libc::SIGUSR1 => "SIGUSR1", + libc::SIGSEGV => "SIGSEGV", + libc::SIGUSR2 => "SIGUSR2", + libc::SIGPIPE => "SIGPIPE", + libc::SIGALRM => "SIGALRM", + libc::SIGTERM => "SIGTERM", + libc::SIGCHLD => "SIGCHLD", + libc::SIGCONT => "SIGCONT", + libc::SIGSTOP => "SIGSTOP", + libc::SIGTSTP => "SIGTSTP", + libc::SIGTTIN => "SIGTTIN", + libc::SIGTTOU => "SIGTTOU", + libc::SIGURG => "SIGURG", + libc::SIGXCPU => "SIGXCPU", + libc::SIGXFSZ => "SIGXFSZ", + libc::SIGVTALRM => "SIGVTALRM", + libc::SIGPROF => "SIGPROF", + libc::SIGWINCH => "SIGWINCH", + libc::SIGIO => "SIGIO", + libc::SIGSYS => "SIGSYS", + #[cfg(not(any( + target_os = "android", + target_os = "emscripten", + target_os = "fuchsia", + target_os = "linux", + target_os = "redox", + target_os = "haiku" + )))] + libc::SIGEMT => "SIGEMT", + #[cfg(not(any( + target_os = "android", + target_os = "emscripten", + target_os = "fuchsia", + target_os = "linux", + target_os = "redox", + target_os = "haiku", + target_os = "aix" + )))] + libc::SIGINFO => "SIGINFO", + _ => "UNKNOWN", + } +} + +pub fn rust_si_code_name(signal: i32, si_code: i32) -> &'static str { + match si_code { + SI_USER => "SI_USER", + SI_KERNEL => "SI_KERNEL", + SI_QUEUE => "SI_QUEUE", + SI_TIMER => "SI_TIMER", + SI_MESGQ => "SI_MESGQ", + SI_ASYNCIO => "SI_ASYNCIO", + SI_SIGIO => "SI_SIGIO", + SI_TKILL => "SI_TKILL", + _ => signal_specific_si_code_name(signal, si_code), + } +} + +#[cfg_attr(not(feature = "collector_signal-safe"), allow(dead_code))] +pub fn signal_has_address(signal: i32) -> bool { + matches!( + signal, + libc::SIGBUS | libc::SIGFPE | libc::SIGILL | libc::SIGSEGV | libc::SIGTRAP + ) +} + +fn signal_specific_si_code_name(signal: i32, si_code: i32) -> &'static str { + match signal { + libc::SIGSEGV => match si_code { + SEGV_MAPERR => "SEGV_MAPERR", + SEGV_ACCERR => "SEGV_ACCERR", + SEGV_BNDERR => "SEGV_BNDERR", + SEGV_PKUERR => "SEGV_PKUERR", + _ => "UNKNOWN", + }, + libc::SIGBUS => match si_code { + BUS_ADRALN => "BUS_ADRALN", + BUS_ADRERR => "BUS_ADRERR", + BUS_OBJERR => "BUS_OBJERR", + BUS_MCEERR_AR => "BUS_MCEERR_AR", + BUS_MCEERR_AO => "BUS_MCEERR_AO", + _ => "UNKNOWN", + }, + libc::SIGILL => match si_code { + ILL_ILLOPC => "ILL_ILLOPC", + ILL_ILLOPN => "ILL_ILLOPN", + ILL_ILLADR => "ILL_ILLADR", + ILL_ILLTRP => "ILL_ILLTRP", + ILL_PRVOPC => "ILL_PRVOPC", + ILL_PRVREG => "ILL_PRVREG", + ILL_COPROC => "ILL_COPROC", + ILL_BADSTK => "ILL_BADSTK", + _ => "UNKNOWN", + }, + libc::SIGFPE => match si_code { + FPE_INTDIV => "FPE_INTDIV", + FPE_INTOVF => "FPE_INTOVF", + FPE_FLTDIV => "FPE_FLTDIV", + FPE_FLTOVF => "FPE_FLTOVF", + FPE_FLTUND => "FPE_FLTUND", + FPE_FLTRES => "FPE_FLTRES", + FPE_FLTINV => "FPE_FLTINV", + FPE_FLTSUB => "FPE_FLTSUB", + _ => "UNKNOWN", + }, + _ => "UNKNOWN", + } +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_USER: i32 = libc::SI_USER; +#[cfg(target_vendor = "apple")] +pub const SI_USER: i32 = 0x10001; +#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))] +pub const SI_USER: i32 = 0; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_KERNEL: i32 = libc::SI_KERNEL; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const SI_KERNEL: i32 = i32::MIN; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_QUEUE: i32 = libc::SI_QUEUE; +#[cfg(target_vendor = "apple")] +pub const SI_QUEUE: i32 = 0x10002; +#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))] +pub const SI_QUEUE: i32 = -1; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_TIMER: i32 = libc::SI_TIMER; +#[cfg(target_vendor = "apple")] +pub const SI_TIMER: i32 = 0x10003; +#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))] +pub const SI_TIMER: i32 = -2; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_MESGQ: i32 = libc::SI_MESGQ; +#[cfg(target_vendor = "apple")] +pub const SI_MESGQ: i32 = 0x10005; +#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))] +pub const SI_MESGQ: i32 = -3; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_ASYNCIO: i32 = libc::SI_ASYNCIO; +#[cfg(target_vendor = "apple")] +pub const SI_ASYNCIO: i32 = 0x10004; +#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))] +pub const SI_ASYNCIO: i32 = -4; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_SIGIO: i32 = libc::SI_SIGIO; +#[cfg(target_vendor = "apple")] +pub const SI_SIGIO: i32 = i32::MIN + 5; +#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))] +pub const SI_SIGIO: i32 = -5; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_TKILL: i32 = libc::SI_TKILL; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const SI_TKILL: i32 = i32::MIN + 6; + +pub const SEGV_MAPERR: i32 = 1; +pub const SEGV_ACCERR: i32 = 2; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SEGV_BNDERR: i32 = 3; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const SEGV_BNDERR: i32 = i32::MIN + 3; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SEGV_PKUERR: i32 = 4; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const SEGV_PKUERR: i32 = i32::MIN + 4; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const BUS_ADRALN: i32 = libc::BUS_ADRALN; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const BUS_ADRALN: i32 = 1; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const BUS_ADRERR: i32 = libc::BUS_ADRERR; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const BUS_ADRERR: i32 = 2; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const BUS_OBJERR: i32 = libc::BUS_OBJERR; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const BUS_OBJERR: i32 = 3; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const BUS_MCEERR_AR: i32 = libc::BUS_MCEERR_AR; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const BUS_MCEERR_AR: i32 = i32::MIN + 1; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const BUS_MCEERR_AO: i32 = libc::BUS_MCEERR_AO; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const BUS_MCEERR_AO: i32 = i32::MIN + 2; + +pub const ILL_ILLOPC: i32 = 1; + +#[cfg(target_vendor = "apple")] +pub const ILL_ILLTRP: i32 = 2; +#[cfg(not(target_vendor = "apple"))] +pub const ILL_ILLOPN: i32 = 2; + +#[cfg(target_vendor = "apple")] +pub const ILL_PRVOPC: i32 = 3; +#[cfg(not(target_vendor = "apple"))] +pub const ILL_ILLADR: i32 = 3; + +#[cfg(target_vendor = "apple")] +pub const ILL_ILLOPN: i32 = 4; +#[cfg(not(target_vendor = "apple"))] +pub const ILL_ILLTRP: i32 = 4; + +#[cfg(target_vendor = "apple")] +pub const ILL_ILLADR: i32 = 5; +#[cfg(not(target_vendor = "apple"))] +pub const ILL_PRVOPC: i32 = 5; + +pub const ILL_PRVREG: i32 = 6; +pub const ILL_COPROC: i32 = 7; +pub const ILL_BADSTK: i32 = 8; + +#[cfg(not(target_vendor = "apple"))] +pub const FPE_INTDIV: i32 = 1; +#[cfg(not(target_vendor = "apple"))] +pub const FPE_INTOVF: i32 = 2; +#[cfg(not(target_vendor = "apple"))] +pub const FPE_FLTDIV: i32 = 3; +#[cfg(not(target_vendor = "apple"))] +pub const FPE_FLTOVF: i32 = 4; +#[cfg(not(target_vendor = "apple"))] +pub const FPE_FLTUND: i32 = 5; +#[cfg(not(target_vendor = "apple"))] +pub const FPE_FLTRES: i32 = 6; +#[cfg(not(target_vendor = "apple"))] +pub const FPE_FLTINV: i32 = 7; +#[cfg(not(target_vendor = "apple"))] +pub const FPE_FLTSUB: i32 = 8; + +#[cfg(target_vendor = "apple")] +pub const FPE_FLTDIV: i32 = 1; +#[cfg(target_vendor = "apple")] +pub const FPE_FLTOVF: i32 = 2; +#[cfg(target_vendor = "apple")] +pub const FPE_FLTUND: i32 = 3; +#[cfg(target_vendor = "apple")] +pub const FPE_FLTRES: i32 = 4; +#[cfg(target_vendor = "apple")] +pub const FPE_FLTINV: i32 = 5; +#[cfg(target_vendor = "apple")] +pub const FPE_FLTSUB: i32 = 6; +#[cfg(target_vendor = "apple")] +pub const FPE_INTDIV: i32 = 7; +#[cfg(target_vendor = "apple")] +pub const FPE_INTOVF: i32 = 8; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signal_names_cover_common_native_faults() { + assert_eq!(rust_signal_name(libc::SIGSEGV), "SIGSEGV"); + assert_eq!(rust_signal_name(libc::SIGABRT), "SIGABRT"); + assert_eq!(rust_signal_name(libc::SIGBUS), "SIGBUS"); + assert_eq!(rust_signal_name(libc::SIGILL), "SIGILL"); + assert_eq!(rust_signal_name(libc::SIGFPE), "SIGFPE"); + assert_eq!(rust_signal_name(999), "UNKNOWN"); + } + + #[test] + fn si_code_names_cover_common_native_faults() { + assert_eq!(rust_si_code_name(libc::SIGSEGV, SEGV_MAPERR), "SEGV_MAPERR"); + assert_eq!(rust_si_code_name(libc::SIGBUS, BUS_ADRALN), "BUS_ADRALN"); + assert_eq!(rust_si_code_name(libc::SIGILL, ILL_ILLOPC), "ILL_ILLOPC"); + assert_eq!(rust_si_code_name(libc::SIGFPE, FPE_INTDIV), "FPE_INTDIV"); + assert_eq!(rust_si_code_name(libc::SIGSEGV, SI_USER), "SI_USER"); + assert_eq!(rust_si_code_name(libc::SIGSEGV, 999), "UNKNOWN"); + } +} diff --git a/libdd-crashtracker/src/shared/signals.rs b/libdd-crashtracker/src/shared/signals.rs new file mode 100644 index 0000000000..4198959da9 --- /dev/null +++ b/libdd-crashtracker/src/shared/signals.rs @@ -0,0 +1,30 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +/// Legacy collector defaults intentionally omit SIGFPE to preserve existing SDK behavior. +#[cfg_attr(not(feature = "std"), allow(dead_code))] +pub const LEGACY_DEFAULT_SIGNALS: [libc::c_int; 4] = + [libc::SIGBUS, libc::SIGABRT, libc::SIGSEGV, libc::SIGILL]; + +/// The signal-safe collector uses a fixed crash-signal set that includes SIGFPE. +#[cfg_attr(not(feature = "collector_signal-safe"), allow(dead_code))] +pub const SIGNAL_SAFE_CRASH_SIGNALS: [libc::c_int; 5] = [ + libc::SIGSEGV, + libc::SIGABRT, + libc::SIGBUS, + libc::SIGILL, + libc::SIGFPE, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signal_safe_crash_signals_include_legacy_defaults() { + for signal in LEGACY_DEFAULT_SIGNALS { + assert!(SIGNAL_SAFE_CRASH_SIGNALS.contains(&signal)); + } + assert!(SIGNAL_SAFE_CRASH_SIGNALS.contains(&libc::SIGFPE)); + } +} diff --git a/libdd-crashtracker/src/shared/stacktrace_collection.rs b/libdd-crashtracker/src/shared/stacktrace_collection.rs new file mode 100644 index 0000000000..3dd2542906 --- /dev/null +++ b/libdd-crashtracker/src/shared/stacktrace_collection.rs @@ -0,0 +1,18 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +/// Stacktrace collection occurs in the context of a crashing process. +/// If the stack is sufficiently corrupted, stacktrace collection itself may fail. +#[repr(C)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "std", derive(schemars::JsonSchema))] +pub enum StacktraceCollection { + #[default] + Disabled, + WithoutSymbols, + /// Resolve symbols in the crashing process. + EnabledWithInprocessSymbols, + EnabledWithSymbolsInReceiver, +} diff --git a/libdd-crashtracker/src/shared/tag_keys.rs b/libdd-crashtracker/src/shared/tag_keys.rs new file mode 100644 index 0000000000..0e8d2f4efc --- /dev/null +++ b/libdd-crashtracker/src/shared/tag_keys.rs @@ -0,0 +1,26 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#![allow(dead_code)] + +pub const LANGUAGE: &str = "language"; +pub const RUNTIME: &str = "runtime"; +pub const IS_CRASH: &str = "is_crash"; +pub const SEVERITY: &str = "severity"; +pub const SERVICE: &str = "service"; +pub const ENV: &str = "env"; +pub const VERSION: &str = "version"; +pub const SERVICE_VERSION: &str = "service_version"; +pub const RUNTIME_ID: &str = "runtime_id"; +pub const RUNTIME_ID_LEGACY: &str = "runtime-id"; +pub const RUNTIME_VERSION: &str = "runtime_version"; +pub const LANGUAGE_VERSION: &str = "language_version"; +pub const LIBRARY_VERSION: &str = "library_version"; +pub const PROFILER_VERSION: &str = "profiler_version"; +pub const PLATFORM: &str = "platform"; +pub const INJECTOR_VERSION: &str = "injector_version"; +pub const PROCESS_TAGS: &str = "process_tags"; +pub const STACKWALK_METHOD: &str = "stackwalk_method"; +pub const CAPABILITIES: &str = "capabilities"; +pub const DEGRADATIONS: &str = "degradations"; +pub const REPORT_DEGRADED: &str = "report_degraded"; diff --git a/libdd-crashtracker/src/shared/ucontext.rs b/libdd-crashtracker/src/shared/ucontext.rs new file mode 100644 index 0000000000..7766641dcd --- /dev/null +++ b/libdd-crashtracker/src/shared/ucontext.rs @@ -0,0 +1,80 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UcontextRegisters { + pub ip: usize, + pub sp: usize, + pub fp: usize, + pub link: usize, +} + +#[cfg(all( + any(target_os = "linux", target_os = "android"), + target_arch = "x86_64" +))] +pub fn ucontext_registers(uc: &libc::ucontext_t) -> Option { + Some(UcontextRegisters { + ip: uc.uc_mcontext.gregs[libc::REG_RIP as usize] as usize, + sp: uc.uc_mcontext.gregs[libc::REG_RSP as usize] as usize, + fp: uc.uc_mcontext.gregs[libc::REG_RBP as usize] as usize, + link: 0, + }) +} + +#[cfg(all( + any(target_os = "linux", target_os = "android"), + target_arch = "aarch64" +))] +pub fn ucontext_registers(uc: &libc::ucontext_t) -> Option { + Some(UcontextRegisters { + ip: uc.uc_mcontext.pc as usize, + sp: uc.uc_mcontext.sp as usize, + fp: uc.uc_mcontext.regs[29] as usize, + link: uc.uc_mcontext.regs[30] as usize, + }) +} + +#[cfg(all(target_vendor = "apple", target_arch = "x86_64"))] +pub fn ucontext_registers(uc: &libc::ucontext_t) -> Option { + let mcontext = uc.uc_mcontext; + if mcontext.is_null() { + return None; + } + let ss = unsafe { &(*mcontext).__ss }; + Some(UcontextRegisters { + ip: ss.__rip as usize, + sp: ss.__rsp as usize, + fp: ss.__rbp as usize, + link: 0, + }) +} + +#[cfg(all(target_vendor = "apple", target_arch = "aarch64"))] +pub fn ucontext_registers(uc: &libc::ucontext_t) -> Option { + let mcontext = uc.uc_mcontext; + if mcontext.is_null() { + return None; + } + let ss = unsafe { &(*mcontext).__ss }; + Some(UcontextRegisters { + ip: ss.__pc as usize, + sp: ss.__sp as usize, + fp: ss.__fp as usize, + link: ss.__lr as usize, + }) +} + +#[cfg(not(any( + all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") + ), + all( + target_vendor = "apple", + any(target_arch = "x86_64", target_arch = "aarch64") + ) +)))] +pub fn ucontext_registers(_uc: &libc::ucontext_t) -> Option { + None +} diff --git a/libdd-crashtracker/src/signal_owner.rs b/libdd-crashtracker/src/signal_owner.rs new file mode 100644 index 0000000000..a54019b78b --- /dev/null +++ b/libdd-crashtracker/src/signal_owner.rs @@ -0,0 +1,26 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::sync::atomic::{AtomicU8, Ordering}; + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum SignalOwner { + #[cfg(feature = "collector")] + StdCollector = 1, + #[cfg(feature = "collector_signal-safe")] + SignalSafeCollector = 2, +} + +static OWNER: AtomicU8 = AtomicU8::new(0); + +pub(crate) fn acquire(owner: SignalOwner) -> bool { + let owner = owner as u8; + OWNER + .compare_exchange(0, owner, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + || OWNER.load(Ordering::Acquire) == owner +} + +pub(crate) fn release(owner: SignalOwner) { + let _ = OWNER.compare_exchange(owner as u8, 0, Ordering::AcqRel, Ordering::Acquire); +} diff --git a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs new file mode 100644 index 0000000000..8a499c141b --- /dev/null +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -0,0 +1,340 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(unix, feature = "collector_signal-safe"))] + +use std::fs; +use std::os::fd::AsRawFd; +#[cfg(any(target_os = "linux", target_os = "android"))] +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +use libdd_crashtracker::collector_signal_safe::{ + bootstrap_complete, init, owned_signal_count, owns_signal, InitConfig, InitResult, +}; + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn signal_safe_receiver_child_process() { + if std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER_CHILD").is_none() { + return; + } + let receiver = std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER").expect("receiver"); + + assert_eq!( + init(&InitConfig { + receiver_path: receiver.as_encoded_bytes(), + service: b"signal-safe-e2e", + env: b"test", + app_version: b"1", + runtime_id: b"00000000-0000-0000-0000-000000000001", + ..InitConfig::default() + }), + InitResult::Enabled + ); + bootstrap_complete(); + + std::process::abort(); +} + +#[test] +fn signal_safe_report_fd_child_process() { + let Some(report) = std::env::var_os("DD_SIGNAL_SAFE_E2E_REPORT_FD_CHILD") else { + return; + }; + + let report = fs::File::create(report).expect("create report"); + assert_eq!( + init(&InitConfig { + receiver_path: b"/definitely/missing-signal-safe-receiver", + service: b"signal-safe-e2e", + env: b"test", + app_version: b"1", + runtime_id: b"00000000-0000-0000-0000-000000000001", + report_fd: report.as_raw_fd(), + ..InitConfig::default() + }), + InitResult::Enabled + ); + bootstrap_complete(); + + std::process::abort(); +} + +#[test] +fn signal_safe_bootstrap_only_child_process() { + let Some(report) = std::env::var_os("DD_SIGNAL_SAFE_E2E_BOOTSTRAP_ONLY_CHILD") else { + return; + }; + + let _report = init_report_fd(report, b"/definitely/missing-signal-safe-receiver", true); + bootstrap_complete(); + std::process::abort(); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn signal_safe_receiver_deleted_child_process() { + let Some(report) = std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER_DELETED_CHILD") else { + return; + }; + let receiver = std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER").expect("receiver"); + + let _report = init_report_fd(report, receiver.as_encoded_bytes(), false); + bootstrap_complete(); + fs::remove_file(receiver).expect("remove receiver"); + std::process::abort(); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn signal_safe_receiver_signaled_child_process() { + let Some(report) = std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER_SIGNALED_CHILD") else { + return; + }; + let receiver = std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER").expect("receiver"); + + let _report = init_report_fd(report, receiver.as_encoded_bytes(), false); + bootstrap_complete(); + std::process::abort(); +} + +#[test] +fn signal_safe_preexisting_app_handler_child_process() { + let Some(report) = std::env::var_os("DD_SIGNAL_SAFE_E2E_APP_HANDLER_CHILD") else { + return; + }; + + install_noop_handler(libc::SIGSEGV); + let _report = init_report_fd(report, b"/definitely/missing-signal-safe-receiver", false); + assert!(!owns_signal(libc::SIGSEGV)); + assert!(owns_signal(libc::SIGABRT)); + assert!(owned_signal_count() < 5); + bootstrap_complete(); + std::process::abort(); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn signal_safe_crash_writes_report_through_receiver() { + let temp = tempfile::tempdir().expect("tempdir"); + let receiver = temp.path().join("receiver.sh"); + let report = temp.path().join("report.txt"); + + fs::write( + &receiver, + b"#!/bin/sh\ncat > \"$DD_SIGNAL_SAFE_E2E_REPORT\"\n", + ) + .expect("write receiver"); + let mut perms = fs::metadata(&receiver).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&receiver, perms).expect("chmod receiver"); + + let current_exe = std::env::current_exe().expect("current_exe"); + let status = Command::new(current_exe) + .arg("--exact") + .arg("signal_safe_receiver_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_RECEIVER_CHILD", "1") + .env("DD_SIGNAL_SAFE_E2E_REPORT", &report) + .env("DD_SIGNAL_SAFE_E2E_RECEIVER", &receiver) + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + + let report = fs::read_to_string(&report).expect("read crash report"); + assert_common_report_shape(&report); + assert!(report.contains("\"si_signo_human_readable\":\"SIGABRT\"")); + let stacktrace_frames = stacktrace_frame_count(&report); + #[cfg(target_arch = "aarch64")] + assert!( + stacktrace_frames >= 3, + "expected a multi-frame receiver stacktrace, got {stacktrace_frames}\n{report}" + ); + #[cfg(not(target_arch = "aarch64"))] + assert!( + stacktrace_frames >= 1, + "expected at least the seed frame, got {stacktrace_frames}\n{report}" + ); +} + +#[test] +fn signal_safe_crash_writes_report_to_fd_when_degraded() { + let temp = tempfile::tempdir().expect("tempdir"); + let report = temp.path().join("report.txt"); + + let current_exe = std::env::current_exe().expect("current_exe"); + let status = Command::new(current_exe) + .arg("--exact") + .arg("signal_safe_report_fd_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_REPORT_FD_CHILD", &report) + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + + let report = fs::read_to_string(&report).expect("read crash report"); + assert_common_report_shape(&report); + assert!(report.contains("\"si_signo_human_readable\":\"SIGABRT\"")); + assert!(report.contains("\"report_degraded:missing_receiver\"")); + assert!(report.contains("\"report_degraded:report_to_fd\"")); +} + +#[test] +fn signal_safe_bootstrap_only_shutdown_suppresses_later_report() { + let temp = tempfile::tempdir().expect("tempdir"); + let report = temp.path().join("report.txt"); + + let current_exe = std::env::current_exe().expect("current_exe"); + let status = Command::new(current_exe) + .arg("--exact") + .arg("signal_safe_bootstrap_only_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_BOOTSTRAP_ONLY_CHILD", &report) + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + let contents = fs::read_to_string(&report).unwrap_or_default(); + assert!(contents.is_empty(), "bootstrap-only mode should not emit"); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn signal_safe_receiver_deleted_after_init_falls_back_to_report_fd() { + let temp = tempfile::tempdir().expect("tempdir"); + let receiver = temp.path().join("receiver.sh"); + let report = temp.path().join("report.txt"); + + fs::write(&receiver, b"#!/bin/sh\ncat >/dev/null\n").expect("write receiver"); + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let mut perms = fs::metadata(&receiver).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&receiver, perms).expect("chmod receiver"); + } + + let current_exe = std::env::current_exe().expect("current_exe"); + let status = Command::new(current_exe) + .arg("--exact") + .arg("signal_safe_receiver_deleted_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_RECEIVER_DELETED_CHILD", &report) + .env("DD_SIGNAL_SAFE_E2E_RECEIVER", &receiver) + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + let report = fs::read_to_string(&report).expect("read crash report"); + assert_common_report_shape(&report); + assert!(report.contains("\"report_degraded:receiver_unavailable\"")); + assert!(report.contains("\"report_degraded:report_to_fd\"")); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn signal_safe_receiver_death_by_signal_falls_back_to_report_fd() { + let temp = tempfile::tempdir().expect("tempdir"); + let receiver = temp.path().join("receiver.sh"); + let report = temp.path().join("report.txt"); + + fs::write(&receiver, b"#!/bin/sh\nkill -9 $$\n").expect("write receiver"); + let mut perms = fs::metadata(&receiver).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&receiver, perms).expect("chmod receiver"); + + let current_exe = std::env::current_exe().expect("current_exe"); + let status = Command::new(current_exe) + .arg("--exact") + .arg("signal_safe_receiver_signaled_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_RECEIVER_SIGNALED_CHILD", &report) + .env("DD_SIGNAL_SAFE_E2E_RECEIVER", &receiver) + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + let report = fs::read_to_string(&report).expect("read crash report"); + assert_common_report_shape(&report); + assert!(report.contains("\"report_degraded:receiver_unavailable\"")); + assert!(report.contains("\"report_degraded:report_to_fd\"")); +} + +#[test] +fn signal_safe_preexisting_app_handler_is_reported_without_internal_state_setup() { + let temp = tempfile::tempdir().expect("tempdir"); + let report = temp.path().join("report.txt"); + + let current_exe = std::env::current_exe().expect("current_exe"); + let status = Command::new(current_exe) + .arg("--exact") + .arg("signal_safe_preexisting_app_handler_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_APP_HANDLER_CHILD", &report) + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + let report = fs::read_to_string(&report).expect("read crash report"); + assert_common_report_shape(&report); + assert!(report.contains("\"report_degraded:app_handler_present\"")); + assert!(report.contains(&format!( + "\"report_degraded:app_handler_present:{}\"", + libc::SIGSEGV + ))); +} + +fn init_report_fd( + report_path: impl AsRef, + receiver_path: &[u8], + only_bootstrap: bool, +) -> fs::File { + let report = fs::File::create(report_path).expect("create report"); + assert_eq!( + init(&InitConfig { + receiver_path, + service: b"signal-safe-e2e", + env: b"test", + app_version: b"1", + runtime_id: b"00000000-0000-0000-0000-000000000001", + report_fd: report.as_raw_fd(), + only_bootstrap, + ..InitConfig::default() + }), + InitResult::Enabled + ); + report +} + +extern "C" fn noop_handler(_: libc::c_int) {} + +fn install_noop_handler(signal: libc::c_int) { + let mut action: libc::sigaction = unsafe { core::mem::zeroed() }; + action.sa_sigaction = noop_handler as *const () as usize; + action.sa_flags = 0; + unsafe { + libc::sigemptyset(&mut action.sa_mask); + assert_eq!(libc::sigaction(signal, &action, core::ptr::null_mut()), 0); + } +} + +fn assert_common_report_shape(report: &str) { + assert!(report.contains("DD_CRASHTRACK_BEGIN_CONFIG\n")); + assert!(report.contains("DD_CRASHTRACK_BEGIN_METADATA\n")); + assert!(report.contains("\"service:signal-safe-e2e\"")); + assert!(report.contains("DD_CRASHTRACK_BEGIN_SIGINFO\n")); + assert!(report.contains("DD_CRASHTRACK_BEGIN_PROCESSINFO\n")); + assert!(report.contains("DD_CRASHTRACK_BEGIN_STACKTRACE\n")); + assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn stacktrace_frame_count(report: &str) -> usize { + report + .split_once("DD_CRASHTRACK_BEGIN_STACKTRACE\n") + .and_then(|(_, rest)| rest.split_once("DD_CRASHTRACK_END_STACKTRACE\n")) + .map(|(stacktrace, _)| stacktrace.lines().filter(|line| !line.is_empty()).count()) + .unwrap_or(0) +} diff --git a/libdd-crashtracker/tests/fixtures/signal_safe_report.golden b/libdd-crashtracker/tests/fixtures/signal_safe_report.golden new file mode 100644 index 0000000000..d1b76730e2 --- /dev/null +++ b/libdd-crashtracker/tests/fixtures/signal_safe_report.golden @@ -0,0 +1,26 @@ +DD_CRASHTRACK_BEGIN_CONFIG +{"resolve_frames":"Disabled"} +DD_CRASHTRACK_END_CONFIG +DD_CRASHTRACK_BEGIN_METADATA +{"library_name":"dd-test","library_version":"1.2.3","family":"native","tags":["language:native","runtime:native","is_crash:true","severity:crash","service:svc","env:prod","version:v1","runtime_id:rid","runtime_version:1.2.3","library_version:1.2.3","platform:linux","injector_version:1.2.3"]} +DD_CRASHTRACK_END_METADATA +DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS +["stackwalk_method:fp_pvr","capabilities:0x00000021","degradations:0x00000100","report_degraded:report_to_fd"] +DD_CRASHTRACK_END_ADDITIONAL_TAGS +DD_CRASHTRACK_BEGIN_KIND +"UnixSignal" +DD_CRASHTRACK_END_KIND +DD_CRASHTRACK_BEGIN_SIGINFO +{"si_signo":11,"si_code":1,"si_signo_human_readable":"SIGSEGV","si_code_human_readable":"SEGV_MAPERR","si_addr":"0x0000000000001234"} +DD_CRASHTRACK_END_SIGINFO +DD_CRASHTRACK_BEGIN_PROCESSINFO +{"pid":123,"tid":456} +DD_CRASHTRACK_END_PROCESSINFO +DD_CRASHTRACK_BEGIN_STACKTRACE +{"ip":"0x0000000000000010"} +{"ip":"0x0000000000000020"} +DD_CRASHTRACK_END_STACKTRACE +DD_CRASHTRACK_BEGIN_MESSAGE +Crash (SIGSEGV) +DD_CRASHTRACK_END_MESSAGE +DD_CRASHTRACK_DONE diff --git a/scripts/semver-level.sh b/scripts/semver-level.sh index abfd530616..1fbfc21eac 100755 --- a/scripts/semver-level.sh +++ b/scripts/semver-level.sh @@ -171,7 +171,7 @@ compute_semver_results() { elif [[ "$semver_level" == "major" ]]; then log_verbose "Skipping cargo-public-api: cargo-semver-checks already at major" else - PUBLIC_API_OUTPUT=$(cargo public-api --package "$crate" --color=never diff "$baseline..$current" 2>&1) + PUBLIC_API_OUTPUT=$(cargo public-api --package "$crate" --color=never --all-features diff "$baseline..$current" 2>&1) EXIT_CODE=$? if [[ $EXIT_CODE -ne 0 ]]; then diff --git a/tools/check_signal_safe_symbols.sh b/tools/check_signal_safe_symbols.sh new file mode 100755 index 0000000000..6c142235e9 --- /dev/null +++ b/tools/check_signal_safe_symbols.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +# This scans whole rlibs, not a call graph rooted at the signal handler. Init-time code is held to +# the same symbol bar deliberately; reviewed exceptions belong in this script, not in source attrs. +target_dir="${CARGO_TARGET_DIR:-target/signal-safe-guard}" +CARGO_TARGET_DIR="${target_dir}" cargo build -p libdd-crashtracker --no-default-features --features collector_signal-safe --lib + +artifacts=() +while IFS= read -r artifact; do + artifacts+=("${artifact}") +done < <(find "${target_dir}/debug/deps" -maxdepth 1 \( \ + -name 'liblibdd_crashtracker*.rlib' -o \ + -name 'librustix*.rlib' -o \ + -name 'libheapless*.rlib' -o \ + -name 'libserde_json_core*.rlib' \ +\) -print) + +if [[ "${#artifacts[@]}" -eq 0 ]]; then + echo "signal-safe rlib artifacts not found" >&2 + exit 1 +fi + +banned='(^|[^[:alnum:]_])(malloc|calloc|realloc|free|posix_memalign|mmap|pthread_mutex_lock|pthread_mutex_unlock|pthread_cond_[[:alnum:]_]+|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|syslog|abort|__libc_[[:alnum:]_]+)([^[:alnum:]_]|$)' + +for artifact in "${artifacts[@]}"; do + if nm -u "${artifact}" 2>/dev/null | grep -E "${banned}"; then + echo "signal-safe crash-path artifact references banned symbols in ${artifact}" >&2 + exit 1 + fi +done diff --git a/tools/src/lib.rs b/tools/src/lib.rs index e07b143cf4..b7c6f56053 100644 --- a/tools/src/lib.rs +++ b/tools/src/lib.rs @@ -111,8 +111,43 @@ pub mod headers { new_content_parts } + fn definition_key(def: &str) -> String { + let mut body = def.trim_start(); + if body.starts_with("/**") { + if let Some(end) = body.find("*/") { + body = body[end + 2..].trim_start(); + } + } + + if let Some(rest) = body.strip_prefix('#') { + let mut words = rest.split_whitespace(); + if matches!(words.next(), Some("define")) { + if let Some(name) = words.next() { + return format!("#define {name}"); + } + } + } + + if body.starts_with("typedef") { + let stem = body.trim_end().trim_end_matches(';').trim_end(); + let name: String = stem + .chars() + .rev() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect::() + .chars() + .rev() + .collect(); + if !name.is_empty() { + return format!("typedef {name}"); + } + } + + def.to_owned() + } + pub fn dedup_headers(base: &str, headers: &[&str]) { - let mut unique_child_defs: Vec = Vec::new(); + let mut unique_child_defs: Vec<(String, String)> = Vec::new(); let mut present = HashSet::new(); for child_def in headers.iter().flat_map(|p| { @@ -129,11 +164,12 @@ pub mod headers { .map(|m| m.str.to_owned()) .collect::>() }) { - if present.contains(&child_def) { + let key = definition_key(&child_def); + if present.contains(&key) { continue; } - unique_child_defs.push(child_def.clone()); - present.insert(child_def); + unique_child_defs.push((key.clone(), child_def)); + present.insert(key); } let base_header = OpenOptions::new() @@ -144,11 +180,11 @@ pub mod headers { let base_header_content = read(&mut BufReader::new(&base_header)); let base_defs = collect_definitions(&base_header_content); - let base_defs_set: HashSet<_> = base_defs.iter().map(|s| s.str).collect(); + let base_defs_set: HashSet<_> = base_defs.iter().map(|s| definition_key(s.str)).collect(); let mut base_new_parts = vec![&base_header_content[..base_defs.last().unwrap().end]]; - for child_def in &unique_child_defs { - if base_defs_set.contains(child_def.as_str()) { + for (key, child_def) in &unique_child_defs { + if base_defs_set.contains(key) { continue; } base_new_parts.push(child_def); @@ -255,6 +291,33 @@ typedef union my_union { test_regex_match(input, expected); } + #[test] + fn definition_key_deduplicates_equivalent_typedef_names() { + let base = r"/** + * Holds the raw parts of a Rust Vec; it should only be created from Rust, + * never from C. + */ +typedef struct ddog_Vec_Tag { + const struct ddog_Tag *ptr; + uintptr_t len; + uintptr_t capacity; +} ddog_Vec_Tag; +"; + let child = r"/** + * Holds the raw parts of a Rust Vec; it should only be created from Rust, + * never from C. + */ +typedef struct ddog_Vec_Tag { + const ddog_Tag *ptr; + uintptr_t len; + uintptr_t capacity; +} ddog_Vec_Tag; +"; + + assert_eq!(definition_key(base), "typedef ddog_Vec_Tag"); + assert_eq!(definition_key(child), "typedef ddog_Vec_Tag"); + } + #[test] fn collect_multiple_definitions() { let input = r"