From 4813dfea4c60d26bf4138bee61d00d6e3a34c369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Sat, 4 Jul 2026 01:29:57 +0200 Subject: [PATCH 01/32] docs(crashtracker): document remaining parity work --- crashtracker-work-we-need-to-do.md | 481 +++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 crashtracker-work-we-need-to-do.md diff --git a/crashtracker-work-we-need-to-do.md b/crashtracker-work-we-need-to-do.md new file mode 100644 index 0000000000..60bf239317 --- /dev/null +++ b/crashtracker-work-we-need-to-do.md @@ -0,0 +1,481 @@ +# Crashtracker Work We Need To Do + +## Scope + +This compares current libdatadog `libdd-crashtracker` against the crashtracking implementation in `/Users/pawel.chojnacki/work/dd-trace-c/crashtracker`. + +Sources inspected: + +- dd-trace-c branch `crashtracker` at `608537b3`. +- dd-trace-c commit `114f4a08`, which ports the in-process collector from `libdd_autoinstrument/crashtracker_linux.c` to `crashtracker_native`. +- dd-trace-c original C crashtracker from `origin/main:libdd_autoinstrument/crashtracker_linux.c`. +- libdatadog current `origin/main` at `d7b2aad37`. + +Important framing: dd-trace-c already uses libdatadog for the receiver sidecar (`crashtracker_rust` is a tiny `libdd_crashtracker::receiver_entry_point_stdin()` wrapper). Most of the missing work is the in-process/preload-grade collector, signal policy, initialization, and packaging behavior. + +## What libdatadog already has + +Do not rebuild these unless needed to support the missing in-process work: + +- Receiver-side stdin protocol parsing and crash upload. +- Telemetry and errors-intake payload generation. +- Configurable receiver process and Unix socket receiver modes. +- Panic hook and unhandled-exception reporting. +- Linux crashing-thread unwinding with bundled `libdd-libunwind-sys`. +- Linux all-thread collection in the receiver via `/proc//task`, `PTRACE_SEIZE`, `PTRACE_INTERRUPT`, and remote libunwind. +- `PR_SET_PTRACER` scoping and sidecar peer PID verification for all-thread collection. +- macOS crashing-thread frame-pointer collection. +- Existing crash protocol sections for counters, spans, traces, additional tags, `/proc/self/maps`, ucontext, runtime stack callbacks, and whole-stack unhandled exceptions. +- Receiver compatibility with dd-trace-c's `PROCESSINFO` wire section. This needs golden coverage, not new parser work. +- Errno preservation in the signal handler. + +## Main gap + +libdatadog has a feature-rich crashtracker, but its current Unix collector is not yet equivalent to dd-trace-c's preload-grade implementation. + +The dd-trace-c implementation assumes the signal handler and forked children are constrained environments. After a `fork`/raw `clone` from a signal handler in a potentially multi-threaded process, the child must be treated as async-signal-safe until `execve` or `_exit`. For the collector child there is no `execve`, so the entire collector child path must stay async-signal-safe. For the receiver child, every setup step before `execve` must stay async-signal-safe. + +Current libdatadog still has `std`, `nix`, `UnixStream`, `File`, `serde_json`, `Box`-backed globals, formatting, and RAII/drop patterns reachable from the signal-handler/fork-child path. Some individual syscalls underneath are safe, but the Rust wrappers and destructors are not enough of a contract for the dd-trace-c preload use case. + +## P0 work + +### 1. Add a signal-safe in-process collector surface + +dd-trace-c has `crashtracker_native`, a `no_std`, no-allocator staticlib linked into `libdd_autoinstrument.so`. It uses fixed-capacity buffers, `heapless`, `serde-json-core`, raw syscalls or `rustix` linux-raw calls, `panic = abort`, and a panic handler that exits immediately. + +libdatadog needs an equivalent path or crate for Unix preload use: + +- No allocation in the signal handler. +- No allocation in the forked collector child. +- No allocation in the receiver child before `execve`. +- No mutexes, stdio, lazy init, `pthread_once`, or logging paths that take locks. +- No `Drop`-bearing values live across calls to app signal handlers, because those handlers may recover with `siglongjmp`. +- No `std::io::Write` trait objects or `serde_json` in the crash path. +- No panics or unwinding through signal-handler or fork-child frames. +- Raw syscall wrappers for `clone`/`fork`, `_exit`, `write`, `close`, `dup2`, `fcntl`, `pipe`, `poll`, `waitpid`, `kill`, `getpid`, `gettid`, `clock_gettime`, and stack-memory probes. + +Concrete libdatadog areas to audit or replace: + +- `libdd-crashtracker/src/collector/crash_handler.rs` +- `libdd-crashtracker/src/collector/collector_manager.rs` +- `libdd-crashtracker/src/collector/receiver_manager.rs` +- `libdd-crashtracker/src/collector/emitters.rs` +- `libdd-common/src/unix_utils/fork.rs` +- `libdd-common/src/unix_utils/process.rs` +- `libdd-common/src/unix_utils/execve.rs` + +Concrete current violations to remove first: + +- `eprintln!` is reachable from signal or fork-child paths and is not async-signal-safe: `signal_handler_manager.rs` in `chain_signal_handler`, `collector_manager.rs` in `run_collector_child`, and `process_handle.rs` in `finish`. Replace with a fixed-buffer raw `write(2)` path or remove from crash paths. +- `libdd-common/src/unix_utils/fork.rs::is_being_traced()` uses `std::fs::File`, `Read`, UTF-8 parsing, and `Drop`-closed fds from `alt_fork()`, which is reachable from `Collector::spawn` and `Receiver::spawn_from_config` on the signal path. The underlying syscalls are not the problem; the std/RAII surface is. +- `collector/api.rs::mark_preload_logger_collector()` is a best-effort preload test hook using `dlsym` from the signal path on Linux. The preload design needs to remove it from the hot path, make it signal-safe, or explicitly scope it away from production crash handling. + +### 2. Treat forked children as async-signal-safe + +This is not optional. The dd-trace-c implementation deliberately treats both children as constrained: + +- Receiver child: reset crash handlers, preserve/relocate the report fd if it is `0`, `1`, or `2`, redirect stdio to `/dev/null`, strip loader env, then `execv`. +- Collector child: reset crash handlers, preserve/relocate the write fd, redirect stdio, collect stack frames, emit the protocol stream, close the fd, then `_exit`. +- Parent: close both pipe ends, wait with bounded budgets, kill only after timeout, then reap. + +Work needed in libdatadog: + +- Remove `UnixStream::from_raw_fd`, `File`, `OwnedFd`, `PreparedExecve::exec()` wrappers, and destructor-dependent cleanup from the signal/fork-child path. +- Add explicit fd relocation before stdio redirection. +- Add child stdio redirection to `/dev/null` without clobbering the crash pipe/socket. +- Reset managed crash signals to `SIG_DFL` in both children so a crash in the child does not recurse into crashtracker. +- Fix the current concrete child gaps: + - `collector_manager.rs::run_collector_child()` closes `0`, `1`, and `2` before writing to `uds_fd`; if `uds_fd` is one of those fds, the crash socket is destroyed. + - `receiver_manager.rs::run_receiver_child()` uses naive `dup2` setup without collision handling for low-numbered report fds. + - `collector_manager.rs::run_collector_child()` does not reset the managed crash signals to `SIG_DFL`; a collector fault while unwinding can re-enter the inherited crash handler. +- Use `_exit`, not Rust process exit or panic paths. +- Replace busy wait/reap behavior with the dd-trace-c bounded wait loop: collector around 500 ms, receiver timeout plus grace, then `SIGKILL` and reap. +- Treat libdatadog's current `ProcessHandle::finish()` semantics as different, not automatically broken: it waits for `POLLHUP`, then `SIGKILL`s and reaps. Matching dd-trace-c's wait-for-exit and kill-only-after-timeout behavior is a robustness/parity upgrade. + +### 3. Add sigaction/signal virtualization + +dd-trace-c installs its handler only on crash signals still set to `SIG_DFL`. It then PLT-interposes libc `sigaction` and `signal` so late app/runtime registrations cannot displace the crashtracker handler. The wrappers record the app's requested disposition in per-signal atomics and answer `oldact` from that virtual state. + +libdatadog currently installs handlers and stores the previous handlers, but it does not keep itself on top if an app registers a handler later. That misses dd-trace-c's core preload behavior. + +Work needed: + +- Add a way to interpose or otherwise mediate `sigaction` and `signal` for owned crash signals. +- Track per-signal state: + - handler pointer (`SIG_DFL`, `SIG_IGN`, or function pointer) + - `SA_SIGINFO` flags + - whether the app has set a handler + - original handler displaced at install + - whether crashtracker owns the kernel handler for this signal +- Virtualize `oldact` for app calls. +- Ensure crashtracker's own sigaction calls bypass the wrapper and hit the real libc function. +- Keep wrappers transparent when crashtracker is disabled or does not own the signal. +- Preserve dd-trace-c limitations explicitly: raw `rt_sigaction` syscalls and later `dlopen` PLT slots are not covered. + +### 4. Port Mode A and Mode B crash policy + +dd-trace-c has two on-crash policies: + +- Mode A, default: managed-runtime-safe. If the app installed a real handler, run it first. If it recovers, do not report. If it restores `SIG_DFL`, report and terminate. +- Mode B, `DD_CRASHTRACKING_ALWAYS_ON_TOP=true`: report first, then chain to the app/runtime handler. + +This matters for HotSpot, V8/Node, .NET, sanitizers, Python faulthandler, and other runtimes that use `SIGSEGV` non-fatally for null checks, safepoints, write barriers, or recovery. + +Work needed: + +- Add policy state and configuration. +- Land this with, or after, `sigaction`/`signal` virtualization. Mode A needs the virtualized effective app disposition to know whether there is a real app handler to call. +- Make the app-first call safe with respect to `siglongjmp`. This is the return-twice hazard: the app handler may jump through crashtracker frames, so no `Drop`-bearing guard or cleanup-required state can be live across the app-first call. +- Account for `SA_NODEFER`, guard splitting, and errno preservation as explicit invariants. +- Do not count a recovered runtime signal as the one crash for the process. +- Add a re-entry guard for crashes inside the app handler. +- Preserve errno across the app-first path and final chain path. +- Add tests for: + - app handler gives up by restoring `SIG_DFL` + - app handler recovers by `siglongjmp` + - Mode B reports before recovery + - registration through `sigaction` + - registration through `signal` + +### 5. Match dd-trace-c chaining and genuine-fault decisions + +dd-trace-c does not report every delivered configured signal. It reports a genuine fault when siginfo is present and either: + +- `si_code` is not `SI_USER` or `SI_TKILL`, or +- the async signal was sent by the process itself. + +External `kill/tgkill` should not become crash telemetry by default. + +After reporting, dd-trace-c chains carefully: + +- `SIG_DFL` plus kernel-raised synchronous fault (`si_code > 0`): restore default and return, letting the faulting instruction re-execute so the kernel terminates with the original address and `si_code`. +- `SIG_DFL` plus async signal: restore default and `raise(sig)`. +- `SIG_IGN`: resume. +- function handler: invoke with the correct `SA_SIGINFO` calling convention. + +Work needed: + +- Add the genuine-fault filter. +- Fix default-disposition chaining to re-fault on synchronous faults rather than always calling `raise`. +- Preserve the original core-dump signal context. +- Keep current errno-preservation behavior. +- Add tests for external async signal, self-sent async signal, default sync re-fault, ignored disposition, and function handler chain. + +### 6. Add whole-process lifetime and teardown semantics + +dd-trace-c keeps crashtracking armed for the whole process by default. `crashtracker_uninstall()` runs at the end of bootstrap, but unless `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true`, it only changes the stage to `application`. The destructor calls `crashtracker_shutdown()` and force-restores handlers so unloaded code is not left installed as a signal handler. + +Current libdatadog has `enable()`/`disable()`, but `disable()` does not restore old handlers and there is no equivalent stage-aware uninstall/shutdown contract. + +Without interposition, a disabled libdatadog handler can only chain to the handler displaced at install time. It cannot see a handler registered later by the application. That caveat belongs with the lifecycle work and reinforces the need for `sigaction`/`signal` virtualization. + +Work needed: + +- Add explicit init, bootstrap-end, and shutdown APIs for preload users. +- Default to whole-process lifetime. +- Support bootstrap-only lifetime. +- Restore effective app handlers on forced shutdown. +- Leave interposition transparent after teardown, or provide safe hook removal if available. +- Ensure no handler can dangle into unloaded code. + +### 7. Add stage tracking and crash stage tags + +dd-trace-c tracks crash stages in an atomic `sig_atomic_t`: + +- `uninitialized` +- `crashtracker_init` +- `platform_init` +- `language_init` +- `plugin_loading` +- `injection_metadata_send` +- `http_client_send` +- `application` +- `crashtracker_uninstall` + +The crash report includes: + +- message: `Crash during ()` +- additional tag: `stage:` + +Work needed: + +- Add a libdatadog-owned or integration-owned stage API. +- Make stage reads signal-safe and race-free. +- Add scoped stage guards for normal code paths. +- Include the stage in the emitted message and additional tags. +- Add tests that crashes during init, HTTP send, and application runtime are labeled correctly. + +### 8. Add dd-trace-c env-driven preload configuration + +dd-trace-c crashtracking can initialize from env without a language tracer constructing a `CrashtrackerConfiguration`. + +Environment inputs used by the implementation: + +- `DD_CRASHTRACKING_ENABLED=false` disables install. +- `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` enables Mode B. +- `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true` enables bootstrap-only lifetime. +- `DD_TRACE_LOG_LEVEL=debug` enables signal-safe debug breadcrumbs. +- `DD_TRACE_C_CRASHTRACKER_PROCESS` overrides the receiver path. +- `DD_SERVICE`, `DD_ENV`, `DD_VERSION`, `DD_INJECT_SENDER_TYPE` feed metadata. +- runtime id is snapshotted from dd-trace-c process metadata during init. +- `DD_TRACE_AGENT_URL` is intentionally resolved by the receiver through libdatadog config, not parsed in the in-process collector. + +The fixed collector config emitted by dd-trace-c is: + +- `additional_files: []` +- `create_alt_stack: false` +- `use_alt_stack: false` +- `demangle_names: true` +- `endpoint: null` +- `resolve_frames: EnabledWithSymbolsInReceiver` +- signals: `SIGSEGV`, `SIGABRT`, `SIGBUS`, `SIGILL`, `SIGFPE` +- timeout: 5 seconds +- `unix_socket_path: null` + +Work needed: + +- Add a preload-oriented config builder or C ABI that caches this state before arming the handler. +- Snapshot env-derived strings during init; do not call `getenv` from the signal path. +- Keep the config JSON stable and test it as a wire contract. +- Decide how to preserve libdatadog's general rule against hidden env reads for normal library callers while still supporting preload bootstrap. + +### 9. Add receiver path discovery and loader-env scrubbing + +dd-trace-c receiver path lookup order: + +1. `DD_TRACE_C_CRASHTRACKER_PROCESS` +2. sibling of the loaded `libdd_autoinstrument.so`, preferring an architecture-suffixed receiver name such as `process-crash-receiver-linux-amd64` +3. sibling plain `process-crash-receiver` +4. baked default install path + +The Rust port keeps a small C glue file because stable Rust cannot express weak `dladdr`/`dl_iterate_phdr` linkage the same way. It also canonicalizes when possible and checks executability. + +Before `execv`, dd-trace-c strips `LD_PRELOAD` and `LD_AUDIT` from `environ` so the receiver does not re-run the preload constructor and recurse. If libdatadog uses an explicit `execve` environment list instead of inheriting `environ`, the equivalent fix is to construct the receiver environment without loader-injection variables while still preserving Datadog config needed by the receiver, such as agent URL/API-key settings. + +Work needed: + +- Add receiver path discovery usable by preload integrations. +- Support non-UTF-8 paths or explicitly document a UTF-8-only limitation. +- Add weak `dladdr`/`dl_iterate_phdr` C glue or another glibc-old-safe resolver. +- Ensure the receiver process does not inherit `LD_PRELOAD` or `LD_AUDIT`: strip them in the child if inheriting `environ`, or exclude them while constructing the explicit receiver env list. +- Add tests for explicit override, sibling suffixed receiver, sibling plain receiver, baked default, and preload recursion prevention. + +### 10. Align metadata tags with dd-trace-c/dd-trace-py + +dd-trace-c emits metadata with: + +- `library_name: dd-trace-c` +- `library_version: ` +- `family: native` +- tags: + - `language:native` + - `runtime:native` + - `is_crash:true` + - `severity:crash` + - `service:` + - `env:` when set + - `version:` when set + - `runtime_id:` + - `runtime_version:` + - `library_version:` + - `platform:` + - `injector_version:` + +Current libdatadog accepts caller-provided metadata; it does not provide this dd-trace-c preload metadata builder. + +Work needed: + +- Add a helper to build native preload metadata with this exact tag set. +- Snapshot runtime id before crash handling. +- Derive the default service name without doing signal-path work. +- Test telemetry/log tags and crash-report metadata against dd-trace-c expectations. + +### 11. Make Linux unwinding optional and add a signal-safe fallback + +Current libdatadog has Linux local libunwind in the forked collector and remote libunwind in the receiver. dd-trace-c's current `crashtracker_native` does not use libunwind in the collector; it walks frame pointers from `ucontext` and probes frame records with `process_vm_readv` on itself. The old C implementation used a `sigsetjmp`/`siglongjmp` recovery handler around raw frame-pointer reads; the Rust port replaced that with `process_vm_readv` so corrupt frame pointers return `EFAULT` instead of crashing the collector. + +This is not just dependency cleanup. Current libdatadog's collector child walks the crashing thread stack with local libunwind. A corrupt frame can fault in the collector child; combined with the current lack of child crash-signal reset, that can re-enter the inherited crash handler. The frame-pointer plus no-fault memory-read path is a crash-safety requirement for preload mode. + +Work needed: + +- Feature-gate or config-gate local libunwind in the collector. +- Provide a frame-pointer fallback for x86_64 and aarch64. +- Use `process_vm_readv` probing, or another explicit no-fault memory-read strategy, for frame records. +- Treat errno-returning `process_vm_readv` failures, such as `EPERM`, as graceful stacktrace loss. Document that `SECCOMP_RET_KILL` is different: it kills the collector clone and may lose the report entirely. +- Consider a later preflight probe for `process_vm_readv`, for example a sacrificial child at init that calls `process_vm_readv` on itself and records whether the syscall returns normally, returns `EPERM`, or is killed by seccomp. This is useful follow-up work, not required for the first signal-safe collector cut. +- Keep remote libunwind for receiver all-thread collection separate from local collector unwinding. +- Decide whether `EnabledWithInprocessSymbols` is allowed in the signal/fork-child path for preload use. It likely should not be the default there. +- Add tests for null ucontext, corrupt frame pointer, seccomp-denied memory probe if practical, and fallback without libunwind. + +### 12. Align signal set and si_code wire behavior + +dd-trace-c manages `SIGSEGV`, `SIGABRT`, `SIGBUS`, `SIGILL`, and `SIGFPE`. Current libdatadog defaults are `SIGBUS`, `SIGABRT`, `SIGSEGV`, and `SIGILL`; `SIGFPE` is not included by default. + +dd-trace-c deliberately maps `SIGFPE` `FPE_*` si_codes to `UNKNOWN` because libdatadog's `SiCodes` enum has no `FPE_*` variants and the receiver deserializes `si_code_human_readable`. + +Work needed: + +- Decide whether libdatadog default signals should include `SIGFPE`. +- If `SIGFPE` is enabled, keep `FPE_*` as `UNKNOWN` until `SiCodes` and receiver deserialization support those variants. +- Add tests for `SIGFPE` reporting and receiver parsing. +- Keep emitted signal and si_code strings compatible with receiver enum names. + +### 13. Add signal-safe debug logging + +dd-trace-c has a debug path that formats into a fixed stack buffer and calls `dd_trace_log_write_signal` exactly once. It is gated by a cached `DD_TRACE_LOG_LEVEL` read from init. + +Work needed: + +- Add a fixed-buffer signal-safe debug writer for the crash path. +- Do not call normal logger paths from signal context. +- Keep normal init/teardown logs separate from crash-path logs. +- Add debug breadcrumbs for install, app-first, genuine-fault decision, collector spawn, duplicate collection, and chain action. + +## P1 work + +### 14. Make the wire emitter deterministic and allocation-free for preload + +dd-trace-c's preload collector emits a minimal stream: + +1. config +2. metadata +3. additional tags +4. kind +5. siginfo +6. procinfo +7. stacktrace +8. message +9. done + +Current libdatadog's full emitter emits more sections and uses std formatting/serialization. The receiver can parse flexible order, but preload should have a small deterministic emitter that cannot allocate. + +Work needed: + +- Add a preload/minimal emitter, or make current emitters swappable by crash mode. +- Use fixed scratch buffers for each JSON section. +- Avoid protocol injection in message and tags. +- Keep section names compatible. The receiver already accepts dd-trace-c's `PROCESSINFO` spelling; add a golden round-trip test instead of treating this as parser work. +- Decide how to preserve crash-ping enrichment if metadata is emitted before message. + +### 15. Preserve and test fd/socket protocol choices + +dd-trace-c uses `pipe()` and execs the receiver with the read end on stdin. libdatadog's general collector uses `socketpair()` and wraps it in `UnixStream`. + +Work needed: + +- Decide whether preload mode should use dd-trace-c's pipe+stdin model for simplicity and safety. +- If socketpair remains, implement a raw-fd sink and raw poll/reap path without `UnixStream`. +- Ensure EOF signaling and receiver completion are deterministic. +- Add tests for closed stdio descriptors, low-numbered pipe/socket fds, `EINTR` write retries, short writes, and receiver EOF. + +### 16. Packaging and build integration + +dd-trace-c builds and ships: + +- `libdd_autoinstrument.so` with the in-process crashtracker staticlib linked in. +- `process-crash-receiver` as a musl/self-contained receiver sidecar. +- Size guard for receiver sidecar, currently 5 MiB in dd-trace-c. +- Build-time `DD_TRACE_C_VERSION` and install path injection. +- `crashtracker_glue.c` for weak self `.so` path resolution and log gate helpers. + +Work needed: + +- Decide whether the signal-safe collector lives inside `libdd-crashtracker`, a new crate, or an integration crate consumed by dd-trace-c. +- Add builder/release artifact support if it becomes a libdatadog-owned artifact. +- Add musl receiver build knobs and self-contained libunwind/libc link handling. +- Add receiver size checks if libdatadog owns the sidecar packaging. +- Keep old-glibc load behavior intact; avoid hard `libdl` symbols. + +### 17. API boundaries and ownership + +There are two plausible designs: + +- Put the generic signal-safe collector in libdatadog and let integrations provide metadata, stage, receiver-path, and hook-engine adapters. +- Put only reusable primitives in libdatadog and keep dd-trace-c's preload-specific policy in dd-trace-c. + +Either way, libdatadog needs a clearer split between: + +- general crashtracker API for language runtimes +- preload/constructor API +- receiver-only API +- signal-safe low-level primitives +- std/async receiver and upload code + +Work needed: + +- Define which API reads env vars and which never does. +- Define which API is allowed to install global signal handlers. +- Define which API is allowed to interpose `sigaction`/`signal`. +- Define teardown ownership for signal handlers and hook tables. +- Document all async-signal-safety contracts on public callbacks. + +## P2 work + +### 18. Backfill integration tests from dd-trace-c + +Port or mirror these dd-trace-c `test/agentapi/crashtracker_preload_test.go` scenarios: + +- genuine crash during injector init uploads a crash report +- crash in dd-trace-c's own HTTP client is labeled `http_client_send` +- `DD_CRASHTRACKING_ENABLED=false` skips install +- stuck receiver is killed and reaped +- app handler gives up, crashtracker reports +- app handler recovers, crashtracker does not report in Mode A +- `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` reports before recovery +- app uses `signal()` instead of `sigaction()` +- whole-process default reports application crash +- `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true` does not report application crash + +Add new libdatadog-specific tests for: + +- late handler registration after crashtracker install +- raw `rt_sigaction` limitation documented and tested if practical +- receiver exec environment excludes `LD_PRELOAD` and `LD_AUDIT` +- receiver path beside `.so`, including suffixed artifacts +- closed stdio fd preservation +- `SIGFPE` and `UNKNOWN` si_code parsing +- external async signal ignored +- self-sent async signal reported +- default sync re-fault preserves crash signal context +- golden preload wire round-trip through the receiver, including `PROCESSINFO` +- forked collector has no allocator/logging/std calls on the hot path, including no `eprintln!`, no `std::fs::File`, and no `dlsym` + +### 19. Keep existing libdatadog strengths + +While adding dd-trace-c parity, avoid regressing current libdatadog functionality: + +- all-thread collection +- unhandled exception reporting +- runtime callback stacks +- counters, spans, traces, and additional files +- Windows collector behavior +- macOS collector behavior +- receiver telemetry debug logs +- errors-intake output + +Preload mode can be smaller and stricter than general mode, but the two should share receiver data models where practical. + +## Suggested PR split + +1. Document the signal-safety model and split the collector path into `std` and signal-safe modules. +2. Add raw syscall/fd/sink primitives with tests. +3. Add allocation-free minimal emitter and golden wire tests. +4. Add frame-pointer/process_vm_readv fallback and libunwind feature/config split. +5. Add receiver child env/fd sanitation and bounded reap semantics. +6. Add stage and metadata builders. +7. Add sigaction/signal virtualization and Mode A/Mode B. Do not implement Mode A before the virtualized effective-handler state exists. +8. Add preload env init and receiver path discovery. +9. Port dd-trace-c integration tests. +10. Decide packaging ownership and wire into builder/release artifacts. + +## Quick risk ranking + +Highest risk missing pieces: + +1. Forked collector child not being treated as async-signal-safe. +2. Linux local unwinding in the collector can re-fault on a corrupt crashing stack and lacks the dd-trace-c frame-pointer/process_vm_readv fallback. +3. No late `sigaction`/`signal` virtualization. +4. No Mode A app-first policy for managed-runtime recovery. +5. Default-disposition chaining via `raise` instead of re-fault for synchronous kernel faults. +6. Receiver exec environment can recurse under `LD_PRELOAD`/`LD_AUDIT` unless the receiver env is sanitized or explicitly built without loader vars. +7. Preload metadata/stage/report shape is not available as a libdatadog helper. From 84eb217150728f818d5471aeb94eee1f837122aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Sat, 4 Jul 2026 02:38:54 +0200 Subject: [PATCH 02/32] feat(crashtracker): add signal-safe collector runtime --- Cargo.lock | 32 + LICENSE-3rdparty.csv | 3 + libdd-crashtracker-ffi/Cargo.toml | 15 +- libdd-crashtracker-ffi/cbindgen.toml | 5 +- .../src/collector_signal_safe.rs | 98 +++ libdd-crashtracker-ffi/src/lib.rs | 8 + libdd-crashtracker/Cargo.toml | 99 ++- .../src/collector_signal_safe/backtrace.rs | 159 ++++ .../src/collector_signal_safe/config.rs | 289 +++++++ .../src/collector_signal_safe/handler.rs | 553 +++++++++++++ .../src/collector_signal_safe/mod.rs | 768 ++++++++++++++++++ .../src/collector_signal_safe/state.rs | 105 +++ .../src/collector_signal_safe/sys.rs | 595 ++++++++++++++ libdd-crashtracker/src/lib.rs | 54 +- .../tests/collector_signal_safe_e2e.rs | 69 ++ 15 files changed, 2801 insertions(+), 51 deletions(-) create mode 100644 libdd-crashtracker-ffi/src/collector_signal_safe.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/backtrace.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/config.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/handler.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/mod.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/state.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/sys.rs create mode 100644 libdd-crashtracker/tests/collector_signal_safe_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index 7b6e9fbe57..5e3d086e67 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" @@ -2941,6 +2961,7 @@ dependencies = [ "cxx-build", "errno", "goblin", + "heapless", "http", "libc", "libdd-common", @@ -2955,6 +2976,7 @@ dependencies = [ "rand 0.8.5", "schemars", "serde", + "serde-json-core", "serde_json", "symbolic-common", "symbolic-demangle", @@ -5179,6 +5201,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" diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 0e794bc021..5718aef37b 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 @@ -375,6 +377,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/libdd-crashtracker-ffi/Cargo.toml b/libdd-crashtracker-ffi/Cargo.toml index 47d07a28e1..5a5b0f76c8 100644 --- a/libdd-crashtracker-ffi/Cargo.toml +++ b/libdd-crashtracker-ffi/Cargo.toml @@ -20,15 +20,18 @@ 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"] +# Enables the signal-safe in-process collection of crash-info +collector_signal-safe = ["libdd-crashtracker/collector_signal-safe"] +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 +41,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/cbindgen.toml b/libdd-crashtracker-ffi/cbindgen.toml index 773064854a..acc187154b 100644 --- a/libdd-crashtracker-ffi/cbindgen.toml +++ b/libdd-crashtracker-ffi/cbindgen.toml @@ -71,4 +71,7 @@ must_use = "DDOG_CHECK_RETURN" [parse] parse_deps = true include = ["libdd-common", "libdd-common-ffi", "libdd-crashtracker", "ux"] -exclude = ["libdd_crashtracker::crash_info::cxx"] +exclude = [ + "libdd_crashtracker::collector_signal_safe", + "libdd_crashtracker::crash_info::cxx", +] diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs new file mode 100644 index 0000000000..0fe23df23b --- /dev/null +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -0,0 +1,98 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::{c_char, CStr}; + +use libdd_crashtracker::collector_signal_safe::{ + bootstrap_complete, init, init_from_env, set_stage, shutdown, SignalSafeInitConfig, Stage, +}; + +#[repr(C)] +pub struct SignalSafeConfig { + pub receiver_path: *const c_char, + pub service: *const c_char, + pub env: *const c_char, + pub app_version: *const c_char, + pub runtime_id: *const c_char, + pub platform: *const c_char, + pub force_on_top: bool, + pub only_bootstrap: bool, + pub debug_logging: bool, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub enum SignalSafeStage { + Uninitialized = 0, + CrashtrackerInit = 1, + PlatformInit = 2, + LanguageInit = 3, + PluginLoading = 4, + InjectionMetadataSend = 5, + HttpClientSend = 6, + Application = 7, + CrashtrackerUninstall = 8, +} + +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> bool { + init_from_env() +} + +/// Initialize the signal-safe crashtracker with explicitly provided metadata. +/// +/// # Safety +/// `config` must be either null or point to a valid `SignalSafeConfig`. Any non-null C string +/// pointer inside `config` must point to a valid NUL-terminated string for the duration of this +/// call. The strings are copied before the function returns. +#[no_mangle] +pub unsafe extern "C" fn ddog_crasht_signal_safe_init(config: *const SignalSafeConfig) -> bool { + let Some(config) = config.as_ref() else { + return false; + }; + + init(&SignalSafeInitConfig { + receiver_path: cstr_bytes(config.receiver_path), + service: cstr_bytes(config.service), + env: cstr_bytes(config.env), + app_version: cstr_bytes(config.app_version), + runtime_id: cstr_bytes(config.runtime_id), + platform: cstr_bytes(config.platform), + force_on_top: config.force_on_top, + only_bootstrap: config.only_bootstrap, + debug_logging: config.debug_logging, + }) +} + +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_bootstrap_complete() { + bootstrap_complete(); +} + +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_shutdown() { + shutdown(); +} + +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_set_stage(stage: SignalSafeStage) { + set_stage(match stage { + SignalSafeStage::Uninitialized => Stage::Uninitialized, + SignalSafeStage::CrashtrackerInit => Stage::CrashtrackerInit, + SignalSafeStage::PlatformInit => Stage::PlatformInit, + SignalSafeStage::LanguageInit => Stage::LanguageInit, + SignalSafeStage::PluginLoading => Stage::PluginLoading, + SignalSafeStage::InjectionMetadataSend => Stage::InjectionMetadataSend, + SignalSafeStage::HttpClientSend => Stage::HttpClientSend, + SignalSafeStage::Application => Stage::Application, + SignalSafeStage::CrashtrackerUninstall => Stage::CrashtrackerUninstall, + }); +} + +unsafe fn cstr_bytes<'a>(ptr: *const c_char) -> &'a [u8] { + if ptr.is_null() { + &[] + } else { + CStr::from_ptr(ptr).to_bytes() + } +} diff --git a/libdd-crashtracker-ffi/src/lib.rs b/libdd-crashtracker-ffi/src/lib.rs index a4aa8a0e53..c0e268094a 100644 --- a/libdd-crashtracker-ffi/src/lib.rs +++ b/libdd-crashtracker-ffi/src/lib.rs @@ -9,21 +9,29 @@ #[cfg(all(unix, feature = "collector"))] mod collector; +#[cfg(all(unix, feature = "collector_signal-safe"))] +mod collector_signal_safe; #[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(unix, feature = "collector_signal-safe"))] +pub use collector_signal_safe::*; #[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 5e88a070c4..fc5fd48d9a 100644 --- a/libdd-crashtracker/Cargo.toml +++ b/libdd-crashtracker/Cargo.toml @@ -10,71 +10,106 @@ 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:num-derive", + "dep:num-traits", + "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", + "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:serde", "dep:serde-json-core"] # 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 } +num-derive = { version = "0.4.2", optional = true } +num-traits = { version = "0.2.19", 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 } +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 = "1.0", 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/src/collector_signal_safe/backtrace.rs b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs new file mode 100644 index 0000000000..b36fc95b10 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs @@ -0,0 +1,159 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::c_void; + +use super::sys; + +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 +} + +#[cfg(all( + any(target_os = "linux", target_os = "android"), + target_arch = "x86_64" +))] +fn arch_seed(uc: &libc::ucontext_t, out: &mut [usize]) -> (usize, usize) { + let ip = uc.uc_mcontext.gregs[libc::REG_RIP as usize] as usize; + let fp = uc.uc_mcontext.gregs[libc::REG_RBP as usize] as usize; + let mut n = 0; + if ip != 0 { + out[0] = ip; + n = 1; + } + (n, fp) +} + +#[cfg(all( + any(target_os = "linux", target_os = "android"), + target_arch = "aarch64" +))] +fn arch_seed(uc: &libc::ucontext_t, out: &mut [usize]) -> (usize, usize) { + let pc = uc.uc_mcontext.pc as usize; + let fp = uc.uc_mcontext.regs[29] as usize; + let lr = uc.uc_mcontext.regs[30] as usize; + let mut n = 0; + if pc != 0 { + out[0] = pc; + n = 1; + } + + let fp_walkable = fp != 0 && aligned_8(fp) && fp >= MMAP_MIN_ADDR; + if !fp_walkable && lr != 0 && n < out.len() { + out[n] = lr; + n += 1; + } + (n, fp) +} + +#[cfg(not(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") +)))] +fn arch_seed(_uc: &libc::ucontext_t, _out: &mut [usize]) -> (usize, usize) { + (0, 0) +} + +pub fn backtrace_from_ucontext(out: &mut [usize], ucontext: *const c_void, self_pid: i32) -> 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); + walk_fp(out, n, self_pid, fp) +} + +#[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]; + + 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 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]; + + 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/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs new file mode 100644 index 0000000000..f7c6a8b881 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -0,0 +1,289 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::c_char; +use core::fmt::Write; +use core::sync::atomic::Ordering::Relaxed; + +use heapless::String as HeaplessString; + +use super::state::{self, meta_mut}; + +pub const TRACE_C_VERSION: &str = match option_env!("DD_TRACE_C_VERSION") { + Some(v) => v, + None => "dev", +}; + +const DEFAULT_RECEIVER_PATH: &str = match option_env!("DD_TRACE_C_CRASHTRACKER_PROCESS_PATH") { + Some(p) => p, + None => "/opt/datadog-packages/datadog-apm-library-c/stable/process-crash-receiver", +}; + +pub const RECEIVER_TIMEOUT_SECS: u32 = 5; + +pub const CRASH_SIGNALS: [i32; 5] = [ + libc::SIGSEGV, + libc::SIGABRT, + libc::SIGBUS, + libc::SIGILL, + libc::SIGFPE, +]; + +pub const CONFIG_JSON_BUF_SIZE: usize = 2048; + +#[derive(Clone, Copy, Debug, Default)] +pub struct SignalSafeInitConfig<'a> { + pub receiver_path: &'a [u8], + pub service: &'a [u8], + pub env: &'a [u8], + pub app_version: &'a [u8], + pub runtime_id: &'a [u8], + pub platform: &'a [u8], + pub force_on_top: bool, + pub only_bootstrap: bool, + pub debug_logging: bool, +} + +pub fn build_config_json(out: &mut HeaplessString) -> bool { + out.clear(); + if out + .push_str( + "{\"additional_files\":[],\ + \"create_alt_stack\":false,\ + \"use_alt_stack\":false,\ + \"demangle_names\":true,\ + \"endpoint\":null,\ + \"resolve_frames\":\"EnabledWithSymbolsInReceiver\",\ + \"signals\":[", + ) + .is_err() + { + return false; + } + + for (i, sig) in CRASH_SIGNALS.iter().enumerate() { + if i > 0 && out.push(',').is_err() { + return false; + } + if write!(out, "{sig}").is_err() { + return false; + } + } + + writeln!( + out, + "],\"timeout\":{{\"secs\":{RECEIVER_TIMEOUT_SECS},\"nanos\":0}},\"unix_socket_path\":null}}" + ) + .is_ok() +} + +pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { + let m = meta_mut(); + if !build_config_json(&mut m.config_json) { + return false; + } + + set_str(&mut m.service, config.service); + set_str(&mut m.env, config.env); + set_str(&mut m.app_version, config.app_version); + set_str(&mut m.runtime_id, config.runtime_id); + set_str(&mut m.platform, config.platform); + if m.platform.is_empty() { + set_str(&mut m.platform, b"host"); + } + + if !set_receiver_path(&mut m.process_path, config.receiver_path) { + return false; + } + + state::FORCE_ON_TOP.store(config.force_on_top, Relaxed); + state::ONLY_BOOTSTRAP.store(config.only_bootstrap, Relaxed); + state::DEBUG_LOG.store(config.debug_logging, Relaxed); + true +} + +pub fn prepare_from_env() -> bool { + if is_false(env_get(b"DD_CRASHTRACKING_ENABLED\0")) { + return false; + } + + let receiver_path = env_get(b"DD_TRACE_C_CRASHTRACKER_PROCESS\0") + .filter(|v| !v.is_empty()) + .unwrap_or(DEFAULT_RECEIVER_PATH.as_bytes()); + let platform = env_get(b"DD_INJECT_SENDER_TYPE\0") + .filter(|v| !v.is_empty()) + .unwrap_or(b"host"); + let debug_logging = parse_log_level(env_get(b"DD_TRACE_LOG_LEVEL\0")) >= DD_LOG_DEBUG; + + prepare(&SignalSafeInitConfig { + receiver_path, + service: env_get(b"DD_SERVICE\0").unwrap_or(&[]), + env: env_get(b"DD_ENV\0").unwrap_or(&[]), + app_version: env_get(b"DD_VERSION\0").unwrap_or(&[]), + runtime_id: env_get(b"DD_RUNTIME_ID\0").unwrap_or(&[]), + platform, + force_on_top: is_true(env_get(b"DD_CRASHTRACKING_ALWAYS_ON_TOP\0")), + only_bootstrap: is_true(env_get(b"DD_CRASHTRACKING_ONLY_BOOTSTRAP\0")), + debug_logging, + }) +} + +fn set_str(dst: &mut HeaplessString, src: &[u8]) { + dst.clear(); + if let Ok(s) = core::str::from_utf8(src) { + for ch in s.chars() { + if dst.push(ch).is_err() { + break; + } + } + } +} + +fn set_receiver_path(dst: &mut heapless::Vec, path: &[u8]) -> bool { + dst.clear(); + let selected = if path.is_empty() { + DEFAULT_RECEIVER_PATH.as_bytes() + } else { + path + }; + if selected.len() >= dst.capacity() { + return false; + } + dst.extend_from_slice(selected).is_ok() && dst.push(0).is_ok() +} + +pub unsafe fn cstr_bytes<'a>(p: *const c_char) -> &'a [u8] { + let mut len = 0usize; + while core::ptr::read_volatile(p.add(len)) != 0 { + len += 1; + } + core::slice::from_raw_parts(p.cast(), len) +} + +fn env_get(name_nul: &[u8]) -> Option<&'static [u8]> { + let p = unsafe { libc::getenv(name_nul.as_ptr().cast()) }; + if p.is_null() { + None + } else { + Some(unsafe { cstr_bytes(p) }) + } +} + +fn eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut i = 0usize; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +fn eq_ic(a: &[u8], lower: &[u8]) -> bool { + if a.len() != lower.len() { + return false; + } + let mut i = 0usize; + while i < a.len() { + if a[i].to_ascii_lowercase() != lower[i] { + return false; + } + i += 1; + } + true +} + +fn is_false(v: Option<&[u8]>) -> bool { + match v { + Some(s) => eq(s, b"0") || eq_ic(s, b"false") || eq_ic(s, b"f"), + None => false, + } +} + +fn is_true(v: Option<&[u8]>) -> bool { + match v { + Some(s) => eq(s, b"1") || eq_ic(s, b"true") || eq_ic(s, b"t"), + None => false, + } +} + +const DD_LOG_INFO: i32 = 3; +const DD_LOG_DEBUG: i32 = 4; + +fn parse_log_level(v: Option<&[u8]>) -> i32 { + match v { + None => DD_LOG_INFO, + Some(s) => { + const LEVELS: [(&[u8], i32); 6] = [ + (b"off", 0), + (b"error", 1), + (b"warn", 2), + (b"info", 3), + (b"debug", 4), + (b"trace", 5), + ]; + for (name, level) in LEVELS { + if eq(s, name) { + return level; + } + } + DD_LOG_INFO + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_json_contains_receiver_contract() { + let mut out = HeaplessString::::new(); + assert!(build_config_json(&mut out)); + assert!(out.contains("\"additional_files\":[]")); + assert!(out.contains("\"resolve_frames\":\"EnabledWithSymbolsInReceiver\"")); + assert!(out.contains("\"unix_socket_path\":null")); + assert!(out.ends_with('\n')); + } + + #[test] + fn bool_and_log_parsing_matches_dd_trace_c() { + assert!(is_false(Some(b"FALSE"))); + assert!(is_false(Some(b"0"))); + assert!(!is_false(Some(b"true"))); + assert!(is_true(Some(b"TrUe"))); + assert!(is_true(Some(b"1"))); + assert_eq!(parse_log_level(Some(b"debug")), DD_LOG_DEBUG); + assert_eq!(parse_log_level(Some(b"DEBUG")), DD_LOG_INFO); + } + + #[test] + fn prepare_caches_fixed_metadata() { + assert!(prepare(&SignalSafeInitConfig { + receiver_path: b"/tmp/receiver", + service: b"svc", + env: b"prod", + app_version: b"1.2.3", + runtime_id: b"rid", + platform: b"host", + force_on_top: true, + only_bootstrap: true, + debug_logging: true, + })); + + let meta = state::meta(); + 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::FORCE_ON_TOP.load(Relaxed)); + assert!(state::ONLY_BOOTSTRAP.load(Relaxed)); + assert!(state::DEBUG_LOG.load(Relaxed)); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs new file mode 100644 index 0000000000..12dd8d5108 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -0,0 +1,553 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::{c_char, c_int, c_void}; +use core::ptr::null_mut; +use core::sync::atomic::{AtomicBool, Ordering::Relaxed}; + +use super::backtrace; +use super::config::{self, SignalSafeInitConfig, TRACE_C_VERSION}; +use super::state::{self, sig_index, Stage}; +use super::sys::{self, FdSink}; +use super::{ + app_handler_is_real, app_recovered, chain_action, is_genuine_fault, should_run_app_first, + ChainAction, CrashContext, Disposition, Report, SignalInfo, +}; + +const EXIT_CODE_FAILURE: i32 = 125; +const BACKTRACE_LEVELS: usize = 32; +const REAP_RECEIVER_TIMEOUT_MS: i64 = config::RECEIVER_TIMEOUT_SECS as i64 * 1000 + 1000; +const REAP_COLLECTOR_TIMEOUT_MS: i64 = 500; +const REAP_KILL_TIMEOUT_MS: i64 = 500; +const REAP_WAIT_INTERVAL_MS: i32 = 100; + +unsafe extern "C" { + static mut environ: *mut *mut c_char; +} + +#[derive(Clone, Copy)] +struct Target { + fn_ptr: *mut c_void, + flags: i32, +} + +pub fn init(config: &SignalSafeInitConfig<'_>) -> bool { + if !config::prepare(config) { + return false; + } + install_all_handlers(); + state::HANDLERS_ENABLED.store(true, Relaxed); + state::INSTALLED.store(true, Relaxed); + state::set_stage(Stage::CrashtrackerInit); + true +} + +pub fn init_from_env() -> bool { + if !config::prepare_from_env() { + return false; + } + install_all_handlers(); + state::HANDLERS_ENABLED.store(true, Relaxed); + state::INSTALLED.store(true, Relaxed); + state::set_stage(Stage::CrashtrackerInit); + true +} + +pub fn bootstrap_complete() { + if state::ONLY_BOOTSTRAP.load(Relaxed) { + shutdown(); + } else { + state::set_stage(Stage::Application); + } +} + +pub fn shutdown() { + state::set_stage(Stage::CrashtrackerUninstall); + state::HANDLERS_ENABLED.store(false, Relaxed); + uninstall_all_handlers(); + state::INSTALLED.store(false, Relaxed); +} + +fn effective_target(idx: usize) -> Target { + Target { + fn_ptr: state::ORIG_FN[idx].load(Relaxed), + flags: state::ORIG_FLAGS[idx].load(Relaxed), + } +} + +unsafe fn invoke_handler( + t: &Target, + sig: c_int, + info: *mut libc::siginfo_t, + ucontext: *mut c_void, +) { + if t.flags & libc::SA_SIGINFO != 0 { + let f: extern "C" fn(c_int, *mut libc::siginfo_t, *mut c_void) = + core::mem::transmute(t.fn_ptr); + f(sig, info, ucontext); + } else { + let f: extern "C" fn(c_int) = core::mem::transmute(t.fn_ptr); + f(sig); + } +} + +fn crash_debug(msg: &[u8], sig: i32) { + if !state::DEBUG_LOG.load(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; 12]; + let written = write_i32(sig, &mut buf); + let _ = super::Sink::put(&mut sink, &buf[..written]); + } + let _ = super::Sink::put(&mut sink, b"\n"); +} + +fn write_i32(value: i32, out: &mut [u8; 12]) -> 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 +} + +fn sanitize_clone(mut keep_fd: i32) -> 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; + } + + reset_handlers_to_default(); + + let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); + 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); + } + } + keep_fd +} + +fn reset_handlers_to_default() { + let mut dfl: libc::sigaction = unsafe { core::mem::zeroed() }; + dfl.sa_sigaction = libc::SIG_DFL; + unsafe { + libc::sigemptyset(&mut dfl.sa_mask); + } + for &sig in &config::CRASH_SIGNALS { + unsafe { + let _ = libc::sigaction(sig, &dfl, null_mut()); + } + } +} + +fn strip_loader_injection_env() { + let env = unsafe { environ }; + 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| cstr_starts_with(entry, p)); + if !injected { + *dst = entry; + dst = dst.add(1); + } + src = src.add(1); + } + *dst = null_mut(); + } +} + +unsafe fn cstr_starts_with(s: *const c_char, prefix: &[u8]) -> bool { + let mut i = 0usize; + while i < prefix.len() { + let c = *s.add(i); + if c == 0 || c as u8 != prefix[i] { + return false; + } + i += 1; + } + true +} + +fn receiver_child(read_fd: i32, write_fd: i32) -> ! { + sys::close(write_fd); + let read_fd = sanitize_clone(read_fd); + 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); + } + 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); +} + +#[allow(clippy::too_many_arguments)] +fn collector_child( + read_fd: i32, + write_fd: i32, + sig: i32, + si_code: i32, + has_info: bool, + si_addr: usize, + pid: i32, + tid: i32, + ucontext: *mut c_void, +) -> ! { + sys::close(read_fd); + let write_fd = sanitize_clone(write_fd); + if write_fd < 0 { + sys::exit_process(EXIT_CODE_FAILURE); + } + + let mut frames = [0usize; BACKTRACE_LEVELS]; + let n = backtrace::backtrace_from_ucontext(&mut frames, ucontext, sys::getpid()); + + let meta = state::meta(); + let runtime_id = if meta.runtime_id.is_empty() { + "00000000-0000-0000-0000-000000000000" + } else { + meta.runtime_id.as_str() + }; + let report = Report { + config_json: meta.config_json.as_str(), + trace_c_version: TRACE_C_VERSION, + service: meta.service.as_str(), + env: meta.env.as_str(), + app_version: meta.app_version.as_str(), + runtime_id, + platform: meta.platform.as_str(), + stage_name: state::current_stage_name(), + }; + let context = CrashContext { + signal: SignalInfo::new(sig, si_code, si_addr, has_info), + pid, + tid, + frames: &frames[..n], + }; + + let mut sink = FdSink::new(write_fd); + let _ = super::emit_report(&mut sink, &report, &context); + sys::close(write_fd); + sys::exit_process(0); +} + +fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) { + let start = sys::monotonic_nanos(); + loop { + let waited = sys::waitpid_nohang(pid); + if waited == pid || waited < 0 { + return; + } + + sys::poll_sleep_ms(REAP_WAIT_INTERVAL_MS); + let elapsed_ms = (sys::monotonic_nanos() - start) / 1_000_000; + if elapsed_ms >= timeout_ms { + if kill_process { + let _ = sys::kill(pid, libc::SIGKILL); + reap_or_kill(pid, REAP_KILL_TIMEOUT_MS, false); + } + return; + } + } +} + +fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontext: *mut c_void) { + let mut fds = [0i32; 2]; + if !sys::pipe(&mut fds) { + return; + } + + let read_fd = fds[0]; + let write_fd = fds[1]; + let pid = sys::getpid(); + let tid = sys::gettid(); + + let receiver = unsafe { sys::fork_raw() }; + if receiver == 0 { + receiver_child(read_fd, write_fd); + } + + let mut collector = -1isize; + if receiver > 0 { + collector = unsafe { sys::fork_raw() }; + if collector == 0 { + collector_child( + read_fd, write_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, + ); + } + } + + sys::close(read_fd); + sys::close(write_fd); + + if collector > 0 { + reap_or_kill(collector as i32, REAP_COLLECTOR_TIMEOUT_MS, true); + } + if receiver > 0 { + reap_or_kill(receiver as i32, REAP_RECEIVER_TIMEOUT_MS, true); + } +} + +extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *mut c_void) { + if !state::HANDLERS_ENABLED.load(Relaxed) { + return; + } + + let saved_errno = sys::errno(); + crash_debug(b"handler entered", sig); + + let idx = sig_index(sig); + let has_info = !info.is_null(); + let si_code = if has_info { + unsafe { (*info).si_code } + } else { + 0 + }; + + if !state::FORCE_ON_TOP.load(Relaxed) { + if let Some(i) = idx { + let target = effective_target(i); + let app_is_real = app_handler_is_real(target.fn_ptr); + if should_run_app_first(false, app_is_real) { + static IN_APP_CHAIN: AtomicBool = AtomicBool::new(false); + if !IN_APP_CHAIN.swap(true, Relaxed) { + sys::set_errno(saved_errno); + unsafe { invoke_handler(&target, sig, info, ucontext) }; + if app_recovered(target.fn_ptr) { + IN_APP_CHAIN.store(false, Relaxed); + sys::set_errno(saved_errno); + return; + } + } + } + } + } + + let self_pid = sys::getpid(); + let si_pid = if has_info { + unsafe { siginfo_pid(info) } + } else { + 0 + }; + if is_genuine_fault(has_info, si_code, si_pid, self_pid) { + static COLLECTING: AtomicBool = AtomicBool::new(false); + if !COLLECTING.swap(true, Relaxed) { + let si_addr = if has_info { + unsafe { siginfo_addr(info) } + } else { + 0 + }; + collect_crash(sig, si_code, has_info, si_addr, ucontext); + } + } + + sys::set_errno(saved_errno); + + let target = match idx { + Some(i) => effective_target(i), + None => Target { + fn_ptr: core::ptr::null_mut(), + flags: 0, + }, + }; + let action = chain_action(disposition_of_target(target.fn_ptr), has_info, si_code); + match action { + ChainAction::RestoreDefaultAndRefault | ChainAction::RestoreDefaultAndReraise => { + let mut dfl: libc::sigaction = unsafe { core::mem::zeroed() }; + dfl.sa_sigaction = libc::SIG_DFL; + unsafe { + libc::sigemptyset(&mut dfl.sa_mask); + if libc::sigaction(sig, &dfl, null_mut()) != 0 { + sys::exit_process(EXIT_CODE_FAILURE); + } + if let ChainAction::RestoreDefaultAndReraise = action { + libc::raise(sig); + sys::exit_process(EXIT_CODE_FAILURE); + } + } + } + ChainAction::Resume => {} + ChainAction::InvokeApp => unsafe { + invoke_handler(&target, sig, info, ucontext); + }, + } +} + +fn disposition_of_target(handler: *mut c_void) -> Disposition { + super::disposition_of(handler) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +unsafe fn siginfo_pid(info: *mut libc::siginfo_t) -> i32 { + (*info).si_pid() +} + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +unsafe fn siginfo_pid(_info: *mut libc::siginfo_t) -> i32 { + sys::getpid() +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +unsafe fn siginfo_addr(info: *mut libc::siginfo_t) -> usize { + (*info).si_addr() as usize +} + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +unsafe fn siginfo_addr(_info: *mut libc::siginfo_t) -> usize { + 0 +} + +fn query_sigaction(sig: c_int, out: *mut libc::sigaction) -> bool { + unsafe { libc::sigaction(sig, null_mut(), out) == 0 } +} + +fn is_default_handler(sig: c_int) -> bool { + let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; + if !query_sigaction(sig, &mut cur) { + return false; + } + cur.sa_sigaction == libc::SIG_DFL +} + +fn is_our_handler(sig: c_int) -> bool { + let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; + if !query_sigaction(sig, &mut cur) { + return false; + } + cur.sa_flags & libc::SA_SIGINFO != 0 && cur.sa_sigaction == crash_handler as *const () as usize +} + +fn install_crash_handler(sig: c_int) { + if !is_default_handler(sig) { + return; + } + + let mut sa: libc::sigaction = unsafe { core::mem::zeroed() }; + sa.sa_sigaction = crash_handler as *const () as usize; + sa.sa_flags = libc::SA_SIGINFO; + unsafe { + libc::sigemptyset(&mut sa.sa_mask); + } + + 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::ORIG_FN[i].store(old.sa_sigaction as *mut c_void, Relaxed); + state::ORIG_FLAGS[i].store(old.sa_flags, Relaxed); + state::OWN_SIGNAL[i].store(true, Relaxed); + } +} + +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 { + libc::sigemptyset(&mut restore.sa_mask); + if libc::sigaction(sig, &restore, null_mut()) == 0 { + state::OWN_SIGNAL[i].store(false, Relaxed); + } + } +} + +fn install_all_handlers() { + state::clear_signal_state(); + for &sig in &config::CRASH_SIGNALS { + install_crash_handler(sig); + } +} + +fn uninstall_all_handlers() { + for &sig in &config::CRASH_SIGNALS { + uninstall_crash_handler(sig); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integer_debug_writer_handles_sign() { + let mut buf = [0u8; 12]; + 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"); + } + + #[test] + fn lifecycle_can_install_and_shutdown() { + let config = SignalSafeInitConfig { + receiver_path: b"/bin/cat", + ..SignalSafeInitConfig::default() + }; + assert!(init(&config)); + assert!(state::INSTALLED.load(Relaxed)); + shutdown(); + assert!(!state::INSTALLED.load(Relaxed)); + } +} 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..049ba5ecd4 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -0,0 +1,768 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::c_void; + +use heapless::{String as HeaplessString, Vec as HeaplessVec}; +use serde::Serialize; + +mod backtrace; +mod config; +mod handler; +mod state; +mod sys; + +pub use config::{build_config_json, prepare, prepare_from_env, SignalSafeInitConfig}; +pub use handler::{bootstrap_complete, init, init_from_env, shutdown}; +pub use state::{set_stage, Stage}; +pub use sys::FdSink; + +pub const SECTION_BUF_CAPACITY: usize = 4096; +pub const TAG_CAPACITY: usize = 288; +pub const MAX_TAGS: usize = 12; +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; + +pub trait Sink { + fn put(&mut self, bytes: &[u8]) -> bool; +} + +pub struct SliceSink<'a> { + buf: &'a mut [u8], + len: usize, +} + +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] + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } +} + +impl Sink for SliceSink<'_> { + fn put(&mut self, bytes: &[u8]) -> bool { + let Some(end) = self.len.checked_add(bytes.len()) else { + return false; + }; + if end > self.buf.len() { + return false; + } + self.buf[self.len..end].copy_from_slice(bytes); + self.len = end; + true + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Disposition { + Default, + Ignore, + Handler, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChainAction { + InvokeApp, + RestoreDefaultAndRefault, + RestoreDefaultAndReraise, + Resume, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SignalContext { + pub has_siginfo: bool, + pub si_code: i32, + pub si_pid: i32, + pub self_pid: i32, +} + +impl SignalContext { + pub fn is_genuine_fault(self) -> bool { + is_genuine_fault(self.has_siginfo, self.si_code, self.si_pid, self.self_pid) + } +} + +pub fn disposition_of(handler: *mut c_void) -> Disposition { + match handler as usize { + SIG_DFL_VALUE => Disposition::Default, + SIG_IGN_VALUE => Disposition::Ignore, + _ => Disposition::Handler, + } +} + +pub fn app_handler_is_real(handler: *mut c_void) -> bool { + matches!(disposition_of(handler), Disposition::Handler) +} + +pub fn should_run_app_first(force_on_top: bool, app_is_real: bool) -> bool { + !force_on_top && app_is_real +} + +pub fn app_recovered(handler_after: *mut c_void) -> bool { + disposition_of(handler_after) != Disposition::Default +} + +pub 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 fn chain_action(disposition: Disposition, has_siginfo: bool, si_code: i32) -> ChainAction { + match disposition { + Disposition::Ignore => ChainAction::Resume, + Disposition::Handler => ChainAction::InvokeApp, + Disposition::Default if has_siginfo && si_code > 0 => ChainAction::RestoreDefaultAndRefault, + Disposition::Default => ChainAction::RestoreDefaultAndReraise, + } +} + +#[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 trace_c_version: &'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 stage_name: &'a str, +} + +pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { + if value.is_empty() { + return true; + } + + let mut tag = 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 { + emit_config(sink, report.config_json) + && emit_metadata(sink, report) + && emit_additional_tags(sink, report.stage_name) + && emit_kind(sink) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_SIGINFO\n", + &context.signal, + b"DD_CRASHTRACK_END_SIGINFO\n", + ) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_PROCESSINFO\n", + &ProcInfo { + pid: context.pid, + tid: context.tid, + }, + b"DD_CRASHTRACK_END_PROCESSINFO\n", + ) + && emit_stacktrace(sink, context.frames) + && emit_message(sink, report.stage_name, &context.signal) + && sink.put(b"DD_CRASHTRACK_DONE\n") +} + +pub fn emit_report_with_metadata( + sink: &mut impl Sink, + config_json: &str, + metadata: &Metadata<'_>, + stage_name: &str, + context: &CrashContext<'_>, +) -> bool { + emit_config(sink, config_json) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_METADATA\n", + metadata, + b"DD_CRASHTRACK_END_METADATA\n", + ) + && emit_additional_tags(sink, stage_name) + && emit_kind(sink) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_SIGINFO\n", + &context.signal, + b"DD_CRASHTRACK_END_SIGINFO\n", + ) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_PROCESSINFO\n", + &ProcInfo { + pid: context.pid, + tid: context.tid, + }, + b"DD_CRASHTRACK_END_PROCESSINFO\n", + ) + && emit_stacktrace(sink, context.frames) + && emit_message(sink, stage_name, &context.signal) + && sink.put(b"DD_CRASHTRACK_DONE\n") +} + +pub fn emit_minimal_report( + sink: &mut impl Sink, + config_json: &str, + metadata: &Metadata<'_>, + signal: &SignalInfo, +) -> bool { + emit_config(sink, config_json) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_METADATA\n", + metadata, + b"DD_CRASHTRACK_END_METADATA\n", + ) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_SIGINFO\n", + signal, + b"DD_CRASHTRACK_END_SIGINFO\n", + ) + && sink.put(b"DD_CRASHTRACK_DONE\n") +} + +pub fn emit_json_section( + sink: &mut impl Sink, + begin: &[u8], + value: &T, + end: &[u8], +) -> bool { + let mut buf = [0u8; SECTION_BUF_CAPACITY]; + match serde_json_core::to_slice(value, &mut buf) { + Ok(len) => sink.put(begin) && sink.put(&buf[..len]) && sink.put(b"\n") && sink.put(end), + Err(_) => false, + } +} + +pub fn rust_signal_name(signal: i32) -> &'static str { + match signal { + libc::SIGABRT => "SIGABRT", + libc::SIGBUS => "SIGBUS", + libc::SIGFPE => "SIGFPE", + libc::SIGILL => "SIGILL", + libc::SIGQUIT => "SIGQUIT", + libc::SIGSEGV => "SIGSEGV", + libc::SIGSYS => "SIGSYS", + libc::SIGTRAP => "SIGTRAP", + _ => "", + } +} + +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), + } +} + +pub fn signal_has_address(signal: i32) -> bool { + matches!( + signal, + libc::SIGBUS | libc::SIGFPE | libc::SIGILL | libc::SIGSEGV | libc::SIGTRAP + ) +} + +pub fn hex_addr(value: usize) -> HeaplessString { + let mut out = HeaplessString::new(); + let _ = out.push_str("0x"); + + for shift in (0..core::mem::size_of::() * 2).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 +} + +fn emit_config(sink: &mut impl Sink, config_json: &str) -> bool { + sink.put(b"DD_CRASHTRACK_BEGIN_CONFIG\n") + && sink.put(config_json.as_bytes()) + && (config_json.ends_with('\n') || sink.put(b"\n")) + && sink.put(b"DD_CRASHTRACK_END_CONFIG\n") +} + +fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { + let service = if report.service.is_empty() { + "dd-trace-c" + } else { + report.service + }; + + let mut metadata = Metadata::new("dd-trace-c", report.trace_c_version, "native"); + push_tag(&mut metadata.tags, "language", "native") + && push_tag(&mut metadata.tags, "runtime", "native") + && push_tag(&mut metadata.tags, "is_crash", "true") + && push_tag(&mut metadata.tags, "severity", "crash") + && push_tag(&mut metadata.tags, "service", service) + && push_tag(&mut metadata.tags, "env", report.env) + && push_tag(&mut metadata.tags, "version", report.app_version) + && push_tag(&mut metadata.tags, "runtime_id", report.runtime_id) + && push_tag( + &mut metadata.tags, + "runtime_version", + report.trace_c_version, + ) + && push_tag( + &mut metadata.tags, + "library_version", + report.trace_c_version, + ) + && push_tag(&mut metadata.tags, "platform", report.platform) + && push_tag( + &mut metadata.tags, + "injector_version", + report.trace_c_version, + ) + && emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_METADATA\n", + &metadata, + b"DD_CRASHTRACK_END_METADATA\n", + ) +} + +fn emit_additional_tags(sink: &mut impl Sink, stage: &str) -> bool { + let mut tags = Tags::new(); + if !push_tag(&mut tags, "stage", stage) { + return false; + } + emit_json_section( + sink, + b"DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS\n", + &tags, + b"DD_CRASHTRACK_END_ADDITIONAL_TAGS\n", + ) +} + +fn emit_kind(sink: &mut impl Sink) -> bool { + sink.put(b"DD_CRASHTRACK_BEGIN_KIND\n\"UnixSignal\"\nDD_CRASHTRACK_END_KIND\n") +} + +fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { + if !sink.put(b"DD_CRASHTRACK_BEGIN_STACKTRACE\n") { + return false; + } + + for ip in frames { + if *ip == 0 { + continue; + } + + let frame = Frame::from_ip(*ip); + let mut buf = [0u8; SECTION_BUF_CAPACITY]; + let Ok(len) = serde_json_core::to_slice(&frame, &mut buf) else { + return false; + }; + if !(sink.put(&buf[..len]) && sink.put(b"\n")) { + return false; + } + } + + sink.put(b"DD_CRASHTRACK_END_STACKTRACE\n") +} + +fn emit_message(sink: &mut impl Sink, stage_name: &str, signal: &SignalInfo) -> bool { + let mut message = HeaplessString::::new(); + message.push_str("Crash during ").is_ok() + && message.push_str(stage_name).is_ok() + && message.push_str(" (").is_ok() + && message.push_str(signal.si_signo_human_readable).is_ok() + && message.push(')').is_ok() + && sink.put(b"DD_CRASHTRACK_BEGIN_MESSAGE\n") + && sink.put(message.as_bytes()) + && sink.put(b"\nDD_CRASHTRACK_END_MESSAGE\n") +} + +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", + _ => "", + }, + libc::SIGBUS => match si_code { + BUS_ADRALN => "BUS_ADRALN", + BUS_ADRERR => "BUS_ADRERR", + BUS_OBJERR => "BUS_OBJERR", + _ => "", + }, + 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", + _ => "", + }, + _ => "", + } +} + +const SIG_DFL_VALUE: usize = 0; +const SIG_IGN_VALUE: usize = 1; + +pub const SI_USER: i32 = 0; +pub const SI_KERNEL: i32 = 128; +pub const SI_QUEUE: i32 = -1; +pub const SI_TIMER: i32 = -2; +pub const SI_MESGQ: i32 = -3; +pub const SI_ASYNCIO: i32 = -4; +pub const SI_SIGIO: i32 = -5; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_TKILL: i32 = -6; + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub const SI_TKILL: i32 = i32::MIN; + +pub const SEGV_MAPERR: i32 = 1; +pub const SEGV_ACCERR: i32 = 2; + +pub const BUS_ADRALN: i32 = 1; +pub const BUS_ADRERR: i32 = 2; +pub const BUS_OBJERR: i32 = 3; + +pub const ILL_ILLOPC: i32 = 1; +pub const ILL_ILLOPN: i32 = 2; +pub const ILL_ILLADR: i32 = 3; +pub const ILL_ILLTRP: i32 = 4; +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(test)] +mod tests { + use super::*; + use std::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 dispositions_match_sigaction_sentinels() { + let dfl = SIG_DFL_VALUE as *mut c_void; + let ign = SIG_IGN_VALUE 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 handler_policy_tracks_application_recovery() { + let dfl = SIG_DFL_VALUE as *mut c_void; + let ign = SIG_IGN_VALUE as *mut c_void; + let handler = 0x1234usize as *mut c_void; + + assert!(should_run_app_first(false, true)); + assert!(!should_run_app_first(true, true)); + assert!(!should_run_app_first(false, false)); + + assert!(app_recovered(handler)); + assert!(app_recovered(ign)); + assert!(!app_recovered(dfl)); + } + + #[test] + fn disposition_based_chain_action_resumes_ignored_signals() { + assert_eq!( + chain_action(Disposition::Ignore, true, SEGV_MAPERR), + ChainAction::Resume + ); + } + + #[test] + fn genuine_fault_filter_ignores_external_async_signal() { + let ctx = SignalContext { + has_siginfo: true, + si_code: SI_USER, + si_pid: 7, + self_pid: 9, + }; + + assert!(!ctx.is_genuine_fault()); + } + + #[test] + fn genuine_fault_filter_accepts_self_sent_async_signal() { + let ctx = SignalContext { + has_siginfo: true, + si_code: SI_USER, + si_pid: 9, + self_pid: 9, + }; + + assert!(ctx.is_genuine_fault()); + } + + #[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::InvokeApp + ); + } + + #[test] + fn signal_names_cover_common_native_faults() { + assert_eq!(rust_signal_name(libc::SIGSEGV), "SIGSEGV"); + 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::SIGSEGV, SI_USER), "SI_USER"); + assert_eq!(rust_si_code_name(libc::SIGSEGV, 999), ""); + } + + #[test] + fn minimal_report_emits_json_sections() { + let mut metadata = Metadata::new("lib", "1.0.0", "native"); + assert!(push_tag(&mut metadata.tags, "stage", "application")); + + let signal = SignalInfo::new(libc::SIGSEGV, SEGV_MAPERR, 0x1234, true); + + let mut buf = [0u8; 1024]; + let mut sink = SliceSink::new(&mut buf); + assert!(emit_minimal_report( + &mut sink, + "{\"resolve_frames\":\"Disabled\"}", + &metadata, + &signal + )); + + let report = str::from_utf8(sink.as_slice()).unwrap(); + let metadata = section( + report, + "DD_CRASHTRACK_BEGIN_METADATA\n", + "DD_CRASHTRACK_END_METADATA\n", + ); + let signal = section( + report, + "DD_CRASHTRACK_BEGIN_SIGINFO\n", + "DD_CRASHTRACK_END_SIGINFO\n", + ); + + let metadata: serde_json::Value = serde_json::from_str(metadata.trim()).unwrap(); + let signal: serde_json::Value = serde_json::from_str(signal.trim()).unwrap(); + + assert_eq!(metadata["library_name"], "lib"); + assert_eq!(metadata["tags"][0], "stage:application"); + assert_eq!(signal["si_signo"], libc::SIGSEGV); + assert_eq!(signal["si_signo_human_readable"], "SIGSEGV"); + assert_eq!(signal["si_code_human_readable"], "SEGV_MAPERR"); + assert_eq!(signal["si_addr"], hex_addr(0x1234).as_str()); + 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\"}", + trace_c_version: "golden-1.0", + service: "", + env: "prod", + app_version: "v1", + runtime_id: "rid", + platform: "linux", + stage_name: "application", + }; + + 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"], "dd-trace-c"); + assert_eq!(metadata["library_version"], "golden-1.0"); + assert_eq!(metadata["tags"][0], "language:native"); + assert_eq!(metadata["tags"][4], "service:dd-trace-c"); + assert_eq!(metadata["tags"][5], "env:prod"); + assert_eq!(metadata["tags"][6], "version:v1"); + assert_eq!(tags[0], "stage:application"); + 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 during application (SIGSEGV)"); + assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); + } + + 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/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs new file mode 100644 index 0000000000..35b4170056 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -0,0 +1,105 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::ffi::c_void; +use core::ptr::{addr_of, addr_of_mut, null_mut}; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, Ordering::Relaxed}; + +use heapless::{String as HeaplessString, Vec as HeaplessVec}; + +use super::config::CONFIG_JSON_BUF_SIZE; + +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, +} + +impl Meta { + 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(), + } + } +} + +static mut META: Meta = Meta::new(); + +pub fn meta() -> &'static Meta { + unsafe { &*addr_of!(META) } +} + +pub fn meta_mut() -> &'static mut Meta { + unsafe { &mut *addr_of_mut!(META) } +} + +pub static ORIG_FN: [AtomicPtr; NSIG] = [const { AtomicPtr::new(null_mut()) }; NSIG]; +pub static ORIG_FLAGS: [AtomicI32; NSIG] = [const { AtomicI32::new(0) }; NSIG]; +pub static OWN_SIGNAL: [AtomicBool; NSIG] = [const { AtomicBool::new(false) }; NSIG]; + +pub static HANDLERS_ENABLED: AtomicBool = AtomicBool::new(false); +pub static FORCE_ON_TOP: AtomicBool = AtomicBool::new(false); +pub static ONLY_BOOTSTRAP: AtomicBool = AtomicBool::new(false); +pub static DEBUG_LOG: AtomicBool = AtomicBool::new(false); +pub static INSTALLED: AtomicBool = AtomicBool::new(false); + +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Stage { + Uninitialized = 0, + CrashtrackerInit = 1, + PlatformInit = 2, + LanguageInit = 3, + PluginLoading = 4, + InjectionMetadataSend = 5, + HttpClientSend = 6, + Application = 7, + CrashtrackerUninstall = 8, +} + +static STAGE: AtomicI32 = AtomicI32::new(Stage::Uninitialized as i32); + +pub fn set_stage(stage: Stage) { + STAGE.store(stage as i32, Relaxed); +} + +pub fn current_stage_name() -> &'static str { + match STAGE.load(Relaxed) { + 1 => "crashtracker_init", + 2 => "platform_init", + 3 => "language_init", + 4 => "plugin_loading", + 5 => "injection_metadata_send", + 6 => "http_client_send", + 7 => "application", + 8 => "crashtracker_uninstall", + _ => "uninitialized", + } +} + +pub fn clear_signal_state() { + let mut i = 0usize; + while i < NSIG { + ORIG_FN[i].store(null_mut(), Relaxed); + ORIG_FLAGS[i].store(0, Relaxed); + OWN_SIGNAL[i].store(false, Relaxed); + i += 1; + } +} 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..1d8bb9e443 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -0,0 +1,595 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#![allow(dead_code)] + +use super::Sink; + +const MAX_WRITE_RETRIES: u32 = 10; + +pub struct FdSink { + fd: i32, +} + +impl FdSink { + pub fn new(fd: i32) -> Self { + Self { fd } + } +} + +impl Sink for FdSink { + fn put(&mut self, bytes: &[u8]) -> bool { + let mut off = 0usize; + let mut retries = 0u32; + while off < bytes.len() { + let n = write(self.fd, &bytes[off..]); + if n > 0 { + off += n as usize; + retries = 0; + continue; + } + if n == -libc::EINTR as isize && retries < MAX_WRITE_RETRIES { + retries += 1; + continue; + } + return false; + } + true + } +} + +#[cfg(all( + any(target_os = "linux", target_os = "android"), + any(target_arch = "x86_64", target_arch = "aarch64") +))] +mod raw { + use super::*; + use core::arch::asm; + use core::ffi::c_void; + + #[cfg(target_arch = "x86_64")] + #[inline] + unsafe fn syscall0(nr: i64) -> isize { + let ret: isize; + asm!( + "syscall", + inlateout("rax") nr as isize => ret, + lateout("rcx") _, + lateout("r11") _, + options(nostack, preserves_flags), + ); + ret + } + + #[cfg(target_arch = "x86_64")] + #[inline] + unsafe fn syscall1(nr: i64, a0: usize) -> isize { + let ret: isize; + asm!( + "syscall", + inlateout("rax") nr as isize => ret, + in("rdi") a0, + lateout("rcx") _, + lateout("r11") _, + options(nostack, preserves_flags), + ); + ret + } + + #[cfg(target_arch = "x86_64")] + #[inline] + unsafe fn syscall2(nr: i64, a0: usize, a1: usize) -> isize { + let ret: isize; + asm!( + "syscall", + inlateout("rax") nr as isize => ret, + in("rdi") a0, + in("rsi") a1, + lateout("rcx") _, + lateout("r11") _, + options(nostack, preserves_flags), + ); + ret + } + + #[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 syscall4(nr: i64, a0: usize, a1: usize, a2: usize, a3: 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, + 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 syscall0(nr: i64) -> isize { + let ret: isize; + asm!( + "svc 0", + in("x8") nr, + lateout("x0") ret, + options(nostack), + ); + ret + } + + #[cfg(target_arch = "aarch64")] + #[inline] + unsafe fn syscall1(nr: i64, a0: usize) -> isize { + let ret: isize; + asm!( + "svc 0", + in("x8") nr, + inlateout("x0") a0 => ret, + options(nostack), + ); + ret + } + + #[cfg(target_arch = "aarch64")] + #[inline] + unsafe fn syscall2(nr: i64, a0: usize, a1: usize) -> isize { + let ret: isize; + asm!( + "svc 0", + in("x8") nr, + inlateout("x0") a0 => ret, + in("x1") a1, + options(nostack), + ); + 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 syscall4(nr: i64, a0: usize, a1: usize, a2: usize, a3: usize) -> isize { + let ret: isize; + asm!( + "svc 0", + in("x8") nr, + inlateout("x0") a0 => ret, + in("x1") a1, + in("x2") a2, + in("x3") a3, + 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 + } + + #[inline] + fn cvt(ret: isize) -> isize { + if ret < 0 && ret >= -4095 { + ret + } else { + ret + } + } + + pub fn write(fd: i32, bytes: &[u8]) -> isize { + cvt(unsafe { + syscall3( + libc::SYS_write, + fd as usize, + bytes.as_ptr() as usize, + bytes.len(), + ) + }) + } + + pub fn close(fd: i32) { + unsafe { + syscall1(libc::SYS_close, fd as usize); + } + } + + 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 fcntl_dupfd(fd: i32, min_fd: i32) -> i32 { + unsafe { + syscall3( + libc::SYS_fcntl, + fd as usize, + libc::F_DUPFD as usize, + min_fd as usize, + ) as i32 + } + } + + pub fn pipe(fds: &mut [i32; 2]) -> bool { + unsafe { syscall2(libc::SYS_pipe2, fds.as_mut_ptr() as usize, 0) == 0 } + } + + pub fn open_readwrite(path: *const u8) -> i32 { + unsafe { + syscall4( + libc::SYS_openat, + libc::AT_FDCWD as usize, + path as usize, + libc::O_RDWR as usize, + 0, + ) as i32 + } + } + + pub unsafe fn fork_raw() -> isize { + #[cfg(target_arch = "x86_64")] + { + let ret: isize; + asm!( + "syscall", + inlateout("rax") libc::SYS_clone as isize => ret, + in("rdi") libc::SIGCHLD as usize, + in("rsi") 0usize, + in("rdx") 0usize, + in("r10") 0usize, + in("r8") 0usize, + lateout("rcx") _, + lateout("r11") _, + options(nostack), + ); + ret + } + #[cfg(target_arch = "aarch64")] + { + let ret: isize; + asm!( + "svc 0", + in("x8") libc::SYS_clone, + inlateout("x0") libc::SIGCHLD as usize => ret, + in("x1") 0usize, + in("x2") 0usize, + in("x3") 0usize, + in("x4") 0usize, + options(nostack), + ); + ret + } + } + + pub fn exit_process(code: i32) -> ! { + loop { + unsafe { + syscall1(libc::SYS_exit_group, code as usize); + } + } + } + + pub fn getpid() -> i32 { + unsafe { syscall0(libc::SYS_getpid) as i32 } + } + + pub fn gettid() -> i32 { + unsafe { syscall0(libc::SYS_gettid) as i32 } + } + + pub fn kill(pid: i32, sig: i32) -> i32 { + unsafe { syscall2(libc::SYS_kill, pid as usize, sig as usize) as i32 } + } + + pub fn waitpid_nohang(pid: i32) -> i32 { + let mut status = 0i32; + unsafe { + syscall4( + libc::SYS_wait4, + pid as usize, + (&mut status as *mut i32) as usize, + libc::WNOHANG as usize, + 0, + ) as i32 + } + } + + pub fn poll_sleep_ms(timeout_ms: i32) { + unsafe { + let _ = syscall3(libc::SYS_poll, 0, 0, timeout_ms as usize); + } + } + + #[repr(C)] + struct KernelTimespec { + tv_sec: i64, + tv_nsec: i64, + } + + pub fn monotonic_nanos() -> i64 { + let mut ts = KernelTimespec { + tv_sec: 0, + tv_nsec: 0, + }; + let rc = unsafe { + syscall2( + libc::SYS_clock_gettime, + libc::CLOCK_MONOTONIC as usize, + (&mut ts as *mut KernelTimespec) as usize, + ) + }; + if rc != 0 { + return 0; + } + ts.tv_sec + .wrapping_mul(1_000_000_000) + .wrapping_add(ts.tv_nsec) + } + + #[repr(C)] + struct IoVec { + iov_base: *mut c_void, + iov_len: usize, + } + + pub fn read_own_mem(pid: i32, src: usize, dst: &mut [u8]) -> bool { + let local = IoVec { + iov_base: dst.as_mut_ptr() as *mut c_void, + iov_len: dst.len(), + }; + let remote = 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 IoVec as usize, + 1, + &remote as *const 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 fn write(fd: i32, bytes: &[u8]) -> isize { + unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) } + } + + pub fn close(fd: i32) { + unsafe { + let _ = libc::close(fd); + } + } + + pub fn dup2(oldfd: i32, newfd: i32) -> i32 { + unsafe { libc::dup2(oldfd, newfd) } + } + + pub fn fcntl_dupfd(fd: i32, min_fd: i32) -> i32 { + unsafe { libc::fcntl(fd, libc::F_DUPFD, min_fd) } + } + + pub fn pipe(fds: &mut [i32; 2]) -> bool { + unsafe { libc::pipe(fds.as_mut_ptr()) == 0 } + } + + pub fn open_readwrite(path: *const u8) -> i32 { + unsafe { libc::open(path.cast(), libc::O_RDWR) } + } + + pub unsafe fn fork_raw() -> isize { + libc::fork() as isize + } + + pub fn exit_process(code: i32) -> ! { + unsafe { + libc::_exit(code); + } + } + + pub fn getpid() -> i32 { + unsafe { libc::getpid() } + } + + 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 kill(pid: i32, sig: i32) -> i32 { + unsafe { libc::kill(pid, sig) } + } + + pub fn waitpid_nohang(pid: i32) -> i32 { + let mut status = 0i32; + unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) } + } + + pub fn poll_sleep_ms(timeout_ms: i32) { + unsafe { + let _ = libc::poll(core::ptr::null_mut(), 0, timeout_ms); + } + } + + pub fn monotonic_nanos() -> i64 { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + let rc = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; + if rc != 0 { + return 0; + } + ts.tv_sec + .wrapping_mul(1_000_000_000) + .wrapping_add(ts.tv_nsec) + } + + pub fn read_own_mem(_pid: i32, _src: usize, _dst: &mut [u8]) -> bool { + false + } +} + +pub use raw::{ + close, dup2, exit_process, fcntl_dupfd, fork_raw, getpid, gettid, kill, monotonic_nanos, + open_readwrite, pipe, poll_sleep_ms, read_own_mem, waitpid_nohang, write, +}; + +pub fn errno() -> i32 { + unsafe { *errno_location() } +} + +pub fn set_errno(errno: i32) { + unsafe { + *errno_location() = errno; + } +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +unsafe fn errno_location() -> *mut i32 { + libc::__errno_location() +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +unsafe fn errno_location() -> *mut i32 { + libc::__error() +} + +#[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" + )) +))] +unsafe fn errno_location() -> *mut i32 { + libc::__errno_location() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fd_sink_writes_to_pipe() { + let mut fds = [0i32; 2]; + assert!(pipe(&mut fds)); + let mut sink = FdSink::new(fds[1]); + assert!(sink.put(b"abc")); + close(fds[1]); + + let mut out = [0u8; 3]; + let n = unsafe { libc::read(fds[0], out.as_mut_ptr().cast(), out.len()) }; + close(fds[0]); + assert_eq!(n, 3); + assert_eq!(&out, b"abc"); + } +} diff --git a/libdd-crashtracker/src/lib.rs b/libdd-crashtracker/src/lib.rs index 475664297d..44733f3b07 100644 --- a/libdd-crashtracker/src/lib.rs +++ b/libdd-crashtracker/src/lib.rs @@ -49,29 +49,53 @@ #![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)] -#[cfg(all(unix, feature = "collector"))] +#[cfg(all(feature = "std", feature = "collector_signal-safe"))] +compile_error!( + "The collector_signal-safe feature requires no_std; build with --no-default-features and without the std feature." +); + +#[cfg(all(not(unix), feature = "collector_signal-safe"))] +compile_error!("The collector_signal-safe feature is only supported on Unix targets."); + +#[cfg(all(test, not(feature = "std")))] +extern crate std; + +#[cfg(all(unix, feature = "std", feature = "collector"))] mod collector; -#[cfg(all(windows, feature = "collector_windows"))] +#[cfg(all(unix, feature = "collector_signal-safe", not(feature = "std")))] +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, feature = "std", feature = "receiver"))] mod receiver; +#[cfg(feature = "std")] mod runtime_callback; // Keep this module private to avoid exposing blazesym to users of the crate -#[cfg(all(unix, any(feature = "collector", feature = "receiver")))] +#[cfg(all( + unix, + feature = "std", + any(feature = "collector", feature = "receiver") +))] #[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, @@ -80,26 +104,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/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs new file mode 100644 index 0000000000..75e13aa2d0 --- /dev/null +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -0,0 +1,69 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(unix, feature = "collector_signal-safe", not(feature = "std")))] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +use libdd_crashtracker::collector_signal_safe::{ + bootstrap_complete, init_from_env, set_stage, Stage, +}; + +#[test] +fn signal_safe_child_process() { + if std::env::var_os("DD_SIGNAL_SAFE_E2E_CHILD").is_none() { + return; + } + + assert!(init_from_env()); + bootstrap_complete(); + set_stage(Stage::Application); + + std::process::abort(); +} + +#[test] +fn signal_safe_crash_writes_report() { + 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_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_CHILD", "1") + .env("DD_SIGNAL_SAFE_E2E_REPORT", &report) + .env("DD_TRACE_C_CRASHTRACKER_PROCESS", &receiver) + .env("DD_SERVICE", "signal-safe-e2e") + .env("DD_ENV", "test") + .env("DD_VERSION", "1") + .env("DD_RUNTIME_ID", "00000000-0000-0000-0000-000000000001") + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + + let report = fs::read_to_string(&report).expect("read crash report"); + 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("\"si_signo_human_readable\":\"SIGABRT\"")); + assert!(report.contains("DD_CRASHTRACK_BEGIN_PROCESSINFO\n")); + assert!(report.contains("DD_CRASHTRACK_BEGIN_STACKTRACE\n")); + assert!(report.contains("Crash during application (SIGABRT)")); + assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); +} From 967c5e7cfd122d350ed886fa783212f6ee743553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 00:04:59 +0200 Subject: [PATCH 03/32] docs(crashtracker): add signal-safe collector improvement plan Co-Authored-By: Claude Fable 5 --- ...gnal_safe_crashtracker_improvement_plan.md | 618 ++++++++++++++++++ 1 file changed, 618 insertions(+) create mode 100644 in_signal_safe_crashtracker_improvement_plan.md diff --git a/in_signal_safe_crashtracker_improvement_plan.md b/in_signal_safe_crashtracker_improvement_plan.md new file mode 100644 index 0000000000..02b1e02ef0 --- /dev/null +++ b/in_signal_safe_crashtracker_improvement_plan.md @@ -0,0 +1,618 @@ +# Signal-Safe Crashtracker Improvement Plan + +Status: draft plan, written 2026-07-05 on branch `signal_safe_crashtracker` (HEAD `84eb21715`). +Audience: an engineer or LLM agent picking this work up with no prior context. + +Terminology used throughout (see Workstream H for the code-side rename): + +- **app-first policy** — default on-crash policy: if the application installed a real + handler, run it first; if it recovers, do not report; if it gave up (restored + `SIG_DFL`), report and terminate. Safe for managed runtimes (HotSpot, V8, .NET, + Python faulthandler) that use SIGSEGV non-fatally. +- **report-first policy** — opt-in (`DD_CRASHTRACKING_ALWAYS_ON_TOP=true`): report + first, then chain to the application handler. + +--- + +## 1. Context + +Branch `signal_safe_crashtracker` added a signal-safe, `no_std`-style in-process crash +collector to `libdd-crashtracker`, targeting preload/injection deployments where the +signal handler, the forked collector child, and the receiver child before `execve` must +all stay async-signal-safe: no allocation, no locks, no stdio, no `Drop`-dependent +cleanup, no panics. `crashtracker-work-we-need-to-do.md` at the repo root holds the full +gap analysis this branch was cut from; this plan builds on it and does not repeat it. + +Code added by commit `84eb21715`: + +| File | Role | +|---|---| +| `libdd-crashtracker/src/collector_signal_safe/mod.rs` | Wire emitter (`DD_CRASHTRACK_*` sections), chain-policy pure functions, `Sink` trait | +| `libdd-crashtracker/src/collector_signal_safe/handler.rs` | Signal handler, fork of receiver+collector children, reap loop, install/uninstall lifecycle | +| `libdd-crashtracker/src/collector_signal_safe/sys.rs` | Raw inline-asm syscalls (x86_64/aarch64 Linux) + libc fallback for other Unix | +| `libdd-crashtracker/src/collector_signal_safe/backtrace.rs` | Frame-pointer walk seeded from `ucontext`, probing memory via `process_vm_readv` | +| `libdd-crashtracker/src/collector_signal_safe/config.rs` | Init config, env snapshot (`DD_*` vars), fixed config JSON contract | +| `libdd-crashtracker/src/collector_signal_safe/state.rs` | Static state: `Meta` strings (heapless), per-signal atomics, stage tracking | +| `libdd-crashtracker-ffi/src/collector_signal_safe.rs` | C ABI: `ddog_crasht_signal_safe_{init,init_from_env,bootstrap_complete,shutdown,set_stage}` | +| `libdd-crashtracker/tests/collector_signal_safe_e2e.rs` | One e2e test (abort → report through a shell-script receiver) | + +Feature gate: `collector_signal-safe` on both crates. The module is only compiled with +`--no-default-features --features collector_signal-safe` because `lib.rs` has a +`compile_error!` when `std` and `collector_signal-safe` are both enabled +(`libdd-crashtracker/src/lib.rs:54-57`). + +Goals of this plan, in priority order: + +1. Fix verified defects (some are build-breaking). +2. **Eliminate libc fallbacks on the crash path — `libc::fork()` above all** (A5/A5a). +3. Handle missing OS/environment capabilities gracefully (seccomp, missing receiver, containers, non-Linux). +4. Add safety options (alt stack, disarm-on-entry, report-to-fd, tunable budgets). +5. Build the test/CI story (currently zero CI coverage). +6. Adopt `rustix` for the syscall layer — decided — under a strict no-`dlsym` constraint (§6). +7. Complete the policy machinery: sigaction/signal virtualization and a working app-first policy (§5). +8. Decouple naming, constants, and metadata from the originating C-tracer integration (§9, Workstream H). + +--- + +## 2. Verified defects (fix first — Workstream A) + +These were confirmed by building/inspecting on 2026-07-05. Each is independently fixable. + +### A1. `--all-features` breaks the whole workspace validation (BUILD-BREAKING) + +`cargo check -p libdd-crashtracker --all-features` fails today with: + +``` +error: The collector_signal-safe feature requires no_std; build with --no-default-features and without the std feature. +``` + +AGENTS.md's standard validation includes +`cargo nextest run --workspace --all-features --exclude builder --exclude test_spawn_from_lib`, +which enables both `std` and `collector_signal-safe` → the documented CI/validation flow +cannot pass. Cargo features must be additive; a `compile_error!` on a feature +combination violates that. + +**Fix:** remove the `compile_error!` at `libdd-crashtracker/src/lib.rs:54-57` and make the +features coexist. The `collector_signal_safe` module is already `core`-only + `libc` + +`heapless` + `serde-json-core`; nothing in it needs `not(feature = "std")`. Change the +module gate at `lib.rs:67` to `#[cfg(all(unix, feature = "collector_signal-safe"))]`. +Guard against *both* collectors being armed at runtime (an atomic "a collector owns the +signals" flag consulted by both `init` paths), not at compile time. This also unlocks the +golden-wire test in §7 (emit with the signal-safe emitter, parse with the std receiver, in +one test binary). + +### A2. Does not compile on aarch64-linux: `libc::SYS_poll` does not exist there + +`sys.rs:385` uses `libc::SYS_poll` in `poll_sleep_ms`. The aarch64 Linux syscall table has +no `poll` — only `ppoll` (verified against libc 0.2.186 sources: aarch64 defines +`SYS_ppoll = 73` and no `SYS_poll`). The raw-asm module is compiled for +`target_arch = "aarch64"`, so an aarch64 build of the feature fails. + +**Fix:** use `SYS_ppoll` with a `KernelTimespec` (the struct already exists in sys.rs for +`clock_gettime`), or `SYS_clock_nanosleep`. Add an aarch64 `cargo check` to CI (§7) so +this class of bug can't land again. The rustix adoption (§6) removes this bug class +structurally; fix it directly only if the rustix PR doesn't land first. + +### A3. App-first "did the app handler recover?" check is a tautology — crashes are never reported when an app handler exists + +`handler.rs:359-375`: after invoking the app handler first, the code checks +`app_recovered(target.fn_ptr)` — but `target.fn_ptr` is the pointer captured *before* the +call, which is by construction a real handler (`Disposition::Handler`), so +`app_recovered()` always returns true and the function always returns without reporting. + +Consequence: with any app crash handler installed at crashtracker-init time, a genuine +fault that the app handler does **not** recover from (it just returns, or restores +`SIG_DFL` and returns) produces **no crash report**. Correct app-first semantics: after +the app-first call, re-read the *current* disposition; only skip reporting if the app +kept a non-default handler installed (recovery contract), report if it reset to +`SIG_DFL`. + +**Fix:** after `invoke_handler` returns, re-query the live disposition with +`sigaction(sig, NULL, &cur)` (later: from the virtualized table, §5) and pass *that* to +`app_recovered`. Also note the `siglongjmp` hazard: if the app handler longjmps out, no +code after `invoke_handler` runs — which is the correct "recovered" outcome, and is why no +`Drop`-bearing state may be live across that call (there is none today; keep it that way +and add a comment stating the invariant). + +Related smaller issues in the same block: +- `IN_APP_CHAIN` (handler.rs:364) is never reset on the not-recovered path. Today the + process usually dies afterwards so it's latent, but combined with `ChainAction::Resume` + paths it can wedge the app-first flow for a later signal. Reset it deterministically. +- The `should_run_app_first(false, ...)` call hardcodes `force_on_top = false` because the + outer `if` already checked `FORCE_ON_TOP` — fine, but fold the check into one place so + the pure function is actually exercised with real inputs. + +### A4. `FdSink` EINTR retry is wrong on the libc fallback path + +`sys.rs` raw path returns `-errno` from the syscall directly, and `FdSink::put` +(sys.rs:31) tests `n == -libc::EINTR as isize`. The **libc fallback** `write` returns +`-1` with the error in `errno` — so on macOS/other-Unix, EINTR is treated as a hard +failure and the report is truncated. **Fix:** normalize both backends to one error +convention (return `-errno`), or check `errno()` when `n == -1` in the fallback. Covered +automatically by adopting `rustix` (§6). + +### A5. libc fallback `fork_raw` is `libc::fork()` — runs `pthread_atfork` handlers. BAN IT. + +`sys.rs:476`. `fork()` itself is nominally async-signal-safe per POSIX, but glibc and +macOS libc run `pthread_atfork` handlers which may take locks and allocate — in a +crashed, possibly lock-holding process this deadlocks or corrupts. This is exactly what +the raw `clone(SIGCHLD)` on Linux avoids, and it defeats the entire point of the +signal-safe collector on any target that hits the fallback. + +**Fix (hard policy, not a patch): `libc::fork()` must not exist anywhere in this module.** +Delete `fork_raw` from the fallback `mod raw` entirely. Fork-based collection is only +available where a raw, atfork-free process-creation primitive exists — today that is +Linux x86_64/aarch64 via raw `clone(SIGCHLD)`. On every other target, `collect_crash` +must take a no-fork path: report-to-fd synchronous emit (§4.4) if configured, else +breadcrumb + clean chaining. There is no supported raw-syscall fork on macOS (Apple's +syscall ABI is private and `pthread_atfork` bypass is unsupported), so macOS gets the +no-fork degraded mode by design, not by accident — the existing std collector remains +the macOS answer. See A5a for the full fallback policy this is part of. + +### A5a. Shrink the libc fallback tier to an explicit allowlist + +The whole `#[cfg(not(linux+x86_64/aarch64))] mod raw` fallback (sys.rs:445-537) is +currently a silent "port" to targets nobody has audited. Replace the open-ended fallback +with an explicit three-tier policy: + +- **Tier 1 (full support, fork-based collection):** Linux x86_64/aarch64 — raw syscalls + only (asm today, `rustix` linux_raw after §6). Zero libc on the crash path. +- **Tier 2 (degraded, no-fork):** other Unix (macOS first). Only libc functions on the + POSIX async-signal-safe list AND free of hidden locks/allocation are allowed, from a + written allowlist: `write`, `close`, `dup2`, `kill`, `_exit`, `clock_gettime`, + `sigaction`, `sigemptyset`, `raise`, `poll`. Explicitly banned on any crash-reachable + path: `fork`, `posix_spawn`, `execv`-after-libc-fork, `waitpid` loops that assume a + child we can't create, `malloc`-adjacent anything, `getenv` (init-only), `dlsym` + (init-only, and see §6 — the shipped artifact must have no `dlsym` reference at all). + Collection on Tier 2 = seed-frame minimal report to a pre-opened fd. +- **Tier 3 (unsupported):** everything else — `compile_error!` naming the feature, so a + new target is a conscious porting decision instead of an untested fallback. + +Enforcement, not convention: the §7.5 symbol guard must fail on `fork`, `posix_spawn`, +`pthread_atfork`, and `dlsym` references in the crash-path objects, and CI builds Tier 2 +(macOS or a cfg-emulated build) so the allowlist cfg boundaries stay honest. + +### A6. Non-Linux `siginfo_pid` fallback misclassifies external kills as genuine faults + +`handler.rs:437-440`: the fallback returns `sys::getpid()`, which makes +`is_genuine_fault(si_code == SI_USER, si_pid == self_pid)` true for **any** external +`kill -SEGV `. On macOS, external async signals would be reported as crashes — +the exact thing the filter exists to prevent. **Fix:** on non-Linux read `si_pid` from the +proper `siginfo_t` field (it exists on macOS/BSD), or return a sentinel that makes the +filter answer "not genuine". + +### A7. `static mut META` + unsynchronized publication + +`state.rs:43-51`: `meta_mut()` hands out `&'static mut` from a `static mut`; two threads +calling `init` concurrently is UB, and all atomics use `Relaxed`, so the handler-enable +flag store does not *publish* the `META` writes to other cores. +**Fix:** make init single-shot via an atomic state machine +(`Uninit -> Initializing -> Ready`, CAS to enter), reject concurrent/second init, and +store `HANDLERS_ENABLED`/`INSTALLED` with `Release` + load with `Acquire` in the handler +path so `META` writes happen-before any handler execution. This keeps the handler +lock-free (reads only after an `Acquire` load of a `Ready` flag). + +### A8. Hygiene (small, do in the same PR as A1) + +- Unused import warning when building only the signal-safe feature (`cargo check + -p libdd-crashtracker --no-default-features --features collector_signal-safe` warns). +- `cvt()` in sys.rs:255-262 is a no-op (`if` and `else` both return `ret`) — delete or + make it meaningful. +- Feature name `collector_signal-safe` mixes `_` and `-`. Before anything ships, rename to + `collector-signal-safe` (or `signal-safe-collector`) on both crates. Breaking-change + cost is zero right now; nonzero later. +- The e2e test's cfg (`not(feature = "std")`) must be updated when A1 lands (drop the + `not(std)` condition). + +--- + +## 3. Workstream B — Missing-capability handling and graceful degradation + +Principle: **probe at init time (safe context), record results in atomics, degrade with +visibility at crash time (constrained context).** Never discover a missing capability for +the first time inside the signal handler, and never fail silently — a degraded report +must say *how* it is degraded. + +### B1. Init-time capability probe + +Add a `capabilities` module + `Capabilities` bitset (one `AtomicU32`), populated by +`init`/`init_from_env` before arming handlers: + +| Capability | Probe | Degradation if absent | +|---|---|---| +| `RECEIVER_OK` | `access(receiver_path, X_OK)` (raw `faccessat`) | Don't fork a receiver at crash time; use report-to-fd mode if configured (§4), else skip collection, still chain correctly | +| `PROC_VM_READV` | Perform one `process_vm_readv` self-read at init (read a stack local) | Stack walk emits seed frames only (IP/LR from ucontext); tag `stackwalk_method:seed_only` | +| `FORK_OK` | Optional: probe `clone(SIGCHLD)`+`_exit(0)`+reap of a trivial child at init | If seccomp forbids fork: in-process minimal emit to pre-opened fd (§4) or nothing; record in tag. Always false on Tier 2 targets (A5a) | +| `DEV_NULL` | `openat(/dev/null)` once | Child stdio redirection skipped; report still emitted (chroot/minimal-container case) | +| `PIPE_OK` | trivially true if `pipe2` works at init | crash-time `pipe2` can still fail on `EMFILE` — handle by aborting collection cleanly and chaining | + +Document explicitly (doc comment + `crashtracker-work-we-need-to-do.md` cross-ref): a +seccomp policy of `SECCOMP_RET_KILL` on `process_vm_readv` kills the collector child — +the init-time probe is precisely what detects this without losing the main process +(probe in a sacrificial child if `FORK_OK`; the gap-analysis doc §11 calls this the +"preflight probe"). First cut: probe in-process for `EPERM`-style failures, +sacrificial-child probe as a follow-up. + +### B2. Degradation visibility on the wire + +Extend `emit_additional_tags` (mod.rs:427) to append, beyond `stage:`: + +- `stackwalk_method:` +- `report_degraded:` when any capability was missing (comma-free single token; + emit one tag per reason). +- Keep tag capacity math in mind: `MAX_TAGS = 12`, `TAG_CAPACITY = 288` (mod.rs:21-22) — + raise `MAX_TAGS` as needed; it's compile-time cost only. + +### B3. Crash-time failure paths that currently lose the report silently + +- `collect_crash` (handler.rs:306): if `pipe` fails → returns with no breadcrumb. Add a + `crash_debug` line for every bail-out (pipe fail, fork fail, receiver fail) so + `DD_TRACE_LOG_LEVEL=debug` diagnoses field issues. +- `receiver_child` exec failure (`execv` returned): exits 125; the parent can't tell + "receiver missing" from "receiver crashed". With `RECEIVER_OK` probed at init this + becomes rare, but still log via `crash_debug` in the parent when the receiver's reap + discovers exit-by-125. +- `reap_or_kill` (handler.rs:286): `waited < 0` (e.g. `ECHILD` if the app has a + `SIGCHLD` reaper thread that stole the wait, or `SA_NOCLDWAIT` is set) returns + immediately — the receiver may then be killed by the subsequent code path or outlive + wrongly. Handle `-ECHILD` as "someone reaped it, treat as done", other negatives as + errors + breadcrumb. Note `SA_NOCLDWAIT`/`SIG_IGN`-on-SIGCHLD as a documented + limitation (children get auto-reaped; `wait4` returns `ECHILD` immediately — the + bounded poll loop then must fall back to a fixed sleep before `SIGKILL`). + +### B4. Missing-capability install cases + +- `install_crash_handler` (handler.rs:472) silently no-ops when a signal already has a + non-default handler (deliberate: only claim `SIG_DFL` signals). Record which signals + were *not* claimed (`OWN_SIGNAL` already exists; also breadcrumb + expose a count from + `init` return or an FFI query `ddog_crasht_signal_safe_owned_signals()`) so + integrators can tell "installed but owns 0 signals" from "installed". +- `init_from_env` returning `bool` collapses "disabled by env" and "failed". Return a + 3-state enum through FFI (`Enabled`, `DisabledByConfig`, `Failed`) — C ABI stability is + not required (repo convention: no C ABI backward-compat guarantees). + +--- + +## 4. Workstream C — Safety options + +New fields on `SignalSafeInitConfig` (and the FFI struct). Defaults preserve current +wire-contract behavior (everything off unless noted). + +1. **Alternate signal stack** (`create_alt_stack`, `use_alt_stack` — mirrors the std + collector's names). Without `SA_ONSTACK`, a stack-overflow SIGSEGV re-faults inside + the handler prologue and the process dies with no report. Implement: `sigaltstack` + with a statically reserved buffer (no mmap needed for the minimal case; static array + of `SIGSTKSZ*2`), set `SA_ONSTACK` when enabled. Keep default off; the config JSON + currently hardcodes `create_alt_stack:false` and must instead reflect the actual + runtime choice — `build_config_json` (config.rs:47) takes these options instead of + hardcoding. +2. **sa_mask policy**: currently `sigemptyset` (handler.rs:481) — another managed signal + arriving on another thread mid-collection is only guarded by the `COLLECTING` latch. + Option to block all managed crash signals in `sa_mask` during handling (default on — + it is strictly safer and cheap). +3. **Disarm-on-entry (double-fault safety)**: option to reset the current signal to + `SIG_DFL` *immediately on handler entry* (before collection), so a fault inside the + handler itself terminates the process with the kernel's original context instead of + recursing. Trade-off vs. app-first chaining (which needs the handler to stay resident + for `Resume`) — document; default off under the app-first policy, consider default-on + under report-first. +4. **Report-to-fd mode**: `report_fd: Option` — when the receiver can't be spawned + (`FORK_OK`/`RECEIVER_OK` absent, or Tier 2 target per A5a) or by explicit choice, emit + the report directly from the handler (no fork at all) into a caller-pre-opened fd + (file or socket). This is the capability-degraded backstop for hardened seccomp + containers **and the only collection mode on Tier 2**. The emitter already works + against any `Sink`; this is mostly plumbing plus a documented invariant that the fd + was opened `O_APPEND|O_CLOEXEC` at init. +5. **Tunable budgets**: `collector_reap_ms` (default 500), `receiver_timeout_secs` + (default 5), `max_frames` (default 32, cap at a compile-time `BACKTRACE_LEVELS_MAX`). + Currently constants at handler.rs:18-22. +6. **Receiver child fd hygiene option**: `close_range(3, ~0U, 0)` in the receiver child + before `execv` (after the report fd is dup'ed to stdin) so app fds don't leak into the + receiver. Raw syscall, Linux 5.9+; fall back to nothing if `ENOSYS`. Default on. +7. **Memory-ordering + single-shot init** — see A7; it belongs to this workstream's + "make the state machine safe" umbrella. + +--- + +## 5. Workstream D — sigaction/signal virtualization and completing the crash policies + +Virtualization is the largest remaining gap *and* it blocks a correct app-first policy +(A3 fixes the tautology, but without interposition the crashtracker never learns about +handlers registered **after** init — `ORIG_FN` only holds install-time state, and a late +app registration simply displaces our handler entirely). + +Phased approach: + +1. **D1 — live re-query (no interposition):** A3's fix. The app-first path consults + `sigaction(sig, NULL, ...)` at crash time to find the current app handler. Correct for + the "app registered before us" case; still blind to "app displaced us later". Cheap, + land with Workstream A. +2. **D2 — exported-symbol interposition (preload artifact only):** new feature + `signal-interpose` on `libdd-crashtracker-ffi` that exports `sigaction` and `signal` + symbols; resolve the real functions **at init, never in the signal path** — and per + the no-`dlsym` constraint (§6), prefer direct `syscall(SYS_rt_sigaction)` for the + pass-through so no `dlsym(RTLD_NEXT)` is needed at all; if a true libc pass-through + proves necessary, isolate any one-time symbol lookup to init and keep it out of the + shipped-symbol guard's crash-path objects. For managed signals, record the app's + requested disposition into the existing per-signal atomics (`ORIG_FN`/`ORIG_FLAGS` — + add `APP_SET: [AtomicBool; NSIG]`), answer `oldact` from virtual state, and do not let + the kernel handler change. Own installs bypass the wrapper. Wrappers must be + transparent when crashtracker is disabled / doesn't own the signal. Known, documented + limitations: raw `rt_sigaction` syscalls and `dlopen`-resolved slots are not covered. + LD_PRELOAD symbol interposition requires no crate; `plt-rs` (§6) is the fallback only + if a non-preload deployment ever needs PLT patching — out of scope for the first cut. +3. **D3 — policy test matrix:** app gives up via `SIG_DFL` restore; app recovers via + `siglongjmp`; report-first reports before recovery; registration via `sigaction` and + via `signal`; `SA_NODEFER` handling; errno preservation across the app-first call; a + recovered runtime signal must not consume the one-crash latch (`COLLECTING` must not + be set by a recovered app-first pass — verify ordering in `crash_handler`: today the + app-first block correctly runs before the `COLLECTING` swap, keep it that way under + refactor). + +Small item to fold into D1: `uninstall_crash_handler` restores the *original* handler — +after D2 exists, forced shutdown must restore the **virtualized app** handler (the app's +latest registration), not the install-time one. + +--- + +## 6. Workstream E — Upstream crate reuse + +Research done 2026-07-05 with `gh repo view`/`gh search repos` (stars / last push / +archived checked). + +### Adopt — DECIDED + +| Crate | Repo health | Use here | +|---|---|---| +| **rustix** (`linux_raw` backend) | bytecodealliance/rustix — 2032★, pushed 2026-06-15, active | Replace `sys.rs` raw asm wholesale: `write`, `close`, `dup3`, `fcntl(F_DUPFD)`, `pipe2`, `openat`, `wait4(WNOHANG)`, `kill`, `ppoll`/`clock_nanosleep`, `clock_gettime`, `getpid`, `gettid`, `faccessat`, `close_range` | +| **sadness-generator** (in EmbarkStudios/crash-handling) | 186★, pushed 2026-05-12, active | **dev-dependency** for e2e tests: generates real SIGSEGV (heap & stack-overflow), SIGBUS, SIGFPE, SIGILL, SIGABRT, SIGTRAP crashes. Zero production footprint | + +**Decision (owner sign-off 2026-07-05): use rustix.** `no_std`, no-alloc, libc-free +syscalls on Linux (`linux_raw` backend); eliminates A2 (per-arch syscall table bugs) and +A4 (error-convention mismatch) as whole bug classes; audited, widely-deployed syscall +stubs instead of ours. + +**Hard constraint discovered during evaluation: no `dlsym` in the shipped artifact.** +rustix compiles its `src/weak.rs` module even on the `linux_raw` backend (gated +`any(linux_raw, all(libc, not(windows/espidf/wasi)))` in `src/lib.rs`), and it declares +an extern `dlsym` used for runtime symbol probing; older auxv code paths also resolved +`getauxval` this way. An undefined `dlsym` reference — even weak — is unacceptable for +the preload artifact: on glibc < 2.34 it drags in `libdl` linkage and the gap-analysis +doc explicitly requires old-glibc-safe loading with no hard libdl symbols. + +rustix adoption rules for the implementer: + +- `default-features = false` (no `std`, no alloc), minimal API features only + (`pipe`, `process`, `event`, `time`, `fs`, `stdio` — trim to what's actually called). +- Force the `linux_raw` backend (default on Linux when `use-libc` is not enabled). +- **Auxv without libc:** rustix needs auxv (vDSO discovery for `clock_gettime`, + `AT_SECURE`, page size). Current rustix main reads it libc-free via the + `PR_GET_AUXV` prctl (kernel ≥ 6.4) with a `/proc/self/auxv` fallback — no `getauxval`, + no `dlsym`. Pin a rustix version with that behavior (verify at adoption time; upgrade + rustix rather than re-introducing a libc/dlsym path). If a pinned older version only + offers weak-`getauxval` resolution, do not use it — bump instead. As a last resort we + can read auxv ourselves without libc (`/proc/self/auxv`, or walking the initial stack + past `environ`) at init and feed rustix through its explicit-auxv mechanism where the + version provides one. +- **Verify, don't trust:** the §7.5 symbol guard must assert the crash-path staticlib + has zero references to `dlsym`, `getauxval`, and `__libc_*` on Tier 1. If any rustix + API we call pulls in a `weak!`-gated path, drop that API and keep a local raw wrapper + for that one call. +- Keep hand-rolled raw asm **only** for what rustix deliberately omits: raw + `clone(SIGCHLD)` fork. Verify rustix coverage of `process_vm_readv` at implementation + time; keep the local asm wrapper if absent. Everything else in `sys.rs::raw` is + deleted. +- On Tier 2 targets (A5a) rustix falls back to its libc backend — thin wrappers around + the same calls, acceptable there *only* for functions on the A5a allowlist; the banned + list (`fork` above all) applies regardless of which layer would provide it. +- Rejected alternatives, for the record: **syscalls** (jasonwhite, 141★, active — thinner + but keeps errno/typing work on us), **linux-raw-sys** (constants only), hand-rolled asm + (the status quo this replaces). + +### Keep (already used, both healthy) + +- **heapless** — rust-embedded/heapless, 1990★, pushed 2026-07-03. Check whether 0.9 is + out of rc and worth the bump; otherwise stay on 0.8. +- **serde-json-core** — rust-embedded-community, 195★, pushed 2025-11-18. Slower cadence + but small and stable in scope. Keep; no action. + +### Evaluate later (P2, do not adopt now) + +- **framehop** (mstange/framehop — 113★, pushed 2026-04-17) and **unwinding** + (nbdd0121/unwinding — 135★, pushed 2026-06-13): better-than-frame-pointer unwinding for + FP-omitted builds. framehop is `no_std`-able and allocation-free at unwind time *if* + unwind tables are prefetched at init. Substantial init-time complexity; only worth it + if seed-frames-only reports prove too weak in practice. Track as an optional + `resolve_frames` upgrade. +- **minidump-writer / minidumper / crash-handler** (rust-minidump org 505★ pushed + 2026-06-29; EmbarkStudios crash-handling 186★): a strategically different architecture + (out-of-process minidump capture). Not compatible with the `DD_CRASHTRACK_*` wire + contract; reference material only. +- **plt-rs** (ohchase/plt-rs — 40★, pushed 2026-06-22): PLT patching for the + interposition workstream if a non-LD_PRELOAD deployment ever needs it. **redhook** + (geofft/redhook) is stale (last push 2022-10) — do not use. +- **itoa** (dtolnay — 379★, active): could replace `write_i32`/`hex_addr`, but those are + ~40 proven lines; a new dep + LICENSE-3rdparty churn isn't worth it. Skip. +- **signal-hook** (vorner — 854★, active): not usable inside a crash handler; relevant + only as documentation of coexistence expectations (apps using signal-hook register + through `sigaction` → covered by D2 virtualization). + +### Repo mechanics for any dependency change + +Every `Cargo.lock` change requires `./scripts/update_license_3rdparty.sh` and +`cargo deny check` (CI-guarded). New files need Apache-2.0 headers +(`./scripts/reformat_copyright.sh`). + +--- + +## 7. Workstream F — Tests and CI + +Current state: 15 unit tests + 1 e2e, and **zero CI coverage** (verified: no reference to +`collector_signal` anywhere in `.github/`), and the e2e cfg means even +`--all-features` runs skip it. Ordered work: + +1. **CI job (blocks everything else being trustworthy):** + - `cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe` + for `x86_64-unknown-linux-gnu` **and** `--target aarch64-unknown-linux-gnu` + (check-only cross-compile is enough to catch A2-class bugs; no qemu needed initially). + - `cargo nextest run -p libdd-crashtracker --no-default-features --features collector_signal-safe` + on x86_64 Linux (runs unit + e2e). + - clippy + fmt on the same feature set (`-D warnings`). + - A Tier 2 build (macOS runner or cfg-emulated) so the A5a allowlist boundaries compile. + - After A1 (features coexist), the default workspace runs also compile the module, + shrinking the special-casing to the nextest invocation. +2. **Unit-test isolation:** `handler::tests::lifecycle_can_install_and_shutdown` + installs real SIGSEGV/SIGABRT handlers inside the shared test process — move + install/uninstall lifecycle tests behind the fork-a-child pattern already used by the + e2e (self-exec with an env marker), or into `bin_tests`. A stray SIGSEGV in another + test thread while these run currently gets eaten/misrouted. +3. **E2e matrix** (extend `collector_signal_safe_e2e.rs`; use sadness-generator per §6): + - each managed signal (SEGV, ABRT, BUS, ILL, FPE) produces a parseable report with the + right `si_signo_human_readable`; + - external `kill -SEGV` → **no** report; self-`raise` → report (genuine-fault filter); + - app handler gives up (`SIG_DFL` restore) → report; app handler `siglongjmp`s → no + report, process continues (app-first policy — after A3/D1); + - `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` → report emitted, then app handler runs + (report-first policy); + - stuck receiver (`sleep`-script) is SIGKILLed within budget; process still terminates + with the original signal disposition; + - crash with fds 0/1/2 closed pre-crash (pipe lands on low fds → relocation path in + `sanitize_clone`); + - receiver env: receiver script dumps `env` → assert `LD_PRELOAD`/`LD_AUDIT` absent, + `DD_*` present; + - stack-overflow crash once alt-stack (§4.1) lands; + - default-disposition **re-fault** path: after report, kernel terminates with original + `si_code`/address (check via `waitpid` status + core pattern in the harness). +4. **Golden wire round-trip:** feed a signal-safe-emitter report into the real std + receiver parser (`libdd-crashtracker` `receiver` feature) and assert the resulting + CrashInfo fields (service/env/tags/frames/si_code, `PROCESSINFO` spelling). Possible + in one test binary after A1; until then in `bin_tests`. +5. **Hot-path hygiene guard:** build a `no_std` staticlib example with the feature, + `nm`/`objdump` the archive in a test script and assert the crash-path object files + reference none of: `malloc`, `free`, `pthread_mutex_lock`, `__rust_alloc`, + panic/`core::fmt` machinery, `getenv`, **`dlsym`, `getauxval`, `fork`, `posix_spawn`, + `pthread_atfork`** (A5a + §6 enforcement). This is the regression fence that keeps + future edits — and future rustix upgrades — honest. (Script under `tools/` or + `bin_tests`, Linux-only.) +6. **Config JSON as a wire contract:** existing `config_json_contains_receiver_contract` + test becomes a full golden-string comparison once `build_config_json` takes options + (§4.1), so accidental contract drift fails loudly. + +--- + +## 8. Workstream G — FFI polish and packaging + +- FFI init should return the 3-state result (§B4) and there should be a query for owned + signals + capability bits (diagnostics for integrators). +- `cbindgen` output: verify the new header entries (the branch touched + `libdd-crashtracker-ffi/cbindgen.toml`); add the new enum/result types. +- No `catch_unwind` needed in these FFI entry points *iff* the crate stays panic-free — + enforce with `#![deny(clippy::panic, clippy::unwrap_used, clippy::expect_used)]` (or + equivalent lint table) on the `collector_signal_safe` module, plus the §7.5 symbol + guard. Repo rule is "FFI must not unwind across the boundary" — deny-by-construction + satisfies it; document that in the module header. +- Packaging decisions — receiver artifact ownership, musl builds, builder feature flag + (`crashtracker` already exists; decide whether signal-safe rides it or gets its own) — + keep as an explicitly deferred decision section; nothing in this plan hard-blocks on + it except the receiver-path-discovery item below. +- **Receiver path discovery:** sibling-of-.so lookup via `dladdr` needs C glue for weak + linkage on old glibc (same no-hard-libdl constraint as §6). Scope it as its own PR; + until then the baked default + env override (config.rs:17-20,110) is the contract. + +--- + +## 9. Workstream H — Decouple naming and constants from the originating C-tracer integration + +The module was ported from a specific C tracer's crashtracker and still carries that +origin in code, naming, and docs. This workstream makes the signal-safe collector a +neutral libdatadog component that any integrator (C tracer, injector, other language +preloads) parameterizes — while keeping wire compatibility for the existing consumer. + +1. **Policy naming:** rename the "always on top" boolean plumbing and all comments/docs + from the origin's mode letters to **app-first** / **report-first** (the terms used in + this plan). Public config field stays `force_on_top` or becomes + `policy: {AppFirst, ReportFirst}` — pick one and rename consistently across module, + FFI, and cbindgen output. +2. **Metadata is integrator-provided, not hardcoded:** `emit_metadata` (mod.rs:387-425) + hardcodes `library_name: "dd-trace-c"`, `family: "native"`, and a default service + name equal to the origin library's name. Replace with fields on + `SignalSafeInitConfig` (`library_name`, `library_version`, `family`, + `default_service`), snapshotted at init into `Meta`. Ship a preset constructor that + reproduces today's exact tag set so the existing consumer's wire output is + byte-identical (golden test, §7.6). +3. **Version constant:** `TRACE_C_VERSION` / `option_env!("DD_TRACE_C_VERSION")` + (config.rs:12-15) becomes `library_version` in the init config; the build-env + injection moves to the integrator's build. `Report::trace_c_version` field renamed + accordingly (it currently feeds `runtime_version`, `library_version`, and + `injector_version` tags — keep that mapping in the preset). +4. **Env var names:** `DD_TRACE_C_CRASHTRACKER_PROCESS` and the origin-specific baked + receiver default (config.rs:17-20) get neutral primary names (e.g. + `DD_CRASHTRACKING_RECEIVER_PATH`), with the old names kept as documented, + lower-priority aliases so existing deployments keep working. `prepare_from_env` + reads new-name-first. +5. **Docs sweep:** module headers, `crashtracker-work-we-need-to-do.md` framing, and FFI + doc comments describe behavior in terms of app-first/report-first and "the preload + integrator", not the originating tracer. The gap-analysis doc stays as historical + record; new docs must not require reading it. +6. **FFI symbol audit:** the `ddog_crasht_signal_safe_*` names are already neutral — + keep. Anything origin-specific that leaks into cbindgen headers gets renamed in the + same PR as (1)-(4) (single breaking change, allowed by repo ABI policy). + +Acceptance for this workstream: `grep -ri "dd-trace-c\|trace_c\|mode a\|mode b"` over +`libdd-crashtracker*/src` returns only (a) the compatibility-preset constants and env +aliases with comments explaining them, and (b) nothing in identifiers or public API. + +--- + +## 10. Suggested PR sequence + +Each PR independently green (fmt, clippy `-D warnings`, nextest, license CSV if deps +changed). Conventional Commits (`feat(crashtracker): ...`, `fix(crashtracker): ...`). + +1. **fix(crashtracker): make signal-safe feature additive + portability fixes** — + A1, A2, A4, A6, A8, feature rename. Adds the CI job (§7.1) in the same PR so the + fixes are locked in. +2. **refactor(crashtracker)!: ban libc fork and adopt rustix syscall layer** — + A5 + A5a (three-tier policy, fallback deletion) together with §6 rustix adoption + (they touch the same file; doing them as one PR avoids porting the fallback twice). + Keeps raw `clone` (+ `process_vm_readv` if absent from rustix); deletes the rest of + sys.rs asm and the entire open-ended libc fallback; adds the §7.5 symbol guard + including the `dlsym`/`fork` bans; LICENSE-3rdparty update. +3. **fix(crashtracker): correct app-first recovery detection and handler state machine** — + A3, A7, D1, IN_APP_CHAIN reset, `sa_mask` default (§4.2). Unit tests for the policy + functions with live re-query fakes; e2e app-handler scenarios (§7.3). +4. **refactor(crashtracker)!: decouple signal-safe collector from origin C tracer** — + Workstream H (§9). Golden wire test proves byte-identical output via the + compatibility preset. +5. **feat(crashtracker): capability probing and degraded-report tags** — §3 (B1-B4). +6. **feat(crashtracker): safety options** — §4 (alt stack, disarm-on-entry, + report-to-fd, budgets, close_range). Config JSON becomes option-driven, golden test. +7. **feat(crashtracker): sigaction/signal virtualization for preload** — D2 + shutdown + restore semantics + D3 matrix. +8. **test(crashtracker): e2e matrix, golden wire round-trip, hot-path symbol guard + completion** — remaining §7 items (can be split across PRs 3-7 where scenarios + become testable). +9. **feat(crashtracker): receiver path discovery** — §8 last item (needs C glue design, + same no-libdl constraint). + +## 11. Acceptance criteria (definition of done for this plan) + +- `cargo check -p libdd-crashtracker --all-features` and the full AGENTS.md validation + suite pass unmodified. +- The feature compiles for `aarch64-unknown-linux-gnu` (CI-enforced). +- **No `libc::fork`, `posix_spawn`, or `pthread_atfork` reference anywhere in the + module; no `dlsym`/`getauxval`/`__libc_*` reference in the Tier 1 crash-path + staticlib — all enforced by the §7.5 symbol guard in CI.** +- `sys.rs` hand-written asm reduced to raw `clone(SIGCHLD)` (+ `process_vm_readv` if + rustix lacks it); everything else through rustix `linux_raw`. +- A genuine crash with a pre-registered non-recovering app handler produces a report + (regression test for A3). +- Every degraded report carries a `report_degraded`/`stackwalk_method` tag; no silent + losses on: missing receiver, `process_vm_readv` EPERM, pipe/fork failure. +- E2e matrix of §7.3 green on x86_64 Linux CI. +- New deps reflected in `LICENSE-3rdparty.csv`; `cargo deny check` green. +- Workstream H grep criterion met: no origin-tracer names or mode letters in + identifiers/public API; compatibility preset covered by a byte-identical golden test. +- `crashtracker-work-we-need-to-do.md` updated: items delivered here checked off, + deferred items (packaging, framehop, sacrificial-child probe, PLT patching) marked as + such. From 67ed434ce72d8e3880a58583eb504d5f312e5483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 01:02:31 +0200 Subject: [PATCH 04/32] wip --- Cargo.lock | 1 + crashtracker-work-we-need-to-do.md | 23 ++ .../src/collector_signal_safe.rs | 74 +++- libdd-crashtracker/Cargo.toml | 3 +- libdd-crashtracker/build.rs | 1 + libdd-crashtracker/src/collector/api.rs | 36 +- .../src/collector_signal_safe/backtrace.rs | 13 +- .../src/collector_signal_safe/config.rs | 197 ++++++++-- .../src/collector_signal_safe/handler.rs | 347 ++++++++++++++---- .../src/collector_signal_safe/mod.rs | 85 ++++- .../src/collector_signal_safe/state.rs | 86 ++++- .../src/collector_signal_safe/sys.rs | 120 +++--- libdd-crashtracker/src/lib.rs | 9 +- .../tests/collector_signal_safe_e2e.rs | 72 +++- 14 files changed, 877 insertions(+), 190 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e3d086e67..9036a7d253 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2974,6 +2974,7 @@ dependencies = [ "page_size", "portable-atomic", "rand 0.8.5", + "rustix 1.1.3", "schemars", "serde", "serde-json-core", diff --git a/crashtracker-work-we-need-to-do.md b/crashtracker-work-we-need-to-do.md index 60bf239317..5582bda0b3 100644 --- a/crashtracker-work-we-need-to-do.md +++ b/crashtracker-work-we-need-to-do.md @@ -37,6 +37,29 @@ The dd-trace-c implementation assumes the signal handler and forked children are Current libdatadog still has `std`, `nix`, `UnixStream`, `File`, `serde_json`, `Box`-backed globals, formatting, and RAII/drop patterns reachable from the signal-handler/fork-child path. Some individual syscalls underneath are safe, but the Rust wrappers and destructors are not enough of a contract for the dd-trace-c preload use case. +## Progress on the signal-safe collector branch + +Status as of 2026-07-06: + +- Implemented a separate `collector_signal-safe` path that can coexist with the standard collector feature, with a runtime signal-owner guard so only one collector arms crash signal handlers. +- Added single-shot signal-safe initialization, release/acquire publication for handler enablement, fixed metadata snapshots, stage tags, and configurable integrator metadata. The C-tracer defaults are now a compatibility preset rather than hardcoded report fields. +- Fixed the app-first recovery check by re-reading the live handler after the app handler returns, and kept the app-handler call path free of `Drop`-dependent state. +- Added init-time capability probes and report degradation tags for missing receiver, missing `process_vm_readv`, no fork support, `/dev/null`, pipe availability, and report-to-fd fallback. +- Replaced broad non-Linux forking with an explicit degraded no-fork policy. Linux x86_64/aarch64 keeps raw `clone(SIGCHLD)` for fork-based collection; other Unix targets can emit a minimal report to a pre-opened fd. +- Adopted `rustix` for ordinary fd/process/time wrappers where it fits, kept raw asm only where needed, normalized fallback errno handling, and removed libc `fork()` from the fallback path. +- Added optional alt-stack, signal-mask, disarm-on-entry, report-fd, timeout, max-frame, and receiver-fd cleanup config fields through Rust and FFI. +- Added Linux receiver e2e coverage, portable report-to-fd degraded e2e coverage, an aarch64 Linux check, and a symbol guard for banned crash-path symbols. + +Deferred follow-up work: + +- Full `sigaction`/`signal` virtualization and PLT interposition for late app/runtime handler registration. +- Receiver path discovery beside the loaded integration library, including architecture-suffixed receiver names. +- Sacrificial-child probing for seccomp policies that kill `process_vm_readv`. +- `close_range`/fd sweep behavior in the receiver child. +- Regenerating and reviewing cbindgen headers for the expanded FFI structs. +- Porting the complete dd-trace-c preload integration matrix, including app-handler recovery by `siglongjmp`, report-first policy tests, stuck receiver timeout tests, and receiver recursion prevention tests. +- Packaging decisions for the sidecar receiver and any preload-owned release artifacts. + ## P0 work ### 1. Add a signal-safe in-process collector surface diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs index 0fe23df23b..b65b202fcc 100644 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -4,7 +4,8 @@ use std::ffi::{c_char, CStr}; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, init, init_from_env, set_stage, shutdown, SignalSafeInitConfig, Stage, + bootstrap_complete, capability_bits, init_from_env_result, init_result, owned_signal_count, + owns_signal, set_stage, shutdown, InitResult, SignalSafeInitConfig, Stage, }; #[repr(C)] @@ -15,9 +16,30 @@ pub struct SignalSafeConfig { pub app_version: *const c_char, pub runtime_id: *const c_char, pub platform: *const c_char, + pub library_name: *const c_char, + pub library_version: *const c_char, + pub family: *const c_char, + pub default_service: *const c_char, pub force_on_top: bool, pub only_bootstrap: bool, pub debug_logging: bool, + pub create_alt_stack: bool, + pub use_alt_stack: bool, + pub block_signals: bool, + pub disarm_on_entry: bool, + pub report_fd: i32, + pub collector_reap_ms: i32, + pub receiver_timeout_secs: u32, + pub max_frames: usize, + pub close_fds_on_receiver: bool, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub enum SignalSafeInitResult { + Enabled = 0, + DisabledByConfig = 1, + Failed = 2, } #[repr(C)] @@ -35,8 +57,8 @@ pub enum SignalSafeStage { } #[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> bool { - init_from_env() +pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> SignalSafeInitResult { + init_result_to_ffi(init_from_env_result()) } /// Initialize the signal-safe crashtracker with explicitly provided metadata. @@ -46,22 +68,37 @@ pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> bool { /// pointer inside `config` must point to a valid NUL-terminated string for the duration of this /// call. The strings are copied before the function returns. #[no_mangle] -pub unsafe extern "C" fn ddog_crasht_signal_safe_init(config: *const SignalSafeConfig) -> bool { +pub unsafe extern "C" fn ddog_crasht_signal_safe_init( + config: *const SignalSafeConfig, +) -> SignalSafeInitResult { let Some(config) = config.as_ref() else { - return false; + return SignalSafeInitResult::Failed; }; - init(&SignalSafeInitConfig { + init_result_to_ffi(init_result(&SignalSafeInitConfig { receiver_path: cstr_bytes(config.receiver_path), service: cstr_bytes(config.service), env: cstr_bytes(config.env), app_version: cstr_bytes(config.app_version), runtime_id: cstr_bytes(config.runtime_id), platform: cstr_bytes(config.platform), + library_name: cstr_bytes(config.library_name), + library_version: cstr_bytes(config.library_version), + family: cstr_bytes(config.family), + default_service: cstr_bytes(config.default_service), force_on_top: config.force_on_top, only_bootstrap: config.only_bootstrap, debug_logging: config.debug_logging, - }) + create_alt_stack: config.create_alt_stack, + use_alt_stack: config.use_alt_stack, + block_signals: config.block_signals, + disarm_on_entry: config.disarm_on_entry, + report_fd: config.report_fd, + collector_reap_ms: config.collector_reap_ms, + receiver_timeout_secs: config.receiver_timeout_secs, + max_frames: config.max_frames, + close_fds_on_receiver: config.close_fds_on_receiver, + })) } #[no_mangle] @@ -89,6 +126,29 @@ pub extern "C" fn ddog_crasht_signal_safe_set_stage(stage: SignalSafeStage) { }); } +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_capabilities() -> u32 { + capability_bits() +} + +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_owned_signal_count() -> u32 { + owned_signal_count() +} + +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_owns_signal(signum: i32) -> bool { + owns_signal(signum) +} + +fn init_result_to_ffi(result: InitResult) -> SignalSafeInitResult { + match result { + InitResult::Enabled => SignalSafeInitResult::Enabled, + InitResult::DisabledByConfig => SignalSafeInitResult::DisabledByConfig, + InitResult::Failed => SignalSafeInitResult::Failed, + } +} + unsafe fn cstr_bytes<'a>(ptr: *const c_char) -> &'a [u8] { if ptr.is_null() { &[] diff --git a/libdd-crashtracker/Cargo.toml b/libdd-crashtracker/Cargo.toml index fc5fd48d9a..688e40863d 100644 --- a/libdd-crashtracker/Cargo.toml +++ b/libdd-crashtracker/Cargo.toml @@ -58,7 +58,7 @@ std = [ # Enables the in-process collection of crash-info collector = ["std"] # Enables the signal-safe in-process collection of crash-info -collector_signal-safe = ["dep:heapless", "dep:libc", "dep:serde", "dep:serde-json-core"] +collector_signal-safe = ["dep:heapless", "dep:libc", "dep:rustix", "dep:serde", "dep:serde-json-core"] # Enables the use of this library to receiver crash-info from a suitable collector receiver = ["std"] # Enables the collection of crash-info on Windows @@ -93,6 +93,7 @@ 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.3", default-features = false, features = ["event", "fs", "pipe", "process", "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 } diff --git a/libdd-crashtracker/build.rs b/libdd-crashtracker/build.rs index ba44902fe0..1830af678e 100644 --- a/libdd-crashtracker/build.rs +++ b/libdd-crashtracker/build.rs @@ -119,6 +119,7 @@ fn main() { std::env::var("TARGET").unwrap() ); + #[cfg(feature = "std")] cc::Build::new() .file("src/crash_info/emit_sicodes.c") .compile("emit_sicodes"); diff --git a/libdd-crashtracker/src/collector/api.rs b/libdd-crashtracker/src/collector/api.rs index 874ff53d02..f3a3cd743c 100644 --- a/libdd-crashtracker/src/collector/api.rs +++ b/libdd-crashtracker/src/collector/api.rs @@ -4,10 +4,14 @@ 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, + signal_owner::{self, SignalOwner}, + update_config, update_metadata, CrashtrackerConfiguration, }; pub static DEFAULT_SYMBOLS: [libc::c_int; 4] = @@ -81,13 +85,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_signal_safe/backtrace.rs b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs index b36fc95b10..e157436583 100644 --- a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs +++ b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs @@ -94,14 +94,23 @@ fn arch_seed(_uc: &libc::ucontext_t, _out: &mut [usize]) -> (usize, usize) { (0, 0) } -pub fn backtrace_from_ucontext(out: &mut [usize], ucontext: *const c_void, self_pid: i32) -> usize { +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); - walk_fp(out, n, self_pid, fp) + if allow_memory_read { + walk_fp(out, n, self_pid, fp) + } else { + n + } } #[cfg(all(test, target_os = "linux"))] diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index f7c6a8b881..cd3b76f588 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -7,19 +7,35 @@ use core::sync::atomic::Ordering::Relaxed; use heapless::String as HeaplessString; -use super::state::{self, meta_mut}; +use super::state::meta_mut; +use super::{capabilities, state}; -pub const TRACE_C_VERSION: &str = match option_env!("DD_TRACE_C_VERSION") { +// Compatibility preset for the existing C-tracer consumer. New integrators should pass +// explicit metadata through SignalSafeInitConfig instead of relying on these defaults. +pub const COMPAT_LIBRARY_VERSION: &str = match option_env!("DD_TRACE_C_VERSION") { Some(v) => v, None => "dev", }; -const DEFAULT_RECEIVER_PATH: &str = match option_env!("DD_TRACE_C_CRASHTRACKER_PROCESS_PATH") { +// Prefer the neutral build-time receiver path name. The DD_TRACE_C_* name remains as a +// lower-priority compatibility alias for existing C-tracer package builds. +const DEFAULT_RECEIVER_PATH: &str = match option_env!("DD_CRASHTRACKING_RECEIVER_PATH") { Some(p) => p, - None => "/opt/datadog-packages/datadog-apm-library-c/stable/process-crash-receiver", + None => match option_env!("DD_TRACE_C_CRASHTRACKER_PROCESS_PATH") { + Some(p) => p, + None => "/opt/datadog-packages/datadog-apm-library-c/stable/process-crash-receiver", + }, }; +pub const COMPAT_LIBRARY_NAME: &str = "dd-trace-c"; +pub const COMPAT_LIBRARY_FAMILY: &str = "native"; +pub const COMPAT_DEFAULT_SERVICE: &str = "dd-trace-c"; + pub const RECEIVER_TIMEOUT_SECS: u32 = 5; +pub const COLLECTOR_REAP_MS: i32 = 500; +pub const RECEIVER_TIMEOUT_GRACE_MS: i32 = 1000; +pub const BACKTRACE_LEVELS_DEFAULT: usize = 32; +pub const BACKTRACE_LEVELS_MAX: usize = 64; pub const CRASH_SIGNALS: [i32; 5] = [ libc::SIGSEGV, @@ -31,7 +47,7 @@ pub const CRASH_SIGNALS: [i32; 5] = [ pub const CONFIG_JSON_BUF_SIZE: usize = 2048; -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug)] pub struct SignalSafeInitConfig<'a> { pub receiver_path: &'a [u8], pub service: &'a [u8], @@ -39,27 +55,78 @@ pub struct SignalSafeInitConfig<'a> { pub app_version: &'a [u8], pub runtime_id: &'a [u8], pub platform: &'a [u8], + pub library_name: &'a [u8], + pub library_version: &'a [u8], + pub family: &'a [u8], + pub default_service: &'a [u8], pub force_on_top: bool, pub only_bootstrap: bool, pub debug_logging: bool, + pub create_alt_stack: bool, + pub use_alt_stack: bool, + pub block_signals: bool, + pub disarm_on_entry: bool, + pub report_fd: i32, + pub collector_reap_ms: i32, + pub receiver_timeout_secs: u32, + pub max_frames: usize, + pub close_fds_on_receiver: bool, } -pub fn build_config_json(out: &mut HeaplessString) -> bool { +impl<'a> Default for SignalSafeInitConfig<'a> { + fn default() -> Self { + Self { + receiver_path: &[], + service: &[], + env: &[], + app_version: &[], + runtime_id: &[], + platform: &[], + library_name: COMPAT_LIBRARY_NAME.as_bytes(), + library_version: COMPAT_LIBRARY_VERSION.as_bytes(), + family: COMPAT_LIBRARY_FAMILY.as_bytes(), + default_service: COMPAT_DEFAULT_SERVICE.as_bytes(), + force_on_top: false, + 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, + receiver_timeout_secs: RECEIVER_TIMEOUT_SECS, + max_frames: BACKTRACE_LEVELS_DEFAULT, + close_fds_on_receiver: true, + } + } +} + +pub fn build_config_json( + out: &mut HeaplessString, + config: &SignalSafeInitConfig<'_>, +) -> bool { out.clear(); if out - .push_str( - "{\"additional_files\":[],\ - \"create_alt_stack\":false,\ - \"use_alt_stack\":false,\ - \"demangle_names\":true,\ - \"endpoint\":null,\ - \"resolve_frames\":\"EnabledWithSymbolsInReceiver\",\ - \"signals\":[", - ) + .push_str("{\"additional_files\":[],\"create_alt_stack\":") .is_err() { return false; } + if write!(out, "{}", config.create_alt_stack).is_err() + || out.push_str(",\"use_alt_stack\":").is_err() + || write!(out, "{}", config.use_alt_stack).is_err() + || out + .push_str( + ",\"demangle_names\":true,\ + \"endpoint\":null,\ + \"resolve_frames\":\"EnabledWithSymbolsInReceiver\",\ + \"signals\":[", + ) + .is_err() + { + return false; + } for (i, sig) in CRASH_SIGNALS.iter().enumerate() { if i > 0 && out.push(',').is_err() { @@ -72,14 +139,15 @@ pub fn build_config_json(out: &mut HeaplessString) -> bool writeln!( out, - "],\"timeout\":{{\"secs\":{RECEIVER_TIMEOUT_SECS},\"nanos\":0}},\"unix_socket_path\":null}}" + "],\"timeout\":{{\"secs\":{},\"nanos\":0}},\"unix_socket_path\":null}}", + normalized_receiver_timeout_secs(config.receiver_timeout_secs) ) .is_ok() } pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { let m = meta_mut(); - if !build_config_json(&mut m.config_json) { + if !build_config_json(&mut m.config_json, config) { return false; } @@ -91,6 +159,26 @@ pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { if m.platform.is_empty() { set_str(&mut m.platform, b"host"); } + set_str_or( + &mut m.library_name, + config.library_name, + COMPAT_LIBRARY_NAME.as_bytes(), + ); + set_str_or( + &mut m.library_version, + config.library_version, + COMPAT_LIBRARY_VERSION.as_bytes(), + ); + set_str_or( + &mut m.family, + config.family, + COMPAT_LIBRARY_FAMILY.as_bytes(), + ); + set_str_or( + &mut m.default_service, + config.default_service, + COMPAT_DEFAULT_SERVICE.as_bytes(), + ); if !set_receiver_path(&mut m.process_path, config.receiver_path) { return false; @@ -99,15 +187,35 @@ pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { state::FORCE_ON_TOP.store(config.force_on_top, Relaxed); state::ONLY_BOOTSTRAP.store(config.only_bootstrap, Relaxed); state::DEBUG_LOG.store(config.debug_logging, Relaxed); + state::CREATE_ALT_STACK.store(config.create_alt_stack, Relaxed); + state::USE_ALT_STACK.store(config.use_alt_stack, Relaxed); + state::BLOCK_SIGNALS.store(config.block_signals, Relaxed); + state::DISARM_ON_ENTRY.store(config.disarm_on_entry, Relaxed); + state::CLOSE_FDS_ON_RECEIVER.store(config.close_fds_on_receiver, Relaxed); + state::REPORT_FD.store(config.report_fd, Relaxed); + state::COLLECTOR_REAP_MS.store( + normalized_collector_reap_ms(config.collector_reap_ms), + Relaxed, + ); + state::RECEIVER_TIMEOUT_MS.store( + normalized_receiver_timeout_secs(config.receiver_timeout_secs) as i32 * 1000 + + RECEIVER_TIMEOUT_GRACE_MS, + Relaxed, + ); + state::MAX_FRAMES.store(normalized_max_frames(config.max_frames), Relaxed); + capabilities::publish(m.process_path.as_slice(), config.report_fd); true } pub fn prepare_from_env() -> bool { - if is_false(env_get(b"DD_CRASHTRACKING_ENABLED\0")) { + if disabled_by_env() { return false; } - let receiver_path = env_get(b"DD_TRACE_C_CRASHTRACKER_PROCESS\0") + // Prefer the neutral runtime receiver path name. DD_TRACE_C_CRASHTRACKER_PROCESS is + // retained as a lower-priority compatibility alias for existing deployments. + let receiver_path = env_get(b"DD_CRASHTRACKING_RECEIVER_PATH\0") + .or_else(|| env_get(b"DD_TRACE_C_CRASHTRACKER_PROCESS\0")) .filter(|v| !v.is_empty()) .unwrap_or(DEFAULT_RECEIVER_PATH.as_bytes()); let platform = env_get(b"DD_INJECT_SENDER_TYPE\0") @@ -125,9 +233,40 @@ pub fn prepare_from_env() -> bool { force_on_top: is_true(env_get(b"DD_CRASHTRACKING_ALWAYS_ON_TOP\0")), only_bootstrap: is_true(env_get(b"DD_CRASHTRACKING_ONLY_BOOTSTRAP\0")), debug_logging, + ..SignalSafeInitConfig::default() }) } +pub fn disabled_by_env() -> bool { + is_false(env_get(b"DD_CRASHTRACKING_ENABLED\0")) +} + +fn normalized_receiver_timeout_secs(value: u32) -> u32 { + if value == 0 { + RECEIVER_TIMEOUT_SECS + } else { + value + } +} + +fn normalized_collector_reap_ms(value: i32) -> i32 { + if value <= 0 { + COLLECTOR_REAP_MS + } 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]) { dst.clear(); if let Ok(s) = core::str::from_utf8(src) { @@ -139,6 +278,14 @@ fn set_str(dst: &mut HeaplessString, src: &[u8]) { } } +fn set_str_or(dst: &mut HeaplessString, src: &[u8], default: &[u8]) { + 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(); let selected = if path.is_empty() { @@ -243,7 +390,10 @@ mod tests { #[test] fn config_json_contains_receiver_contract() { let mut out = HeaplessString::::new(); - assert!(build_config_json(&mut out)); + assert!(build_config_json( + &mut out, + &SignalSafeInitConfig::default() + )); assert!(out.contains("\"additional_files\":[]")); assert!(out.contains("\"resolve_frames\":\"EnabledWithSymbolsInReceiver\"")); assert!(out.contains("\"unix_socket_path\":null")); @@ -251,7 +401,7 @@ mod tests { } #[test] - fn bool_and_log_parsing_matches_dd_trace_c() { + fn bool_and_log_parsing_matches_compatibility_inputs() { assert!(is_false(Some(b"FALSE"))); assert!(is_false(Some(b"0"))); assert!(!is_false(Some(b"true"))); @@ -263,6 +413,10 @@ mod tests { #[test] fn prepare_caches_fixed_metadata() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + assert!(prepare(&SignalSafeInitConfig { receiver_path: b"/tmp/receiver", service: b"svc", @@ -273,6 +427,7 @@ mod tests { force_on_top: true, only_bootstrap: true, debug_logging: true, + ..SignalSafeInitConfig::default() })); let meta = state::meta(); diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index 12dd8d5108..b0fc9f01e7 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -1,25 +1,31 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +use core::cell::UnsafeCell; use core::ffi::{c_char, c_int, c_void}; use core::ptr::null_mut; -use core::sync::atomic::{AtomicBool, Ordering::Relaxed}; +use core::sync::atomic::{AtomicBool, Ordering}; -use super::backtrace; -use super::config::{self, SignalSafeInitConfig, TRACE_C_VERSION}; +use super::config::{self, SignalSafeInitConfig}; use super::state::{self, sig_index, Stage}; use super::sys::{self, FdSink}; use super::{ app_handler_is_real, app_recovered, chain_action, is_genuine_fault, should_run_app_first, ChainAction, CrashContext, Disposition, Report, SignalInfo, }; +use super::{backtrace, capabilities}; +use crate::signal_owner::{self, SignalOwner}; const EXIT_CODE_FAILURE: i32 = 125; -const BACKTRACE_LEVELS: usize = 32; -const REAP_RECEIVER_TIMEOUT_MS: i64 = config::RECEIVER_TIMEOUT_SECS as i64 * 1000 + 1000; -const REAP_COLLECTOR_TIMEOUT_MS: i64 = 500; const REAP_KILL_TIMEOUT_MS: i64 = 500; const REAP_WAIT_INTERVAL_MS: i32 = 100; +const ALT_STACK_SIZE: usize = 64 * 1024; + +struct AltStackStorage(UnsafeCell<[u8; ALT_STACK_SIZE]>); + +unsafe impl Sync for AltStackStorage {} + +static ALT_STACK: AltStackStorage = AltStackStorage(UnsafeCell::new([0; ALT_STACK_SIZE])); unsafe extern "C" { static mut environ: *mut *mut c_char; @@ -31,30 +37,79 @@ struct Target { flags: i32, } +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InitResult { + Enabled = 0, + DisabledByConfig = 1, + Failed = 2, +} + pub fn init(config: &SignalSafeInitConfig<'_>) -> bool { + matches!(init_result(config), InitResult::Enabled) +} + +pub fn init_result(config: &SignalSafeInitConfig<'_>) -> InitResult { + if !state::begin_init() { + return InitResult::Failed; + } + if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { + state::fail_init(); + return InitResult::Failed; + } if !config::prepare(config) { - return false; + signal_owner::release(SignalOwner::SignalSafeCollector); + state::fail_init(); + return InitResult::Failed; + } + if !install_alt_stack_if_requested() { + signal_owner::release(SignalOwner::SignalSafeCollector); + state::fail_init(); + return InitResult::Failed; } install_all_handlers(); - state::HANDLERS_ENABLED.store(true, Relaxed); - state::INSTALLED.store(true, Relaxed); state::set_stage(Stage::CrashtrackerInit); - true + state::INSTALLED.store(true, Ordering::Release); + state::finish_init(); + state::HANDLERS_ENABLED.store(true, Ordering::Release); + InitResult::Enabled } pub fn init_from_env() -> bool { + matches!(init_from_env_result(), InitResult::Enabled) +} + +pub fn init_from_env_result() -> InitResult { + if config::disabled_by_env() { + return InitResult::DisabledByConfig; + } + if !state::begin_init() { + return InitResult::Failed; + } + if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { + state::fail_init(); + return InitResult::Failed; + } if !config::prepare_from_env() { - return false; + signal_owner::release(SignalOwner::SignalSafeCollector); + state::fail_init(); + return InitResult::Failed; + } + if !install_alt_stack_if_requested() { + signal_owner::release(SignalOwner::SignalSafeCollector); + state::fail_init(); + return InitResult::Failed; } install_all_handlers(); - state::HANDLERS_ENABLED.store(true, Relaxed); - state::INSTALLED.store(true, Relaxed); state::set_stage(Stage::CrashtrackerInit); - true + state::INSTALLED.store(true, Ordering::Release); + state::finish_init(); + state::HANDLERS_ENABLED.store(true, Ordering::Release); + InitResult::Enabled } pub fn bootstrap_complete() { - if state::ONLY_BOOTSTRAP.load(Relaxed) { + if state::ONLY_BOOTSTRAP.load(Ordering::Relaxed) { shutdown(); } else { state::set_stage(Stage::Application); @@ -63,15 +118,16 @@ pub fn bootstrap_complete() { pub fn shutdown() { state::set_stage(Stage::CrashtrackerUninstall); - state::HANDLERS_ENABLED.store(false, Relaxed); + state::HANDLERS_ENABLED.store(false, Ordering::Release); uninstall_all_handlers(); - state::INSTALLED.store(false, Relaxed); + state::INSTALLED.store(false, Ordering::Release); + signal_owner::release(SignalOwner::SignalSafeCollector); } fn effective_target(idx: usize) -> Target { Target { - fn_ptr: state::ORIG_FN[idx].load(Relaxed), - flags: state::ORIG_FLAGS[idx].load(Relaxed), + fn_ptr: state::ORIG_FN[idx].load(Ordering::Relaxed), + flags: state::ORIG_FLAGS[idx].load(Ordering::Relaxed), } } @@ -92,7 +148,7 @@ unsafe fn invoke_handler( } fn crash_debug(msg: &[u8], sig: i32) { - if !state::DEBUG_LOG.load(Relaxed) { + if !state::DEBUG_LOG.load(Ordering::Relaxed) { return; } let mut sink = FdSink::new(libc::STDERR_FILENO); @@ -175,6 +231,35 @@ fn reset_handlers_to_default() { } } +fn reset_signal_to_default(sig: 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); + libc::sigaction(sig, &dfl, null_mut()) == 0 + } +} + +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_alt_stack_if_requested() -> bool { + if !state::CREATE_ALT_STACK.load(Ordering::Relaxed) { + return true; + } + + let stack = libc::stack_t { + ss_sp: ALT_STACK.0.get().cast(), + ss_flags: 0, + ss_size: ALT_STACK_SIZE, + }; + unsafe { libc::sigaltstack(&stack, null_mut()) == 0 } +} + fn strip_loader_injection_env() { let env = unsafe { environ }; if env.is_null() { @@ -251,8 +336,37 @@ fn collector_child( sys::exit_process(EXIT_CODE_FAILURE); } - let mut frames = [0usize; BACKTRACE_LEVELS]; - let n = backtrace::backtrace_from_ucontext(&mut frames, ucontext, sys::getpid()); + let _ = emit_crash_report( + write_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, true, + ); + sys::exit_process(0); +} + +#[allow(clippy::too_many_arguments)] +fn emit_crash_report( + write_fd: i32, + sig: i32, + si_code: i32, + has_info: bool, + si_addr: usize, + pid: i32, + tid: i32, + ucontext: *mut c_void, + close_when_done: bool, +) -> bool { + let mut frames = [0usize; config::BACKTRACE_LEVELS_MAX]; + let max_frames = state::MAX_FRAMES + .load(Ordering::Relaxed) + .min(config::BACKTRACE_LEVELS_MAX); + let can_walk = capabilities::has(capabilities::PROC_VM_READV); + let n = backtrace::backtrace_from_ucontext(&mut frames[..max_frames], ucontext, pid, can_walk); + let stackwalk_method = if n == 0 { + "none" + } else if can_walk { + "fp_pvr" + } else { + "seed_only" + }; let meta = state::meta(); let runtime_id = if meta.runtime_id.is_empty() { @@ -262,13 +376,18 @@ fn collector_child( }; let report = Report { config_json: meta.config_json.as_str(), - trace_c_version: TRACE_C_VERSION, + 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, platform: meta.platform.as_str(), stage_name: state::current_stage_name(), + stackwalk_method, + degradation_bits: capabilities::degradations(), }; let context = CrashContext { signal: SignalInfo::new(sig, si_code, si_addr, has_info), @@ -278,16 +397,24 @@ fn collector_child( }; let mut sink = FdSink::new(write_fd); - let _ = super::emit_report(&mut sink, &report, &context); - sys::close(write_fd); - sys::exit_process(0); + let emitted = super::emit_report(&mut sink, &report, &context); + if close_when_done { + sys::close(write_fd); + } + emitted } fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) { let start = sys::monotonic_nanos(); loop { let waited = sys::waitpid_nohang(pid); - if waited == pid || waited < 0 { + if waited == pid { + return; + } + if waited < 0 { + if waited != -libc::ECHILD { + crash_debug(b"waitpid failed", -1); + } return; } @@ -304,22 +431,51 @@ fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) { } fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontext: *mut c_void) { + let pid = sys::getpid(); + let tid = sys::gettid(); + let report_fd = state::REPORT_FD.load(Ordering::Relaxed); + + let direct_report = |reason: u32| { + capabilities::note_degraded(reason | capabilities::DEGRADED_REPORT_TO_FD); + if report_fd >= 0 { + let _ = emit_crash_report( + report_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, false, + ); + } + }; + + if !capabilities::has(capabilities::FORK_OK) { + crash_debug(b"fork unavailable", sig); + direct_report(capabilities::DEGRADED_NO_FORK); + return; + } + if !capabilities::has(capabilities::RECEIVER_OK) { + crash_debug(b"receiver unavailable", sig); + direct_report(capabilities::DEGRADED_RECEIVER_UNAVAILABLE); + return; + } + if !capabilities::has(capabilities::PIPE_OK) { + crash_debug(b"pipe unavailable", sig); + direct_report(capabilities::DEGRADED_NO_PIPE); + return; + } + let mut fds = [0i32; 2]; if !sys::pipe(&mut fds) { + crash_debug(b"pipe failed", sig); + direct_report(capabilities::DEGRADED_PIPE_FAILED); return; } let read_fd = fds[0]; let write_fd = fds[1]; - let pid = sys::getpid(); - let tid = sys::gettid(); let receiver = unsafe { sys::fork_raw() }; if receiver == 0 { receiver_child(read_fd, write_fd); } - let mut collector = -1isize; + let collector: isize; if receiver > 0 { collector = unsafe { sys::fork_raw() }; if collector == 0 { @@ -327,26 +483,46 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex read_fd, write_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, ); } + } else { + crash_debug(b"receiver fork failed", sig); + sys::close(read_fd); + sys::close(write_fd); + direct_report(capabilities::DEGRADED_FORK_FAILED); + return; } sys::close(read_fd); sys::close(write_fd); if collector > 0 { - reap_or_kill(collector as i32, REAP_COLLECTOR_TIMEOUT_MS, true); + reap_or_kill( + collector as i32, + state::COLLECTOR_REAP_MS.load(Ordering::Relaxed) as i64, + true, + ); + } else if receiver > 0 { + crash_debug(b"collector fork failed", sig); + direct_report(capabilities::DEGRADED_FORK_FAILED); } if receiver > 0 { - reap_or_kill(receiver as i32, REAP_RECEIVER_TIMEOUT_MS, true); + reap_or_kill( + receiver as i32, + state::RECEIVER_TIMEOUT_MS.load(Ordering::Relaxed) as i64, + true, + ); } } extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *mut c_void) { - if !state::HANDLERS_ENABLED.load(Relaxed) { + if !state::HANDLERS_ENABLED.load(Ordering::Acquire) { return; } let saved_errno = sys::errno(); crash_debug(b"handler entered", sig); + if state::DISARM_ON_ENTRY.load(Ordering::Relaxed) { + let _ = reset_signal_to_default(sig); + } let idx = sig_index(sig); let has_info = !info.is_null(); @@ -356,20 +532,23 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m 0 }; - if !state::FORCE_ON_TOP.load(Relaxed) { - if let Some(i) = idx { - let target = effective_target(i); - let app_is_real = app_handler_is_real(target.fn_ptr); - if should_run_app_first(false, app_is_real) { - static IN_APP_CHAIN: AtomicBool = AtomicBool::new(false); - if !IN_APP_CHAIN.swap(true, Relaxed) { + let force_on_top = state::FORCE_ON_TOP.load(Ordering::Relaxed); + if let Some(i) = idx { + let target = effective_target(i); + let app_is_real = app_handler_is_real(target.fn_ptr); + if should_run_app_first(force_on_top, app_is_real) { + static IN_APP_CHAIN: AtomicBool = AtomicBool::new(false); + if !IN_APP_CHAIN.swap(true, Ordering::Relaxed) { + sys::set_errno(saved_errno); + // If the application handler recovers with siglongjmp, no code after this call + // runs. Keep this path free of Drop-dependent state. + unsafe { invoke_handler(&target, sig, info, ucontext) }; + + let handler_after = live_handler_for_recovery(sig).unwrap_or(target.fn_ptr); + IN_APP_CHAIN.store(false, Ordering::Relaxed); + if app_recovered(handler_after) { sys::set_errno(saved_errno); - unsafe { invoke_handler(&target, sig, info, ucontext) }; - if app_recovered(target.fn_ptr) { - IN_APP_CHAIN.store(false, Relaxed); - sys::set_errno(saved_errno); - return; - } + return; } } } @@ -383,7 +562,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m }; if is_genuine_fault(has_info, si_code, si_pid, self_pid) { static COLLECTING: AtomicBool = AtomicBool::new(false); - if !COLLECTING.swap(true, Relaxed) { + if !COLLECTING.swap(true, Ordering::Relaxed) { let si_addr = if has_info { unsafe { siginfo_addr(info) } } else { @@ -405,14 +584,12 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m let action = chain_action(disposition_of_target(target.fn_ptr), has_info, si_code); match action { ChainAction::RestoreDefaultAndRefault | ChainAction::RestoreDefaultAndReraise => { - let mut dfl: libc::sigaction = unsafe { core::mem::zeroed() }; - dfl.sa_sigaction = libc::SIG_DFL; + if !reset_signal_to_default(sig) { + sys::exit_process(EXIT_CODE_FAILURE); + } unsafe { - libc::sigemptyset(&mut dfl.sa_mask); - if libc::sigaction(sig, &dfl, null_mut()) != 0 { - sys::exit_process(EXIT_CODE_FAILURE); - } if let ChainAction::RestoreDefaultAndReraise = action { + unblock_signal(sig); libc::raise(sig); sys::exit_process(EXIT_CODE_FAILURE); } @@ -429,22 +606,51 @@ fn disposition_of_target(handler: *mut c_void) -> Disposition { super::disposition_of(handler) } -#[cfg(any(target_os = "linux", target_os = "android"))] +fn live_handler_for_recovery(sig: c_int) -> Option<*mut c_void> { + let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; + if query_sigaction(sig, &mut cur) { + Some(cur.sa_sigaction as *mut c_void) + } else { + None + } +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] unsafe fn siginfo_pid(info: *mut libc::siginfo_t) -> i32 { (*info).si_pid() } -#[cfg(not(any(target_os = "linux", target_os = "android")))] +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +)))] unsafe fn siginfo_pid(_info: *mut libc::siginfo_t) -> i32 { - sys::getpid() + i32::MIN } -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] unsafe fn siginfo_addr(info: *mut libc::siginfo_t) -> usize { (*info).si_addr() as usize } -#[cfg(not(any(target_os = "linux", target_os = "android")))] +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +)))] unsafe fn siginfo_addr(_info: *mut libc::siginfo_t) -> usize { 0 } @@ -477,8 +683,16 @@ fn install_crash_handler(sig: c_int) { 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::USE_ALT_STACK.load(Ordering::Relaxed) { + sa.sa_flags |= libc::SA_ONSTACK; + } unsafe { libc::sigemptyset(&mut sa.sa_mask); + if state::BLOCK_SIGNALS.load(Ordering::Relaxed) { + for &blocked in &config::CRASH_SIGNALS { + let _ = libc::sigaddset(&mut sa.sa_mask, blocked); + } + } } let mut old: libc::sigaction = unsafe { core::mem::zeroed() }; @@ -487,9 +701,9 @@ fn install_crash_handler(sig: c_int) { } if let Some(i) = sig_index(sig) { - state::ORIG_FN[i].store(old.sa_sigaction as *mut c_void, Relaxed); - state::ORIG_FLAGS[i].store(old.sa_flags, Relaxed); - state::OWN_SIGNAL[i].store(true, Relaxed); + state::ORIG_FN[i].store(old.sa_sigaction as *mut c_void, Ordering::Relaxed); + state::ORIG_FLAGS[i].store(old.sa_flags, Ordering::Relaxed); + state::OWN_SIGNAL[i].store(true, Ordering::Relaxed); } } @@ -508,7 +722,7 @@ fn uninstall_crash_handler(sig: c_int) { unsafe { libc::sigemptyset(&mut restore.sa_mask); if libc::sigaction(sig, &restore, null_mut()) == 0 { - state::OWN_SIGNAL[i].store(false, Relaxed); + state::OWN_SIGNAL[i].store(false, Ordering::Relaxed); } } } @@ -539,15 +753,20 @@ mod tests { assert_eq!(&buf[..n], b"42"); } + #[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 = SignalSafeInitConfig { receiver_path: b"/bin/cat", ..SignalSafeInitConfig::default() }; assert!(init(&config)); - assert!(state::INSTALLED.load(Relaxed)); + assert!(state::INSTALLED.load(Ordering::Acquire)); shutdown(); - assert!(!state::INSTALLED.load(Relaxed)); + assert!(!state::INSTALLED.load(Ordering::Acquire)); } } diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index 049ba5ecd4..2e0837996d 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -7,25 +7,48 @@ use heapless::{String as HeaplessString, Vec as HeaplessVec}; use serde::Serialize; mod backtrace; +mod capabilities; mod config; mod handler; mod state; mod sys; +#[cfg(test)] +pub(crate) static TEST_GLOBAL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + pub use config::{build_config_json, prepare, prepare_from_env, SignalSafeInitConfig}; -pub use handler::{bootstrap_complete, init, init_from_env, shutdown}; +pub use handler::{ + bootstrap_complete, init, init_from_env, init_from_env_result, init_result, shutdown, + InitResult, +}; pub use state::{set_stage, Stage}; pub use sys::FdSink; pub const SECTION_BUF_CAPACITY: usize = 4096; pub const TAG_CAPACITY: usize = 288; -pub const MAX_TAGS: usize = 12; +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; +pub fn capability_bits() -> u32 { + capabilities::get() +} + +pub fn degradation_bits() -> u32 { + capabilities::degradations() +} + +pub fn owned_signal_count() -> u32 { + state::owned_signal_count() +} + +pub fn owns_signal(sig: i32) -> bool { + state::owns_signal(sig) +} + pub trait Sink { fn put(&mut self, bytes: &[u8]) -> bool; } @@ -208,13 +231,18 @@ pub struct CrashContext<'a> { pub struct Report<'a> { pub config_json: &'a str, - pub trace_c_version: &'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 stage_name: &'a str, + pub stackwalk_method: &'a str, + pub degradation_bits: u32, } pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { @@ -232,7 +260,12 @@ pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { pub fn emit_report(sink: &mut impl Sink, report: &Report<'_>, context: &CrashContext<'_>) -> bool { emit_config(sink, report.config_json) && emit_metadata(sink, report) - && emit_additional_tags(sink, report.stage_name) + && emit_additional_tags( + sink, + report.stage_name, + report.stackwalk_method, + report.degradation_bits, + ) && emit_kind(sink) && emit_json_section( sink, @@ -268,7 +301,7 @@ pub fn emit_report_with_metadata( metadata, b"DD_CRASHTRACK_END_METADATA\n", ) - && emit_additional_tags(sink, stage_name) + && emit_additional_tags(sink, stage_name, "fp_pvr", 0) && emit_kind(sink) && emit_json_section( sink, @@ -386,12 +419,12 @@ fn emit_config(sink: &mut impl Sink, config_json: &str) -> bool { fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { let service = if report.service.is_empty() { - "dd-trace-c" + report.default_service } else { report.service }; - let mut metadata = Metadata::new("dd-trace-c", report.trace_c_version, "native"); + let mut metadata = Metadata::new(report.library_name, report.library_version, report.family); push_tag(&mut metadata.tags, "language", "native") && push_tag(&mut metadata.tags, "runtime", "native") && push_tag(&mut metadata.tags, "is_crash", "true") @@ -403,18 +436,18 @@ fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { && push_tag( &mut metadata.tags, "runtime_version", - report.trace_c_version, + report.library_version, ) && push_tag( &mut metadata.tags, "library_version", - report.trace_c_version, + report.library_version, ) && push_tag(&mut metadata.tags, "platform", report.platform) && push_tag( &mut metadata.tags, "injector_version", - report.trace_c_version, + report.library_version, ) && emit_json_section( sink, @@ -424,11 +457,24 @@ fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { ) } -fn emit_additional_tags(sink: &mut impl Sink, stage: &str) -> bool { +fn emit_additional_tags( + sink: &mut impl Sink, + stage: &str, + stackwalk_method: &str, + degradation_bits: u32, +) -> bool { let mut tags = Tags::new(); if !push_tag(&mut tags, "stage", stage) { return false; } + if !push_tag(&mut tags, "stackwalk_method", stackwalk_method) { + return false; + } + for &(bit, reason) in capabilities::DEGRADATION_REASONS { + if degradation_bits & bit != 0 && !push_tag(&mut tags, "report_degraded", reason) { + return false; + } + } emit_json_section( sink, b"DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS\n", @@ -692,13 +738,18 @@ mod tests { }; let report = Report { config_json: "{\"resolve_frames\":\"Disabled\"}", - trace_c_version: "golden-1.0", + library_name: config::COMPAT_LIBRARY_NAME, + library_version: "golden-1.0", + family: config::COMPAT_LIBRARY_FAMILY, + default_service: config::COMPAT_DEFAULT_SERVICE, service: "", env: "prod", app_version: "v1", runtime_id: "rid", platform: "linux", stage_name: "application", + stackwalk_method: "fp_pvr", + degradation_bits: 0, }; let mut buf = [0u8; 4096]; @@ -742,10 +793,16 @@ mod tests { 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"], "dd-trace-c"); + assert_eq!(metadata["library_name"], config::COMPAT_LIBRARY_NAME); assert_eq!(metadata["library_version"], "golden-1.0"); assert_eq!(metadata["tags"][0], "language:native"); - assert_eq!(metadata["tags"][4], "service:dd-trace-c"); + assert_eq!( + metadata["tags"][4] + .as_str() + .unwrap() + .strip_prefix("service:"), + Some(config::COMPAT_DEFAULT_SERVICE) + ); assert_eq!(metadata["tags"][5], "env:prod"); assert_eq!(metadata["tags"][6], "version:v1"); assert_eq!(tags[0], "stage:application"); diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index 35b4170056..cdc23e8cb0 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -1,9 +1,10 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +use core::cell::UnsafeCell; use core::ffi::c_void; -use core::ptr::{addr_of, addr_of_mut, null_mut}; -use core::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, Ordering::Relaxed}; +use core::ptr::null_mut; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, AtomicUsize, Ordering}; use heapless::{String as HeaplessString, Vec as HeaplessVec}; @@ -24,6 +25,10 @@ pub struct Meta { 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 { @@ -36,18 +41,52 @@ impl Meta { 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(), } } } -static mut META: Meta = Meta::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; +const INIT_FAILED: i32 = 3; + +static INIT_STATE: AtomicI32 = AtomicI32::new(INIT_UNINIT); + +pub fn begin_init() -> bool { + INIT_STATE + .compare_exchange( + INIT_UNINIT, + INIT_INITIALIZING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() +} + +pub fn finish_init() { + INIT_STATE.store(INIT_READY, Ordering::Release); +} + +pub fn fail_init() { + INIT_STATE.store(INIT_FAILED, Ordering::Release); +} pub fn meta() -> &'static Meta { - unsafe { &*addr_of!(META) } + unsafe { &*META.0.get() } } pub fn meta_mut() -> &'static mut Meta { - unsafe { &mut *addr_of_mut!(META) } + unsafe { &mut *META.0.get() } } pub static ORIG_FN: [AtomicPtr; NSIG] = [const { AtomicPtr::new(null_mut()) }; NSIG]; @@ -59,6 +98,15 @@ pub static FORCE_ON_TOP: AtomicBool = AtomicBool::new(false); pub static ONLY_BOOTSTRAP: AtomicBool = AtomicBool::new(false); pub static DEBUG_LOG: AtomicBool = AtomicBool::new(false); pub static INSTALLED: AtomicBool = AtomicBool::new(false); +pub static CREATE_ALT_STACK: AtomicBool = AtomicBool::new(false); +pub static USE_ALT_STACK: AtomicBool = AtomicBool::new(false); +pub static BLOCK_SIGNALS: AtomicBool = AtomicBool::new(true); +pub static DISARM_ON_ENTRY: AtomicBool = AtomicBool::new(false); +pub static CLOSE_FDS_ON_RECEIVER: AtomicBool = AtomicBool::new(true); +pub static REPORT_FD: AtomicI32 = AtomicI32::new(-1); +pub static COLLECTOR_REAP_MS: AtomicI32 = AtomicI32::new(500); +pub static RECEIVER_TIMEOUT_MS: AtomicI32 = AtomicI32::new(6_000); +pub static MAX_FRAMES: AtomicUsize = AtomicUsize::new(32); #[repr(i32)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -77,11 +125,11 @@ pub enum Stage { static STAGE: AtomicI32 = AtomicI32::new(Stage::Uninitialized as i32); pub fn set_stage(stage: Stage) { - STAGE.store(stage as i32, Relaxed); + STAGE.store(stage as i32, Ordering::Relaxed); } pub fn current_stage_name() -> &'static str { - match STAGE.load(Relaxed) { + match STAGE.load(Ordering::Relaxed) { 1 => "crashtracker_init", 2 => "platform_init", 3 => "language_init", @@ -97,9 +145,27 @@ pub fn current_stage_name() -> &'static str { pub fn clear_signal_state() { let mut i = 0usize; while i < NSIG { - ORIG_FN[i].store(null_mut(), Relaxed); - ORIG_FLAGS[i].store(0, Relaxed); - OWN_SIGNAL[i].store(false, Relaxed); + ORIG_FN[i].store(null_mut(), Ordering::Relaxed); + ORIG_FLAGS[i].store(0, Ordering::Relaxed); + OWN_SIGNAL[i].store(false, Ordering::Relaxed); + i += 1; + } +} + +pub fn owns_signal(sig: i32) -> bool { + sig_index(sig) + .map(|i| OWN_SIGNAL[i].load(Ordering::Acquire)) + .unwrap_or(false) +} + +pub fn owned_signal_count() -> u32 { + let mut count = 0u32; + let mut i = 0usize; + while i < NSIG { + if OWN_SIGNAL[i].load(Ordering::Acquire) { + count += 1; + } i += 1; } + count } diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index 1d8bb9e443..b1c5d77435 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -28,7 +28,7 @@ impl Sink for FdSink { retries = 0; continue; } - if n == -libc::EINTR as isize && retries < MAX_WRITE_RETRIES { + if n == -(libc::EINTR as isize) && retries < MAX_WRITE_RETRIES { retries += 1; continue; } @@ -43,9 +43,19 @@ impl Sink for FdSink { any(target_arch = "x86_64", target_arch = "aarch64") ))] mod raw { - use super::*; use core::arch::asm; use core::ffi::c_void; + use rustix::fd::{BorrowedFd, IntoRawFd}; + + #[inline] + fn neg_errno(err: rustix::io::Errno) -> isize { + -(err.raw_os_error() as isize) + } + + #[inline] + unsafe fn borrowed_fd(fd: i32) -> BorrowedFd<'static> { + BorrowedFd::borrow_raw(fd) + } #[cfg(target_arch = "x86_64")] #[inline] @@ -252,29 +262,16 @@ mod raw { ret } - #[inline] - fn cvt(ret: isize) -> isize { - if ret < 0 && ret >= -4095 { - ret - } else { - ret - } - } - pub fn write(fd: i32, bytes: &[u8]) -> isize { - cvt(unsafe { - syscall3( - libc::SYS_write, - fd as usize, - bytes.as_ptr() as usize, - bytes.len(), - ) - }) + match rustix::io::write(unsafe { borrowed_fd(fd) }, bytes) { + Ok(n) => n as isize, + Err(err) => neg_errno(err), + } } pub fn close(fd: i32) { unsafe { - syscall1(libc::SYS_close, fd as usize); + rustix::io::close(fd); } } @@ -297,7 +294,14 @@ mod raw { } pub fn pipe(fds: &mut [i32; 2]) -> bool { - unsafe { syscall2(libc::SYS_pipe2, fds.as_mut_ptr() as usize, 0) == 0 } + match rustix::pipe::pipe() { + Ok((read_fd, write_fd)) => { + fds[0] = read_fd.into_raw_fd(); + fds[1] = write_fd.into_raw_fd(); + true + } + Err(_) => false, + } } pub fn open_readwrite(path: *const u8) -> i32 { @@ -312,6 +316,21 @@ mod raw { } } + pub fn access_executable(path: *const u8) -> bool { + unsafe { + syscall3( + libc::SYS_faccessat, + libc::AT_FDCWD as usize, + path as usize, + libc::X_OK as usize, + ) == 0 + } + } + + pub fn fork_supported() -> bool { + true + } + pub unsafe fn fork_raw() -> isize { #[cfg(target_arch = "x86_64")] { @@ -356,11 +375,11 @@ mod raw { } pub fn getpid() -> i32 { - unsafe { syscall0(libc::SYS_getpid) as i32 } + rustix::process::getpid().as_raw_pid() } pub fn gettid() -> i32 { - unsafe { syscall0(libc::SYS_gettid) as i32 } + rustix::thread::gettid().as_raw_pid() } pub fn kill(pid: i32, sig: i32) -> i32 { @@ -381,9 +400,14 @@ mod raw { } pub fn poll_sleep_ms(timeout_ms: i32) { - unsafe { - let _ = syscall3(libc::SYS_poll, 0, 0, timeout_ms as usize); + 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); } #[repr(C)] @@ -393,23 +417,10 @@ mod raw { } pub fn monotonic_nanos() -> i64 { - let mut ts = KernelTimespec { - tv_sec: 0, - tv_nsec: 0, - }; - let rc = unsafe { - syscall2( - libc::SYS_clock_gettime, - libc::CLOCK_MONOTONIC as usize, - (&mut ts as *mut KernelTimespec) as usize, - ) - }; - if rc != 0 { - return 0; - } + let ts = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic); ts.tv_sec .wrapping_mul(1_000_000_000) - .wrapping_add(ts.tv_nsec) + .wrapping_add(ts.tv_nsec as i64) } #[repr(C)] @@ -448,7 +459,12 @@ mod raw { )))] mod raw { pub fn write(fd: i32, bytes: &[u8]) -> isize { - unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) } + let ret = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) }; + if ret < 0 { + -(super::errno() as isize) + } else { + ret + } } pub fn close(fd: i32) { @@ -473,8 +489,16 @@ mod raw { unsafe { libc::open(path.cast(), libc::O_RDWR) } } + pub fn access_executable(path: *const u8) -> bool { + unsafe { libc::access(path.cast(), libc::X_OK) == 0 } + } + + pub fn fork_supported() -> bool { + false + } + pub unsafe fn fork_raw() -> isize { - libc::fork() as isize + -(libc::ENOSYS as isize) } pub fn exit_process(code: i32) -> ! { @@ -508,7 +532,12 @@ mod raw { pub fn waitpid_nohang(pid: i32) -> i32 { let mut status = 0i32; - unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) } + let ret = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; + if ret < 0 { + -super::errno() + } else { + ret + } } pub fn poll_sleep_ms(timeout_ms: i32) { @@ -537,8 +566,9 @@ mod raw { } pub use raw::{ - close, dup2, exit_process, fcntl_dupfd, fork_raw, getpid, gettid, kill, monotonic_nanos, - open_readwrite, pipe, poll_sleep_ms, read_own_mem, waitpid_nohang, write, + access_executable, close, dup2, exit_process, fcntl_dupfd, fork_raw, fork_supported, getpid, + gettid, kill, monotonic_nanos, open_readwrite, pipe, poll_sleep_ms, read_own_mem, + waitpid_nohang, write, }; pub fn errno() -> i32 { diff --git a/libdd-crashtracker/src/lib.rs b/libdd-crashtracker/src/lib.rs index 44733f3b07..84bb48c7fa 100644 --- a/libdd-crashtracker/src/lib.rs +++ b/libdd-crashtracker/src/lib.rs @@ -51,11 +51,6 @@ #![cfg_attr(not(test), deny(clippy::unimplemented))] #![cfg_attr(not(feature = "std"), no_std)] -#[cfg(all(feature = "std", feature = "collector_signal-safe"))] -compile_error!( - "The collector_signal-safe feature requires no_std; build with --no-default-features and without the std feature." -); - #[cfg(all(not(unix), feature = "collector_signal-safe"))] compile_error!("The collector_signal-safe feature is only supported on Unix targets."); @@ -64,7 +59,7 @@ extern crate std; #[cfg(all(unix, feature = "std", feature = "collector"))] mod collector; -#[cfg(all(unix, feature = "collector_signal-safe", not(feature = "std")))] +#[cfg(all(unix, feature = "collector_signal-safe"))] pub mod collector_signal_safe; #[cfg(all(windows, feature = "std", feature = "collector_windows"))] mod collector_windows; @@ -76,6 +71,8 @@ mod crash_info; 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( diff --git a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index 75e13aa2d0..6a25e4aff2 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -1,19 +1,24 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -#![cfg(all(unix, feature = "collector_signal-safe", not(feature = "std")))] +#![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; +#[cfg(any(target_os = "linux", target_os = "android"))] +use libdd_crashtracker::collector_signal_safe::init_from_env; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, init_from_env, set_stage, Stage, + bootstrap_complete, init, set_stage, SignalSafeInitConfig, Stage, }; +#[cfg(any(target_os = "linux", target_os = "android"))] #[test] -fn signal_safe_child_process() { - if std::env::var_os("DD_SIGNAL_SAFE_E2E_CHILD").is_none() { +fn signal_safe_receiver_child_process() { + if std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER_CHILD").is_none() { return; } @@ -25,7 +30,30 @@ fn signal_safe_child_process() { } #[test] -fn signal_safe_crash_writes_report() { +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!(init(&SignalSafeInitConfig { + 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(), + ..SignalSafeInitConfig::default() + })); + bootstrap_complete(); + set_stage(Stage::Application); + + 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"); @@ -42,9 +70,9 @@ fn signal_safe_crash_writes_report() { let current_exe = std::env::current_exe().expect("current_exe"); let status = Command::new(current_exe) .arg("--exact") - .arg("signal_safe_child_process") + .arg("signal_safe_receiver_child_process") .arg("--nocapture") - .env("DD_SIGNAL_SAFE_E2E_CHILD", "1") + .env("DD_SIGNAL_SAFE_E2E_RECEIVER_CHILD", "1") .env("DD_SIGNAL_SAFE_E2E_REPORT", &report) .env("DD_TRACE_C_CRASHTRACKER_PROCESS", &receiver) .env("DD_SERVICE", "signal-safe-e2e") @@ -57,13 +85,39 @@ fn signal_safe_crash_writes_report() { 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\"")); +} + +#[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\"")); +} + +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("\"si_signo_human_readable\":\"SIGABRT\"")); assert!(report.contains("DD_CRASHTRACK_BEGIN_PROCESSINFO\n")); assert!(report.contains("DD_CRASHTRACK_BEGIN_STACKTRACE\n")); - assert!(report.contains("Crash during application (SIGABRT)")); assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); } From 8ceed85b0b44512ad888409b00cbb4b6870bc317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 01:20:25 +0200 Subject: [PATCH 05/32] wip --- ...gnal_safe_crashtracker_improvement_plan.md | 618 ------------------ 1 file changed, 618 deletions(-) delete mode 100644 in_signal_safe_crashtracker_improvement_plan.md diff --git a/in_signal_safe_crashtracker_improvement_plan.md b/in_signal_safe_crashtracker_improvement_plan.md deleted file mode 100644 index 02b1e02ef0..0000000000 --- a/in_signal_safe_crashtracker_improvement_plan.md +++ /dev/null @@ -1,618 +0,0 @@ -# Signal-Safe Crashtracker Improvement Plan - -Status: draft plan, written 2026-07-05 on branch `signal_safe_crashtracker` (HEAD `84eb21715`). -Audience: an engineer or LLM agent picking this work up with no prior context. - -Terminology used throughout (see Workstream H for the code-side rename): - -- **app-first policy** — default on-crash policy: if the application installed a real - handler, run it first; if it recovers, do not report; if it gave up (restored - `SIG_DFL`), report and terminate. Safe for managed runtimes (HotSpot, V8, .NET, - Python faulthandler) that use SIGSEGV non-fatally. -- **report-first policy** — opt-in (`DD_CRASHTRACKING_ALWAYS_ON_TOP=true`): report - first, then chain to the application handler. - ---- - -## 1. Context - -Branch `signal_safe_crashtracker` added a signal-safe, `no_std`-style in-process crash -collector to `libdd-crashtracker`, targeting preload/injection deployments where the -signal handler, the forked collector child, and the receiver child before `execve` must -all stay async-signal-safe: no allocation, no locks, no stdio, no `Drop`-dependent -cleanup, no panics. `crashtracker-work-we-need-to-do.md` at the repo root holds the full -gap analysis this branch was cut from; this plan builds on it and does not repeat it. - -Code added by commit `84eb21715`: - -| File | Role | -|---|---| -| `libdd-crashtracker/src/collector_signal_safe/mod.rs` | Wire emitter (`DD_CRASHTRACK_*` sections), chain-policy pure functions, `Sink` trait | -| `libdd-crashtracker/src/collector_signal_safe/handler.rs` | Signal handler, fork of receiver+collector children, reap loop, install/uninstall lifecycle | -| `libdd-crashtracker/src/collector_signal_safe/sys.rs` | Raw inline-asm syscalls (x86_64/aarch64 Linux) + libc fallback for other Unix | -| `libdd-crashtracker/src/collector_signal_safe/backtrace.rs` | Frame-pointer walk seeded from `ucontext`, probing memory via `process_vm_readv` | -| `libdd-crashtracker/src/collector_signal_safe/config.rs` | Init config, env snapshot (`DD_*` vars), fixed config JSON contract | -| `libdd-crashtracker/src/collector_signal_safe/state.rs` | Static state: `Meta` strings (heapless), per-signal atomics, stage tracking | -| `libdd-crashtracker-ffi/src/collector_signal_safe.rs` | C ABI: `ddog_crasht_signal_safe_{init,init_from_env,bootstrap_complete,shutdown,set_stage}` | -| `libdd-crashtracker/tests/collector_signal_safe_e2e.rs` | One e2e test (abort → report through a shell-script receiver) | - -Feature gate: `collector_signal-safe` on both crates. The module is only compiled with -`--no-default-features --features collector_signal-safe` because `lib.rs` has a -`compile_error!` when `std` and `collector_signal-safe` are both enabled -(`libdd-crashtracker/src/lib.rs:54-57`). - -Goals of this plan, in priority order: - -1. Fix verified defects (some are build-breaking). -2. **Eliminate libc fallbacks on the crash path — `libc::fork()` above all** (A5/A5a). -3. Handle missing OS/environment capabilities gracefully (seccomp, missing receiver, containers, non-Linux). -4. Add safety options (alt stack, disarm-on-entry, report-to-fd, tunable budgets). -5. Build the test/CI story (currently zero CI coverage). -6. Adopt `rustix` for the syscall layer — decided — under a strict no-`dlsym` constraint (§6). -7. Complete the policy machinery: sigaction/signal virtualization and a working app-first policy (§5). -8. Decouple naming, constants, and metadata from the originating C-tracer integration (§9, Workstream H). - ---- - -## 2. Verified defects (fix first — Workstream A) - -These were confirmed by building/inspecting on 2026-07-05. Each is independently fixable. - -### A1. `--all-features` breaks the whole workspace validation (BUILD-BREAKING) - -`cargo check -p libdd-crashtracker --all-features` fails today with: - -``` -error: The collector_signal-safe feature requires no_std; build with --no-default-features and without the std feature. -``` - -AGENTS.md's standard validation includes -`cargo nextest run --workspace --all-features --exclude builder --exclude test_spawn_from_lib`, -which enables both `std` and `collector_signal-safe` → the documented CI/validation flow -cannot pass. Cargo features must be additive; a `compile_error!` on a feature -combination violates that. - -**Fix:** remove the `compile_error!` at `libdd-crashtracker/src/lib.rs:54-57` and make the -features coexist. The `collector_signal_safe` module is already `core`-only + `libc` + -`heapless` + `serde-json-core`; nothing in it needs `not(feature = "std")`. Change the -module gate at `lib.rs:67` to `#[cfg(all(unix, feature = "collector_signal-safe"))]`. -Guard against *both* collectors being armed at runtime (an atomic "a collector owns the -signals" flag consulted by both `init` paths), not at compile time. This also unlocks the -golden-wire test in §7 (emit with the signal-safe emitter, parse with the std receiver, in -one test binary). - -### A2. Does not compile on aarch64-linux: `libc::SYS_poll` does not exist there - -`sys.rs:385` uses `libc::SYS_poll` in `poll_sleep_ms`. The aarch64 Linux syscall table has -no `poll` — only `ppoll` (verified against libc 0.2.186 sources: aarch64 defines -`SYS_ppoll = 73` and no `SYS_poll`). The raw-asm module is compiled for -`target_arch = "aarch64"`, so an aarch64 build of the feature fails. - -**Fix:** use `SYS_ppoll` with a `KernelTimespec` (the struct already exists in sys.rs for -`clock_gettime`), or `SYS_clock_nanosleep`. Add an aarch64 `cargo check` to CI (§7) so -this class of bug can't land again. The rustix adoption (§6) removes this bug class -structurally; fix it directly only if the rustix PR doesn't land first. - -### A3. App-first "did the app handler recover?" check is a tautology — crashes are never reported when an app handler exists - -`handler.rs:359-375`: after invoking the app handler first, the code checks -`app_recovered(target.fn_ptr)` — but `target.fn_ptr` is the pointer captured *before* the -call, which is by construction a real handler (`Disposition::Handler`), so -`app_recovered()` always returns true and the function always returns without reporting. - -Consequence: with any app crash handler installed at crashtracker-init time, a genuine -fault that the app handler does **not** recover from (it just returns, or restores -`SIG_DFL` and returns) produces **no crash report**. Correct app-first semantics: after -the app-first call, re-read the *current* disposition; only skip reporting if the app -kept a non-default handler installed (recovery contract), report if it reset to -`SIG_DFL`. - -**Fix:** after `invoke_handler` returns, re-query the live disposition with -`sigaction(sig, NULL, &cur)` (later: from the virtualized table, §5) and pass *that* to -`app_recovered`. Also note the `siglongjmp` hazard: if the app handler longjmps out, no -code after `invoke_handler` runs — which is the correct "recovered" outcome, and is why no -`Drop`-bearing state may be live across that call (there is none today; keep it that way -and add a comment stating the invariant). - -Related smaller issues in the same block: -- `IN_APP_CHAIN` (handler.rs:364) is never reset on the not-recovered path. Today the - process usually dies afterwards so it's latent, but combined with `ChainAction::Resume` - paths it can wedge the app-first flow for a later signal. Reset it deterministically. -- The `should_run_app_first(false, ...)` call hardcodes `force_on_top = false` because the - outer `if` already checked `FORCE_ON_TOP` — fine, but fold the check into one place so - the pure function is actually exercised with real inputs. - -### A4. `FdSink` EINTR retry is wrong on the libc fallback path - -`sys.rs` raw path returns `-errno` from the syscall directly, and `FdSink::put` -(sys.rs:31) tests `n == -libc::EINTR as isize`. The **libc fallback** `write` returns -`-1` with the error in `errno` — so on macOS/other-Unix, EINTR is treated as a hard -failure and the report is truncated. **Fix:** normalize both backends to one error -convention (return `-errno`), or check `errno()` when `n == -1` in the fallback. Covered -automatically by adopting `rustix` (§6). - -### A5. libc fallback `fork_raw` is `libc::fork()` — runs `pthread_atfork` handlers. BAN IT. - -`sys.rs:476`. `fork()` itself is nominally async-signal-safe per POSIX, but glibc and -macOS libc run `pthread_atfork` handlers which may take locks and allocate — in a -crashed, possibly lock-holding process this deadlocks or corrupts. This is exactly what -the raw `clone(SIGCHLD)` on Linux avoids, and it defeats the entire point of the -signal-safe collector on any target that hits the fallback. - -**Fix (hard policy, not a patch): `libc::fork()` must not exist anywhere in this module.** -Delete `fork_raw` from the fallback `mod raw` entirely. Fork-based collection is only -available where a raw, atfork-free process-creation primitive exists — today that is -Linux x86_64/aarch64 via raw `clone(SIGCHLD)`. On every other target, `collect_crash` -must take a no-fork path: report-to-fd synchronous emit (§4.4) if configured, else -breadcrumb + clean chaining. There is no supported raw-syscall fork on macOS (Apple's -syscall ABI is private and `pthread_atfork` bypass is unsupported), so macOS gets the -no-fork degraded mode by design, not by accident — the existing std collector remains -the macOS answer. See A5a for the full fallback policy this is part of. - -### A5a. Shrink the libc fallback tier to an explicit allowlist - -The whole `#[cfg(not(linux+x86_64/aarch64))] mod raw` fallback (sys.rs:445-537) is -currently a silent "port" to targets nobody has audited. Replace the open-ended fallback -with an explicit three-tier policy: - -- **Tier 1 (full support, fork-based collection):** Linux x86_64/aarch64 — raw syscalls - only (asm today, `rustix` linux_raw after §6). Zero libc on the crash path. -- **Tier 2 (degraded, no-fork):** other Unix (macOS first). Only libc functions on the - POSIX async-signal-safe list AND free of hidden locks/allocation are allowed, from a - written allowlist: `write`, `close`, `dup2`, `kill`, `_exit`, `clock_gettime`, - `sigaction`, `sigemptyset`, `raise`, `poll`. Explicitly banned on any crash-reachable - path: `fork`, `posix_spawn`, `execv`-after-libc-fork, `waitpid` loops that assume a - child we can't create, `malloc`-adjacent anything, `getenv` (init-only), `dlsym` - (init-only, and see §6 — the shipped artifact must have no `dlsym` reference at all). - Collection on Tier 2 = seed-frame minimal report to a pre-opened fd. -- **Tier 3 (unsupported):** everything else — `compile_error!` naming the feature, so a - new target is a conscious porting decision instead of an untested fallback. - -Enforcement, not convention: the §7.5 symbol guard must fail on `fork`, `posix_spawn`, -`pthread_atfork`, and `dlsym` references in the crash-path objects, and CI builds Tier 2 -(macOS or a cfg-emulated build) so the allowlist cfg boundaries stay honest. - -### A6. Non-Linux `siginfo_pid` fallback misclassifies external kills as genuine faults - -`handler.rs:437-440`: the fallback returns `sys::getpid()`, which makes -`is_genuine_fault(si_code == SI_USER, si_pid == self_pid)` true for **any** external -`kill -SEGV `. On macOS, external async signals would be reported as crashes — -the exact thing the filter exists to prevent. **Fix:** on non-Linux read `si_pid` from the -proper `siginfo_t` field (it exists on macOS/BSD), or return a sentinel that makes the -filter answer "not genuine". - -### A7. `static mut META` + unsynchronized publication - -`state.rs:43-51`: `meta_mut()` hands out `&'static mut` from a `static mut`; two threads -calling `init` concurrently is UB, and all atomics use `Relaxed`, so the handler-enable -flag store does not *publish* the `META` writes to other cores. -**Fix:** make init single-shot via an atomic state machine -(`Uninit -> Initializing -> Ready`, CAS to enter), reject concurrent/second init, and -store `HANDLERS_ENABLED`/`INSTALLED` with `Release` + load with `Acquire` in the handler -path so `META` writes happen-before any handler execution. This keeps the handler -lock-free (reads only after an `Acquire` load of a `Ready` flag). - -### A8. Hygiene (small, do in the same PR as A1) - -- Unused import warning when building only the signal-safe feature (`cargo check - -p libdd-crashtracker --no-default-features --features collector_signal-safe` warns). -- `cvt()` in sys.rs:255-262 is a no-op (`if` and `else` both return `ret`) — delete or - make it meaningful. -- Feature name `collector_signal-safe` mixes `_` and `-`. Before anything ships, rename to - `collector-signal-safe` (or `signal-safe-collector`) on both crates. Breaking-change - cost is zero right now; nonzero later. -- The e2e test's cfg (`not(feature = "std")`) must be updated when A1 lands (drop the - `not(std)` condition). - ---- - -## 3. Workstream B — Missing-capability handling and graceful degradation - -Principle: **probe at init time (safe context), record results in atomics, degrade with -visibility at crash time (constrained context).** Never discover a missing capability for -the first time inside the signal handler, and never fail silently — a degraded report -must say *how* it is degraded. - -### B1. Init-time capability probe - -Add a `capabilities` module + `Capabilities` bitset (one `AtomicU32`), populated by -`init`/`init_from_env` before arming handlers: - -| Capability | Probe | Degradation if absent | -|---|---|---| -| `RECEIVER_OK` | `access(receiver_path, X_OK)` (raw `faccessat`) | Don't fork a receiver at crash time; use report-to-fd mode if configured (§4), else skip collection, still chain correctly | -| `PROC_VM_READV` | Perform one `process_vm_readv` self-read at init (read a stack local) | Stack walk emits seed frames only (IP/LR from ucontext); tag `stackwalk_method:seed_only` | -| `FORK_OK` | Optional: probe `clone(SIGCHLD)`+`_exit(0)`+reap of a trivial child at init | If seccomp forbids fork: in-process minimal emit to pre-opened fd (§4) or nothing; record in tag. Always false on Tier 2 targets (A5a) | -| `DEV_NULL` | `openat(/dev/null)` once | Child stdio redirection skipped; report still emitted (chroot/minimal-container case) | -| `PIPE_OK` | trivially true if `pipe2` works at init | crash-time `pipe2` can still fail on `EMFILE` — handle by aborting collection cleanly and chaining | - -Document explicitly (doc comment + `crashtracker-work-we-need-to-do.md` cross-ref): a -seccomp policy of `SECCOMP_RET_KILL` on `process_vm_readv` kills the collector child — -the init-time probe is precisely what detects this without losing the main process -(probe in a sacrificial child if `FORK_OK`; the gap-analysis doc §11 calls this the -"preflight probe"). First cut: probe in-process for `EPERM`-style failures, -sacrificial-child probe as a follow-up. - -### B2. Degradation visibility on the wire - -Extend `emit_additional_tags` (mod.rs:427) to append, beyond `stage:`: - -- `stackwalk_method:` -- `report_degraded:` when any capability was missing (comma-free single token; - emit one tag per reason). -- Keep tag capacity math in mind: `MAX_TAGS = 12`, `TAG_CAPACITY = 288` (mod.rs:21-22) — - raise `MAX_TAGS` as needed; it's compile-time cost only. - -### B3. Crash-time failure paths that currently lose the report silently - -- `collect_crash` (handler.rs:306): if `pipe` fails → returns with no breadcrumb. Add a - `crash_debug` line for every bail-out (pipe fail, fork fail, receiver fail) so - `DD_TRACE_LOG_LEVEL=debug` diagnoses field issues. -- `receiver_child` exec failure (`execv` returned): exits 125; the parent can't tell - "receiver missing" from "receiver crashed". With `RECEIVER_OK` probed at init this - becomes rare, but still log via `crash_debug` in the parent when the receiver's reap - discovers exit-by-125. -- `reap_or_kill` (handler.rs:286): `waited < 0` (e.g. `ECHILD` if the app has a - `SIGCHLD` reaper thread that stole the wait, or `SA_NOCLDWAIT` is set) returns - immediately — the receiver may then be killed by the subsequent code path or outlive - wrongly. Handle `-ECHILD` as "someone reaped it, treat as done", other negatives as - errors + breadcrumb. Note `SA_NOCLDWAIT`/`SIG_IGN`-on-SIGCHLD as a documented - limitation (children get auto-reaped; `wait4` returns `ECHILD` immediately — the - bounded poll loop then must fall back to a fixed sleep before `SIGKILL`). - -### B4. Missing-capability install cases - -- `install_crash_handler` (handler.rs:472) silently no-ops when a signal already has a - non-default handler (deliberate: only claim `SIG_DFL` signals). Record which signals - were *not* claimed (`OWN_SIGNAL` already exists; also breadcrumb + expose a count from - `init` return or an FFI query `ddog_crasht_signal_safe_owned_signals()`) so - integrators can tell "installed but owns 0 signals" from "installed". -- `init_from_env` returning `bool` collapses "disabled by env" and "failed". Return a - 3-state enum through FFI (`Enabled`, `DisabledByConfig`, `Failed`) — C ABI stability is - not required (repo convention: no C ABI backward-compat guarantees). - ---- - -## 4. Workstream C — Safety options - -New fields on `SignalSafeInitConfig` (and the FFI struct). Defaults preserve current -wire-contract behavior (everything off unless noted). - -1. **Alternate signal stack** (`create_alt_stack`, `use_alt_stack` — mirrors the std - collector's names). Without `SA_ONSTACK`, a stack-overflow SIGSEGV re-faults inside - the handler prologue and the process dies with no report. Implement: `sigaltstack` - with a statically reserved buffer (no mmap needed for the minimal case; static array - of `SIGSTKSZ*2`), set `SA_ONSTACK` when enabled. Keep default off; the config JSON - currently hardcodes `create_alt_stack:false` and must instead reflect the actual - runtime choice — `build_config_json` (config.rs:47) takes these options instead of - hardcoding. -2. **sa_mask policy**: currently `sigemptyset` (handler.rs:481) — another managed signal - arriving on another thread mid-collection is only guarded by the `COLLECTING` latch. - Option to block all managed crash signals in `sa_mask` during handling (default on — - it is strictly safer and cheap). -3. **Disarm-on-entry (double-fault safety)**: option to reset the current signal to - `SIG_DFL` *immediately on handler entry* (before collection), so a fault inside the - handler itself terminates the process with the kernel's original context instead of - recursing. Trade-off vs. app-first chaining (which needs the handler to stay resident - for `Resume`) — document; default off under the app-first policy, consider default-on - under report-first. -4. **Report-to-fd mode**: `report_fd: Option` — when the receiver can't be spawned - (`FORK_OK`/`RECEIVER_OK` absent, or Tier 2 target per A5a) or by explicit choice, emit - the report directly from the handler (no fork at all) into a caller-pre-opened fd - (file or socket). This is the capability-degraded backstop for hardened seccomp - containers **and the only collection mode on Tier 2**. The emitter already works - against any `Sink`; this is mostly plumbing plus a documented invariant that the fd - was opened `O_APPEND|O_CLOEXEC` at init. -5. **Tunable budgets**: `collector_reap_ms` (default 500), `receiver_timeout_secs` - (default 5), `max_frames` (default 32, cap at a compile-time `BACKTRACE_LEVELS_MAX`). - Currently constants at handler.rs:18-22. -6. **Receiver child fd hygiene option**: `close_range(3, ~0U, 0)` in the receiver child - before `execv` (after the report fd is dup'ed to stdin) so app fds don't leak into the - receiver. Raw syscall, Linux 5.9+; fall back to nothing if `ENOSYS`. Default on. -7. **Memory-ordering + single-shot init** — see A7; it belongs to this workstream's - "make the state machine safe" umbrella. - ---- - -## 5. Workstream D — sigaction/signal virtualization and completing the crash policies - -Virtualization is the largest remaining gap *and* it blocks a correct app-first policy -(A3 fixes the tautology, but without interposition the crashtracker never learns about -handlers registered **after** init — `ORIG_FN` only holds install-time state, and a late -app registration simply displaces our handler entirely). - -Phased approach: - -1. **D1 — live re-query (no interposition):** A3's fix. The app-first path consults - `sigaction(sig, NULL, ...)` at crash time to find the current app handler. Correct for - the "app registered before us" case; still blind to "app displaced us later". Cheap, - land with Workstream A. -2. **D2 — exported-symbol interposition (preload artifact only):** new feature - `signal-interpose` on `libdd-crashtracker-ffi` that exports `sigaction` and `signal` - symbols; resolve the real functions **at init, never in the signal path** — and per - the no-`dlsym` constraint (§6), prefer direct `syscall(SYS_rt_sigaction)` for the - pass-through so no `dlsym(RTLD_NEXT)` is needed at all; if a true libc pass-through - proves necessary, isolate any one-time symbol lookup to init and keep it out of the - shipped-symbol guard's crash-path objects. For managed signals, record the app's - requested disposition into the existing per-signal atomics (`ORIG_FN`/`ORIG_FLAGS` — - add `APP_SET: [AtomicBool; NSIG]`), answer `oldact` from virtual state, and do not let - the kernel handler change. Own installs bypass the wrapper. Wrappers must be - transparent when crashtracker is disabled / doesn't own the signal. Known, documented - limitations: raw `rt_sigaction` syscalls and `dlopen`-resolved slots are not covered. - LD_PRELOAD symbol interposition requires no crate; `plt-rs` (§6) is the fallback only - if a non-preload deployment ever needs PLT patching — out of scope for the first cut. -3. **D3 — policy test matrix:** app gives up via `SIG_DFL` restore; app recovers via - `siglongjmp`; report-first reports before recovery; registration via `sigaction` and - via `signal`; `SA_NODEFER` handling; errno preservation across the app-first call; a - recovered runtime signal must not consume the one-crash latch (`COLLECTING` must not - be set by a recovered app-first pass — verify ordering in `crash_handler`: today the - app-first block correctly runs before the `COLLECTING` swap, keep it that way under - refactor). - -Small item to fold into D1: `uninstall_crash_handler` restores the *original* handler — -after D2 exists, forced shutdown must restore the **virtualized app** handler (the app's -latest registration), not the install-time one. - ---- - -## 6. Workstream E — Upstream crate reuse - -Research done 2026-07-05 with `gh repo view`/`gh search repos` (stars / last push / -archived checked). - -### Adopt — DECIDED - -| Crate | Repo health | Use here | -|---|---|---| -| **rustix** (`linux_raw` backend) | bytecodealliance/rustix — 2032★, pushed 2026-06-15, active | Replace `sys.rs` raw asm wholesale: `write`, `close`, `dup3`, `fcntl(F_DUPFD)`, `pipe2`, `openat`, `wait4(WNOHANG)`, `kill`, `ppoll`/`clock_nanosleep`, `clock_gettime`, `getpid`, `gettid`, `faccessat`, `close_range` | -| **sadness-generator** (in EmbarkStudios/crash-handling) | 186★, pushed 2026-05-12, active | **dev-dependency** for e2e tests: generates real SIGSEGV (heap & stack-overflow), SIGBUS, SIGFPE, SIGILL, SIGABRT, SIGTRAP crashes. Zero production footprint | - -**Decision (owner sign-off 2026-07-05): use rustix.** `no_std`, no-alloc, libc-free -syscalls on Linux (`linux_raw` backend); eliminates A2 (per-arch syscall table bugs) and -A4 (error-convention mismatch) as whole bug classes; audited, widely-deployed syscall -stubs instead of ours. - -**Hard constraint discovered during evaluation: no `dlsym` in the shipped artifact.** -rustix compiles its `src/weak.rs` module even on the `linux_raw` backend (gated -`any(linux_raw, all(libc, not(windows/espidf/wasi)))` in `src/lib.rs`), and it declares -an extern `dlsym` used for runtime symbol probing; older auxv code paths also resolved -`getauxval` this way. An undefined `dlsym` reference — even weak — is unacceptable for -the preload artifact: on glibc < 2.34 it drags in `libdl` linkage and the gap-analysis -doc explicitly requires old-glibc-safe loading with no hard libdl symbols. - -rustix adoption rules for the implementer: - -- `default-features = false` (no `std`, no alloc), minimal API features only - (`pipe`, `process`, `event`, `time`, `fs`, `stdio` — trim to what's actually called). -- Force the `linux_raw` backend (default on Linux when `use-libc` is not enabled). -- **Auxv without libc:** rustix needs auxv (vDSO discovery for `clock_gettime`, - `AT_SECURE`, page size). Current rustix main reads it libc-free via the - `PR_GET_AUXV` prctl (kernel ≥ 6.4) with a `/proc/self/auxv` fallback — no `getauxval`, - no `dlsym`. Pin a rustix version with that behavior (verify at adoption time; upgrade - rustix rather than re-introducing a libc/dlsym path). If a pinned older version only - offers weak-`getauxval` resolution, do not use it — bump instead. As a last resort we - can read auxv ourselves without libc (`/proc/self/auxv`, or walking the initial stack - past `environ`) at init and feed rustix through its explicit-auxv mechanism where the - version provides one. -- **Verify, don't trust:** the §7.5 symbol guard must assert the crash-path staticlib - has zero references to `dlsym`, `getauxval`, and `__libc_*` on Tier 1. If any rustix - API we call pulls in a `weak!`-gated path, drop that API and keep a local raw wrapper - for that one call. -- Keep hand-rolled raw asm **only** for what rustix deliberately omits: raw - `clone(SIGCHLD)` fork. Verify rustix coverage of `process_vm_readv` at implementation - time; keep the local asm wrapper if absent. Everything else in `sys.rs::raw` is - deleted. -- On Tier 2 targets (A5a) rustix falls back to its libc backend — thin wrappers around - the same calls, acceptable there *only* for functions on the A5a allowlist; the banned - list (`fork` above all) applies regardless of which layer would provide it. -- Rejected alternatives, for the record: **syscalls** (jasonwhite, 141★, active — thinner - but keeps errno/typing work on us), **linux-raw-sys** (constants only), hand-rolled asm - (the status quo this replaces). - -### Keep (already used, both healthy) - -- **heapless** — rust-embedded/heapless, 1990★, pushed 2026-07-03. Check whether 0.9 is - out of rc and worth the bump; otherwise stay on 0.8. -- **serde-json-core** — rust-embedded-community, 195★, pushed 2025-11-18. Slower cadence - but small and stable in scope. Keep; no action. - -### Evaluate later (P2, do not adopt now) - -- **framehop** (mstange/framehop — 113★, pushed 2026-04-17) and **unwinding** - (nbdd0121/unwinding — 135★, pushed 2026-06-13): better-than-frame-pointer unwinding for - FP-omitted builds. framehop is `no_std`-able and allocation-free at unwind time *if* - unwind tables are prefetched at init. Substantial init-time complexity; only worth it - if seed-frames-only reports prove too weak in practice. Track as an optional - `resolve_frames` upgrade. -- **minidump-writer / minidumper / crash-handler** (rust-minidump org 505★ pushed - 2026-06-29; EmbarkStudios crash-handling 186★): a strategically different architecture - (out-of-process minidump capture). Not compatible with the `DD_CRASHTRACK_*` wire - contract; reference material only. -- **plt-rs** (ohchase/plt-rs — 40★, pushed 2026-06-22): PLT patching for the - interposition workstream if a non-LD_PRELOAD deployment ever needs it. **redhook** - (geofft/redhook) is stale (last push 2022-10) — do not use. -- **itoa** (dtolnay — 379★, active): could replace `write_i32`/`hex_addr`, but those are - ~40 proven lines; a new dep + LICENSE-3rdparty churn isn't worth it. Skip. -- **signal-hook** (vorner — 854★, active): not usable inside a crash handler; relevant - only as documentation of coexistence expectations (apps using signal-hook register - through `sigaction` → covered by D2 virtualization). - -### Repo mechanics for any dependency change - -Every `Cargo.lock` change requires `./scripts/update_license_3rdparty.sh` and -`cargo deny check` (CI-guarded). New files need Apache-2.0 headers -(`./scripts/reformat_copyright.sh`). - ---- - -## 7. Workstream F — Tests and CI - -Current state: 15 unit tests + 1 e2e, and **zero CI coverage** (verified: no reference to -`collector_signal` anywhere in `.github/`), and the e2e cfg means even -`--all-features` runs skip it. Ordered work: - -1. **CI job (blocks everything else being trustworthy):** - - `cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe` - for `x86_64-unknown-linux-gnu` **and** `--target aarch64-unknown-linux-gnu` - (check-only cross-compile is enough to catch A2-class bugs; no qemu needed initially). - - `cargo nextest run -p libdd-crashtracker --no-default-features --features collector_signal-safe` - on x86_64 Linux (runs unit + e2e). - - clippy + fmt on the same feature set (`-D warnings`). - - A Tier 2 build (macOS runner or cfg-emulated) so the A5a allowlist boundaries compile. - - After A1 (features coexist), the default workspace runs also compile the module, - shrinking the special-casing to the nextest invocation. -2. **Unit-test isolation:** `handler::tests::lifecycle_can_install_and_shutdown` - installs real SIGSEGV/SIGABRT handlers inside the shared test process — move - install/uninstall lifecycle tests behind the fork-a-child pattern already used by the - e2e (self-exec with an env marker), or into `bin_tests`. A stray SIGSEGV in another - test thread while these run currently gets eaten/misrouted. -3. **E2e matrix** (extend `collector_signal_safe_e2e.rs`; use sadness-generator per §6): - - each managed signal (SEGV, ABRT, BUS, ILL, FPE) produces a parseable report with the - right `si_signo_human_readable`; - - external `kill -SEGV` → **no** report; self-`raise` → report (genuine-fault filter); - - app handler gives up (`SIG_DFL` restore) → report; app handler `siglongjmp`s → no - report, process continues (app-first policy — after A3/D1); - - `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` → report emitted, then app handler runs - (report-first policy); - - stuck receiver (`sleep`-script) is SIGKILLed within budget; process still terminates - with the original signal disposition; - - crash with fds 0/1/2 closed pre-crash (pipe lands on low fds → relocation path in - `sanitize_clone`); - - receiver env: receiver script dumps `env` → assert `LD_PRELOAD`/`LD_AUDIT` absent, - `DD_*` present; - - stack-overflow crash once alt-stack (§4.1) lands; - - default-disposition **re-fault** path: after report, kernel terminates with original - `si_code`/address (check via `waitpid` status + core pattern in the harness). -4. **Golden wire round-trip:** feed a signal-safe-emitter report into the real std - receiver parser (`libdd-crashtracker` `receiver` feature) and assert the resulting - CrashInfo fields (service/env/tags/frames/si_code, `PROCESSINFO` spelling). Possible - in one test binary after A1; until then in `bin_tests`. -5. **Hot-path hygiene guard:** build a `no_std` staticlib example with the feature, - `nm`/`objdump` the archive in a test script and assert the crash-path object files - reference none of: `malloc`, `free`, `pthread_mutex_lock`, `__rust_alloc`, - panic/`core::fmt` machinery, `getenv`, **`dlsym`, `getauxval`, `fork`, `posix_spawn`, - `pthread_atfork`** (A5a + §6 enforcement). This is the regression fence that keeps - future edits — and future rustix upgrades — honest. (Script under `tools/` or - `bin_tests`, Linux-only.) -6. **Config JSON as a wire contract:** existing `config_json_contains_receiver_contract` - test becomes a full golden-string comparison once `build_config_json` takes options - (§4.1), so accidental contract drift fails loudly. - ---- - -## 8. Workstream G — FFI polish and packaging - -- FFI init should return the 3-state result (§B4) and there should be a query for owned - signals + capability bits (diagnostics for integrators). -- `cbindgen` output: verify the new header entries (the branch touched - `libdd-crashtracker-ffi/cbindgen.toml`); add the new enum/result types. -- No `catch_unwind` needed in these FFI entry points *iff* the crate stays panic-free — - enforce with `#![deny(clippy::panic, clippy::unwrap_used, clippy::expect_used)]` (or - equivalent lint table) on the `collector_signal_safe` module, plus the §7.5 symbol - guard. Repo rule is "FFI must not unwind across the boundary" — deny-by-construction - satisfies it; document that in the module header. -- Packaging decisions — receiver artifact ownership, musl builds, builder feature flag - (`crashtracker` already exists; decide whether signal-safe rides it or gets its own) — - keep as an explicitly deferred decision section; nothing in this plan hard-blocks on - it except the receiver-path-discovery item below. -- **Receiver path discovery:** sibling-of-.so lookup via `dladdr` needs C glue for weak - linkage on old glibc (same no-hard-libdl constraint as §6). Scope it as its own PR; - until then the baked default + env override (config.rs:17-20,110) is the contract. - ---- - -## 9. Workstream H — Decouple naming and constants from the originating C-tracer integration - -The module was ported from a specific C tracer's crashtracker and still carries that -origin in code, naming, and docs. This workstream makes the signal-safe collector a -neutral libdatadog component that any integrator (C tracer, injector, other language -preloads) parameterizes — while keeping wire compatibility for the existing consumer. - -1. **Policy naming:** rename the "always on top" boolean plumbing and all comments/docs - from the origin's mode letters to **app-first** / **report-first** (the terms used in - this plan). Public config field stays `force_on_top` or becomes - `policy: {AppFirst, ReportFirst}` — pick one and rename consistently across module, - FFI, and cbindgen output. -2. **Metadata is integrator-provided, not hardcoded:** `emit_metadata` (mod.rs:387-425) - hardcodes `library_name: "dd-trace-c"`, `family: "native"`, and a default service - name equal to the origin library's name. Replace with fields on - `SignalSafeInitConfig` (`library_name`, `library_version`, `family`, - `default_service`), snapshotted at init into `Meta`. Ship a preset constructor that - reproduces today's exact tag set so the existing consumer's wire output is - byte-identical (golden test, §7.6). -3. **Version constant:** `TRACE_C_VERSION` / `option_env!("DD_TRACE_C_VERSION")` - (config.rs:12-15) becomes `library_version` in the init config; the build-env - injection moves to the integrator's build. `Report::trace_c_version` field renamed - accordingly (it currently feeds `runtime_version`, `library_version`, and - `injector_version` tags — keep that mapping in the preset). -4. **Env var names:** `DD_TRACE_C_CRASHTRACKER_PROCESS` and the origin-specific baked - receiver default (config.rs:17-20) get neutral primary names (e.g. - `DD_CRASHTRACKING_RECEIVER_PATH`), with the old names kept as documented, - lower-priority aliases so existing deployments keep working. `prepare_from_env` - reads new-name-first. -5. **Docs sweep:** module headers, `crashtracker-work-we-need-to-do.md` framing, and FFI - doc comments describe behavior in terms of app-first/report-first and "the preload - integrator", not the originating tracer. The gap-analysis doc stays as historical - record; new docs must not require reading it. -6. **FFI symbol audit:** the `ddog_crasht_signal_safe_*` names are already neutral — - keep. Anything origin-specific that leaks into cbindgen headers gets renamed in the - same PR as (1)-(4) (single breaking change, allowed by repo ABI policy). - -Acceptance for this workstream: `grep -ri "dd-trace-c\|trace_c\|mode a\|mode b"` over -`libdd-crashtracker*/src` returns only (a) the compatibility-preset constants and env -aliases with comments explaining them, and (b) nothing in identifiers or public API. - ---- - -## 10. Suggested PR sequence - -Each PR independently green (fmt, clippy `-D warnings`, nextest, license CSV if deps -changed). Conventional Commits (`feat(crashtracker): ...`, `fix(crashtracker): ...`). - -1. **fix(crashtracker): make signal-safe feature additive + portability fixes** — - A1, A2, A4, A6, A8, feature rename. Adds the CI job (§7.1) in the same PR so the - fixes are locked in. -2. **refactor(crashtracker)!: ban libc fork and adopt rustix syscall layer** — - A5 + A5a (three-tier policy, fallback deletion) together with §6 rustix adoption - (they touch the same file; doing them as one PR avoids porting the fallback twice). - Keeps raw `clone` (+ `process_vm_readv` if absent from rustix); deletes the rest of - sys.rs asm and the entire open-ended libc fallback; adds the §7.5 symbol guard - including the `dlsym`/`fork` bans; LICENSE-3rdparty update. -3. **fix(crashtracker): correct app-first recovery detection and handler state machine** — - A3, A7, D1, IN_APP_CHAIN reset, `sa_mask` default (§4.2). Unit tests for the policy - functions with live re-query fakes; e2e app-handler scenarios (§7.3). -4. **refactor(crashtracker)!: decouple signal-safe collector from origin C tracer** — - Workstream H (§9). Golden wire test proves byte-identical output via the - compatibility preset. -5. **feat(crashtracker): capability probing and degraded-report tags** — §3 (B1-B4). -6. **feat(crashtracker): safety options** — §4 (alt stack, disarm-on-entry, - report-to-fd, budgets, close_range). Config JSON becomes option-driven, golden test. -7. **feat(crashtracker): sigaction/signal virtualization for preload** — D2 + shutdown - restore semantics + D3 matrix. -8. **test(crashtracker): e2e matrix, golden wire round-trip, hot-path symbol guard - completion** — remaining §7 items (can be split across PRs 3-7 where scenarios - become testable). -9. **feat(crashtracker): receiver path discovery** — §8 last item (needs C glue design, - same no-libdl constraint). - -## 11. Acceptance criteria (definition of done for this plan) - -- `cargo check -p libdd-crashtracker --all-features` and the full AGENTS.md validation - suite pass unmodified. -- The feature compiles for `aarch64-unknown-linux-gnu` (CI-enforced). -- **No `libc::fork`, `posix_spawn`, or `pthread_atfork` reference anywhere in the - module; no `dlsym`/`getauxval`/`__libc_*` reference in the Tier 1 crash-path - staticlib — all enforced by the §7.5 symbol guard in CI.** -- `sys.rs` hand-written asm reduced to raw `clone(SIGCHLD)` (+ `process_vm_readv` if - rustix lacks it); everything else through rustix `linux_raw`. -- A genuine crash with a pre-registered non-recovering app handler produces a report - (regression test for A3). -- Every degraded report carries a `report_degraded`/`stackwalk_method` tag; no silent - losses on: missing receiver, `process_vm_readv` EPERM, pipe/fork failure. -- E2e matrix of §7.3 green on x86_64 Linux CI. -- New deps reflected in `LICENSE-3rdparty.csv`; `cargo deny check` green. -- Workstream H grep criterion met: no origin-tracer names or mode letters in - identifiers/public API; compatibility preset covered by a byte-identical golden test. -- `crashtracker-work-we-need-to-do.md` updated: items delivered here checked off, - deferred items (packaging, framehop, sacrificial-child probe, PLT patching) marked as - such. From 6ae6136f3da8735da3fcbc08413b8872454dcd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 01:30:30 +0200 Subject: [PATCH 06/32] test(crashtracker): add signal-safe coverage files --- .../workflows/crashtracker-signal-safe.yml | 46 ++++++++ .../src/collector_signal_safe/capabilities.rs | 107 ++++++++++++++++++ libdd-crashtracker/src/signal_owner.rs | 26 +++++ tools/check_signal_safe_symbols.sh | 29 +++++ 4 files changed, 208 insertions(+) create mode 100644 .github/workflows/crashtracker-signal-safe.yml create mode 100644 libdd-crashtracker/src/collector_signal_safe/capabilities.rs create mode 100644 libdd-crashtracker/src/signal_owner.rs create mode 100644 tools/check_signal_safe_symbols.sh diff --git a/.github/workflows/crashtracker-signal-safe.yml b/.github/workflows/crashtracker-signal-safe.yml new file mode 100644 index 0000000000..9dada418e5 --- /dev/null +++ b/.github/workflows/crashtracker-signal-safe.yml @@ -0,0 +1,46 @@ +# 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/**" + - "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@d12e869b89167df346dd0ff65da342d1fb1202fb # v2.57.4 + 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 signal-safe FFI + run: cargo check -p libdd-crashtracker-ffi --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: Symbol guard + run: bash tools/check_signal_safe_symbols.sh 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..953fb0f6fd --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -0,0 +1,107 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::sync::atomic::{AtomicU32, Ordering}; + +use super::sys; + +pub const RECEIVER_OK: u32 = 1 << 0; +pub const PROC_VM_READV: u32 = 1 << 1; +pub const FORK_OK: u32 = 1 << 2; +pub const DEV_NULL: u32 = 1 << 3; +pub const PIPE_OK: u32 = 1 << 4; +pub const REPORT_FD_OK: u32 = 1 << 5; + +pub const DEGRADED_MISSING_RECEIVER: u32 = 1 << 0; +pub const DEGRADED_NO_PROC_VM_READV: u32 = 1 << 1; +pub const DEGRADED_NO_FORK: u32 = 1 << 2; +pub const DEGRADED_NO_DEV_NULL: u32 = 1 << 3; +pub const DEGRADED_NO_PIPE: u32 = 1 << 4; +pub const DEGRADED_PIPE_FAILED: u32 = 1 << 5; +pub const DEGRADED_FORK_FAILED: u32 = 1 << 6; +pub const DEGRADED_RECEIVER_UNAVAILABLE: u32 = 1 << 7; +pub const DEGRADED_REPORT_TO_FD: u32 = 1 << 8; + +pub const DEGRADATION_REASONS: &[(u32, &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"), +]; + +static CAPABILITIES: AtomicU32 = AtomicU32::new(0); +static DEGRADATIONS: AtomicU32 = AtomicU32::new(0); + +pub fn publish(receiver_path: &[u8], report_fd: i32) { + let mut caps = 0u32; + let mut degraded = 0u32; + + if sys::access_executable(receiver_path.as_ptr()) { + caps |= RECEIVER_OK; + } else { + degraded |= DEGRADED_MISSING_RECEIVER; + } + + if probe_process_vm_readv() { + caps |= PROC_VM_READV; + } else { + degraded |= DEGRADED_NO_PROC_VM_READV; + } + + if sys::fork_supported() { + caps |= FORK_OK; + } else { + degraded |= DEGRADED_NO_FORK; + } + + let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); + if devnull >= 0 { + caps |= DEV_NULL; + sys::close(devnull); + } else { + degraded |= DEGRADED_NO_DEV_NULL; + } + + let mut fds = [0i32; 2]; + if sys::pipe(&mut fds) { + caps |= PIPE_OK; + sys::close(fds[0]); + sys::close(fds[1]); + } else { + degraded |= DEGRADED_NO_PIPE; + } + + if report_fd >= 0 { + caps |= REPORT_FD_OK; + } + + CAPABILITIES.store(caps, Ordering::Release); + DEGRADATIONS.store(degraded, Ordering::Release); +} + +pub fn get() -> u32 { + CAPABILITIES.load(Ordering::Acquire) +} + +pub fn has(capability: u32) -> bool { + get() & capability != 0 +} + +pub fn degradations() -> u32 { + DEGRADATIONS.load(Ordering::Acquire) +} + +pub fn note_degraded(reason: u32) { + DEGRADATIONS.fetch_or(reason, 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 +} 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/tools/check_signal_safe_symbols.sh b/tools/check_signal_safe_symbols.sh new file mode 100644 index 0000000000..3c3143d9ec --- /dev/null +++ b/tools/check_signal_safe_symbols.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +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/debug/deps -maxdepth 1 \( \ + -name 'liblibdd_crashtracker*.rlib' -o \ + -name 'librustix*.rlib' \ +\) -print) + +if [[ "${#artifacts[@]}" -eq 0 ]]; then + echo "signal-safe rlib artifacts not found" >&2 + exit 1 +fi + +banned='(^|[^[:alnum:]_])(malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__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 From 79f8b2a0d0349261e3aed5aa113cd3e04603d1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 01:48:01 +0200 Subject: [PATCH 07/32] docs(crashtracker): add signal-safe collector improvement plan Co-Authored-By: Claude Fable 5 --- signal_safe_collector_improvement_plan.md | 762 ++++++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 signal_safe_collector_improvement_plan.md diff --git a/signal_safe_collector_improvement_plan.md b/signal_safe_collector_improvement_plan.md new file mode 100644 index 0000000000..152f19cec5 --- /dev/null +++ b/signal_safe_collector_improvement_plan.md @@ -0,0 +1,762 @@ +# Signal-Safe Crashtracker Collector — Improvement Plan + +Status date: 2026-07-06. Branch: `signal_safe_crashtracker` (HEAD `dd9922905`, +merge of `6ae6136f3` which restored the previously missing files). + +This plan is written to be executed by another engineer/LLM with no prior context. +It covers: restoring the branch to a compilable state, hardening the existing +signal-safe collector, missing-capability handling, safety options, the test +strategy, and reuse/compatibility with the existing crashtracker and the rest of +libdatadog. The dd-trace-c parity analysis lives in +`crashtracker-work-we-need-to-do.md`; this plan references it but does not repeat +it. Where the two overlap, this plan is the actionable order of work. + +--- + +## Guiding principles + +Applied throughout this plan (and to be applied to the code): + +- **Errors never pass silently.** Every degraded path must leave a trace: + a `report_degraded:*` tag, a distinct `InitResult` variant, or a debug + breadcrumb. A crash report that silently loses its stack, or an `init` that + returns a bare `Failed`, is a bug even when the behavior is otherwise correct. +- **Explicit is better than implicit.** Env reads happen only in the two + `*_from_env` entry points; everything else takes explicit config. Failure + reasons are enum variants, not booleans. Options that interact + (`create_alt_stack`/`use_alt_stack`) are validated, not auto-corrected. +- **One obvious way.** Where earlier drafts offered alternatives, this plan now + commits to one approach per problem and records why the alternative lost. + The remaining genuinely-open questions are in the decision table below — in + the face of ambiguity, confirm with the team instead of guessing. +- **Simple over complex, and explainable.** The crash path stays a straight + line: probe at init, one handler, two forked children, fixed buffers, no + hidden state machines. Anything hard to explain in a doc comment (the 1.1 + stack-position heuristic is the one exception) gets that doc comment. + +### Open decisions + +Everything else in this plan is decided; these need a human call before the +affected PR: + +| # | Decision | Recommendation | Blocks | +|---|---|---|---| +| D1 | Ship `collector_signal-safe` in the released FFI artifact now, or keep opt-in? | Team/product call — packaging §5.5 | PR 8 | +| D2 | If re-init after `shutdown()` turns out unsound during the `Meta` audit (1.4), fall back to documented one-shot + `AlreadyInitialized`? | Attempt re-init first; fall back only with a written reason | PR 3 | +| D3 | macOS: stay degraded-fd-only, or add best-effort libc `fork()` mode later? | Stay degraded; revisit only on demand (§2.5) | nothing now | + +## 0. Orientation + +### What exists on this branch + +New module `libdd-crashtracker/src/collector_signal_safe/` behind cargo feature +`collector_signal-safe` (note the hyphen), designed to coexist with the standard +`collector` feature: + +| File | Contents | +|---|---| +| `mod.rs` | Wire emitter (`emit_report`, `Sink`/`SliceSink`), policy pure functions (`chain_action`, `is_genuine_fault`, `should_run_app_first`, `app_recovered`), signal/si_code naming, capacity constants | +| `config.rs` | `SignalSafeInitConfig`, `prepare()`/`prepare_from_env()`, config-JSON builder, env parsing (`DD_CRASHTRACKING_*`, `DD_SERVICE`, …), compat presets for dd-trace-c | +| `handler.rs` | `init`/`init_from_env`/`bootstrap_complete`/`shutdown`, the `crash_handler` itself, fork/receiver/collector children, bounded reap, alt-stack install, loader-env scrubbing | +| `state.rs` | Static `Meta` (heapless strings), init-state machine, per-signal `ORIG_FN`/`ORIG_FLAGS`/`OWN_SIGNAL` atomics, runtime option atomics, `Stage` enum | +| `sys.rs` | Raw syscall layer: rustix + inline asm on Linux x86_64/aarch64 (`fork_raw` via `clone(SIGCHLD)`, `process_vm_readv`, `wait4`, …), libc fallback elsewhere, errno save/restore | +| `backtrace.rs` | Frame-pointer walk seeded from `ucontext`, probing frame records with `process_vm_readv` (`read_own_mem`) so corrupt frames return failure instead of faulting | + +Plus: + +- FFI surface: `libdd-crashtracker-ffi/src/collector_signal_safe.rs` + (`ddog_crasht_signal_safe_init[_from_env]`, `_bootstrap_complete`, `_shutdown`, + `_set_stage`, `_capabilities`, `_owned_signal_count`, `_owns_signal`). +- Feature re-plumbing: `libdd-crashtracker/Cargo.toml` gained a `std` feature; all + std-only deps are optional; `collector_signal-safe` pulls only + `heapless`, `libc`, `rustix`, `serde` (no-std), `serde-json-core`. +- Std collector integration: `libdd-crashtracker/src/collector/api.rs` now + acquires a `signal_owner` guard so only one collector arms handlers. +- E2E tests: `libdd-crashtracker/tests/collector_signal_safe_e2e.rs` — Linux + receiver round-trip through a shell receiver, and a portable degraded + report-to-fd test. +- cbindgen currently *excludes* `libdd_crashtracker::collector_signal_safe` + (`libdd-crashtracker-ffi/cbindgen.toml:74`) — headers are not yet generated. + +### Restored support files (commit `6ae6136f3`, verified present and green) + +- `libdd-crashtracker/src/signal_owner.rs` — `AtomicU8` owner slot; + `acquire` is **reentrant for the same owner** (CAS-or-already-mine), + `release` only clears when the caller owns it. +- `libdd-crashtracker/src/collector_signal_safe/capabilities.rs` — capability + bits `RECEIVER_OK|PROC_VM_READV|FORK_OK|DEV_NULL|PIPE_OK|REPORT_FD_OK`, + degradation bits with reason strings (`missing_receiver`, + `no_process_vm_readv`, `no_fork`, `no_dev_null`, `no_pipe`, `pipe_failed`, + `fork_failed`, `receiver_unavailable`, `report_to_fd`); `publish()` probes + receiver executability, a `process_vm_readv` self-read, fork support, + `/dev/null`, and pipe creation, and `store`s both atomics (safe for re-init). +- `.github/workflows/crashtracker-signal-safe.yml` — check (x86_64 + aarch64 + cross-check), clippy, nextest, symbol guard. +- `tools/check_signal_safe_symbols.sh` — bans undefined symbols + (`malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__libc_*`) + in the no-default-features rlib via `nm -u`. + +### What is broken right now (verified 2026-07-06) + +Compile, unit tests (20), and e2e tests (4) are green with +`--no-default-features --features collector_signal-safe`. One failure: + +**`tools/check_signal_safe_symbols.sh` fails: `U getenv` in the +libdd-crashtracker rlib.** Source: `collector_signal_safe/config.rs:311` +(`env_get` → `libc::getenv`), reachable only at init time +(`prepare_from_env`), never on the crash path — but the guard scans the whole +rlib's undefined symbols and cannot scope to the crash path. Fix in Phase 0. + +### Validation commands (run after every phase) + +```bash +cargo check -p libdd-crashtracker --features collector_signal-safe +cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe # no-std-ish build must stay green +cargo check -p libdd-crashtracker-ffi --no-default-features --features collector_signal-safe +cargo build --workspace --exclude builder # default features unaffected +cargo +nightly-2026-02-08 fmt --all -- --check +cargo +stable clippy --workspace --all-targets --all-features -- -D warnings +cargo nextest run -p libdd-crashtracker --features collector_signal-safe +cargo nextest run --workspace --no-fail-fast +cargo test --doc +``` + +If `Cargo.lock` changes: `./scripts/update_license_3rdparty.sh && cargo deny check` +(the branch already added `heapless`, `rustix`, `serde-json-core` to +`LICENSE-3rdparty.csv`; keep it in sync). If FFI is touched: `cargo ffi-test`. +New files need Apache-2.0 headers (`./scripts/reformat_copyright.sh`). + +--- + +## Phase 0 — Make the restored CI gate green (P0, do first) + +The missing modules are back (see §0); the remaining P0 items are small. + +### 0.1 Fix the symbol-guard failure: drop `libc::getenv` + +`tools/check_signal_safe_symbols.sh` currently fails on `U getenv` +(`config.rs:311`, `env_get`). + +Fix: replace `libc::getenv` with a direct walk of the global `environ` array +(`unsafe extern "C" { static mut environ: *mut *mut c_char; }` is already +declared in `handler.rs:30-32` — hoist it into `sys.rs` and share). Match +`NAME=` by byte prefix, return the value slice. This keeps env reads +init-time-only *and* keeps the strict guard honest: unlike `getenv`, the walk +is trivially auditable as allocation-free and lock-free. + +Rejected alternative: removing `getenv` from the banned regex ("env reads are +init-only by construction"). Rejected because a future crash-path `getenv` +regression would then pass CI silently — the guard exists precisely to make +that error loud. + +Add a unit test for the new lookup (set a var via `std::env::set_var` in a +`#[cfg(test)]` std context, read it back through the new function). + +### 0.2 Guard-script robustness + +`tools/check_signal_safe_symbols.sh` globs *all* +`liblibdd_crashtracker*.rlib` under `target/debug/deps`. A developer's dirty +target dir can contain stale rlibs from std-feature builds (false failures), +and conversely a stale no-std rlib could mask a regression (false pass — +`cargo build` may not rebuild if fingerprints match but old artifacts linger). +Fix: build into a dedicated target dir +(`CARGO_TARGET_DIR=target/signal-safe-guard`) inside the script, or parse the +freshly built artifact path from `cargo build --message-format=json`. + +### 0.3 Housekeeping before the first real PR + +- `cargo check -p libdd-crashtracker --features collector_signal-safe` (with + default `std` also enabled) emits an unused-import warning for + `access_executable`/`fork_supported` re-exports at `sys.rs:568-572`? — + verified clean now that capabilities.rs uses both; re-check under + `--all-features` clippy which CI runs with `-D warnings`. +- The branch history is `wip` commits + a merge. Rebase/squash into + conventional commits before opening PRs (PR split at the end of this plan). +- `cargo nextest` is used by the restored workflow; it is not installed in the + local dev environment — install it or keep using `cargo test` locally + (nextest's process-per-test isolation matters here: the signal-safe tests + mutate process-global signal state; `TEST_GLOBAL_LOCK` covers plain + `cargo test`). + +Contract notes for later phases (actual restored implementations): + +- `signal_owner::acquire` is reentrant for the same owner. This means a second + `init_result` call by the same collector does not fail at the owner gate — + single-shot-ness is enforced only by `state::begin_init`. Keep this in mind + for lifecycle work (1.4) and the interplay tests (5.2). +- Capability bit names: `RECEIVER_OK, PROC_VM_READV, FORK_OK, DEV_NULL, + PIPE_OK, REPORT_FD_OK`; degradations include *both* init-time + `DEGRADED_MISSING_RECEIVER` ("missing_receiver") and crash-time + `DEGRADED_RECEIVER_UNAVAILABLE` ("receiver_unavailable") — use the former for + probe failures, the latter for crash-time exec/exit failures (2.3). +- `capabilities::publish` `store`s (not `fetch_or`s) both atomics, so re-init + (1.4) gets fresh state for free; only `note_degraded` accumulates. + +--- + +## Phase 1 — Correctness and safety hardening (concrete findings in current code) + +Each item below is a real issue found by review of the committed code; fix with +a test where possible. + +### 1.1 `IN_APP_CHAIN` never resets after `siglongjmp` recovery (`handler.rs:540`) + +If the app handler recovers via `siglongjmp`, control never returns to +`crash_handler`, so the function-local `static IN_APP_CHAIN` stays `true` +forever. Every subsequent crash then *skips* the app-first path and reports +immediately — silently breaking Mode A after the first recovered signal (which +is exactly the HotSpot/V8 hot case: recoverable SIGSEGVs happen constantly). + +A plain boolean cannot express what the guard needs to know. On entry the +handler must distinguish three cases: a fresh signal delivery, true recursion +(a crash *inside* the app handler), and a stale flag left by a `siglongjmp` +that abandoned a previous handler frame. Clear-on-return misses the third +case; a sticky flag conflates it with the second. + +Design (replace `IN_APP_CHAIN` with two atomics): + +- Before invoking the app handler, store the handling thread's tid + (`AtomicI32`) and an approximate stack position (`&local as usize`, + `AtomicUsize`). Clear both on the normal-return path. +- On entry with a stored tid equal to our own, the previous invocation on this + thread never returned — it either longjmp'd away or we are nested inside it. + Disambiguate by stack position: if the current frame is *outside* the + recorded frame (the stack has unwound past it), the old invocation is dead → + reset the guard and run app-first normally. If the current frame is *inside* + (deeper than) the recorded one, this is true recursion → skip app-first and + go straight to reporting. +- On entry with a different thread's tid stored, skip app-first; the report + path is already serialized by `COLLECTING`. + +This is the one deliberately non-obvious mechanism in the module — per the +guiding principles it gets a doc comment explaining the three cases and the +stack-growth-direction assumption, plus both e2e tests from 4.1 +(recover-then-crash-again must still run the app handler first; +crash-inside-app-handler must report once and not loop). + +### 1.2 Mode A infinite loop when the app handler returns without recovering + +Flow today: app handler returns normally, still installed → `app_recovered()` +returns true (`handler_after` is a function pointer, not `SIG_DFL`) → we return +without reporting. For a *synchronous* fault the kernel re-executes the faulting +instruction → our handler runs again → app-first again → infinite ping-pong. + +dd-trace-c's semantics: "recovered" means the app handler transferred control +away (longjmp) or fixed the fault. A returning handler that leaves itself +installed and did not fix the fault will loop *regardless* of crashtracker — +but we amplify it by adding fork/report machinery never, since we skip +reporting. Mitigation: + +- Add a per-signal repeat counter (same PC + same address within N entries → + treat as unrecovered: report once, then chain with + `RestoreDefaultAndRefault`). Cap cheap: two `AtomicUsize` (last_pc, count). +- Test: app handler that returns without fixing (install handler, deref null, + handler just returns) — process must terminate with SIGSEGV and produce + exactly one report, not hang. + +### 1.3 Alt-stack is process-global but `sigaltstack` is per-thread (`handler.rs:250-261`) + +`install_alt_stack_if_requested` installs the single static 64 KiB stack only on +the *calling* thread. Crashes on other threads run on their normal stack even +with `SA_ONSTACK` set (kernel ignores the flag if that thread has no alt stack), +and — worse — if two threads did share one alt stack and crashed concurrently, +they would corrupt each other. Actions: + +- Document loudly on `create_alt_stack`/`use_alt_stack` (Rust doc + FFI header + comment): "alt stack applies to the init thread only; stack-overflow crashes + on other threads are collected on the faulting thread's stack". +- Keep dd-trace-c default (`false`/`false`) as-is. +- Optional follow-up (defer): expose a per-thread `ddog_crasht_signal_safe_arm_thread()` + that installs a caller-provided alt stack on the current thread. +- Compare with the std collector's alt-stack handling in + `collector/signal_handler_manager.rs` — reuse its sizing policy + (`page_size`-aware, guard page) if we ever create per-thread stacks. + +### 1.4 `shutdown()` → re-`init()` is permanently bricked (`state.rs:58-82`) + +`INIT_STATE` moves `UNINIT → INITIALIZING → READY`; `begin_init` only succeeds +from `UNINIT`, and `shutdown()` never resets it. Also `fail_init` parks the +state at `FAILED` forever (e.g. a transient bad receiver path can never be +retried). + +Chosen approach — allow re-init: `shutdown()` stores `INIT_UNINIT` after +releasing the signal owner; `fail_init()` stores `INIT_UNINIT` too (each +failure path in `init_result` already unwinds owner + state coherently). +Prerequisite audit of `Meta` publication: `prepare()` mutates the static `Meta` +via `meta_mut()`, which is only sound while no handler can run; the current +order (prepare → install handlers → `HANDLERS_ENABLED.store(true, Release)`) +is correct, and on re-init `begin_init`'s CAS keeps mutation exclusive. Keep +that order and add a comment that `meta_mut` may only be called between +`begin_init` and handler installation. + +If the audit finds a soundness blocker, fall back (decision D2) to documented +one-shot semantics with a distinct `AlreadyInitialized` result — never a bare +`Failed`, so the caller can tell the difference. + +Test: init → shutdown → init → crash e2e still produces a report. + +### 1.5 `uninstall_crash_handler` drops the original `sa_mask` (`handler.rs:710-728`) + +Only `sa_sigaction` and `sa_flags` are saved/restored; the displaced handler's +signal mask is discarded (restored with an empty mask). Save `old.sa_mask` at +install (needs a static array of `libc::sigset_t` — wrap in an +`UnsafeCell`+`Sync` holder like `AltStackStorage`, guarded by the same +init-exclusivity argument) and restore it on uninstall. Same for the app-chain +`invoke_handler` path: dd-trace-c applies the app handler's mask semantics; at +minimum document that we invoke the app handler with *our* mask in effect. + +### 1.6 Capability/degradation state across re-init — already handled + +`capabilities::publish` `store`s both atomics, wiping prior probe results and +`note_degraded` accumulation, and `state::clear_signal_state()` runs in +`install_all_handlers`. Nothing to do beyond a regression test once 1.4 lands +(re-init after a degraded crash must not carry stale degradation tags). + +### 1.7 FFI entry points must not unwind (AGENTS.md rule) + +`ddog_crasht_signal_safe_*` call into code designed not to panic, but the repo +rule is explicit: wrap each FFI body in `std::panic::catch_unwind` and map to +`SignalSafeInitResult::Failed` / no-op. Cheap, mechanical. (The FFI crate is +std; only the library crate is no-std-capable.) + +### 1.8 `sanitize_clone` when `/dev/null` open fails (`handler.rs:209-218`) + +If `open_readwrite` fails (containers with restricted /dev), the children keep +the app's stdin/stdout/stderr. For the *receiver* child this leaks the app's +stdio to an exec'd process; for the collector child it is harmless. The init +probe already reports this (`DEGRADED_NO_DEV_NULL` / `no_dev_null` tag), but +`sanitize_clone` ignores the `DEV_NULL` capability bit at crash time — +optionally close stdio outright in the receiver child when /dev/null is +unavailable (receiver must tolerate closed stdout/stderr — verify against +`receiver_entry_point_stdin`). + +### 1.9 Emitter capacity truncation is silent + +`SECTION_BUF_CAPACITY` is 4096 and `emit_json_section` returns `false` on +overflow, which aborts the whole remaining report (`&&` chains). A long +service/env or 64 frames × 20 bytes fits, but config JSON is capped at 2048 and +metadata with 20 × 288-byte tags can exceed 4096 → *entire report silently +truncated after config*. Actions: + +- Compute worst-case section sizes from the heapless capacities and either + raise `SECTION_BUF_CAPACITY` for the metadata section or split tag emission. +- On section-emit failure, still attempt to write `DD_CRASHTRACK_DONE` and the + message section so the receiver processes a partial report instead of timing + out; add degradation tag `report_degraded:truncated`. +- Unit test: metadata at max tag capacity round-trips; oversized section + produces a partial-but-terminated stream. + +### 1.10 Small items + +- `handler.rs:19` `EXIT_CODE_FAILURE = 125` collides with common shell/xargs + conventions; fine, but document. +- `config.rs:302-308` `cstr_bytes` uses `read_volatile` per byte — replace with + a plain loop (volatile is unnecessary and slow); it also has no length cap: + bound it (e.g. 4096) to avoid walking off an unterminated string from FFI. +- `config.rs:270-279` `set_str` silently truncates at capacity mid-char-loop — + fine, but truncation of `service` should probably be observable + (degradation bit or debug breadcrumb). +- `sys.rs` non-Linux `poll_sleep_ms` uses `libc::poll` with null fds — OK; add + a comment that EINTR shortens the sleep and the reap loop tolerates it. +- `state.rs:13` `NSIG = 128` — index is the raw signal number; fine for Linux + and BSDs. Add a compile-time assert or comment. +- `mod.rs` `SI_TKILL` fallback for non-Linux is `i32::MIN` — document why + (macOS has no SI_TKILL; the sentinel must never match). + +--- + +## Phase 2 — Missing-capability handling (beyond restore) + +Goal: the collector must *always* do the best thing available and say what it +couldn't do. The restored `capabilities.rs` taxonomy is the foundation; this +phase extends it. + +### 2.1 Probe results in the report and via FFI + +- `ddog_crasht_signal_safe_capabilities()` already returns the bits; also add + `ddog_crasht_signal_safe_degradations()` so integrators can log at init. +- Emit `capability_bits`/`degradation_bits` as additional tags + (`capabilities:`) — cheap and makes fleet-wide triage possible. Keep the + human-readable `report_degraded:` tags as the primary signal. + +### 2.2 Seccomp sacrificial-child probe (deferred item from the notes, now scheduled) + +`process_vm_readv` under seccomp can (a) return `EPERM` → handled gracefully +today, or (b) `SECCOMP_RET_KILL` → kills the *collector child* mid-crash and the +report loses its stack silently. Add an opt-in init probe: + +- `SignalSafeInitConfig::probe_seccomp: bool` (default `false` — forking at init + is a global effect; per repo conventions it must be opt-in). +- Probe: `fork_raw()`; child calls `read_own_mem` on itself and `_exit(0)`; + parent waits ≤100 ms. Exit 0 → OK; killed by SIGSYS/SIGKILL → clear + `PROC_VM_READV`, set `DEGRADED_NO_PROC_VM_READV`; timeout → kill + treat as + unknown (keep capability, it's the status quo). Note the existing in-process + probe in `capabilities::probe_process_vm_readv` only catches errno-returning + denials (`EPERM`); the sacrificial child exists precisely for + `SECCOMP_RET_KILL` policies. +- With the capability cleared, `emit_crash_report` already degrades to + `seed_only` stackwalk (ip + lr only) — that path exists (`handler.rs:361-369`). + +### 2.3 Receiver re-validation at crash time + +`RECEIVER_OK` is probed once at init; the receiver binary can be deleted/moved +later (container image GC, tmp cleanup). At crash time the failure mode today +is: fork succeeds, `execv` fails, receiver child exits 125, collector writes +into a pipe nobody drains until the pipe buffer fills, then the parent reaps on +timeout. Improvements: + +- In `collect_crash`, after reaping the receiver, check its exit status + (`waitpid_nohang` currently discards status — return it) and if it exited 125 + and `report_fd` is set, re-emit the report to `report_fd` with + `DEGRADED_RECEIVER_UNAVAILABLE|DEGRADED_REPORT_TO_FD`. This makes the fd + fallback cover late disappearance, not just init-time absence. +- Order matters: reap receiver *before* deciding the crash path is done; today + the collector is reaped first with a 500 ms budget, then the receiver with + timeout+grace — keep that order (writer first), just capture status. + +### 2.4 `close_fds_on_receiver` is plumbed but unimplemented + +`CLOSE_FDS_ON_RECEIVER` is stored (`state.rs:105`) and configurable through FFI +but nothing reads it. Implement in `receiver_child` before `execv`: +`close_range(3, ~0u, 0)` via raw syscall (`SYS_close_range`, Linux 5.9+; on +ENOSYS fall back to nothing — do NOT iterate /proc/self/fd, that's not +fork-safe), after the report fd has been dup'd to stdin. Test: open a marker fd +with `O_CLOEXEC` unset in the parent, crash, assert the receiver does not see it +(receiver script checks `/proc/self/fd`). + +### 2.5 Non-Linux degraded mode documentation + +On macOS/other Unix, `fork_supported() == false` → report-to-fd only. Make this +an explicit, documented support matrix in `collector_signal_safe/mod.rs` docs +and the FFI header: + +| Target | fork collection | stackwalk | fallback | +|---|---|---|---| +| Linux x86_64/aarch64 | clone(SIGCHLD) | fp + process_vm_readv | report_fd | +| other Linux arches | none (libc fallback module) | none | report_fd | +| macOS/iOS | none | none | report_fd (siginfo-only minimal report) | +| non-Unix | compile_error (lib.rs:54) | — | — | + +macOS best-effort `fork()` mode is decision D3: stay degraded-fd-only, revisit +only if an integrator asks. + +--- + +## Phase 3 — Safety options surface + +The option set on `SignalSafeInitConfig` is good; this phase makes each option +sound, validated, and documented. + +### 3.1 Validation & clamping (config.rs) + +Central `fn validate(config) -> Result` instead of the +scattered `normalized_*` helpers, and make `init_result` return *why* it failed: + +```rust +#[repr(i32)] pub enum InitResult { + Enabled = 0, + DisabledByConfig = 1, + Failed = 2, // keep for ABI compat + AlreadyInitialized = 3, + OwnerConflict = 4, // std collector holds the signals + InvalidConfig = 5, // path too long, bad fd, etc. +} +``` + +(The FFI enum mirrors it; cbindgen headers regenerated in Phase 5.) Checks: + +- `receiver_path` length < 512 (today `set_receiver_path` fails → generic + `Failed`; surface as `InvalidConfig`). +- `report_fd`: if ≥ 0, verify with `fcntl(F_GETFD)` at init; warn-degrade if bad. +- `max_frames` clamp (exists), `collector_reap_ms`/`receiver_timeout_secs` + bounds (exists) — move into `validate` and unit-test the boundaries. +- `use_alt_stack && !create_alt_stack`: allowed (caller made their own alt + stack) — document. `create_alt_stack && !use_alt_stack` is pointless → + `InvalidConfig`. (dd-trace-c silently pairs them; we reject instead — + explicit is better than implicit, and a rejected config is visible at init + while an auto-corrected one hides the integrator's misunderstanding.) + +### 3.2 `disarm_on_entry` semantics (`handler.rs:523-525`) + +Today it resets the *current* signal to SIG_DFL on entry, which is a +crash-loop-proofing option, but the chain logic at the bottom then consults +`effective_target` and may reinstall/raise accordingly. Interaction bugs: + +- After disarm, `ChainAction::Resume` (app disposition SIG_IGN) leaves the + signal at SIG_DFL, not restored to our handler nor to SIG_IGN — next + occurrence terminates the process with no report and without honoring the + app's IGN. Chosen behavior: with `disarm_on_entry`, after a non-genuine + signal, restore the pre-entry disposition (our handler) before returning. + Add tests for (disarm × {genuine, external-async, ignored}). + +### 3.3 `block_signals` and `SA_NODEFER` + +`BLOCK_SIGNALS` adds all crash signals to `sa_mask` (good default). Document +the interplay: when we invoke the app handler *from* our handler, the app runs +with our mask (all crash signals blocked) — a crash inside the app handler on a +*different* signal is deferred, not delivered, until we return; combined with +1.1's recursion guard this is the intended behavior. Add this to the module docs; +no code change. + +### 3.4 Stack-overflow self-protection in the handler + +The handler itself uses ~4–8 KiB of stack (`SECTION_BUF_CAPACITY` buffer is in +the *collector child*'s `emit_crash_report` frame, but `crash_debug`'s FdSink +and locals are small). On a SIGSEGV from stack overflow *without* alt stack, the +handler may fault immediately → kernel kills with default action (our handler +can't run: the signal is blocked during delivery and the second fault while +SIGSEGV is blocked force-terminates). That is acceptable and matches dd-trace-c, +but: verify `emit_crash_report`'s 4 KiB+ frame only ever exists in the forked +child or in the degraded direct-report path (parent handler frame!). Today +`direct_report` runs `emit_crash_report` **in the signal handler frame** +(`handler.rs:438-445`) — that's a ~5 KiB stack requirement in degraded mode. +Either document ("degraded fd reporting needs ~8 KiB of remaining stack") or +move the buffers to static storage (`UnsafeCell` scratch, safe because +`COLLECTING` guarantees single entry). + +### 3.5 Timeouts + +`RECEIVER_TIMEOUT_MS` is `secs*1000 + grace` capped only by u32 input; a huge +env-provided value stalls the crashing process for that long (the process is +dying anyway, but supervisors may SIGKILL and lose the report). Clamp to e.g. +60 s in `validate`. `poll_sleep_ms(100)` granularity is fine. + +--- + +## Phase 4 — Test strategy + +### 4.1 Keep and extend the two-binary e2e pattern + +The self-exec pattern in `tests/collector_signal_safe_e2e.rs` (env-var-gated +"child" tests + orchestrating tests) is good; extend the matrix. Each scenario +is a child test fn + assertion block: + +| Scenario | Child behavior | Assert | +|---|---|---| +| SIGSEGV genuine | deref null | report, `SEGV_MAPERR`, non-zero frames on x86_64/aarch64 | +| SIGFPE | integer div by zero | report emitted; si_code name policy (see 5.3) | +| External async signal | parent sends SIGSEGV via `kill` to child pid | **no** report; child terminates by default action | +| Self-sent async | child `raise(SIGSEGV)` | report (genuine per self-pid rule) | +| App handler recovers (Mode A) | install SIGSEGV handler with `siglongjmp`; crash; continue | no report; process exits 0 | +| Recover **then** genuine crash | as above, then deref null with handler removed | exactly one report (regression for 1.1) | +| App handler gives up | handler restores SIG_DFL and returns | report, then process dies with SIGSEGV (refault, not raise — check exit signal) | +| Mode B | `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` + recovering handler | report *and* process exits 0 | +| App registered via `signal()` not `sigaction()` | flags without SA_SIGINFO | app invoked with 1-arg convention (covers `invoke_handler` transmute) | +| Stuck receiver | receiver script sleeps forever | crashing process terminates within timeout+grace+ε; receiver reaped (no zombie) | +| Receiver deleted post-init | unlink receiver after init | fd fallback report with both degradation tags (2.3) | +| Bootstrap-only | `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true`, crash after `bootstrap_complete()` | no report | +| Stage tags | crash before/after `bootstrap_complete` | `stage:crashtracker_init` vs `stage:application` in tags & message | +| Low-fd collision | child sets report pipe up so read end lands on fd 0-2 | report intact (covers `sanitize_clone` relocation) | +| Re-init after shutdown (if 1.4 chosen) | init→shutdown→init→crash | one report | + +Timeout-sensitive tests must use generous budgets (CI is slow); gate the stuck- +receiver test behind `#[cfg(target_os = "linux")]`. + +### 4.2 Golden receiver round-trip (reuse the real parser) + +Highest-value compat test: feed the signal-safe emitter's exact byte stream into +the *actual* std receiver parser. + +- Dev-only test in `libdd-crashtracker/tests/` compiled with + `--features collector_signal-safe,receiver` (nextest already builds + per-test-binary features via the workspace run with `--all-features`; add an + explicit CI invocation `cargo nextest run -p libdd-crashtracker --features + "collector_signal-safe,receiver" signal_safe_golden`). +- Build a `Report`+`CrashContext` with fixed values → `emit_report` into a + `SliceSink` → run through the receiver's stdin parsing entry + (`receiver` module exposes the line-protocol parser; use + `receiver_entry_point_stdin`-adjacent internals or spawn + `crashtracker-receiver` binary with stdin = the bytes and a file endpoint). +- Assert the resulting `CrashInfo`: sig names, `PROCESSINFO` section accepted, + tags including `stage:`/`report_degraded:` preserved, config JSON deserializes + into `CrashtrackerConfiguration` (this pins `build_config_json` as a wire + contract — `resolve_frames: EnabledWithSymbolsInReceiver`, timeout struct + shape, signal list). +- Golden file: check in the emitted stream (`tests/fixtures/signal_safe_report.golden`) + and diff against regenerated output, so accidental wire changes are loud. + +### 4.3 Banned-symbol guard (exists — refine) + +`tools/check_signal_safe_symbols.sh` + the `Symbol guard` step in +`.github/workflows/crashtracker-signal-safe.yml` already implement the +link-level check (`nm -u` over the no-default-features rlib + rustix rlibs, +banning `malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__libc_*`). +Remaining work: + +- Fix the current `U getenv` failure (Phase 0.1) — the guard is red today. +- Isolate the build (Phase 0.2, dedicated `CARGO_TARGET_DIR`) so stale + artifacts can't produce false results in either direction. +- Known limitation to document in the script: it scans the *entire* rlib's + undefined symbols, so it enforces "the whole no-std build is clean", not + "the crash path is clean" — stricter than strictly needed (init-time code is + held to crash-path standards), which is the right trade-off; any exception + must be an explicit regex change reviewed in the script, not an attribute in + code. +- Optional addition: `heapless`/`serde-json-core` rlibs are not scanned — add + them to the `find` list (they are no-std by construction, so this is cheap + insurance). + +### 4.4 Unit-test gaps + +- `chain_action` × `disarm_on_entry` matrix (3.2). +- `write_i32` boundary: `i32::MIN` (current code does `wrapping_neg` on i64 — + correct, but assert it). +- `hex_addr(usize::MAX)`, `hex_addr(0)`. +- `build_config_json` exact-string golden (not just `contains`). +- `capabilities` probe unit tests (probe failure paths: missing receiver path + → `DEGRADED_MISSING_RECEIVER`; `report_fd` propagation → `REPORT_FD_OK`). +- aarch64: the restored workflow only runs `cargo check --target + aarch64-unknown-linux-gnu` — compilation coverage, no execution. Consider + upgrading to actually *run* the unit tests under qemu (`cross test` or a + `taiki-e/setup-cross-toolchain-action` job); the aarch64-specific code + (`arch_seed` LR fallback, `fork_raw` via `svc 0`, syscall wrappers) is + exactly the kind that passes `check` and fails at runtime. + +--- + +## Phase 5 — Reuse & compatibility with the existing crashtracker / libdatadog + +### 5.1 Share the wire-protocol constants + +`DD_CRASHTRACK_BEGIN_*` markers are hardcoded as byte strings in +`collector_signal_safe/mod.rs` *and* exist in the std collector/receiver +(`shared/constants.rs` or `crash_info` — locate with +`grep -rn "DD_CRASHTRACK_BEGIN" libdd-crashtracker/src --include=*.rs`). +Extract a no-std-safe `pub(crate) mod protocol` (plain `&'static str`/`&[u8]` +consts, zero deps, compiled under both `std` and `collector_signal-safe`) and +use it from the std emitter, the signal-safe emitter, and the receiver parser. +One source of truth kills silent divergence; the golden test (4.2) enforces it. + +### 5.2 Signal-owner interplay tests (both directions) + +`collector/api.rs` now bails if the signal-safe collector owns signals, and +`handler.rs` bails in reverse. Add tests: + +- std `init` after signal-safe `init` → error string mentions the conflict + (test with both features enabled). +- signal-safe `init_result` after std `init` → `OwnerConflict` (new variant, 3.1). +- std `shutdown`/`disable` releasing ownership → signal-safe init then succeeds + (verify `collector/api.rs` release path — the diff shows release on failure; + confirm the success-path teardown releases too; if the std collector has no + full uninstall, document that switching collectors requires process restart). + +### 5.3 si_code / signal-name parity with the receiver + +The receiver deserializes `si_code_human_readable` into its `SiCodes` enum. +Audit `rust_si_code_name` + `rust_signal_name` outputs against +`crash_info`'s enums (`grep -n "SEGV_MAPERR\|SiCodes" libdd-crashtracker/src/crash_info/`). +Two known items from the parity doc (§12): + +- `SIGFPE` `FPE_*` codes: `SiCodes` has no FPE variants → signal-safe emitter + currently returns `""` for FPE codes. Confirm the receiver's serde + tolerates `""` (it must map to an UNKNOWN variant, not error). If it + errors, emit `"UNKNOWN"` or whatever the receiver's fallback spelling is — + pin with a golden test (SIGFPE e2e in 4.1). +- `""` for unrecognized si_codes generally — same verification. + +### 5.4 FFI headers (cbindgen) and the FFI type duplication + +- Remove the `libdd_crashtracker::collector_signal_safe` exclusion from + `libdd-crashtracker-ffi/cbindgen.toml` **or** keep the exclusion and ensure + all `#[repr(C)]` types live in the FFI crate only (current approach — + `SignalSafeConfig`, `SignalSafeInitResult`, `SignalSafeStage` are FFI-crate + types; the exclusion just stops cbindgen from chasing internal lib types. + That's fine — verify generated headers actually contain the + `ddog_crasht_signal_safe_*` functions and structs: build with the `cbindgen` + feature and inspect the header). +- The `Stage`/`InitResult` enums are duplicated (lib + FFI) with manual mapping — + keep (repo FFI convention), but add a unit test asserting variant-value + equality so they can't drift. +- Add a C example under `examples/ffi/` exercising + `ddog_crasht_signal_safe_init` + abort + receiver script, wired into + `cargo ffi-test` (per AGENTS.md step 4). + +### 5.5 Builder/release integration + +`builder` crate feature flags gate release artifacts (`crashtracker` flag). The +`std` feature refactor changed `crate-type` from `["lib","staticlib"]` to +`["lib"]` in `libdd-crashtracker/Cargo.toml` — verify the builder and any +downstream consumer didn't rely on the staticlib (grep `builder/` for +`crashtracker` staticlib references; run `cargo run --bin release -- --out /tmp/out` +smoke). Whether `collector_signal-safe` becomes part of the released FFI +artifact now or stays opt-in is decision D1 (team call, blocks PR 8); if +released, add the feature to the builder's crashtracker flag set and +regenerate headers. + +### 5.6 Don't regress the std path + +The `std` feature refactor touches every consumer of `libdd-crashtracker`. +Verify: + +- `cargo check` for every workspace crate depending on `libdd-crashtracker` + (sidecar, profiling-ffi if it re-exports crashtracker, bin_tests): + `cargo build --workspace --exclude builder` covers it — plus + `cargo nextest run --workspace --all-features --exclude builder --exclude test_spawn_from_lib`. +- `bin_tests` crash-tracking integration tests still pass (they exercise the + std collector end-to-end): `cargo nextest run -p bin_tests`. +- crashtracker unit-test features still work: + `cargo nextest run -p libdd-crashtracker --features libdd-crashtracker/generate-unit-test-files`. + +### 5.7 Env-read policy carve-out + +Repo convention: no hidden env reads. `prepare_from_env`/`init_from_env` are the +sanctioned preload bootstrap exception. Document this explicitly in the module +docs and keep `init(&SignalSafeInitConfig)` 100% env-free (audit: it is today — +`prepare()` never calls `env_get`). Add a doc section to +`libdd-crashtracker/src/README.md` describing the two entry points and which +one language tracers should use (the explicit one). + +--- + +## Phase 6 — Deferred parity work (tracked, not in this plan's execution scope) + +From `crashtracker-work-we-need-to-do.md`; keep priorities but do them after +Phases 0–5: + +1. `sigaction`/`signal` PLT interposition + virtualized per-signal state + (§3 there) — prerequisite for full Mode A fidelity with late-registering + runtimes. The current `ORIG_FN` snapshot only sees handlers displaced at + install time. +2. Receiver path discovery beside the loaded `.so` (dladdr glue, arch-suffixed + names) (§9). +3. Packaging: musl receiver sidecar, size guard, staticlib + link-level symbol + guard CI (§16, 4.3 here). +4. Whole integration-test matrix port from dd-trace-c's + `crashtracker_preload_test.go` (§18) — the 4.1 matrix covers the highest-value + half already. + +## Suggested PR sequence + +1. **fix(crashtracker): make signal-safe symbol guard pass** — Phase 0 + (environ-based env lookup replacing `libc::getenv`, guard-script target-dir + isolation). Rebase the `wip`+merge history into clean conventional commits. +2. **fix(crashtracker): signal-safe handler chaining hardening** — 1.1, 1.2, 1.5, + 1.10 + chain e2e tests from 4.1. +3. **feat(crashtracker): lifecycle re-init and richer InitResult** — 1.4, 1.6, + 1.7, 3.1, 5.2 tests. +4. **feat(crashtracker): capability probes and degraded-mode coverage** — 2.1–2.5, + 1.8, probe tests. +5. **test(crashtracker): golden receiver round-trip + symbol guard + e2e matrix** + — 4.2, 4.3, remaining 4.1, 5.3. +6. **refactor(crashtracker): shared protocol constants** — 5.1 (pure refactor, + golden test keeps it honest). +7. **feat(crashtracker-ffi): headers, C example, ffi-test wiring** — 5.4, 1.7 if + not done in PR 3. +8. **build: builder/release + CI (aarch64, staticlib nm guard)** — 5.5, 4.4 CI. + +Every PR: conventional-commit title, run the full validation list from §0, and +`./scripts/update_license_3rdparty.sh` if the lockfile moved. + +## Key risks + +1. The restored CI workflow's symbol guard is red (`U getenv`) — nothing lands + until Phase 0.1; and `signal_owner::acquire`'s same-owner reentrancy means + the owner gate alone does not enforce single init (only + `state::begin_init` does) — don't weaken that CAS during lifecycle work. +2. Mode A guard redesign (1.1/1.2) is subtle; land it only with the + recover-then-crash-again and handler-returns-without-fixing e2e tests. +3. The `std` feature refactor is the largest blast radius — Phase 5.6 checks are + not optional. +4. `serde-json-core`/`heapless` in the crash path: allocation-free but capacity- + bounded; 1.9's truncation semantics decide whether reports degrade loudly or + vanish. From 5da67273c563f0951daaefc7eceef41a0c120525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 02:02:18 +0200 Subject: [PATCH 08/32] fix(crashtracker): harden signal-safe collector --- .../src/collector_signal_safe.rs | 163 +++++++--- .../src/collector_signal_safe/backtrace.rs | 15 + .../src/collector_signal_safe/capabilities.rs | 2 +- .../src/collector_signal_safe/config.rs | 98 ++++-- .../src/collector_signal_safe/handler.rs | 280 ++++++++++++++---- .../src/collector_signal_safe/mod.rs | 24 +- .../src/collector_signal_safe/state.rs | 54 +++- .../src/collector_signal_safe/sys.rs | 121 +++++++- tools/check_signal_safe_symbols.sh | 9 +- 9 files changed, 612 insertions(+), 154 deletions(-) diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs index b65b202fcc..4a7eb7000e 100644 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -1,13 +1,16 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use std::ffi::{c_char, CStr}; +use std::ffi::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, capability_bits, init_from_env_result, init_result, owned_signal_count, - owns_signal, set_stage, shutdown, InitResult, SignalSafeInitConfig, Stage, + bootstrap_complete, capability_bits, degradation_bits, init_from_env_result, init_result, + owned_signal_count, owns_signal, set_stage, shutdown, InitResult, SignalSafeInitConfig, Stage, }; +const CSTR_MAX_LEN: usize = 4096; + #[repr(C)] pub struct SignalSafeConfig { pub receiver_path: *const c_char, @@ -40,6 +43,9 @@ pub enum SignalSafeInitResult { Enabled = 0, DisabledByConfig = 1, Failed = 2, + AlreadyInitialized = 3, + OwnerConflict = 4, + InvalidConfig = 5, } #[repr(C)] @@ -58,7 +64,7 @@ pub enum SignalSafeStage { #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> SignalSafeInitResult { - init_result_to_ffi(init_from_env_result()) + ffi_result(|| init_result_to_ffi(init_from_env_result())) } /// Initialize the signal-safe crashtracker with explicitly provided metadata. @@ -71,74 +77,83 @@ pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> SignalSafeInitResul pub unsafe extern "C" fn ddog_crasht_signal_safe_init( config: *const SignalSafeConfig, ) -> SignalSafeInitResult { - let Some(config) = config.as_ref() else { - return SignalSafeInitResult::Failed; - }; - - init_result_to_ffi(init_result(&SignalSafeInitConfig { - receiver_path: cstr_bytes(config.receiver_path), - service: cstr_bytes(config.service), - env: cstr_bytes(config.env), - app_version: cstr_bytes(config.app_version), - runtime_id: cstr_bytes(config.runtime_id), - platform: cstr_bytes(config.platform), - library_name: cstr_bytes(config.library_name), - library_version: cstr_bytes(config.library_version), - family: cstr_bytes(config.family), - default_service: cstr_bytes(config.default_service), - force_on_top: config.force_on_top, - only_bootstrap: config.only_bootstrap, - debug_logging: config.debug_logging, - create_alt_stack: config.create_alt_stack, - use_alt_stack: config.use_alt_stack, - block_signals: config.block_signals, - disarm_on_entry: config.disarm_on_entry, - report_fd: config.report_fd, - collector_reap_ms: config.collector_reap_ms, - receiver_timeout_secs: config.receiver_timeout_secs, - max_frames: config.max_frames, - close_fds_on_receiver: config.close_fds_on_receiver, - })) + ffi_result(|| { + let Some(config) = config.as_ref() else { + return SignalSafeInitResult::Failed; + }; + + init_result_to_ffi(init_result(&SignalSafeInitConfig { + receiver_path: cstr_bytes(config.receiver_path), + service: cstr_bytes(config.service), + env: cstr_bytes(config.env), + app_version: cstr_bytes(config.app_version), + runtime_id: cstr_bytes(config.runtime_id), + platform: cstr_bytes(config.platform), + library_name: cstr_bytes(config.library_name), + library_version: cstr_bytes(config.library_version), + family: cstr_bytes(config.family), + default_service: cstr_bytes(config.default_service), + force_on_top: config.force_on_top, + only_bootstrap: config.only_bootstrap, + debug_logging: config.debug_logging, + create_alt_stack: config.create_alt_stack, + use_alt_stack: config.use_alt_stack, + block_signals: config.block_signals, + disarm_on_entry: config.disarm_on_entry, + report_fd: config.report_fd, + collector_reap_ms: config.collector_reap_ms, + receiver_timeout_secs: config.receiver_timeout_secs, + max_frames: config.max_frames, + close_fds_on_receiver: config.close_fds_on_receiver, + })) + }) } #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_bootstrap_complete() { - bootstrap_complete(); + ffi_void(bootstrap_complete); } #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_shutdown() { - shutdown(); + ffi_void(shutdown); } #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_set_stage(stage: SignalSafeStage) { - set_stage(match stage { - SignalSafeStage::Uninitialized => Stage::Uninitialized, - SignalSafeStage::CrashtrackerInit => Stage::CrashtrackerInit, - SignalSafeStage::PlatformInit => Stage::PlatformInit, - SignalSafeStage::LanguageInit => Stage::LanguageInit, - SignalSafeStage::PluginLoading => Stage::PluginLoading, - SignalSafeStage::InjectionMetadataSend => Stage::InjectionMetadataSend, - SignalSafeStage::HttpClientSend => Stage::HttpClientSend, - SignalSafeStage::Application => Stage::Application, - SignalSafeStage::CrashtrackerUninstall => Stage::CrashtrackerUninstall, + ffi_void(|| { + set_stage(match stage { + SignalSafeStage::Uninitialized => Stage::Uninitialized, + SignalSafeStage::CrashtrackerInit => Stage::CrashtrackerInit, + SignalSafeStage::PlatformInit => Stage::PlatformInit, + SignalSafeStage::LanguageInit => Stage::LanguageInit, + SignalSafeStage::PluginLoading => Stage::PluginLoading, + SignalSafeStage::InjectionMetadataSend => Stage::InjectionMetadataSend, + SignalSafeStage::HttpClientSend => Stage::HttpClientSend, + SignalSafeStage::Application => Stage::Application, + SignalSafeStage::CrashtrackerUninstall => Stage::CrashtrackerUninstall, + }); }); } #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_capabilities() -> u32 { - capability_bits() + ffi_u32(capability_bits) +} + +#[no_mangle] +pub extern "C" fn ddog_crasht_signal_safe_degradations() -> u32 { + ffi_u32(degradation_bits) } #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_owned_signal_count() -> u32 { - owned_signal_count() + ffi_u32(owned_signal_count) } #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_owns_signal(signum: i32) -> bool { - owns_signal(signum) + catch_unwind(AssertUnwindSafe(|| owns_signal(signum))).unwrap_or(false) } fn init_result_to_ffi(result: InitResult) -> SignalSafeInitResult { @@ -146,13 +161,65 @@ fn init_result_to_ffi(result: InitResult) -> SignalSafeInitResult { InitResult::Enabled => SignalSafeInitResult::Enabled, InitResult::DisabledByConfig => SignalSafeInitResult::DisabledByConfig, InitResult::Failed => SignalSafeInitResult::Failed, + InitResult::AlreadyInitialized => SignalSafeInitResult::AlreadyInitialized, + InitResult::OwnerConflict => SignalSafeInitResult::OwnerConflict, + InitResult::InvalidConfig => SignalSafeInitResult::InvalidConfig, } } +fn ffi_result(f: impl FnOnce() -> SignalSafeInitResult) -> SignalSafeInitResult { + catch_unwind(AssertUnwindSafe(f)).unwrap_or(SignalSafeInitResult::Failed) +} + +fn ffi_void(f: impl FnOnce()) { + let _ = catch_unwind(AssertUnwindSafe(f)); +} + +fn ffi_u32(f: impl FnOnce() -> u32) -> u32 { + catch_unwind(AssertUnwindSafe(f)).unwrap_or(0) +} + unsafe fn cstr_bytes<'a>(ptr: *const c_char) -> &'a [u8] { if ptr.is_null() { &[] } else { - CStr::from_ptr(ptr).to_bytes() + let mut len = 0usize; + while len < CSTR_MAX_LEN && *ptr.add(len) != 0 { + len += 1; + } + std::slice::from_raw_parts(ptr.cast(), len) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ffi_init_result_values_match_library_values() { + assert_eq!( + SignalSafeInitResult::Enabled as i32, + InitResult::Enabled as i32 + ); + assert_eq!( + SignalSafeInitResult::DisabledByConfig as i32, + InitResult::DisabledByConfig as i32 + ); + assert_eq!( + SignalSafeInitResult::Failed as i32, + InitResult::Failed as i32 + ); + assert_eq!( + SignalSafeInitResult::AlreadyInitialized as i32, + InitResult::AlreadyInitialized as i32 + ); + assert_eq!( + SignalSafeInitResult::OwnerConflict as i32, + InitResult::OwnerConflict as i32 + ); + assert_eq!( + SignalSafeInitResult::InvalidConfig as i32, + InitResult::InvalidConfig as i32 + ); } } diff --git a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs index e157436583..a8f5292e48 100644 --- a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs +++ b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs @@ -94,6 +94,21 @@ fn arch_seed(_uc: &libc::ucontext_t, _out: &mut [usize]) -> (usize, usize) { (0, 0) } +pub fn instruction_pointer(ucontext: *const c_void) -> usize { + if ucontext.is_null() { + return 0; + } + + let uc = unsafe { &*(ucontext as *const libc::ucontext_t) }; + let mut out = [0usize; 1]; + let (n, _) = arch_seed(uc, &mut out); + if n == 0 { + 0 + } else { + out[0] + } +} + pub fn backtrace_from_ucontext( out: &mut [usize], ucontext: *const c_void, diff --git a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index 953fb0f6fd..97c13ee2c4 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -76,7 +76,7 @@ pub fn publish(receiver_path: &[u8], report_fd: i32) { degraded |= DEGRADED_NO_PIPE; } - if report_fd >= 0 { + if sys::fd_valid(report_fd) { caps |= REPORT_FD_OK; } diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index cd3b76f588..4c445da65f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -1,14 +1,13 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use core::ffi::c_char; use core::fmt::Write; use core::sync::atomic::Ordering::Relaxed; use heapless::String as HeaplessString; use super::state::meta_mut; -use super::{capabilities, state}; +use super::{capabilities, state, sys}; // Compatibility preset for the existing C-tracer consumer. New integrators should pass // explicit metadata through SignalSafeInitConfig instead of relying on these defaults. @@ -32,6 +31,7 @@ pub const COMPAT_LIBRARY_FAMILY: &str = "native"; pub const COMPAT_DEFAULT_SERVICE: &str = "dd-trace-c"; pub const RECEIVER_TIMEOUT_SECS: u32 = 5; +pub const RECEIVER_TIMEOUT_SECS_MAX: u32 = 60; pub const COLLECTOR_REAP_MS: i32 = 500; pub const RECEIVER_TIMEOUT_GRACE_MS: i32 = 1000; pub const BACKTRACE_LEVELS_DEFAULT: usize = 32; @@ -73,6 +73,12 @@ pub struct SignalSafeInitConfig<'a> { pub close_fds_on_receiver: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrepareError { + InvalidConfig, + Failed, +} + impl<'a> Default for SignalSafeInitConfig<'a> { fn default() -> Self { Self { @@ -146,9 +152,15 @@ pub fn build_config_json( } pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { + prepare_result(config).is_ok() +} + +pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { + validate(config)?; + let m = meta_mut(); if !build_config_json(&mut m.config_json, config) { - return false; + return Err(PrepareError::Failed); } set_str(&mut m.service, config.service); @@ -181,7 +193,7 @@ pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { ); if !set_receiver_path(&mut m.process_path, config.receiver_path) { - return false; + return Err(PrepareError::InvalidConfig); } state::FORCE_ON_TOP.store(config.force_on_top, Relaxed); @@ -204,12 +216,16 @@ pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { ); state::MAX_FRAMES.store(normalized_max_frames(config.max_frames), Relaxed); capabilities::publish(m.process_path.as_slice(), config.report_fd); - true + Ok(()) } pub fn prepare_from_env() -> bool { + prepare_from_env_result().is_ok() +} + +pub fn prepare_from_env_result() -> Result<(), PrepareError> { if disabled_by_env() { - return false; + return Err(PrepareError::InvalidConfig); } // Prefer the neutral runtime receiver path name. DD_TRACE_C_CRASHTRACKER_PROCESS is @@ -223,7 +239,7 @@ pub fn prepare_from_env() -> bool { .unwrap_or(b"host"); let debug_logging = parse_log_level(env_get(b"DD_TRACE_LOG_LEVEL\0")) >= DD_LOG_DEBUG; - prepare(&SignalSafeInitConfig { + prepare_result(&SignalSafeInitConfig { receiver_path, service: env_get(b"DD_SERVICE\0").unwrap_or(&[]), env: env_get(b"DD_ENV\0").unwrap_or(&[]), @@ -244,6 +260,8 @@ pub fn disabled_by_env() -> bool { fn normalized_receiver_timeout_secs(value: u32) -> u32 { if value == 0 { RECEIVER_TIMEOUT_SECS + } else if value > RECEIVER_TIMEOUT_SECS_MAX { + RECEIVER_TIMEOUT_SECS_MAX } else { value } @@ -299,21 +317,29 @@ fn set_receiver_path(dst: &mut heapless::Vec, path: &[u8]) -> bool { dst.extend_from_slice(selected).is_ok() && dst.push(0).is_ok() } -pub unsafe fn cstr_bytes<'a>(p: *const c_char) -> &'a [u8] { - let mut len = 0usize; - while core::ptr::read_volatile(p.add(len)) != 0 { - len += 1; - } - core::slice::from_raw_parts(p.cast(), len) +fn env_get(name_nul: &[u8]) -> Option<&'static [u8]> { + sys::env_get(name_nul) } -fn env_get(name_nul: &[u8]) -> Option<&'static [u8]> { - let p = unsafe { libc::getenv(name_nul.as_ptr().cast()) }; - if p.is_null() { - None +fn validate(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { + if config.create_alt_stack && !config.use_alt_stack { + return Err(PrepareError::InvalidConfig); + } + + let receiver_path = if config.receiver_path.is_empty() { + DEFAULT_RECEIVER_PATH.as_bytes() } else { - Some(unsafe { cstr_bytes(p) }) + config.receiver_path + }; + if receiver_path.len() >= 513 { + return Err(PrepareError::InvalidConfig); + } + + if config.report_fd >= 0 && !sys::fd_valid(config.report_fd) { + return Err(PrepareError::InvalidConfig); } + + Ok(()) } fn eq(a: &[u8], b: &[u8]) -> bool { @@ -400,6 +426,42 @@ mod tests { assert!(out.ends_with('\n')); } + #[test] + fn timeout_seconds_are_clamped() { + assert_eq!(normalized_receiver_timeout_secs(0), RECEIVER_TIMEOUT_SECS); + 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(&SignalSafeInitConfig { + create_alt_stack: true, + use_alt_stack: false, + ..SignalSafeInitConfig::default() + }), + Err(PrepareError::InvalidConfig) + ); + } + + #[test] + fn env_get_walks_environ_without_getenv() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + + std::env::set_var("DD_SIGNAL_SAFE_ENV_GET_TEST", "walked"); + assert_eq!( + env_get(b"DD_SIGNAL_SAFE_ENV_GET_TEST\0"), + Some(&b"walked"[..]) + ); + std::env::remove_var("DD_SIGNAL_SAFE_ENV_GET_TEST"); + } + #[test] fn bool_and_log_parsing_matches_compatibility_inputs() { assert!(is_false(Some(b"FALSE"))); diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index b0fc9f01e7..c556c777ac 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -4,10 +4,10 @@ use core::cell::UnsafeCell; use core::ffi::{c_char, c_int, c_void}; use core::ptr::null_mut; -use core::sync::atomic::{AtomicBool, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; -use super::config::{self, SignalSafeInitConfig}; -use super::state::{self, sig_index, Stage}; +use super::config::{self, PrepareError, SignalSafeInitConfig}; +use super::state::{self, sig_index, BeginInitError, Stage}; use super::sys::{self, FdSink}; use super::{ app_handler_is_real, app_recovered, chain_action, is_genuine_fault, should_run_app_first, @@ -16,6 +16,7 @@ use super::{ use super::{backtrace, capabilities}; use crate::signal_owner::{self, SignalOwner}; +// Used only by forked children; 125 matches the existing shell-like "cannot exec" convention. const EXIT_CODE_FAILURE: i32 = 125; const REAP_KILL_TIMEOUT_MS: i64 = 500; const REAP_WAIT_INTERVAL_MS: i32 = 100; @@ -27,22 +28,28 @@ unsafe impl Sync for AltStackStorage {} static ALT_STACK: AltStackStorage = AltStackStorage(UnsafeCell::new([0; ALT_STACK_SIZE])); -unsafe extern "C" { - static mut environ: *mut *mut c_char; -} - #[derive(Clone, Copy)] struct Target { fn_ptr: *mut c_void, flags: i32, } +static APP_CHAIN_TID: AtomicI32 = AtomicI32::new(0); +static APP_CHAIN_STACK: AtomicUsize = AtomicUsize::new(0); +static REPEAT_FAULT_PC: [AtomicUsize; state::NSIG] = [const { AtomicUsize::new(0) }; state::NSIG]; +static REPEAT_FAULT_ADDR: [AtomicUsize; state::NSIG] = [const { AtomicUsize::new(0) }; state::NSIG]; +static REPEAT_FAULT_COUNT: [AtomicUsize; state::NSIG] = + [const { AtomicUsize::new(0) }; state::NSIG]; + #[repr(i32)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum InitResult { Enabled = 0, DisabledByConfig = 1, Failed = 2, + AlreadyInitialized = 3, + OwnerConflict = 4, + InvalidConfig = 5, } pub fn init(config: &SignalSafeInitConfig<'_>) -> bool { @@ -50,17 +57,18 @@ pub fn init(config: &SignalSafeInitConfig<'_>) -> bool { } pub fn init_result(config: &SignalSafeInitConfig<'_>) -> InitResult { - if !state::begin_init() { - return InitResult::Failed; + let begin = state::begin_init(); + if let Err(err) = begin { + return begin_init_error(err); } if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { state::fail_init(); - return InitResult::Failed; + return InitResult::OwnerConflict; } - if !config::prepare(config) { + if let Err(err) = config::prepare_result(config) { signal_owner::release(SignalOwner::SignalSafeCollector); state::fail_init(); - return InitResult::Failed; + return prepare_error(err); } if !install_alt_stack_if_requested() { signal_owner::release(SignalOwner::SignalSafeCollector); @@ -83,17 +91,18 @@ pub fn init_from_env_result() -> InitResult { if config::disabled_by_env() { return InitResult::DisabledByConfig; } - if !state::begin_init() { - return InitResult::Failed; + let begin = state::begin_init(); + if let Err(err) = begin { + return begin_init_error(err); } if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { state::fail_init(); - return InitResult::Failed; + return InitResult::OwnerConflict; } - if !config::prepare_from_env() { + if let Err(err) = config::prepare_from_env_result() { signal_owner::release(SignalOwner::SignalSafeCollector); state::fail_init(); - return InitResult::Failed; + return prepare_error(err); } if !install_alt_stack_if_requested() { signal_owner::release(SignalOwner::SignalSafeCollector); @@ -122,6 +131,21 @@ pub fn shutdown() { uninstall_all_handlers(); state::INSTALLED.store(false, Ordering::Release); signal_owner::release(SignalOwner::SignalSafeCollector); + state::reset_init(); +} + +fn begin_init_error(err: BeginInitError) -> InitResult { + match err { + BeginInitError::AlreadyInitialized => InitResult::AlreadyInitialized, + BeginInitError::Busy => InitResult::Failed, + } +} + +fn prepare_error(err: PrepareError) -> InitResult { + match err { + PrepareError::InvalidConfig => InitResult::InvalidConfig, + PrepareError::Failed => InitResult::Failed, + } } fn effective_target(idx: usize) -> Target { @@ -147,6 +171,60 @@ unsafe fn invoke_handler( } } +/// Tracks app-first handler invocation without relying on cleanup after the call. +/// +/// A recovering app handler may leave this frame via siglongjmp, so a simple boolean would stay +/// set forever. Supported Unix targets use downward-growing stacks: a nested crash inside the app +/// handler has a stack address below the recorded frame, while a later signal after longjmp has +/// unwound above it. Different-thread entries skip app-first while the earlier handler is active. +fn enter_app_chain(tid: i32, stack_pos: usize) -> bool { + let owner = APP_CHAIN_TID.load(Ordering::Relaxed); + if owner == 0 { + APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); + APP_CHAIN_TID.store(tid, Ordering::Relaxed); + return true; + } + + if owner != tid { + return false; + } + + let recorded = APP_CHAIN_STACK.load(Ordering::Relaxed); + if stack_pos > recorded { + APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); + APP_CHAIN_TID.store(tid, Ordering::Relaxed); + true + } else { + false + } +} + +fn leave_app_chain(tid: i32, stack_pos: usize) { + if APP_CHAIN_TID.load(Ordering::Relaxed) == tid + && APP_CHAIN_STACK.load(Ordering::Relaxed) == stack_pos + { + APP_CHAIN_STACK.store(0, Ordering::Relaxed); + APP_CHAIN_TID.store(0, Ordering::Relaxed); + } +} + +fn app_return_repeated_fault(idx: usize, pc: usize, addr: usize) -> bool { + if pc == 0 { + return false; + } + + let last_pc = REPEAT_FAULT_PC[idx].load(Ordering::Relaxed); + let last_addr = REPEAT_FAULT_ADDR[idx].load(Ordering::Relaxed); + if last_pc == pc && last_addr == addr { + REPEAT_FAULT_COUNT[idx].fetch_add(1, Ordering::Relaxed) + 1 >= 2 + } else { + REPEAT_FAULT_ADDR[idx].store(addr, Ordering::Relaxed); + REPEAT_FAULT_COUNT[idx].store(1, Ordering::Relaxed); + REPEAT_FAULT_PC[idx].store(pc, Ordering::Relaxed); + false + } +} + fn crash_debug(msg: &[u8], sig: i32) { if !state::DEBUG_LOG.load(Ordering::Relaxed) { return; @@ -194,7 +272,7 @@ fn write_i32(value: i32, out: &mut [u8; 12]) -> usize { off + len } -fn sanitize_clone(mut keep_fd: i32) -> i32 { +fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> 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 { @@ -214,6 +292,10 @@ fn sanitize_clone(mut keep_fd: i32) -> i32 { if devnull > libc::STDERR_FILENO { sys::close(devnull); } + } else if close_stdio_without_devnull { + sys::close(libc::STDIN_FILENO); + sys::close(libc::STDOUT_FILENO); + sys::close(libc::STDERR_FILENO); } keep_fd } @@ -261,7 +343,7 @@ fn install_alt_stack_if_requested() -> bool { } fn strip_loader_injection_env() { - let env = unsafe { environ }; + let env = sys::environ_ptr(); if env.is_null() { return; } @@ -271,7 +353,7 @@ fn strip_loader_injection_env() { let mut dst = env; while !(*src).is_null() { let entry = *src; - let injected = PREFIXES.iter().any(|p| cstr_starts_with(entry, p)); + let injected = PREFIXES.iter().any(|p| sys::cstr_starts_with(entry, p)); if !injected { *dst = entry; dst = dst.add(1); @@ -282,21 +364,9 @@ fn strip_loader_injection_env() { } } -unsafe fn cstr_starts_with(s: *const c_char, prefix: &[u8]) -> bool { - let mut i = 0usize; - while i < prefix.len() { - let c = *s.add(i); - if c == 0 || c as u8 != prefix[i] { - return false; - } - i += 1; - } - true -} - fn receiver_child(read_fd: i32, write_fd: i32) -> ! { sys::close(write_fd); - let read_fd = sanitize_clone(read_fd); + let read_fd = sanitize_clone(read_fd, true); if read_fd < 0 { sys::exit_process(EXIT_CODE_FAILURE); } @@ -304,6 +374,9 @@ fn receiver_child(read_fd: i32, write_fd: i32) -> ! { let _ = sys::dup2(read_fd, libc::STDIN_FILENO); sys::close(read_fd); } + if state::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(); @@ -331,7 +404,7 @@ fn collector_child( ucontext: *mut c_void, ) -> ! { sys::close(read_fd); - let write_fd = sanitize_clone(write_fd); + let write_fd = sanitize_clone(write_fd, false); if write_fd < 0 { sys::exit_process(EXIT_CODE_FAILURE); } @@ -387,6 +460,7 @@ fn emit_crash_report( platform: meta.platform.as_str(), stage_name: state::current_stage_name(), stackwalk_method, + capability_bits: capabilities::get(), degradation_bits: capabilities::degradations(), }; let context = CrashContext { @@ -404,18 +478,19 @@ fn emit_crash_report( emitted } -fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) { +fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) -> Option { let start = sys::monotonic_nanos(); loop { - let waited = sys::waitpid_nohang(pid); + let mut status = 0i32; + let waited = sys::waitpid_nohang_status(pid, &mut status); if waited == pid { - return; + return Some(status); } if waited < 0 { if waited != -libc::ECHILD { crash_debug(b"waitpid failed", -1); } - return; + return None; } sys::poll_sleep_ms(REAP_WAIT_INTERVAL_MS); @@ -423,13 +498,17 @@ fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) { if elapsed_ms >= timeout_ms { if kill_process { let _ = sys::kill(pid, libc::SIGKILL); - reap_or_kill(pid, REAP_KILL_TIMEOUT_MS, false); + return reap_or_kill(pid, REAP_KILL_TIMEOUT_MS, false); } - return; + return None; } } } +fn exited_with(status: i32, code: i32) -> bool { + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == code +} + fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontext: *mut c_void) { let pid = sys::getpid(); let tid = sys::gettid(); @@ -495,7 +574,7 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex sys::close(write_fd); if collector > 0 { - reap_or_kill( + let _ = reap_or_kill( collector as i32, state::COLLECTOR_REAP_MS.load(Ordering::Relaxed) as i64, true, @@ -505,11 +584,15 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex direct_report(capabilities::DEGRADED_FORK_FAILED); } if receiver > 0 { - reap_or_kill( + let receiver_status = reap_or_kill( receiver as i32, state::RECEIVER_TIMEOUT_MS.load(Ordering::Relaxed) as i64, true, ); + if receiver_status.is_some_and(|status| exited_with(status, EXIT_CODE_FAILURE)) { + crash_debug(b"receiver exec failed", sig); + direct_report(capabilities::DEGRADED_RECEIVER_UNAVAILABLE); + } } } @@ -520,9 +603,8 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m let saved_errno = sys::errno(); crash_debug(b"handler entered", sig); - if state::DISARM_ON_ENTRY.load(Ordering::Relaxed) { - let _ = reset_signal_to_default(sig); - } + let disarmed_on_entry = + state::DISARM_ON_ENTRY.load(Ordering::Relaxed) && reset_signal_to_default(sig); let idx = sig_index(sig); let has_info = !info.is_null(); @@ -531,25 +613,42 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m } else { 0 }; + let si_addr = if has_info { + unsafe { siginfo_addr(info) } + } else { + 0 + }; let force_on_top = state::FORCE_ON_TOP.load(Ordering::Relaxed); if let Some(i) = idx { let target = effective_target(i); let app_is_real = app_handler_is_real(target.fn_ptr); if should_run_app_first(force_on_top, app_is_real) { - static IN_APP_CHAIN: AtomicBool = AtomicBool::new(false); - if !IN_APP_CHAIN.swap(true, Ordering::Relaxed) { + let stack_marker = 0u8; + let stack_pos = (&stack_marker as *const u8) as usize; + let tid = sys::gettid(); + if enter_app_chain(tid, stack_pos) { sys::set_errno(saved_errno); // If the application handler recovers with siglongjmp, no code after this call // runs. Keep this path free of Drop-dependent state. unsafe { invoke_handler(&target, sig, info, ucontext) }; let handler_after = live_handler_for_recovery(sig).unwrap_or(target.fn_ptr); - IN_APP_CHAIN.store(false, Ordering::Relaxed); + leave_app_chain(tid, stack_pos); if app_recovered(handler_after) { - sys::set_errno(saved_errno); - return; + let pc = backtrace::instruction_pointer(ucontext); + if app_return_repeated_fault(i, pc, si_addr) { + crash_debug(b"app handler returned without recovery", sig); + } else { + if disarmed_on_entry { + restore_our_handler(sig); + } + sys::set_errno(saved_errno); + return; + } } + } else { + crash_debug(b"app handler recursion detected", sig); } } } @@ -560,14 +659,10 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m } else { 0 }; - if is_genuine_fault(has_info, si_code, si_pid, self_pid) { + let genuine_fault = is_genuine_fault(has_info, si_code, si_pid, self_pid); + if genuine_fault { static COLLECTING: AtomicBool = AtomicBool::new(false); if !COLLECTING.swap(true, Ordering::Relaxed) { - let si_addr = if has_info { - unsafe { siginfo_addr(info) } - } else { - 0 - }; collect_crash(sig, si_code, has_info, si_addr, ucontext); } } @@ -595,8 +690,15 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m } } } - ChainAction::Resume => {} + ChainAction::Resume => { + if disarmed_on_entry && !genuine_fault { + restore_our_handler(sig); + } + } ChainAction::InvokeApp => unsafe { + if disarmed_on_entry && !genuine_fault { + restore_our_handler(sig); + } invoke_handler(&target, sig, info, ucontext); }, } @@ -675,11 +777,7 @@ fn is_our_handler(sig: c_int) -> bool { cur.sa_flags & libc::SA_SIGINFO != 0 && cur.sa_sigaction == crash_handler as *const () as usize } -fn install_crash_handler(sig: c_int) { - if !is_default_handler(sig) { - return; - } - +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; @@ -694,7 +792,22 @@ fn install_crash_handler(sig: c_int) { } } } + sa +} + +fn restore_our_handler(sig: c_int) { + let sa = build_crash_sigaction(); + unsafe { + let _ = libc::sigaction(sig, &sa, null_mut()); + } +} + +fn install_crash_handler(sig: c_int) { + if !is_default_handler(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; @@ -703,6 +816,7 @@ fn install_crash_handler(sig: c_int) { if let Some(i) = sig_index(sig) { state::ORIG_FN[i].store(old.sa_sigaction as *mut c_void, Ordering::Relaxed); state::ORIG_FLAGS[i].store(old.sa_flags, Ordering::Relaxed); + state::store_orig_mask(i, &old.sa_mask); state::OWN_SIGNAL[i].store(true, Ordering::Relaxed); } } @@ -720,7 +834,7 @@ fn uninstall_crash_handler(sig: c_int) { restore.sa_sigaction = target.fn_ptr as usize; restore.sa_flags = target.flags; unsafe { - libc::sigemptyset(&mut restore.sa_mask); + state::load_orig_mask(i, &mut restore.sa_mask); if libc::sigaction(sig, &restore, null_mut()) == 0 { state::OWN_SIGNAL[i].store(false, Ordering::Relaxed); } @@ -751,6 +865,41 @@ mod tests { assert_eq!(&buf[..n], b"-123"); let n = write_i32(42, &mut buf); assert_eq!(&buf[..n], b"42"); + let n = write_i32(i32::MIN, &mut buf); + assert_eq!(&buf[..n], b"-2147483648"); + } + + #[test] + fn app_chain_guard_distinguishes_recursion_from_unwind() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + + APP_CHAIN_TID.store(0, Ordering::Relaxed); + APP_CHAIN_STACK.store(0, Ordering::Relaxed); + + assert!(enter_app_chain(123, 1_000)); + assert!(!enter_app_chain(123, 900)); + assert!(!enter_app_chain(456, 1_100)); + assert!(enter_app_chain(123, 1_100)); + leave_app_chain(123, 1_100); + assert_eq!(APP_CHAIN_TID.load(Ordering::Relaxed), 0); + } + + #[test] + fn repeated_app_return_trips_on_second_same_fault() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + let idx = sig_index(libc::SIGSEGV).expect("SIGSEGV index"); + + REPEAT_FAULT_PC[idx].store(0, Ordering::Relaxed); + REPEAT_FAULT_ADDR[idx].store(0, Ordering::Relaxed); + REPEAT_FAULT_COUNT[idx].store(0, Ordering::Relaxed); + + assert!(!app_return_repeated_fault(idx, 0x1234, 0)); + assert!(app_return_repeated_fault(idx, 0x1234, 0)); + assert!(!app_return_repeated_fault(idx, 0x5678, 0)); } #[cfg(not(feature = "collector"))] @@ -766,6 +915,11 @@ mod tests { }; assert!(init(&config)); assert!(state::INSTALLED.load(Ordering::Acquire)); + assert_eq!(init_result(&config), InitResult::AlreadyInitialized); + shutdown(); + assert!(!state::INSTALLED.load(Ordering::Acquire)); + assert!(init(&config)); + assert!(state::INSTALLED.load(Ordering::Acquire)); shutdown(); assert!(!state::INSTALLED.load(Ordering::Acquire)); } diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index 2e0837996d..a8376c27c1 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use core::ffi::c_void; +use core::fmt::Write; use heapless::{String as HeaplessString, Vec as HeaplessVec}; use serde::Serialize; @@ -242,6 +243,7 @@ pub struct Report<'a> { pub platform: &'a str, pub stage_name: &'a str, pub stackwalk_method: &'a str, + pub capability_bits: u32, pub degradation_bits: u32, } @@ -264,6 +266,7 @@ pub fn emit_report(sink: &mut impl Sink, report: &Report<'_>, context: &CrashCon sink, report.stage_name, report.stackwalk_method, + report.capability_bits, report.degradation_bits, ) && emit_kind(sink) @@ -301,7 +304,7 @@ pub fn emit_report_with_metadata( metadata, b"DD_CRASHTRACK_END_METADATA\n", ) - && emit_additional_tags(sink, stage_name, "fp_pvr", 0) + && emit_additional_tags(sink, stage_name, "fp_pvr", 0, 0) && emit_kind(sink) && emit_json_section( sink, @@ -461,6 +464,7 @@ fn emit_additional_tags( sink: &mut impl Sink, stage: &str, stackwalk_method: &str, + capability_bits: u32, degradation_bits: u32, ) -> bool { let mut tags = Tags::new(); @@ -470,6 +474,14 @@ fn emit_additional_tags( if !push_tag(&mut tags, "stackwalk_method", stackwalk_method) { return false; } + let capabilities = hex_u32(capability_bits); + if !push_tag(&mut tags, "capabilities", capabilities.as_str()) { + return false; + } + let degradations = hex_u32(degradation_bits); + if !push_tag(&mut tags, "degradations", degradations.as_str()) { + return false; + } for &(bit, reason) in capabilities::DEGRADATION_REASONS { if degradation_bits & bit != 0 && !push_tag(&mut tags, "report_degraded", reason) { return false; @@ -487,6 +499,12 @@ fn emit_kind(sink: &mut impl Sink) -> bool { sink.put(b"DD_CRASHTRACK_BEGIN_KIND\n\"UnixSignal\"\nDD_CRASHTRACK_END_KIND\n") } +fn hex_u32(value: u32) -> HeaplessString<10> { + let mut out = HeaplessString::new(); + let _ = write!(out, "0x{value:08x}"); + out +} + fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { if !sink.put(b"DD_CRASHTRACK_BEGIN_STACKTRACE\n") { return false; @@ -749,6 +767,7 @@ mod tests { platform: "linux", stage_name: "application", stackwalk_method: "fp_pvr", + capability_bits: 0x21, degradation_bits: 0, }; @@ -806,6 +825,9 @@ mod tests { assert_eq!(metadata["tags"][5], "env:prod"); assert_eq!(metadata["tags"][6], "version:v1"); assert_eq!(tags[0], "stage:application"); + assert_eq!(tags[1], "stackwalk_method:fp_pvr"); + assert_eq!(tags[2], "capabilities:0x00000021"); + assert_eq!(tags[3], "degradations:0x00000000"); assert_eq!(kind, "UnixSignal"); assert_eq!(procinfo["pid"], 123); assert_eq!(procinfo["tid"], 456); diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index cdc23e8cb0..1f2117bda5 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -3,6 +3,7 @@ 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}; @@ -10,6 +11,7 @@ use heapless::{String as HeaplessString, Vec as HeaplessVec}; use super::config::CONFIG_JSON_BUF_SIZE; +// Raw signal numbers index these arrays. 128 covers Linux and BSD/macOS signal ranges used here. pub const NSIG: usize = 128; #[inline] @@ -58,19 +60,26 @@ static META: StaticMeta = StaticMeta(UnsafeCell::new(Meta::new())); const INIT_UNINIT: i32 = 0; const INIT_INITIALIZING: i32 = 1; const INIT_READY: i32 = 2; -const INIT_FAILED: i32 = 3; static INIT_STATE: AtomicI32 = AtomicI32::new(INIT_UNINIT); -pub fn begin_init() -> bool { - INIT_STATE - .compare_exchange( - INIT_UNINIT, - INIT_INITIALIZING, - Ordering::AcqRel, - Ordering::Acquire, - ) - .is_ok() +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BeginInitError { + AlreadyInitialized, + Busy, +} + +pub fn begin_init() -> Result<(), BeginInitError> { + match INIT_STATE.compare_exchange( + INIT_UNINIT, + INIT_INITIALIZING, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => Ok(()), + Err(INIT_READY) => Err(BeginInitError::AlreadyInitialized), + Err(_) => Err(BeginInitError::Busy), + } } pub fn finish_init() { @@ -78,7 +87,11 @@ pub fn finish_init() { } pub fn fail_init() { - INIT_STATE.store(INIT_FAILED, Ordering::Release); + INIT_STATE.store(INIT_UNINIT, Ordering::Release); +} + +pub fn reset_init() { + INIT_STATE.store(INIT_UNINIT, Ordering::Release); } pub fn meta() -> &'static Meta { @@ -93,6 +106,25 @@ pub static ORIG_FN: [AtomicPtr; NSIG] = [const { AtomicPtr::new(null_mut pub static ORIG_FLAGS: [AtomicI32; NSIG] = [const { AtomicI32::new(0) }; NSIG]; pub static OWN_SIGNAL: [AtomicBool; NSIG] = [const { AtomicBool::new(false) }; NSIG]; +struct SigMaskStorage(UnsafeCell<[MaybeUninit; NSIG]>); + +unsafe impl Sync for SigMaskStorage {} + +static ORIG_MASKS: SigMaskStorage = + SigMaskStorage(UnsafeCell::new([const { MaybeUninit::uninit() }; NSIG])); + +pub fn store_orig_mask(idx: usize, mask: &libc::sigset_t) { + unsafe { + (*ORIG_MASKS.0.get())[idx].as_mut_ptr().write(*mask); + } +} + +pub fn load_orig_mask(idx: usize, out: &mut libc::sigset_t) { + unsafe { + out.clone_from(&*(*ORIG_MASKS.0.get())[idx].as_ptr()); + } +} + pub static HANDLERS_ENABLED: AtomicBool = AtomicBool::new(false); pub static FORCE_ON_TOP: AtomicBool = AtomicBool::new(false); pub static ONLY_BOOTSTRAP: AtomicBool = AtomicBool::new(false); diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index b1c5d77435..c3ff73c1c0 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -3,9 +3,16 @@ #![allow(dead_code)] +use core::ffi::c_char; + use super::Sink; const MAX_WRITE_RETRIES: u32 = 10; +const CSTR_MAX_LEN: usize = 4096; + +unsafe extern "C" { + static mut environ: *mut *mut c_char; +} pub struct FdSink { fd: i32, @@ -293,6 +300,22 @@ mod raw { } } + pub fn fd_valid(fd: i32) -> bool { + fd >= 0 && unsafe { syscall3(libc::SYS_fcntl, fd as usize, libc::F_GETFD as usize, 0) >= 0 } + } + + pub fn close_range_from(first_fd: i32) -> bool { + first_fd >= 0 + && unsafe { + syscall3( + libc::SYS_close_range, + first_fd as usize, + u32::MAX as usize, + 0, + ) == 0 + } + } + pub fn pipe(fds: &mut [i32; 2]) -> bool { match rustix::pipe::pipe() { Ok((read_fd, write_fd)) => { @@ -386,13 +409,12 @@ mod raw { unsafe { syscall2(libc::SYS_kill, pid as usize, sig as usize) as i32 } } - pub fn waitpid_nohang(pid: i32) -> i32 { - let mut status = 0i32; + pub fn waitpid_nohang_status(pid: i32, status: &mut i32) -> i32 { unsafe { syscall4( libc::SYS_wait4, pid as usize, - (&mut status as *mut i32) as usize, + (status as *mut i32) as usize, libc::WNOHANG as usize, 0, ) as i32 @@ -481,6 +503,14 @@ mod raw { unsafe { libc::fcntl(fd, libc::F_DUPFD, min_fd) } } + pub fn fd_valid(fd: i32) -> bool { + fd >= 0 && unsafe { libc::fcntl(fd, libc::F_GETFD) >= 0 } + } + + pub fn close_range_from(_first_fd: i32) -> bool { + false + } + pub fn pipe(fds: &mut [i32; 2]) -> bool { unsafe { libc::pipe(fds.as_mut_ptr()) == 0 } } @@ -530,9 +560,8 @@ mod raw { unsafe { libc::kill(pid, sig) } } - pub fn waitpid_nohang(pid: i32) -> i32 { - let mut status = 0i32; - let ret = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; + pub fn waitpid_nohang_status(pid: i32, status: &mut i32) -> i32 { + let ret = unsafe { libc::waitpid(pid, status, libc::WNOHANG) }; if ret < 0 { -super::errno() } else { @@ -566,11 +595,85 @@ mod raw { } pub use raw::{ - access_executable, close, dup2, exit_process, fcntl_dupfd, fork_raw, fork_supported, getpid, - gettid, kill, monotonic_nanos, open_readwrite, pipe, poll_sleep_ms, read_own_mem, - waitpid_nohang, write, + access_executable, close, close_range_from, dup2, exit_process, fcntl_dupfd, fd_valid, + fork_raw, fork_supported, getpid, gettid, kill, monotonic_nanos, open_readwrite, pipe, + poll_sleep_ms, read_own_mem, waitpid_nohang_status, write, }; +pub fn waitpid_nohang(pid: i32) -> i32 { + let mut status = 0i32; + waitpid_nohang_status(pid, &mut status) +} + +pub fn env_get(name_nul: &[u8]) -> Option<&'static [u8]> { + if name_nul.is_empty() || name_nul[name_nul.len() - 1] != 0 { + return None; + } + + let name = &name_nul[..name_nul.len() - 1]; + let env = unsafe { environ }; + if env.is_null() { + return None; + } + + unsafe { + let mut cur = env; + while !(*cur).is_null() { + let entry = *cur; + if let Some(value) = env_entry_value(entry, name) { + return Some(cstr_bytes_bounded(value)); + } + cur = cur.add(1); + } + } + None +} + +pub fn environ_ptr() -> *mut *mut c_char { + unsafe { environ } +} + +pub unsafe fn cstr_bytes_bounded<'a>(p: *const c_char) -> &'a [u8] { + if p.is_null() { + return &[]; + } + + let mut len = 0usize; + while len < CSTR_MAX_LEN && *p.add(len) != 0 { + len += 1; + } + core::slice::from_raw_parts(p.cast(), len) +} + +pub unsafe fn cstr_starts_with(s: *const c_char, prefix: &[u8]) -> bool { + let mut i = 0usize; + while i < prefix.len() { + let c = *s.add(i); + if c == 0 || c as u8 != prefix[i] { + return false; + } + i += 1; + } + true +} + +unsafe fn env_entry_value(entry: *const c_char, name: &[u8]) -> Option<*const c_char> { + let mut i = 0usize; + while i < name.len() { + let c = *entry.add(i); + if c == 0 || c as u8 != name[i] { + return None; + } + i += 1; + } + + if *entry.add(name.len()) as u8 == b'=' { + Some(entry.add(name.len() + 1)) + } else { + None + } +} + pub fn errno() -> i32 { unsafe { *errno_location() } } diff --git a/tools/check_signal_safe_symbols.sh b/tools/check_signal_safe_symbols.sh index 3c3143d9ec..961bdc24cd 100644 --- a/tools/check_signal_safe_symbols.sh +++ b/tools/check_signal_safe_symbols.sh @@ -4,14 +4,17 @@ set -euo pipefail -cargo build -p libdd-crashtracker --no-default-features --features collector_signal-safe --lib +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/debug/deps -maxdepth 1 \( \ +done < <(find "${target_dir}/debug/deps" -maxdepth 1 \( \ -name 'liblibdd_crashtracker*.rlib' -o \ - -name 'librustix*.rlib' \ + -name 'librustix*.rlib' -o \ + -name 'libheapless*.rlib' -o \ + -name 'libserde_json_core*.rlib' \ \) -print) if [[ "${#artifacts[@]}" -eq 0 ]]; then From b59e4407122721d24a09fc7ef2193ebb2e0bc136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 02:12:21 +0200 Subject: [PATCH 09/32] fix(crashtracker): harden signal-safe report protocol --- .../src/collector_signal_safe.rs | 9 + .../src/collector_signal_safe/capabilities.rs | 36 +++ .../src/collector_signal_safe/config.rs | 24 +- .../src/collector_signal_safe/mod.rs | 237 ++++++++++++++---- libdd-crashtracker/src/lib.rs | 9 + libdd-crashtracker/src/protocol.rs | 39 +++ libdd-crashtracker/src/receiver/mod.rs | 74 ++++++ libdd-crashtracker/src/shared/constants.rs | 35 +-- 8 files changed, 372 insertions(+), 91 deletions(-) create mode 100644 libdd-crashtracker/src/protocol.rs diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs index 4a7eb7000e..30717d1599 100644 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -26,8 +26,17 @@ pub struct SignalSafeConfig { pub force_on_top: bool, pub only_bootstrap: bool, pub debug_logging: bool, + /// Installs the built-in alternate signal stack on the init thread only. + /// + /// Signal alternate stacks are per-thread kernel state. Stack-overflow crashes on other + /// threads require those threads to install their own alternate stacks. pub create_alt_stack: bool, + /// Registers 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, + /// Runs app handlers invoked from the signal-safe handler with managed crash signals blocked. pub block_signals: bool, pub disarm_on_entry: bool, pub report_fd: i32, diff --git a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index 97c13ee2c4..617775445e 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -21,6 +21,7 @@ pub const DEGRADED_PIPE_FAILED: u32 = 1 << 5; pub const DEGRADED_FORK_FAILED: u32 = 1 << 6; pub const DEGRADED_RECEIVER_UNAVAILABLE: u32 = 1 << 7; pub const DEGRADED_REPORT_TO_FD: u32 = 1 << 8; +pub const DEGRADED_TRUNCATED: u32 = 1 << 9; pub const DEGRADATION_REASONS: &[(u32, &str)] = &[ (DEGRADED_MISSING_RECEIVER, "missing_receiver"), @@ -32,6 +33,7 @@ pub const DEGRADATION_REASONS: &[(u32, &str)] = &[ (DEGRADED_FORK_FAILED, "fork_failed"), (DEGRADED_RECEIVER_UNAVAILABLE, "receiver_unavailable"), (DEGRADED_REPORT_TO_FD, "report_to_fd"), + (DEGRADED_TRUNCATED, "truncated"), ]; static CAPABILITIES: AtomicU32 = AtomicU32::new(0); @@ -105,3 +107,37 @@ fn probe_process_vm_readv() -> bool { let mut dst = [0u8; 1]; sys::read_own_mem(sys::getpid(), (&src as *const u8) as usize, &mut dst) && dst[0] == src } + +#[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); + + assert_eq!(get() & RECEIVER_OK, 0); + assert_ne!(degradations() & DEGRADED_MISSING_RECEIVER, 0); + assert_eq!(get() & REPORT_FD_OK, 0); + } + + #[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(), + ); + + assert_ne!(get() & REPORT_FD_OK, 0); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 4c445da65f..127b8cc7af 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -49,6 +49,7 @@ pub const CONFIG_JSON_BUF_SIZE: usize = 2048; #[derive(Clone, Copy, Debug)] pub struct SignalSafeInitConfig<'a> { + /// Receiver executable path. Empty uses the compatibility default. pub receiver_path: &'a [u8], pub service: &'a [u8], pub env: &'a [u8], @@ -62,8 +63,19 @@ pub struct SignalSafeInitConfig<'a> { pub force_on_top: bool, pub only_bootstrap: bool, 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 handler mask. + /// + /// Application handlers invoked by the signal-safe handler run with this mask in effect. pub block_signals: bool, pub disarm_on_entry: bool, pub report_fd: i32, @@ -420,10 +432,14 @@ mod tests { &mut out, &SignalSafeInitConfig::default() )); - assert!(out.contains("\"additional_files\":[]")); - assert!(out.contains("\"resolve_frames\":\"EnabledWithSymbolsInReceiver\"")); - assert!(out.contains("\"unix_socket_path\":null")); - assert!(out.ends_with('\n')); + assert_eq!( + out.as_str(), + "{\"additional_files\":[],\"create_alt_stack\":false,\"use_alt_stack\":false,\ + \"demangle_names\":true,\"endpoint\":null,\ + \"resolve_frames\":\"EnabledWithSymbolsInReceiver\",\ + \"signals\":[11,6,10,4,8],\"timeout\":{\"secs\":5,\"nanos\":0},\ + \"unix_socket_path\":null}\n" + ); } #[test] diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index a8376c27c1..848e33486d 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -1,12 +1,36 @@ // 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 and does not read environment variables. +//! `init_from_env` is the preload/bootstrap compatibility entry point and is the only path that +//! reads `DD_CRASHTRACKING_*`, `DD_SERVICE`, `DD_ENV`, `DD_VERSION`, and `DD_RUNTIME_ID`. +//! +//! 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 | unsupported | unsupported | compile error | +//! +//! `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, app handlers invoked from this handler run with the +//! crash-signal mask in effect; a nested crash on another managed signal is deferred until the +//! app handler returns. + use core::ffi::c_void; use core::fmt::Write; use heapless::{String as HeaplessString, Vec as HeaplessVec}; use serde::Serialize; +use crate::protocol; + mod backtrace; mod capabilities; mod config; @@ -260,34 +284,37 @@ pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { } pub fn emit_report(sink: &mut impl Sink, report: &Report<'_>, context: &CrashContext<'_>) -> bool { - emit_config(sink, report.config_json) - && emit_metadata(sink, report) - && emit_additional_tags( + if !emit_config(sink, report.config_json) + || !emit_metadata(sink, report) + || !emit_additional_tags( sink, report.stage_name, report.stackwalk_method, report.capability_bits, report.degradation_bits, ) - && emit_kind(sink) - && emit_json_section( + || !emit_kind(sink) + || !emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_SIGINFO\n", + protocol::DD_CRASHTRACK_BEGIN_SIGINFO, &context.signal, - b"DD_CRASHTRACK_END_SIGINFO\n", + protocol::DD_CRASHTRACK_END_SIGINFO, ) - && emit_json_section( + || !emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_PROCESSINFO\n", + protocol::DD_CRASHTRACK_BEGIN_PROCINFO, &ProcInfo { pid: context.pid, tid: context.tid, }, - b"DD_CRASHTRACK_END_PROCESSINFO\n", + protocol::DD_CRASHTRACK_END_PROCINFO, ) - && emit_stacktrace(sink, context.frames) - && emit_message(sink, report.stage_name, &context.signal) - && sink.put(b"DD_CRASHTRACK_DONE\n") + || !emit_stacktrace(sink, context.frames) + { + return emit_truncated_tail(sink, report, context); + } + + emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) } pub fn emit_report_with_metadata( @@ -297,33 +324,44 @@ pub fn emit_report_with_metadata( stage_name: &str, context: &CrashContext<'_>, ) -> bool { - emit_config(sink, config_json) - && emit_json_section( + if !emit_config(sink, config_json) + || !emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_METADATA\n", + protocol::DD_CRASHTRACK_BEGIN_METADATA, metadata, - b"DD_CRASHTRACK_END_METADATA\n", + protocol::DD_CRASHTRACK_END_METADATA, ) - && emit_additional_tags(sink, stage_name, "fp_pvr", 0, 0) - && emit_kind(sink) - && emit_json_section( + || !emit_additional_tags(sink, stage_name, "fp_pvr", 0, 0) + || !emit_kind(sink) + || !emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_SIGINFO\n", + protocol::DD_CRASHTRACK_BEGIN_SIGINFO, &context.signal, - b"DD_CRASHTRACK_END_SIGINFO\n", + protocol::DD_CRASHTRACK_END_SIGINFO, ) - && emit_json_section( + || !emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_PROCESSINFO\n", + protocol::DD_CRASHTRACK_BEGIN_PROCINFO, &ProcInfo { pid: context.pid, tid: context.tid, }, - b"DD_CRASHTRACK_END_PROCESSINFO\n", + protocol::DD_CRASHTRACK_END_PROCINFO, ) - && emit_stacktrace(sink, context.frames) - && emit_message(sink, stage_name, &context.signal) - && sink.put(b"DD_CRASHTRACK_DONE\n") + || !emit_stacktrace(sink, context.frames) + { + capabilities::note_degraded(capabilities::DEGRADED_TRUNCATED); + let _ = emit_additional_tags( + sink, + stage_name, + "fp_pvr", + 0, + capabilities::DEGRADED_TRUNCATED, + ); + return emit_message(sink, stage_name, &context.signal) && emit_done(sink); + } + + emit_message(sink, stage_name, &context.signal) && emit_done(sink) } pub fn emit_minimal_report( @@ -335,28 +373,33 @@ pub fn emit_minimal_report( emit_config(sink, config_json) && emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_METADATA\n", + protocol::DD_CRASHTRACK_BEGIN_METADATA, metadata, - b"DD_CRASHTRACK_END_METADATA\n", + protocol::DD_CRASHTRACK_END_METADATA, ) && emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_SIGINFO\n", + protocol::DD_CRASHTRACK_BEGIN_SIGINFO, signal, - b"DD_CRASHTRACK_END_SIGINFO\n", + protocol::DD_CRASHTRACK_END_SIGINFO, ) - && sink.put(b"DD_CRASHTRACK_DONE\n") + && emit_done(sink) } pub fn emit_json_section( sink: &mut impl Sink, - begin: &[u8], + begin: &str, value: &T, - end: &[u8], + end: &str, ) -> bool { let mut buf = [0u8; SECTION_BUF_CAPACITY]; match serde_json_core::to_slice(value, &mut buf) { - Ok(len) => sink.put(begin) && sink.put(&buf[..len]) && sink.put(b"\n") && sink.put(end), + Ok(len) => { + put_marker_line(sink, begin) + && sink.put(&buf[..len]) + && sink.put(b"\n") + && put_marker_line(sink, end) + } Err(_) => false, } } @@ -371,7 +414,7 @@ pub fn rust_signal_name(signal: i32) -> &'static str { libc::SIGSEGV => "SIGSEGV", libc::SIGSYS => "SIGSYS", libc::SIGTRAP => "SIGTRAP", - _ => "", + _ => "UNKNOWN", } } @@ -414,10 +457,10 @@ pub fn hex_addr(value: usize) -> HeaplessString { } fn emit_config(sink: &mut impl Sink, config_json: &str) -> bool { - sink.put(b"DD_CRASHTRACK_BEGIN_CONFIG\n") + put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_CONFIG) && sink.put(config_json.as_bytes()) && (config_json.ends_with('\n') || sink.put(b"\n")) - && sink.put(b"DD_CRASHTRACK_END_CONFIG\n") + && put_marker_line(sink, protocol::DD_CRASHTRACK_END_CONFIG) } fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { @@ -454,9 +497,9 @@ fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { ) && emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_METADATA\n", + protocol::DD_CRASHTRACK_BEGIN_METADATA, &metadata, - b"DD_CRASHTRACK_END_METADATA\n", + protocol::DD_CRASHTRACK_END_METADATA, ) } @@ -489,14 +532,16 @@ fn emit_additional_tags( } emit_json_section( sink, - b"DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS\n", + protocol::DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS, &tags, - b"DD_CRASHTRACK_END_ADDITIONAL_TAGS\n", + protocol::DD_CRASHTRACK_END_ADDITIONAL_TAGS, ) } fn emit_kind(sink: &mut impl Sink) -> bool { - sink.put(b"DD_CRASHTRACK_BEGIN_KIND\n\"UnixSignal\"\nDD_CRASHTRACK_END_KIND\n") + put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_KIND) + && sink.put(b"\"UnixSignal\"\n") + && put_marker_line(sink, protocol::DD_CRASHTRACK_END_KIND) } fn hex_u32(value: u32) -> HeaplessString<10> { @@ -506,7 +551,7 @@ fn hex_u32(value: u32) -> HeaplessString<10> { } fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { - if !sink.put(b"DD_CRASHTRACK_BEGIN_STACKTRACE\n") { + if !put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_STACKTRACE) { return false; } @@ -525,7 +570,7 @@ fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { } } - sink.put(b"DD_CRASHTRACK_END_STACKTRACE\n") + put_marker_line(sink, protocol::DD_CRASHTRACK_END_STACKTRACE) } fn emit_message(sink: &mut impl Sink, stage_name: &str, signal: &SignalInfo) -> bool { @@ -535,9 +580,34 @@ fn emit_message(sink: &mut impl Sink, stage_name: &str, signal: &SignalInfo) -> && message.push_str(" (").is_ok() && message.push_str(signal.si_signo_human_readable).is_ok() && message.push(')').is_ok() - && sink.put(b"DD_CRASHTRACK_BEGIN_MESSAGE\n") + && put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_MESSAGE) && sink.put(message.as_bytes()) - && sink.put(b"\nDD_CRASHTRACK_END_MESSAGE\n") + && sink.put(b"\n") + && put_marker_line(sink, protocol::DD_CRASHTRACK_END_MESSAGE) +} + +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.stage_name, + report.stackwalk_method, + report.capability_bits, + report.degradation_bits | capabilities::DEGRADED_TRUNCATED, + ); + emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) +} + +fn put_marker_line(sink: &mut impl Sink, marker: &str) -> bool { + sink.put(marker.as_bytes()) && sink.put(b"\n") +} + +fn emit_done(sink: &mut impl Sink) -> bool { + put_marker_line(sink, protocol::DD_CRASHTRACK_DONE) } fn signal_specific_si_code_name(signal: i32, si_code: i32) -> &'static str { @@ -545,13 +615,13 @@ fn signal_specific_si_code_name(signal: i32, si_code: i32) -> &'static str { libc::SIGSEGV => match si_code { SEGV_MAPERR => "SEGV_MAPERR", SEGV_ACCERR => "SEGV_ACCERR", - _ => "", + _ => "UNKNOWN", }, libc::SIGBUS => match si_code { BUS_ADRALN => "BUS_ADRALN", BUS_ADRERR => "BUS_ADRERR", BUS_OBJERR => "BUS_OBJERR", - _ => "", + _ => "UNKNOWN", }, libc::SIGILL => match si_code { ILL_ILLOPC => "ILL_ILLOPC", @@ -562,9 +632,9 @@ fn signal_specific_si_code_name(signal: i32, si_code: i32) -> &'static str { ILL_PRVREG => "ILL_PRVREG", ILL_COPROC => "ILL_COPROC", ILL_BADSTK => "ILL_BADSTK", - _ => "", + _ => "UNKNOWN", }, - _ => "", + _ => "UNKNOWN", } } @@ -583,6 +653,7 @@ pub const SI_SIGIO: i32 = -5; pub const SI_TKILL: i32 = -6; #[cfg(not(any(target_os = "linux", target_os = "android")))] +// Non-Linux platforms do not define SI_TKILL; use a sentinel that cannot match a real si_code. pub const SI_TKILL: i32 = i32::MIN; pub const SEGV_MAPERR: i32 = 1; @@ -701,7 +772,29 @@ mod tests { 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::SIGSEGV, SI_USER), "SI_USER"); - assert_eq!(rust_si_code_name(libc::SIGSEGV, 999), ""); + assert_eq!(rust_si_code_name(libc::SIGSEGV, 999), "UNKNOWN"); + assert_eq!(rust_signal_name(999), "UNKNOWN"); + } + + #[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] @@ -744,6 +837,44 @@ mod tests { assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); } + #[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::COMPAT_LIBRARY_FAMILY, + default_service: config::COMPAT_DEFAULT_SERVICE, + service: "", + env: "prod", + app_version: "v1", + runtime_id: "rid", + platform: "linux", + stage_name: "application", + stackwalk_method: "fp_pvr", + capability_bits: 0x21, + degradation_bits: 0, + }; + + 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); diff --git a/libdd-crashtracker/src/lib.rs b/libdd-crashtracker/src/lib.rs index 84bb48c7fa..48b39ec123 100644 --- a/libdd-crashtracker/src/lib.rs +++ b/libdd-crashtracker/src/lib.rs @@ -67,6 +67,15 @@ mod collector_windows; mod common; #[cfg(feature = "std")] mod crash_info; +#[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")] diff --git a/libdd-crashtracker/src/protocol.rs b/libdd-crashtracker/src/protocol.rs new file mode 100644 index 0000000000..d96b85e9c6 --- /dev/null +++ b/libdd-crashtracker/src/protocol.rs @@ -0,0 +1,39 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#![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"; diff --git a/libdd-crashtracker/src/receiver/mod.rs b/libdd-crashtracker/src/receiver/mod.rs index 1265cfca66..12d8e794eb 100644 --- a/libdd-crashtracker/src/receiver/mod.rs +++ b/libdd-crashtracker/src/receiver/mod.rs @@ -105,4 +105,78 @@ 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; + + 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", + stage_name: "application", + stackwalk_method: "fp_pvr", + capability_bits: 0x21, + degradation_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 == "stage:application")); + assert!(tags.iter().any(|tag| tag == "stackwalk_method:fp_pvr")); + assert!(tags.iter().any(|tag| tag == "report_degraded:report_to_fd")); + + Ok(()) + } } diff --git a/libdd-crashtracker/src/shared/constants.rs b/libdd-crashtracker/src/shared/constants.rs index 09999300f7..3f87c9a80d 100644 --- a/libdd-crashtracker/src/shared/constants.rs +++ b/libdd-crashtracker/src/shared/constants.rs @@ -3,39 +3,6 @@ use std::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"; +pub use crate::protocol::*; pub const DD_CRASHTRACK_DEFAULT_TIMEOUT: Duration = Duration::from_millis(5_000); From 13e6912bb784cb86759878ef4f930a5a20ee7a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 02:45:13 +0200 Subject: [PATCH 10/32] docs(crashtracker): consolidate signal-safe improvement plan Supersede signal_safe_collector_improvement_plan.md with a consolidated signal_safe_crashtracker_plan.md. Co-Authored-By: Claude Fable 5 --- signal_safe_collector_improvement_plan.md | 762 ---------------------- signal_safe_crashtracker_plan.md | 598 +++++++++++++++++ 2 files changed, 598 insertions(+), 762 deletions(-) delete mode 100644 signal_safe_collector_improvement_plan.md create mode 100644 signal_safe_crashtracker_plan.md diff --git a/signal_safe_collector_improvement_plan.md b/signal_safe_collector_improvement_plan.md deleted file mode 100644 index 152f19cec5..0000000000 --- a/signal_safe_collector_improvement_plan.md +++ /dev/null @@ -1,762 +0,0 @@ -# Signal-Safe Crashtracker Collector — Improvement Plan - -Status date: 2026-07-06. Branch: `signal_safe_crashtracker` (HEAD `dd9922905`, -merge of `6ae6136f3` which restored the previously missing files). - -This plan is written to be executed by another engineer/LLM with no prior context. -It covers: restoring the branch to a compilable state, hardening the existing -signal-safe collector, missing-capability handling, safety options, the test -strategy, and reuse/compatibility with the existing crashtracker and the rest of -libdatadog. The dd-trace-c parity analysis lives in -`crashtracker-work-we-need-to-do.md`; this plan references it but does not repeat -it. Where the two overlap, this plan is the actionable order of work. - ---- - -## Guiding principles - -Applied throughout this plan (and to be applied to the code): - -- **Errors never pass silently.** Every degraded path must leave a trace: - a `report_degraded:*` tag, a distinct `InitResult` variant, or a debug - breadcrumb. A crash report that silently loses its stack, or an `init` that - returns a bare `Failed`, is a bug even when the behavior is otherwise correct. -- **Explicit is better than implicit.** Env reads happen only in the two - `*_from_env` entry points; everything else takes explicit config. Failure - reasons are enum variants, not booleans. Options that interact - (`create_alt_stack`/`use_alt_stack`) are validated, not auto-corrected. -- **One obvious way.** Where earlier drafts offered alternatives, this plan now - commits to one approach per problem and records why the alternative lost. - The remaining genuinely-open questions are in the decision table below — in - the face of ambiguity, confirm with the team instead of guessing. -- **Simple over complex, and explainable.** The crash path stays a straight - line: probe at init, one handler, two forked children, fixed buffers, no - hidden state machines. Anything hard to explain in a doc comment (the 1.1 - stack-position heuristic is the one exception) gets that doc comment. - -### Open decisions - -Everything else in this plan is decided; these need a human call before the -affected PR: - -| # | Decision | Recommendation | Blocks | -|---|---|---|---| -| D1 | Ship `collector_signal-safe` in the released FFI artifact now, or keep opt-in? | Team/product call — packaging §5.5 | PR 8 | -| D2 | If re-init after `shutdown()` turns out unsound during the `Meta` audit (1.4), fall back to documented one-shot + `AlreadyInitialized`? | Attempt re-init first; fall back only with a written reason | PR 3 | -| D3 | macOS: stay degraded-fd-only, or add best-effort libc `fork()` mode later? | Stay degraded; revisit only on demand (§2.5) | nothing now | - -## 0. Orientation - -### What exists on this branch - -New module `libdd-crashtracker/src/collector_signal_safe/` behind cargo feature -`collector_signal-safe` (note the hyphen), designed to coexist with the standard -`collector` feature: - -| File | Contents | -|---|---| -| `mod.rs` | Wire emitter (`emit_report`, `Sink`/`SliceSink`), policy pure functions (`chain_action`, `is_genuine_fault`, `should_run_app_first`, `app_recovered`), signal/si_code naming, capacity constants | -| `config.rs` | `SignalSafeInitConfig`, `prepare()`/`prepare_from_env()`, config-JSON builder, env parsing (`DD_CRASHTRACKING_*`, `DD_SERVICE`, …), compat presets for dd-trace-c | -| `handler.rs` | `init`/`init_from_env`/`bootstrap_complete`/`shutdown`, the `crash_handler` itself, fork/receiver/collector children, bounded reap, alt-stack install, loader-env scrubbing | -| `state.rs` | Static `Meta` (heapless strings), init-state machine, per-signal `ORIG_FN`/`ORIG_FLAGS`/`OWN_SIGNAL` atomics, runtime option atomics, `Stage` enum | -| `sys.rs` | Raw syscall layer: rustix + inline asm on Linux x86_64/aarch64 (`fork_raw` via `clone(SIGCHLD)`, `process_vm_readv`, `wait4`, …), libc fallback elsewhere, errno save/restore | -| `backtrace.rs` | Frame-pointer walk seeded from `ucontext`, probing frame records with `process_vm_readv` (`read_own_mem`) so corrupt frames return failure instead of faulting | - -Plus: - -- FFI surface: `libdd-crashtracker-ffi/src/collector_signal_safe.rs` - (`ddog_crasht_signal_safe_init[_from_env]`, `_bootstrap_complete`, `_shutdown`, - `_set_stage`, `_capabilities`, `_owned_signal_count`, `_owns_signal`). -- Feature re-plumbing: `libdd-crashtracker/Cargo.toml` gained a `std` feature; all - std-only deps are optional; `collector_signal-safe` pulls only - `heapless`, `libc`, `rustix`, `serde` (no-std), `serde-json-core`. -- Std collector integration: `libdd-crashtracker/src/collector/api.rs` now - acquires a `signal_owner` guard so only one collector arms handlers. -- E2E tests: `libdd-crashtracker/tests/collector_signal_safe_e2e.rs` — Linux - receiver round-trip through a shell receiver, and a portable degraded - report-to-fd test. -- cbindgen currently *excludes* `libdd_crashtracker::collector_signal_safe` - (`libdd-crashtracker-ffi/cbindgen.toml:74`) — headers are not yet generated. - -### Restored support files (commit `6ae6136f3`, verified present and green) - -- `libdd-crashtracker/src/signal_owner.rs` — `AtomicU8` owner slot; - `acquire` is **reentrant for the same owner** (CAS-or-already-mine), - `release` only clears when the caller owns it. -- `libdd-crashtracker/src/collector_signal_safe/capabilities.rs` — capability - bits `RECEIVER_OK|PROC_VM_READV|FORK_OK|DEV_NULL|PIPE_OK|REPORT_FD_OK`, - degradation bits with reason strings (`missing_receiver`, - `no_process_vm_readv`, `no_fork`, `no_dev_null`, `no_pipe`, `pipe_failed`, - `fork_failed`, `receiver_unavailable`, `report_to_fd`); `publish()` probes - receiver executability, a `process_vm_readv` self-read, fork support, - `/dev/null`, and pipe creation, and `store`s both atomics (safe for re-init). -- `.github/workflows/crashtracker-signal-safe.yml` — check (x86_64 + aarch64 - cross-check), clippy, nextest, symbol guard. -- `tools/check_signal_safe_symbols.sh` — bans undefined symbols - (`malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__libc_*`) - in the no-default-features rlib via `nm -u`. - -### What is broken right now (verified 2026-07-06) - -Compile, unit tests (20), and e2e tests (4) are green with -`--no-default-features --features collector_signal-safe`. One failure: - -**`tools/check_signal_safe_symbols.sh` fails: `U getenv` in the -libdd-crashtracker rlib.** Source: `collector_signal_safe/config.rs:311` -(`env_get` → `libc::getenv`), reachable only at init time -(`prepare_from_env`), never on the crash path — but the guard scans the whole -rlib's undefined symbols and cannot scope to the crash path. Fix in Phase 0. - -### Validation commands (run after every phase) - -```bash -cargo check -p libdd-crashtracker --features collector_signal-safe -cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe # no-std-ish build must stay green -cargo check -p libdd-crashtracker-ffi --no-default-features --features collector_signal-safe -cargo build --workspace --exclude builder # default features unaffected -cargo +nightly-2026-02-08 fmt --all -- --check -cargo +stable clippy --workspace --all-targets --all-features -- -D warnings -cargo nextest run -p libdd-crashtracker --features collector_signal-safe -cargo nextest run --workspace --no-fail-fast -cargo test --doc -``` - -If `Cargo.lock` changes: `./scripts/update_license_3rdparty.sh && cargo deny check` -(the branch already added `heapless`, `rustix`, `serde-json-core` to -`LICENSE-3rdparty.csv`; keep it in sync). If FFI is touched: `cargo ffi-test`. -New files need Apache-2.0 headers (`./scripts/reformat_copyright.sh`). - ---- - -## Phase 0 — Make the restored CI gate green (P0, do first) - -The missing modules are back (see §0); the remaining P0 items are small. - -### 0.1 Fix the symbol-guard failure: drop `libc::getenv` - -`tools/check_signal_safe_symbols.sh` currently fails on `U getenv` -(`config.rs:311`, `env_get`). - -Fix: replace `libc::getenv` with a direct walk of the global `environ` array -(`unsafe extern "C" { static mut environ: *mut *mut c_char; }` is already -declared in `handler.rs:30-32` — hoist it into `sys.rs` and share). Match -`NAME=` by byte prefix, return the value slice. This keeps env reads -init-time-only *and* keeps the strict guard honest: unlike `getenv`, the walk -is trivially auditable as allocation-free and lock-free. - -Rejected alternative: removing `getenv` from the banned regex ("env reads are -init-only by construction"). Rejected because a future crash-path `getenv` -regression would then pass CI silently — the guard exists precisely to make -that error loud. - -Add a unit test for the new lookup (set a var via `std::env::set_var` in a -`#[cfg(test)]` std context, read it back through the new function). - -### 0.2 Guard-script robustness - -`tools/check_signal_safe_symbols.sh` globs *all* -`liblibdd_crashtracker*.rlib` under `target/debug/deps`. A developer's dirty -target dir can contain stale rlibs from std-feature builds (false failures), -and conversely a stale no-std rlib could mask a regression (false pass — -`cargo build` may not rebuild if fingerprints match but old artifacts linger). -Fix: build into a dedicated target dir -(`CARGO_TARGET_DIR=target/signal-safe-guard`) inside the script, or parse the -freshly built artifact path from `cargo build --message-format=json`. - -### 0.3 Housekeeping before the first real PR - -- `cargo check -p libdd-crashtracker --features collector_signal-safe` (with - default `std` also enabled) emits an unused-import warning for - `access_executable`/`fork_supported` re-exports at `sys.rs:568-572`? — - verified clean now that capabilities.rs uses both; re-check under - `--all-features` clippy which CI runs with `-D warnings`. -- The branch history is `wip` commits + a merge. Rebase/squash into - conventional commits before opening PRs (PR split at the end of this plan). -- `cargo nextest` is used by the restored workflow; it is not installed in the - local dev environment — install it or keep using `cargo test` locally - (nextest's process-per-test isolation matters here: the signal-safe tests - mutate process-global signal state; `TEST_GLOBAL_LOCK` covers plain - `cargo test`). - -Contract notes for later phases (actual restored implementations): - -- `signal_owner::acquire` is reentrant for the same owner. This means a second - `init_result` call by the same collector does not fail at the owner gate — - single-shot-ness is enforced only by `state::begin_init`. Keep this in mind - for lifecycle work (1.4) and the interplay tests (5.2). -- Capability bit names: `RECEIVER_OK, PROC_VM_READV, FORK_OK, DEV_NULL, - PIPE_OK, REPORT_FD_OK`; degradations include *both* init-time - `DEGRADED_MISSING_RECEIVER` ("missing_receiver") and crash-time - `DEGRADED_RECEIVER_UNAVAILABLE` ("receiver_unavailable") — use the former for - probe failures, the latter for crash-time exec/exit failures (2.3). -- `capabilities::publish` `store`s (not `fetch_or`s) both atomics, so re-init - (1.4) gets fresh state for free; only `note_degraded` accumulates. - ---- - -## Phase 1 — Correctness and safety hardening (concrete findings in current code) - -Each item below is a real issue found by review of the committed code; fix with -a test where possible. - -### 1.1 `IN_APP_CHAIN` never resets after `siglongjmp` recovery (`handler.rs:540`) - -If the app handler recovers via `siglongjmp`, control never returns to -`crash_handler`, so the function-local `static IN_APP_CHAIN` stays `true` -forever. Every subsequent crash then *skips* the app-first path and reports -immediately — silently breaking Mode A after the first recovered signal (which -is exactly the HotSpot/V8 hot case: recoverable SIGSEGVs happen constantly). - -A plain boolean cannot express what the guard needs to know. On entry the -handler must distinguish three cases: a fresh signal delivery, true recursion -(a crash *inside* the app handler), and a stale flag left by a `siglongjmp` -that abandoned a previous handler frame. Clear-on-return misses the third -case; a sticky flag conflates it with the second. - -Design (replace `IN_APP_CHAIN` with two atomics): - -- Before invoking the app handler, store the handling thread's tid - (`AtomicI32`) and an approximate stack position (`&local as usize`, - `AtomicUsize`). Clear both on the normal-return path. -- On entry with a stored tid equal to our own, the previous invocation on this - thread never returned — it either longjmp'd away or we are nested inside it. - Disambiguate by stack position: if the current frame is *outside* the - recorded frame (the stack has unwound past it), the old invocation is dead → - reset the guard and run app-first normally. If the current frame is *inside* - (deeper than) the recorded one, this is true recursion → skip app-first and - go straight to reporting. -- On entry with a different thread's tid stored, skip app-first; the report - path is already serialized by `COLLECTING`. - -This is the one deliberately non-obvious mechanism in the module — per the -guiding principles it gets a doc comment explaining the three cases and the -stack-growth-direction assumption, plus both e2e tests from 4.1 -(recover-then-crash-again must still run the app handler first; -crash-inside-app-handler must report once and not loop). - -### 1.2 Mode A infinite loop when the app handler returns without recovering - -Flow today: app handler returns normally, still installed → `app_recovered()` -returns true (`handler_after` is a function pointer, not `SIG_DFL`) → we return -without reporting. For a *synchronous* fault the kernel re-executes the faulting -instruction → our handler runs again → app-first again → infinite ping-pong. - -dd-trace-c's semantics: "recovered" means the app handler transferred control -away (longjmp) or fixed the fault. A returning handler that leaves itself -installed and did not fix the fault will loop *regardless* of crashtracker — -but we amplify it by adding fork/report machinery never, since we skip -reporting. Mitigation: - -- Add a per-signal repeat counter (same PC + same address within N entries → - treat as unrecovered: report once, then chain with - `RestoreDefaultAndRefault`). Cap cheap: two `AtomicUsize` (last_pc, count). -- Test: app handler that returns without fixing (install handler, deref null, - handler just returns) — process must terminate with SIGSEGV and produce - exactly one report, not hang. - -### 1.3 Alt-stack is process-global but `sigaltstack` is per-thread (`handler.rs:250-261`) - -`install_alt_stack_if_requested` installs the single static 64 KiB stack only on -the *calling* thread. Crashes on other threads run on their normal stack even -with `SA_ONSTACK` set (kernel ignores the flag if that thread has no alt stack), -and — worse — if two threads did share one alt stack and crashed concurrently, -they would corrupt each other. Actions: - -- Document loudly on `create_alt_stack`/`use_alt_stack` (Rust doc + FFI header - comment): "alt stack applies to the init thread only; stack-overflow crashes - on other threads are collected on the faulting thread's stack". -- Keep dd-trace-c default (`false`/`false`) as-is. -- Optional follow-up (defer): expose a per-thread `ddog_crasht_signal_safe_arm_thread()` - that installs a caller-provided alt stack on the current thread. -- Compare with the std collector's alt-stack handling in - `collector/signal_handler_manager.rs` — reuse its sizing policy - (`page_size`-aware, guard page) if we ever create per-thread stacks. - -### 1.4 `shutdown()` → re-`init()` is permanently bricked (`state.rs:58-82`) - -`INIT_STATE` moves `UNINIT → INITIALIZING → READY`; `begin_init` only succeeds -from `UNINIT`, and `shutdown()` never resets it. Also `fail_init` parks the -state at `FAILED` forever (e.g. a transient bad receiver path can never be -retried). - -Chosen approach — allow re-init: `shutdown()` stores `INIT_UNINIT` after -releasing the signal owner; `fail_init()` stores `INIT_UNINIT` too (each -failure path in `init_result` already unwinds owner + state coherently). -Prerequisite audit of `Meta` publication: `prepare()` mutates the static `Meta` -via `meta_mut()`, which is only sound while no handler can run; the current -order (prepare → install handlers → `HANDLERS_ENABLED.store(true, Release)`) -is correct, and on re-init `begin_init`'s CAS keeps mutation exclusive. Keep -that order and add a comment that `meta_mut` may only be called between -`begin_init` and handler installation. - -If the audit finds a soundness blocker, fall back (decision D2) to documented -one-shot semantics with a distinct `AlreadyInitialized` result — never a bare -`Failed`, so the caller can tell the difference. - -Test: init → shutdown → init → crash e2e still produces a report. - -### 1.5 `uninstall_crash_handler` drops the original `sa_mask` (`handler.rs:710-728`) - -Only `sa_sigaction` and `sa_flags` are saved/restored; the displaced handler's -signal mask is discarded (restored with an empty mask). Save `old.sa_mask` at -install (needs a static array of `libc::sigset_t` — wrap in an -`UnsafeCell`+`Sync` holder like `AltStackStorage`, guarded by the same -init-exclusivity argument) and restore it on uninstall. Same for the app-chain -`invoke_handler` path: dd-trace-c applies the app handler's mask semantics; at -minimum document that we invoke the app handler with *our* mask in effect. - -### 1.6 Capability/degradation state across re-init — already handled - -`capabilities::publish` `store`s both atomics, wiping prior probe results and -`note_degraded` accumulation, and `state::clear_signal_state()` runs in -`install_all_handlers`. Nothing to do beyond a regression test once 1.4 lands -(re-init after a degraded crash must not carry stale degradation tags). - -### 1.7 FFI entry points must not unwind (AGENTS.md rule) - -`ddog_crasht_signal_safe_*` call into code designed not to panic, but the repo -rule is explicit: wrap each FFI body in `std::panic::catch_unwind` and map to -`SignalSafeInitResult::Failed` / no-op. Cheap, mechanical. (The FFI crate is -std; only the library crate is no-std-capable.) - -### 1.8 `sanitize_clone` when `/dev/null` open fails (`handler.rs:209-218`) - -If `open_readwrite` fails (containers with restricted /dev), the children keep -the app's stdin/stdout/stderr. For the *receiver* child this leaks the app's -stdio to an exec'd process; for the collector child it is harmless. The init -probe already reports this (`DEGRADED_NO_DEV_NULL` / `no_dev_null` tag), but -`sanitize_clone` ignores the `DEV_NULL` capability bit at crash time — -optionally close stdio outright in the receiver child when /dev/null is -unavailable (receiver must tolerate closed stdout/stderr — verify against -`receiver_entry_point_stdin`). - -### 1.9 Emitter capacity truncation is silent - -`SECTION_BUF_CAPACITY` is 4096 and `emit_json_section` returns `false` on -overflow, which aborts the whole remaining report (`&&` chains). A long -service/env or 64 frames × 20 bytes fits, but config JSON is capped at 2048 and -metadata with 20 × 288-byte tags can exceed 4096 → *entire report silently -truncated after config*. Actions: - -- Compute worst-case section sizes from the heapless capacities and either - raise `SECTION_BUF_CAPACITY` for the metadata section or split tag emission. -- On section-emit failure, still attempt to write `DD_CRASHTRACK_DONE` and the - message section so the receiver processes a partial report instead of timing - out; add degradation tag `report_degraded:truncated`. -- Unit test: metadata at max tag capacity round-trips; oversized section - produces a partial-but-terminated stream. - -### 1.10 Small items - -- `handler.rs:19` `EXIT_CODE_FAILURE = 125` collides with common shell/xargs - conventions; fine, but document. -- `config.rs:302-308` `cstr_bytes` uses `read_volatile` per byte — replace with - a plain loop (volatile is unnecessary and slow); it also has no length cap: - bound it (e.g. 4096) to avoid walking off an unterminated string from FFI. -- `config.rs:270-279` `set_str` silently truncates at capacity mid-char-loop — - fine, but truncation of `service` should probably be observable - (degradation bit or debug breadcrumb). -- `sys.rs` non-Linux `poll_sleep_ms` uses `libc::poll` with null fds — OK; add - a comment that EINTR shortens the sleep and the reap loop tolerates it. -- `state.rs:13` `NSIG = 128` — index is the raw signal number; fine for Linux - and BSDs. Add a compile-time assert or comment. -- `mod.rs` `SI_TKILL` fallback for non-Linux is `i32::MIN` — document why - (macOS has no SI_TKILL; the sentinel must never match). - ---- - -## Phase 2 — Missing-capability handling (beyond restore) - -Goal: the collector must *always* do the best thing available and say what it -couldn't do. The restored `capabilities.rs` taxonomy is the foundation; this -phase extends it. - -### 2.1 Probe results in the report and via FFI - -- `ddog_crasht_signal_safe_capabilities()` already returns the bits; also add - `ddog_crasht_signal_safe_degradations()` so integrators can log at init. -- Emit `capability_bits`/`degradation_bits` as additional tags - (`capabilities:`) — cheap and makes fleet-wide triage possible. Keep the - human-readable `report_degraded:` tags as the primary signal. - -### 2.2 Seccomp sacrificial-child probe (deferred item from the notes, now scheduled) - -`process_vm_readv` under seccomp can (a) return `EPERM` → handled gracefully -today, or (b) `SECCOMP_RET_KILL` → kills the *collector child* mid-crash and the -report loses its stack silently. Add an opt-in init probe: - -- `SignalSafeInitConfig::probe_seccomp: bool` (default `false` — forking at init - is a global effect; per repo conventions it must be opt-in). -- Probe: `fork_raw()`; child calls `read_own_mem` on itself and `_exit(0)`; - parent waits ≤100 ms. Exit 0 → OK; killed by SIGSYS/SIGKILL → clear - `PROC_VM_READV`, set `DEGRADED_NO_PROC_VM_READV`; timeout → kill + treat as - unknown (keep capability, it's the status quo). Note the existing in-process - probe in `capabilities::probe_process_vm_readv` only catches errno-returning - denials (`EPERM`); the sacrificial child exists precisely for - `SECCOMP_RET_KILL` policies. -- With the capability cleared, `emit_crash_report` already degrades to - `seed_only` stackwalk (ip + lr only) — that path exists (`handler.rs:361-369`). - -### 2.3 Receiver re-validation at crash time - -`RECEIVER_OK` is probed once at init; the receiver binary can be deleted/moved -later (container image GC, tmp cleanup). At crash time the failure mode today -is: fork succeeds, `execv` fails, receiver child exits 125, collector writes -into a pipe nobody drains until the pipe buffer fills, then the parent reaps on -timeout. Improvements: - -- In `collect_crash`, after reaping the receiver, check its exit status - (`waitpid_nohang` currently discards status — return it) and if it exited 125 - and `report_fd` is set, re-emit the report to `report_fd` with - `DEGRADED_RECEIVER_UNAVAILABLE|DEGRADED_REPORT_TO_FD`. This makes the fd - fallback cover late disappearance, not just init-time absence. -- Order matters: reap receiver *before* deciding the crash path is done; today - the collector is reaped first with a 500 ms budget, then the receiver with - timeout+grace — keep that order (writer first), just capture status. - -### 2.4 `close_fds_on_receiver` is plumbed but unimplemented - -`CLOSE_FDS_ON_RECEIVER` is stored (`state.rs:105`) and configurable through FFI -but nothing reads it. Implement in `receiver_child` before `execv`: -`close_range(3, ~0u, 0)` via raw syscall (`SYS_close_range`, Linux 5.9+; on -ENOSYS fall back to nothing — do NOT iterate /proc/self/fd, that's not -fork-safe), after the report fd has been dup'd to stdin. Test: open a marker fd -with `O_CLOEXEC` unset in the parent, crash, assert the receiver does not see it -(receiver script checks `/proc/self/fd`). - -### 2.5 Non-Linux degraded mode documentation - -On macOS/other Unix, `fork_supported() == false` → report-to-fd only. Make this -an explicit, documented support matrix in `collector_signal_safe/mod.rs` docs -and the FFI header: - -| Target | fork collection | stackwalk | fallback | -|---|---|---|---| -| Linux x86_64/aarch64 | clone(SIGCHLD) | fp + process_vm_readv | report_fd | -| other Linux arches | none (libc fallback module) | none | report_fd | -| macOS/iOS | none | none | report_fd (siginfo-only minimal report) | -| non-Unix | compile_error (lib.rs:54) | — | — | - -macOS best-effort `fork()` mode is decision D3: stay degraded-fd-only, revisit -only if an integrator asks. - ---- - -## Phase 3 — Safety options surface - -The option set on `SignalSafeInitConfig` is good; this phase makes each option -sound, validated, and documented. - -### 3.1 Validation & clamping (config.rs) - -Central `fn validate(config) -> Result` instead of the -scattered `normalized_*` helpers, and make `init_result` return *why* it failed: - -```rust -#[repr(i32)] pub enum InitResult { - Enabled = 0, - DisabledByConfig = 1, - Failed = 2, // keep for ABI compat - AlreadyInitialized = 3, - OwnerConflict = 4, // std collector holds the signals - InvalidConfig = 5, // path too long, bad fd, etc. -} -``` - -(The FFI enum mirrors it; cbindgen headers regenerated in Phase 5.) Checks: - -- `receiver_path` length < 512 (today `set_receiver_path` fails → generic - `Failed`; surface as `InvalidConfig`). -- `report_fd`: if ≥ 0, verify with `fcntl(F_GETFD)` at init; warn-degrade if bad. -- `max_frames` clamp (exists), `collector_reap_ms`/`receiver_timeout_secs` - bounds (exists) — move into `validate` and unit-test the boundaries. -- `use_alt_stack && !create_alt_stack`: allowed (caller made their own alt - stack) — document. `create_alt_stack && !use_alt_stack` is pointless → - `InvalidConfig`. (dd-trace-c silently pairs them; we reject instead — - explicit is better than implicit, and a rejected config is visible at init - while an auto-corrected one hides the integrator's misunderstanding.) - -### 3.2 `disarm_on_entry` semantics (`handler.rs:523-525`) - -Today it resets the *current* signal to SIG_DFL on entry, which is a -crash-loop-proofing option, but the chain logic at the bottom then consults -`effective_target` and may reinstall/raise accordingly. Interaction bugs: - -- After disarm, `ChainAction::Resume` (app disposition SIG_IGN) leaves the - signal at SIG_DFL, not restored to our handler nor to SIG_IGN — next - occurrence terminates the process with no report and without honoring the - app's IGN. Chosen behavior: with `disarm_on_entry`, after a non-genuine - signal, restore the pre-entry disposition (our handler) before returning. - Add tests for (disarm × {genuine, external-async, ignored}). - -### 3.3 `block_signals` and `SA_NODEFER` - -`BLOCK_SIGNALS` adds all crash signals to `sa_mask` (good default). Document -the interplay: when we invoke the app handler *from* our handler, the app runs -with our mask (all crash signals blocked) — a crash inside the app handler on a -*different* signal is deferred, not delivered, until we return; combined with -1.1's recursion guard this is the intended behavior. Add this to the module docs; -no code change. - -### 3.4 Stack-overflow self-protection in the handler - -The handler itself uses ~4–8 KiB of stack (`SECTION_BUF_CAPACITY` buffer is in -the *collector child*'s `emit_crash_report` frame, but `crash_debug`'s FdSink -and locals are small). On a SIGSEGV from stack overflow *without* alt stack, the -handler may fault immediately → kernel kills with default action (our handler -can't run: the signal is blocked during delivery and the second fault while -SIGSEGV is blocked force-terminates). That is acceptable and matches dd-trace-c, -but: verify `emit_crash_report`'s 4 KiB+ frame only ever exists in the forked -child or in the degraded direct-report path (parent handler frame!). Today -`direct_report` runs `emit_crash_report` **in the signal handler frame** -(`handler.rs:438-445`) — that's a ~5 KiB stack requirement in degraded mode. -Either document ("degraded fd reporting needs ~8 KiB of remaining stack") or -move the buffers to static storage (`UnsafeCell` scratch, safe because -`COLLECTING` guarantees single entry). - -### 3.5 Timeouts - -`RECEIVER_TIMEOUT_MS` is `secs*1000 + grace` capped only by u32 input; a huge -env-provided value stalls the crashing process for that long (the process is -dying anyway, but supervisors may SIGKILL and lose the report). Clamp to e.g. -60 s in `validate`. `poll_sleep_ms(100)` granularity is fine. - ---- - -## Phase 4 — Test strategy - -### 4.1 Keep and extend the two-binary e2e pattern - -The self-exec pattern in `tests/collector_signal_safe_e2e.rs` (env-var-gated -"child" tests + orchestrating tests) is good; extend the matrix. Each scenario -is a child test fn + assertion block: - -| Scenario | Child behavior | Assert | -|---|---|---| -| SIGSEGV genuine | deref null | report, `SEGV_MAPERR`, non-zero frames on x86_64/aarch64 | -| SIGFPE | integer div by zero | report emitted; si_code name policy (see 5.3) | -| External async signal | parent sends SIGSEGV via `kill` to child pid | **no** report; child terminates by default action | -| Self-sent async | child `raise(SIGSEGV)` | report (genuine per self-pid rule) | -| App handler recovers (Mode A) | install SIGSEGV handler with `siglongjmp`; crash; continue | no report; process exits 0 | -| Recover **then** genuine crash | as above, then deref null with handler removed | exactly one report (regression for 1.1) | -| App handler gives up | handler restores SIG_DFL and returns | report, then process dies with SIGSEGV (refault, not raise — check exit signal) | -| Mode B | `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` + recovering handler | report *and* process exits 0 | -| App registered via `signal()` not `sigaction()` | flags without SA_SIGINFO | app invoked with 1-arg convention (covers `invoke_handler` transmute) | -| Stuck receiver | receiver script sleeps forever | crashing process terminates within timeout+grace+ε; receiver reaped (no zombie) | -| Receiver deleted post-init | unlink receiver after init | fd fallback report with both degradation tags (2.3) | -| Bootstrap-only | `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true`, crash after `bootstrap_complete()` | no report | -| Stage tags | crash before/after `bootstrap_complete` | `stage:crashtracker_init` vs `stage:application` in tags & message | -| Low-fd collision | child sets report pipe up so read end lands on fd 0-2 | report intact (covers `sanitize_clone` relocation) | -| Re-init after shutdown (if 1.4 chosen) | init→shutdown→init→crash | one report | - -Timeout-sensitive tests must use generous budgets (CI is slow); gate the stuck- -receiver test behind `#[cfg(target_os = "linux")]`. - -### 4.2 Golden receiver round-trip (reuse the real parser) - -Highest-value compat test: feed the signal-safe emitter's exact byte stream into -the *actual* std receiver parser. - -- Dev-only test in `libdd-crashtracker/tests/` compiled with - `--features collector_signal-safe,receiver` (nextest already builds - per-test-binary features via the workspace run with `--all-features`; add an - explicit CI invocation `cargo nextest run -p libdd-crashtracker --features - "collector_signal-safe,receiver" signal_safe_golden`). -- Build a `Report`+`CrashContext` with fixed values → `emit_report` into a - `SliceSink` → run through the receiver's stdin parsing entry - (`receiver` module exposes the line-protocol parser; use - `receiver_entry_point_stdin`-adjacent internals or spawn - `crashtracker-receiver` binary with stdin = the bytes and a file endpoint). -- Assert the resulting `CrashInfo`: sig names, `PROCESSINFO` section accepted, - tags including `stage:`/`report_degraded:` preserved, config JSON deserializes - into `CrashtrackerConfiguration` (this pins `build_config_json` as a wire - contract — `resolve_frames: EnabledWithSymbolsInReceiver`, timeout struct - shape, signal list). -- Golden file: check in the emitted stream (`tests/fixtures/signal_safe_report.golden`) - and diff against regenerated output, so accidental wire changes are loud. - -### 4.3 Banned-symbol guard (exists — refine) - -`tools/check_signal_safe_symbols.sh` + the `Symbol guard` step in -`.github/workflows/crashtracker-signal-safe.yml` already implement the -link-level check (`nm -u` over the no-default-features rlib + rustix rlibs, -banning `malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__libc_*`). -Remaining work: - -- Fix the current `U getenv` failure (Phase 0.1) — the guard is red today. -- Isolate the build (Phase 0.2, dedicated `CARGO_TARGET_DIR`) so stale - artifacts can't produce false results in either direction. -- Known limitation to document in the script: it scans the *entire* rlib's - undefined symbols, so it enforces "the whole no-std build is clean", not - "the crash path is clean" — stricter than strictly needed (init-time code is - held to crash-path standards), which is the right trade-off; any exception - must be an explicit regex change reviewed in the script, not an attribute in - code. -- Optional addition: `heapless`/`serde-json-core` rlibs are not scanned — add - them to the `find` list (they are no-std by construction, so this is cheap - insurance). - -### 4.4 Unit-test gaps - -- `chain_action` × `disarm_on_entry` matrix (3.2). -- `write_i32` boundary: `i32::MIN` (current code does `wrapping_neg` on i64 — - correct, but assert it). -- `hex_addr(usize::MAX)`, `hex_addr(0)`. -- `build_config_json` exact-string golden (not just `contains`). -- `capabilities` probe unit tests (probe failure paths: missing receiver path - → `DEGRADED_MISSING_RECEIVER`; `report_fd` propagation → `REPORT_FD_OK`). -- aarch64: the restored workflow only runs `cargo check --target - aarch64-unknown-linux-gnu` — compilation coverage, no execution. Consider - upgrading to actually *run* the unit tests under qemu (`cross test` or a - `taiki-e/setup-cross-toolchain-action` job); the aarch64-specific code - (`arch_seed` LR fallback, `fork_raw` via `svc 0`, syscall wrappers) is - exactly the kind that passes `check` and fails at runtime. - ---- - -## Phase 5 — Reuse & compatibility with the existing crashtracker / libdatadog - -### 5.1 Share the wire-protocol constants - -`DD_CRASHTRACK_BEGIN_*` markers are hardcoded as byte strings in -`collector_signal_safe/mod.rs` *and* exist in the std collector/receiver -(`shared/constants.rs` or `crash_info` — locate with -`grep -rn "DD_CRASHTRACK_BEGIN" libdd-crashtracker/src --include=*.rs`). -Extract a no-std-safe `pub(crate) mod protocol` (plain `&'static str`/`&[u8]` -consts, zero deps, compiled under both `std` and `collector_signal-safe`) and -use it from the std emitter, the signal-safe emitter, and the receiver parser. -One source of truth kills silent divergence; the golden test (4.2) enforces it. - -### 5.2 Signal-owner interplay tests (both directions) - -`collector/api.rs` now bails if the signal-safe collector owns signals, and -`handler.rs` bails in reverse. Add tests: - -- std `init` after signal-safe `init` → error string mentions the conflict - (test with both features enabled). -- signal-safe `init_result` after std `init` → `OwnerConflict` (new variant, 3.1). -- std `shutdown`/`disable` releasing ownership → signal-safe init then succeeds - (verify `collector/api.rs` release path — the diff shows release on failure; - confirm the success-path teardown releases too; if the std collector has no - full uninstall, document that switching collectors requires process restart). - -### 5.3 si_code / signal-name parity with the receiver - -The receiver deserializes `si_code_human_readable` into its `SiCodes` enum. -Audit `rust_si_code_name` + `rust_signal_name` outputs against -`crash_info`'s enums (`grep -n "SEGV_MAPERR\|SiCodes" libdd-crashtracker/src/crash_info/`). -Two known items from the parity doc (§12): - -- `SIGFPE` `FPE_*` codes: `SiCodes` has no FPE variants → signal-safe emitter - currently returns `""` for FPE codes. Confirm the receiver's serde - tolerates `""` (it must map to an UNKNOWN variant, not error). If it - errors, emit `"UNKNOWN"` or whatever the receiver's fallback spelling is — - pin with a golden test (SIGFPE e2e in 4.1). -- `""` for unrecognized si_codes generally — same verification. - -### 5.4 FFI headers (cbindgen) and the FFI type duplication - -- Remove the `libdd_crashtracker::collector_signal_safe` exclusion from - `libdd-crashtracker-ffi/cbindgen.toml` **or** keep the exclusion and ensure - all `#[repr(C)]` types live in the FFI crate only (current approach — - `SignalSafeConfig`, `SignalSafeInitResult`, `SignalSafeStage` are FFI-crate - types; the exclusion just stops cbindgen from chasing internal lib types. - That's fine — verify generated headers actually contain the - `ddog_crasht_signal_safe_*` functions and structs: build with the `cbindgen` - feature and inspect the header). -- The `Stage`/`InitResult` enums are duplicated (lib + FFI) with manual mapping — - keep (repo FFI convention), but add a unit test asserting variant-value - equality so they can't drift. -- Add a C example under `examples/ffi/` exercising - `ddog_crasht_signal_safe_init` + abort + receiver script, wired into - `cargo ffi-test` (per AGENTS.md step 4). - -### 5.5 Builder/release integration - -`builder` crate feature flags gate release artifacts (`crashtracker` flag). The -`std` feature refactor changed `crate-type` from `["lib","staticlib"]` to -`["lib"]` in `libdd-crashtracker/Cargo.toml` — verify the builder and any -downstream consumer didn't rely on the staticlib (grep `builder/` for -`crashtracker` staticlib references; run `cargo run --bin release -- --out /tmp/out` -smoke). Whether `collector_signal-safe` becomes part of the released FFI -artifact now or stays opt-in is decision D1 (team call, blocks PR 8); if -released, add the feature to the builder's crashtracker flag set and -regenerate headers. - -### 5.6 Don't regress the std path - -The `std` feature refactor touches every consumer of `libdd-crashtracker`. -Verify: - -- `cargo check` for every workspace crate depending on `libdd-crashtracker` - (sidecar, profiling-ffi if it re-exports crashtracker, bin_tests): - `cargo build --workspace --exclude builder` covers it — plus - `cargo nextest run --workspace --all-features --exclude builder --exclude test_spawn_from_lib`. -- `bin_tests` crash-tracking integration tests still pass (they exercise the - std collector end-to-end): `cargo nextest run -p bin_tests`. -- crashtracker unit-test features still work: - `cargo nextest run -p libdd-crashtracker --features libdd-crashtracker/generate-unit-test-files`. - -### 5.7 Env-read policy carve-out - -Repo convention: no hidden env reads. `prepare_from_env`/`init_from_env` are the -sanctioned preload bootstrap exception. Document this explicitly in the module -docs and keep `init(&SignalSafeInitConfig)` 100% env-free (audit: it is today — -`prepare()` never calls `env_get`). Add a doc section to -`libdd-crashtracker/src/README.md` describing the two entry points and which -one language tracers should use (the explicit one). - ---- - -## Phase 6 — Deferred parity work (tracked, not in this plan's execution scope) - -From `crashtracker-work-we-need-to-do.md`; keep priorities but do them after -Phases 0–5: - -1. `sigaction`/`signal` PLT interposition + virtualized per-signal state - (§3 there) — prerequisite for full Mode A fidelity with late-registering - runtimes. The current `ORIG_FN` snapshot only sees handlers displaced at - install time. -2. Receiver path discovery beside the loaded `.so` (dladdr glue, arch-suffixed - names) (§9). -3. Packaging: musl receiver sidecar, size guard, staticlib + link-level symbol - guard CI (§16, 4.3 here). -4. Whole integration-test matrix port from dd-trace-c's - `crashtracker_preload_test.go` (§18) — the 4.1 matrix covers the highest-value - half already. - -## Suggested PR sequence - -1. **fix(crashtracker): make signal-safe symbol guard pass** — Phase 0 - (environ-based env lookup replacing `libc::getenv`, guard-script target-dir - isolation). Rebase the `wip`+merge history into clean conventional commits. -2. **fix(crashtracker): signal-safe handler chaining hardening** — 1.1, 1.2, 1.5, - 1.10 + chain e2e tests from 4.1. -3. **feat(crashtracker): lifecycle re-init and richer InitResult** — 1.4, 1.6, - 1.7, 3.1, 5.2 tests. -4. **feat(crashtracker): capability probes and degraded-mode coverage** — 2.1–2.5, - 1.8, probe tests. -5. **test(crashtracker): golden receiver round-trip + symbol guard + e2e matrix** - — 4.2, 4.3, remaining 4.1, 5.3. -6. **refactor(crashtracker): shared protocol constants** — 5.1 (pure refactor, - golden test keeps it honest). -7. **feat(crashtracker-ffi): headers, C example, ffi-test wiring** — 5.4, 1.7 if - not done in PR 3. -8. **build: builder/release + CI (aarch64, staticlib nm guard)** — 5.5, 4.4 CI. - -Every PR: conventional-commit title, run the full validation list from §0, and -`./scripts/update_license_3rdparty.sh` if the lockfile moved. - -## Key risks - -1. The restored CI workflow's symbol guard is red (`U getenv`) — nothing lands - until Phase 0.1; and `signal_owner::acquire`'s same-owner reentrancy means - the owner gate alone does not enforce single init (only - `state::begin_init` does) — don't weaken that CAS during lifecycle work. -2. Mode A guard redesign (1.1/1.2) is subtle; land it only with the - recover-then-crash-again and handler-returns-without-fixing e2e tests. -3. The `std` feature refactor is the largest blast radius — Phase 5.6 checks are - not optional. -4. `serde-json-core`/`heapless` in the crash path: allocation-free but capacity- - bounded; 1.9's truncation semantics decide whether reports degrade loudly or - vanish. diff --git a/signal_safe_crashtracker_plan.md b/signal_safe_crashtracker_plan.md new file mode 100644 index 0000000000..9e883da9c5 --- /dev/null +++ b/signal_safe_crashtracker_plan.md @@ -0,0 +1,598 @@ +# Signal-Safe Crashtracker — Consolidated Improvement Plan + +Status date: 2026-07-06. Branch: `signal_safe_crashtracker` (HEAD `b59e44071`). + +This document supersedes `signal_safe_collector_improvement_plan.md` (deleted from +the working tree; recoverable at `git show 79f8b2a0d:signal_safe_collector_improvement_plan.md`). +The dd-trace-c parity analysis remains in `crashtracker-work-we-need-to-do.md`; +this plan references it and does not repeat it. + +It is written to be executed by another engineer/LLM with **no prior context**. +Every claim marked *(verified)* was checked against the working tree on the +status date. Section 0 lists what is already done — do not redo it. + +--- + +## Guiding principles + +Apply these to every change (they are also the review bar): + +- **Errors never pass silently.** Every degraded path leaves a trace: a + `report_degraded:*` tag, a distinct `InitResult` variant, or a + `crash_debug` breadcrumb. A silently skipped handler install or a silently + truncated string is a bug even when the behavior is otherwise correct. +- **Explicit is better than implicit.** Env reads happen only in the + `*_from_env` entry points. Failure reasons are enum variants, not booleans. + Interacting options are validated, not auto-corrected. +- **There should be one obvious way — and one source of truth.** Duplicated + constants, duplicated invariants, and hand-rolled serializers that shadow a + serde schema are where wire formats silently diverge. This plan's Phase 1 + exists to remove every such duplication that can be removed, and to pin the + rest with drift tests. +- **Simple is better than complex.** The crash path stays a straight line: + probe at init, one handler, two forked children, fixed buffers. Prefer + deleting code to adding it. Anything genuinely non-obvious (the app-chain + stack-position guard) carries a doc comment explaining why. + +--- + +## 0. Ground truth (verified 2026-07-06) + +### What exists + +`libdd-crashtracker/src/collector_signal_safe/` behind cargo feature +`collector_signal-safe` (note the hyphen), coexisting with the std `collector` +feature via the shared `signal_owner.rs` arbiter: + +| File | Lines | Contents | +|---|---|---| +| `mod.rs` | 978 | Wire emitter (`emit_report`, `Sink`/`SliceSink`), chain-policy pure functions, signal/si_code name tables, report data types, capacity constants, ~300 lines of tests | +| `handler.rs` | 926 | `init`/`init_from_env`/`bootstrap_complete`/`shutdown`, `InitResult`, the `crash_handler`, double-fork collect path, app-chain guard, repeat-fault detector, alt-stack, reap logic | +| `config.rs` | 522 | `SignalSafeInitConfig`, `validate`, `prepare[_from_env]_result`, hand-rolled config-JSON builder, env parsing, clamps | +| `sys.rs` | 728 | Raw syscall layer: rustix + inline asm on Linux x86_64/aarch64 (`fork_raw` via `clone(SIGCHLD)`, `process_vm_readv`, `wait4`, `close_range`), libc fallback elsewhere, `environ` walk (no `getenv`) | +| `state.rs` | 203 | Static `Meta` (heapless), init-state machine, per-signal orig-handler/mask atomics, runtime option atomics, `Stage` | +| `backtrace.rs` | 183 | Frame-pointer walk seeded from `ucontext`, probing frames with `process_vm_readv` so corrupt stacks fail instead of faulting | +| `capabilities.rs` | 143 | Capability bits (`RECEIVER_OK, PROC_VM_READV, FORK_OK, DEV_NULL, PIPE_OK, REPORT_FD_OK`), degradation bits + `DEGRADATION_REASONS` tag table, init-time `publish()` probes | + +Plus: FFI surface `libdd-crashtracker-ffi/src/collector_signal_safe.rs` +(`ddog_crasht_signal_safe_*`, all `catch_unwind`-wrapped, `#[repr(C)]` enums +with lib↔FFI value-equality tests); shared `src/protocol.rs` (wire markers, +used by std emitter, signal-safe emitter, and receiver — `shared/constants.rs` +now re-exports it); e2e tests `tests/collector_signal_safe_e2e.rs`; receiver +round-trip test `receiver/mod.rs:112` feeding the signal-safe emitter's bytes +through the real receiver parser; CI workflow +`.github/workflows/crashtracker-signal-safe.yml`; symbol guard +`tools/check_signal_safe_symbols.sh`. + +### Already done — do not redo + +The previous plan's Phases 0–3 were largely implemented by commits `5da67273c` +and `b59e44071` *(all verified in the tree)*: + +- `getenv` replaced by a direct `environ` walk (`sys.rs:614`); symbol guard + **passes** (`bash tools/check_signal_safe_symbols.sh` → exit 0). +- App-chain recursion guard is tid + stack-position based + (`handler.rs:37-38,180-209`), surviving `siglongjmp` recovery. +- Repeat-fault detector for app handlers that return without fixing the fault + (`handler.rs:39-41,211-226`). +- `shutdown()`/`fail_init()` reset `INIT_STATE` to `UNINIT` → re-init works + (`state.rs:89-95`). +- Original `sa_mask` saved and restored (`handler.rs:819,837`). +- `InitResult` has `DisabledByConfig/AlreadyInitialized/OwnerConflict/InvalidConfig` + variants plus a central `validate()` (`config.rs:336`). +- `close_fds_on_receiver` implemented via raw `SYS_close_range` + (`handler.rs:377-378`, `sys.rs:307`). +- Receiver exit-125 detection with re-emit to `report_fd` + (`handler.rs:592`). +- Truncation degrades loudly: `emit_truncated_tail` always terminates the + stream and tags `report_degraded:truncated` (`mod.rs:589`). +- Loader-env scrubbing (`LD_PRELOAD`/`LD_AUDIT`) before receiver exec + (`handler.rs:345`). +- FFI `catch_unwind` on every entry point; support-matrix doc in `mod.rs:10-17`. + +### What is red right now + +Exactly one failure *(verified)*: + +**`collector_signal_safe::config::tests::config_json_contains_receiver_contract` +fails on Linux.** The golden string at `config.rs:435-443` hardcodes +`"signals":[11,6,10,4,8]` — `10` is SIGBUS on BSD/macOS, but +`CRASH_SIGNALS` (`config.rs:40`) uses `libc::SIGBUS`, which is `7` on Linux. +The test encodes one platform's numbers; the code is portable. Fix in Phase 0. + +Also *(verified)*: `tools/check_signal_safe_symbols.sh` is mode `100644` — +`./tools/...` fails with *Permission denied*; CI survives only because the +workflow says `bash tools/...` (`crashtracker-signal-safe.yml:46`). + +### Validation commands (run after every phase) + +```bash +cargo check -p libdd-crashtracker --features collector_signal-safe +cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe +cargo check -p libdd-crashtracker-ffi --no-default-features --features collector_signal-safe +cargo build --workspace --exclude builder +cargo +nightly-2026-02-08 fmt --all -- --check +cargo +stable clippy --workspace --all-targets --all-features -- -D warnings +cargo nextest run -p libdd-crashtracker --features collector_signal-safe # or cargo test if nextest absent +cargo nextest run --workspace --no-fail-fast +cargo test --doc +bash tools/check_signal_safe_symbols.sh +``` + +If `Cargo.lock` changes: `./scripts/update_license_3rdparty.sh && cargo deny check`. +If FFI is touched: `cargo ffi-test`. New files need Apache-2.0 headers +(`./scripts/reformat_copyright.sh`). Signal-state-mutating tests must hold +`TEST_GLOBAL_LOCK` (`mod.rs:42`) — nextest's process-per-test isolation is why +CI uses it. + +--- + +## Phase 0 — Green branch (P0, do first) + +### 0.1 Fix the platform-dependent golden test + +Replace the hardcoded signal numbers in +`config_json_contains_receiver_contract` (`config.rs:429-443`) with an +expectation built from `CRASH_SIGNALS` itself (format the array the same way +`build_config_json` does), or assert the full string with the signal segment +spliced in. The golden test's job is to pin the *shape* of the receiver +contract; the numeric values are `libc`'s to define per-platform. Keep one +additional assertion that `CRASH_SIGNALS` contains SIGSEGV/SIGABRT/SIGBUS/ +SIGILL/SIGFPE by symbolic name so the set itself is still pinned. + +### 0.2 Housekeeping + +- `chmod +x tools/check_signal_safe_symbols.sh` (`git update-index --chmod=+x`). +- Branch history contains `wip` commits and merges — rebase/squash into + conventional commits before opening PRs (sequence at the end). +- `cargo nextest` is not installed in the local dev environment; either + install it or use `cargo test` locally (the `TEST_GLOBAL_LOCK` makes plain + `cargo test` safe). + +--- + +## Phase 1 — DRY and simplification + +This is the headline phase. Each item removes a duplication or shrinks the +code; none changes wire behavior (the receiver round-trip test at +`receiver/mod.rs:112` and the config golden test are the guardrails — run them +after every step). + +### 1.1 Split `mod.rs` (978 lines) into cohesive submodules + +`mod.rs` currently mixes four concerns. Split, keeping `mod.rs` as the façade +with the same `pub use` surface (zero API change): + +| New file | Moves from `mod.rs` | +|---|---| +| `emitter.rs` | `Sink`, `SliceSink`, `emit_report[_with_metadata]`, `emit_minimal_report`, `emit_json_section`, `emit_config/metadata/additional_tags/kind/stacktrace/message/truncated_tail/done`, `put_marker_line`, capacity constants, `push_tag` | +| `policy.rs` | `Disposition`, `ChainAction`, `SignalContext`, `disposition_of`, `app_handler_is_real`, `should_run_app_first`, `app_recovered`, `is_genuine_fault`, `chain_action` | +| `signal_names.rs` | `rust_signal_name`, `rust_si_code_name`, `signal_specific_si_code_name`, `signal_has_address`, the `SI_*`/`SEGV_*`/`BUS_*`/`ILL_*`/`FPE_*` consts | +| `report.rs` | `Metadata`, `SignalInfo`, `ProcInfo`, `Frame`, `Report`, `CrashContext`, `Tag`/`Tags` aliases | + +Move each block's tests with it. Pure mechanical refactor; do it in one commit +with no logic changes so review is trivial. + +### 1.2 Replace the hand-rolled config-JSON builder with a serde struct + +`build_config_json` (`config.rs:123-164`) is 40 lines of `push_str`/`write!` +chains hand-assembling JSON that must match `CrashtrackerConfiguration`'s +serde schema — the classic shadow-serializer. The module already depends on +`serde` + `serde-json-core` and already serializes every report section that +way (`emit_json_section`, `mod.rs:389`). Replace with: + +```rust +#[derive(Serialize)] +struct WireConfig<'a> { + additional_files: [&'a str; 0], + create_alt_stack: bool, + use_alt_stack: bool, + demangle_names: bool, + endpoint: Option<()>, // serializes as null + resolve_frames: &'a str, // "EnabledWithSymbolsInReceiver" + signals: &'a [i32], + timeout: WireTimeout, // { secs: u32, nanos: u32 } + unix_socket_path: Option<()>, +} +``` + +serialized with `serde_json_core::to_slice` into the existing +`CONFIG_JSON_BUF_SIZE` buffer (plus the trailing `\n`). This deletes the +manual escaping/ordering logic entirely; field order in the struct pins the +byte-exact output, and the receiver round-trip test proves +`CrashtrackerConfiguration` still deserializes it. Keep the (now +platform-correct, per 0.1) golden test as the wire contract. + +*Rejected alternative*: reusing `CrashtrackerConfiguration` itself — it is +std-only (`Vec`, `Endpoint`) and its serde output is the *target*, not a tool +usable in a no-std crate. The drift risk is covered by the round-trip test. + +### 1.3 One source of truth for wire/receiver-shared constants + +Three small duplications *(verified)* to collapse into the shared layer: + +- **Crash-signal list.** `config::CRASH_SIGNALS = [SIGSEGV, SIGABRT, SIGBUS, + SIGILL, SIGFPE]` (`config.rs:40`) vs legacy `DEFAULT_SIGNALS = [SIGBUS, + SIGABRT, SIGSEGV, SIGILL]` (`collector/api.rs:17-22`) — the legacy set omits + SIGFPE. These are semantically different (fixed set vs configurable + default), so don't force one list; instead move both to a tiny shared + `crate::shared::signals` (or extend `protocol.rs`) as named consts with a + comment stating the intended difference, and add a test asserting the + signal-safe set is a superset of the legacy default. If the difference is + *not* intended, that surfaces immediately (decision D2 below). +- **Default receiver timeout.** `DD_CRASHTRACK_DEFAULT_TIMEOUT = 5000 ms` + (`shared/constants.rs:8`) vs `RECEIVER_TIMEOUT_SECS = 5` (`config.rs:33`). + Derive the latter from the former. +- **`#![allow(dead_code)]` at `protocol.rs:4`** — with three consumers + (std emitter, signal-safe emitter, receiver) most constants are live; scope + the allow to the actually-unused items or remove it so dead protocol + constants become visible again. + +### 1.4 si_code / signal-name drift-proofing + +`signal_names.rs` (post-1.1) deliberately reimplements the receiver's +`SignalNames`/`SiCodes` tables allocation-free — that duplication stays (the +legacy path uses `num_derive` + a C shim, unusable in-handler). Pin it instead +of merging it: add a `#[cfg(all(test, feature = "receiver"))]` test that, for +every signal in `CRASH_SIGNALS` × every named si_code, feeds +`rust_signal_name`/`rust_si_code_name` output through the receiver's serde +deserialization of `SigInfo` and asserts it produces the corresponding enum +variant (and that the `""` fallback maps to the receiver's UNKNOWN +handling rather than an error). Wire the combined-features invocation into the +CI workflow. This turns silent table drift into a red test. + +### 1.5 Crash-path micro-simplifications + +- **`emit_stacktrace` allocates a fresh `[0u8; 4096]` per frame inside its + loop** (`mod.rs:564`, up to 64 iterations). Hoist the buffer out of the + loop. A frame line needs ~40 bytes; also consider a dedicated small buffer + (e.g. 256 B) — the 64 KiB alt stack is the budget, spend it deliberately. +- **Consolidate the hand-rolled formatters** — `hex_addr` (`mod.rs:442`), + `hex_u32` (`mod.rs:547`), `write_i32` (`handler.rs:244`) — into one + `fmt.rs` with unit tests (`i32::MIN`, `usize::MAX`, `0`). `hex_u32` is the + only `core::fmt` user on the crash path; rewriting it in the same style as + `hex_addr` makes the crash path fmt-free, which also shrinks the code the + symbol guard has to trust. +- **`cstr_bytes` in the FFI** (`collector_signal_safe.rs:191-201`) and + `set_str` truncation (`config.rs:300-309`): truncating `service` or + `receiver_path` silently is an errors-pass-silently violation. Receiver-path + truncation already returns `InvalidConfig` *(verified)*; make metadata + truncation observable with a `crash_debug` breadcrumb or an init-time + degradation note. + +### 1.6 Alt-stack: one documented policy + +Two divergent implementations *(verified)*: legacy mmaps +`max(SIGSTKSZ, 16 × page)` plus a `PROT_NONE` guard page +(`collector/signal_handler_manager.rs:139-173`); signal-safe uses a static +64 KiB array with no guard page (`handler.rs:23-29,332-343`). The static +approach is right for the no-std path (no mmap at init required), but: + +- Document both choices side by side in a comment in each file referencing + the other ("static because no-std/no-mmap; no guard page — overflow past the + alt stack corrupts adjacent statics rather than faulting; acceptable because + the process is already crashing"). +- Optional improvement (small, worth it): place the alt stack in its own + page-aligned static and `mprotect` its first page `PROT_NONE` *at init time* + (init may syscall freely). That restores guard-page semantics without any + crash-path cost. Fold the decision into D3. + +### 1.7 Uniform capability gating + +`collect_crash` checks `report_fd >= 0` directly (`handler.rs:519`) while +`REPORT_FD_OK` exists as a capability bit; `DEV_NULL` is probed and published +but never gated on *(verified)*. Make the handler consult capabilities +uniformly: gate the fd fallback on `REPORT_FD_OK`, and have `sanitize_clone` +branch on `DEV_NULL` (it already has the no-devnull path, +`close_stdio_without_devnull`). One idiom for one concept — and the +capability bits reported via FFI then truthfully describe what the handler +will actually do. + +--- + +## Phase 2 — Missing capabilities and degraded modes + +### 2.1 Silent handler-install skip must become visible + +`install_crash_handler` returns silently when a signal's disposition is not +`SIG_DFL` (`handler.rs:805-806`) — an app that installed a SIGSEGV handler +before us means we never collect for that signal, observable only by polling +`owned_signal_count`. Add a degradation bit + reason +(`DEGRADED_HANDLER_PRESENT` / `report_degraded:app_handler_present:` — or +one bit plus a per-signal mask exposed through a new +`ddog_crasht_signal_safe_unowned_signals()` FFI getter). Init still succeeds +(partial coverage is better than none) but the fact is now on the record at +init time and in any report produced by the signals we do own. + +Note: full Mode-A fidelity for *late*-registering runtimes needs the +sigaction/PLT virtualization deferred to Phase 6 — this item only makes the +install-time case loud. + +### 2.2 Seccomp sacrificial-child probe (opt-in) + +`probe_process_vm_readv` (`capabilities.rs:105`) catches errno-returning +denials (`EPERM`) but not `SECCOMP_RET_KILL` policies, which would kill the +collector child mid-crash and silently lose the stack. Add +`SignalSafeInitConfig::probe_seccomp: bool` (default `false` — forking at init +is a global effect and must be opt-in per repo conventions): +`fork_raw()`; child calls `read_own_mem` on itself and `_exit(0)`; parent +waits ≤100 ms. Killed by signal → clear `PROC_VM_READV`, set +`DEGRADED_NO_PROC_VM_READV`; timeout → kill, keep the capability (status +quo). The `seed_only` stackwalk fallback already exists downstream. + +### 2.3 Make the one-shot `COLLECTING` semantics explicit + +`COLLECTING` is set once and never reset (`handler.rs:663-667`): exactly one +collection per process lifetime, by design (a second concurrent crash must not +re-enter, and a second sequential crash is almost always the same fault). Keep +the behavior, but (a) doc-comment it at the static, (b) reset it in +`shutdown()` so the re-init lifecycle is coherent, and (c) add an e2e assert +that a second crash after a first report chains straight to default +disposition without forking. + +### 2.4 Config surface gaps — deliberate scope decision + +Currently not configurable *(verified)*: the signal set, alt-stack size, +`endpoint`/`unix_socket_path` (hardcoded null → receiver decides), and +stackwalk mode (always `EnabledWithSymbolsInReceiver`). Recommendation: +**add none of them now.** Each is a knob without a requesting integrator, and +the config JSON's hardcoded nulls are what keep the receiver contract simple. +Record this as decided (table below, D4); revisit per-signal opt-out first if +a runtime conflict report arrives (e.g. a JVM that must own SIGFPE — today +that case is handled by the app-handler-present skip from 2.1). + +### 2.5 Non-Linux coverage + +macOS/other-Unix code paths (`fork_supported() == false` → minimal report to +`report_fd`) compile but never run in CI *(verified: only ubuntu runners)*. +Add a `macos-latest` job running +`cargo test -p libdd-crashtracker --features collector_signal-safe` — the +degraded-fd e2e test is already portable (`collector_signal_safe_e2e.rs:93`). +This is cheap and pins the entire libc-fallback half of `sys.rs`, which today +is check-only dead weight that could regress freely. + +--- + +## Phase 3 — Safety-option hardening + +### 3.1 `disarm_on_entry` interaction matrix + +`DISARM_ON_ENTRY` resets the signal to `SIG_DFL` on entry (`handler.rs:606`), +then the tail chain logic reinstalls/raises per `chain_action`. Unit-test the +matrix (disarm × {genuine fault, external async → `Resume`, ignored +disposition}) and fix the known gap: after disarm + `Resume`, the pre-entry +disposition (our handler) must be restored before returning, else the next +occurrence terminates with no report. + +### 3.2 Stack-budget audit + +Document the crash-path stack requirement next to `ALT_STACK_SIZE` +(`handler.rs:23`): handler frame + `collect_crash` locals + +(degraded path only) `emit_crash_report`'s section buffer ≈ 8–12 KiB, vs the +64 KiB alt stack. `direct_report` runs the emitter **in the signal-handler +frame** — with 1.5's per-frame-buffer hoist this is one 4 KiB buffer, fine, +but write the number down and add a `const _: () = assert!(...)` relating +`SECTION_BUF_CAPACITY` to `ALT_STACK_SIZE` so a future capacity bump can't +silently outgrow the stack. + +### 3.3 Symbol guard hardening + +The guard *(currently green, verified)* scans `nm -u` of the +no-default-features rlibs for +`malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__libc_*` +(`tools/check_signal_safe_symbols.sh:25`). Extend: + +- Add `calloc|realloc|posix_memalign|mmap|pthread_mutex_unlock|pthread_cond_[a-z]+|syslog|abort` — + note `abort` will require auditing that nothing on the crash path links it + (panics are already banned by clippy config). +- Build into a dedicated `CARGO_TARGET_DIR=target/signal-safe-guard` inside + the script so stale dev artifacts can't produce false results in either + direction. +- Known limitation, document in the script header: it scans the whole rlib, + so init-time code is held to crash-path standards. That is the right + trade-off; exceptions must be explicit regex changes reviewed in the + script, never attributes in code. +- Longer term (with 5.1's release wiring): run `nm -u` on the released + staticlib/cdylib too — rlib scanning can miss symbols introduced at final + link. + +--- + +## Phase 4 — Test strategy + +### 4.1 E2E matrix (extend `tests/collector_signal_safe_e2e.rs`) + +The self-exec pattern (env-gated child test fns + orchestrating asserts) is +good. Current coverage is exactly two scenarios *(verified)*: SIGABRT through +a shell receiver, and degraded report-to-fd. Highest-value additions, in +order: + +| Scenario | Child behavior | Assert | +|---|---|---| +| **SIGSEGV with real stackwalk** | deref null | report, `SEGV_MAPERR`, >1 frame, `stackwalk_method:fp_pvr` — this is the only test that exercises `arch_seed`/`walk_fp`/`process_vm_readv` for real | +| App handler recovers (Mode A) | `siglongjmp` handler; crash; continue | no report; exit 0 | +| Recover, then genuine crash | as above, then handler removed, crash | exactly one report (pins the app-chain guard) | +| App handler gives up | handler restores `SIG_DFL`, returns | one report; process dies by SIGSEGV re-fault (check termination signal) | +| Mode B (`force_on_top`) | recovering handler + `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` | report **and** exit 0 | +| External async signal | parent `kill -SEGV` child | **no** report; default termination | +| Self-sent async | child `raise(SIGSEGV)` | report | +| Stuck receiver | receiver script sleeps forever | process terminates within timeout+grace+ε; no zombie | +| Receiver deleted post-init | unlink receiver after init | fd fallback with `receiver_unavailable` + `report_to_fd` tags | +| Bootstrap-only | `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true`, crash after `bootstrap_complete()` | no report | +| Stage tags | crash before vs after `bootstrap_complete` | `stage:crashtracker_init` vs `stage:application` | +| Loader-env scrub | init under `LD_PRELOAD=` | receiver env lacks `LD_PRELOAD`/`LD_AUDIT` (receiver script dumps env) | +| fd hygiene | parent opens marker fd without `O_CLOEXEC` | receiver's `/proc/self/fd` lacks it (pins `close_range`) | +| SIGFPE | integer div-by-zero | report; si_code string accepted by receiver (with 1.4's drift test) | +| Re-init lifecycle | init → shutdown → init → crash | one report (pins 2.3's `COLLECTING` reset) | + +Gate Linux-only scenarios `#[cfg(target_os = "linux")]`; use generous +timeouts (CI is slow). + +### 4.2 Plug into `bin_tests` (the big reuse win) + +The `bin_tests` harness is producer-agnostic: it asserts on the `CrashInfo` +JSON that the receiver writes to a `file://` endpoint +(`bin_tests/src/test_runner.rs:121-160`). Add: + +- a new bin artifact (alongside `crashtracker_bin_test`) that calls + `ddog_crasht_signal_safe_init` (through the FFI, so the C surface is what's + tested) and crashes per a mode argument; +- a `TestMode`/validator asserting the signal-safe-specific fields: `stage:*`, + `stackwalk_method:*`, `report_degraded:*` tags, and frames non-empty for + SIGSEGV; +- reuse `test_crashtracker_receiver` unchanged — same protocol, same receiver + *(already proven by the `receiver/mod.rs:112` round-trip test)*. + +This buys the full validation pipeline (real receiver binary, real JSON +schema, telemetry file checks) for free and is the compatibility proof that +the two collectors remain interchangeable in front of one receiver. + +### 4.3 Golden wire fixture + +Check in the emitted byte stream for a fixed `Report`/`CrashContext` +(`tests/fixtures/signal_safe_report.golden`) and diff against regenerated +output in a test. The receiver round-trip test proves *parseability*; the +golden file makes any wire change *visible in review*. Regeneration path: +a `#[ignore]`d test that rewrites the fixture when run explicitly. + +### 4.4 CI upgrades (`.github/workflows/crashtracker-signal-safe.yml`) + +- aarch64 is check-only today *(verified)* — the inline-asm syscall stubs and + `arch_seed` LR fallback are exactly the code that passes `check` and fails + at runtime. Add an execution job (`cross test` or + `taiki-e/setup-cross-toolchain-action` + qemu). +- macOS job (2.5). +- Combined-features run for the drift/round-trip tests: + `cargo nextest run -p libdd-crashtracker --features "collector_signal-safe,receiver"`. +- Optional but cheap: an ASan job for the e2e tests (fork + raw syscalls + + signal handlers is ASan's home turf; it will not understand the crash + itself, so scope it to the lifecycle/init tests). + +--- + +## Phase 5 — Reuse and compatibility with the rest of libdatadog + +### 5.1 Ship it: release wiring (currently a dead end, verified) + +The feature exists end-to-end in the crashtracker crates but **cannot reach +the released artifact**: `libdd-profiling-ffi/Cargo.toml` has passthroughs for +`crashtracker-collector`/`crashtracker-receiver` but none for +`collector_signal-safe`, and the builder's feature string +(`builder/src/profiling.rs:135`) doesn't mention it either. To ship: + +1. Add `crashtracker-collector-signal-safe = ["libdd-crashtracker-ffi/collector_signal-safe"]` + passthrough in `libdd-profiling-ffi/Cargo.toml`. +2. Add it to the builder's crashtracker feature set in + `builder/src/profiling.rs` (gated on decision D1 below). +3. Remove the `libdd_crashtracker::collector_signal_safe` exclusion from + `libdd-crashtracker-ffi/cbindgen.toml:75` **only if** cbindgen needs to + chase lib types; the current design keeps all `#[repr(C)]` types in the + FFI crate, so instead just verify the generated `crashtracker.h` contains + the `ddog_crasht_signal_safe_*` functions and structs (build with the + `cbindgen` feature and inspect). +4. Add a C example under `examples/ffi/` exercising init + abort + a receiver + script, wired into `cargo ffi-test` (AGENTS.md step 4). +5. Smoke: `cargo run --bin release -- --out /tmp/out` and `nm -u` the + produced staticlib for the banned-symbol list (closes the 3.3 gap). + +### 5.2 Signal-owner interplay tests (both directions) + +`signal_owner.rs` arbitration exists *(verified)*; test it explicitly with +both features enabled: std `init` after signal-safe `init` → error naming the +conflict; signal-safe `init_result` after std `init` → `OwnerConflict`; std +teardown releasing ownership → signal-safe init then succeeds (if the std +collector has no full uninstall, document that switching collectors requires +a process restart). Remember `signal_owner::acquire` is reentrant for the +same owner — single-init is enforced by `state::begin_init`'s CAS, not the +owner gate; don't weaken that CAS. + +### 5.3 Explicit non-reuse decisions (recorded so nobody relitigates) + +Surveyed the workspace for reuse; these are **deliberate no's** — the DRY +principle applies to sources of truth, not to forcing shared code across the +std/no-std boundary: + +| Candidate | Verdict | Why | +|---|---|---| +| `libdd-capabilities`(-impl) | No | Trait-DI abstractions for HTTP/sleep/spawn (wasm portability), unrelated to runtime capability probing despite the name | +| `libdd-alloc::LinearAllocator` | No (for now) | Genuinely signal-safe bump allocator, but the collector's buffers are fixed-size by design; heapless capacities *are* the report size contract. Revisit only if variable-size scratch becomes necessary | +| `spawn_worker` | No | Trampoline-binary process spawner; allocates, memfd/tempfiles — not signal-safe, wrong tool | +| `libdd_common::unix_utils` (`alt_fork`, `PreparedExecve`…) | No in-handler | std/nix-based; the legacy collector's tools. `sys.rs` is the signal-safe equivalent and must stay independent | +| `blazesym`/symbolication | No in-collector | Symbolication stays in the receiver (`EnabledWithSymbolsInReceiver`); collector emits raw IPs only | +| Receiver, `CrashInfo` model, telemetry/errors-intake uploaders, `protocol.rs` | **Yes — already reused** | Collector-agnostic; the whole design hinges on one receiver parsing both collectors | + +### 5.4 Don't regress the std path + +The `std`-feature re-plumbing in `libdd-crashtracker/Cargo.toml` touches every +consumer. Non-optional checks: `cargo build --workspace --exclude builder`; +`cargo nextest run --workspace --all-features --exclude builder --exclude test_spawn_from_lib`; +`cargo nextest run -p bin_tests` (std collector e2e); +`cargo nextest run -p libdd-crashtracker --features generate-unit-test-files` +(per AGENTS.md). Verify no downstream crate relied on the removed +`staticlib` crate-type (grep `builder/` before assuming). + +--- + +## Phase 6 — Deferred parity work (tracked, out of execution scope) + +From `crashtracker-work-we-need-to-do.md`, in priority order; do after +Phases 0–5: + +1. `sigaction`/`signal` PLT interposition + virtualized per-signal state + (§3 there) — prerequisite for full Mode-A fidelity with late-registering + runtimes; today's `ORIG_FN` snapshot sees only install-time handlers. +2. Receiver path discovery beside the loaded `.so` (dladdr glue, + arch-suffixed names) (§9). +3. Packaging: musl receiver sidecar, size guard (§16). +4. Remainder of the dd-trace-c `crashtracker_preload_test.go` matrix (§18) — + Phase 4.1 covers the highest-value half. + +--- + +## Open decisions + +Everything else in this plan is decided; these need a human call before the +affected PR: + +| # | Decision | Recommendation | Blocks | +|---|---|---|---| +| D1 | Ship `collector_signal-safe` in the released FFI artifact now, or keep opt-in? | Team/product call | PR 7 (5.1 steps 2–5) | +| D2 | Legacy default signal set omits SIGFPE — intended divergence or historical accident? | Keep the divergence, document it (changing legacy defaults is a behavior change for existing SDKs) | PR 2 (1.3) | +| D3 | Add init-time `mprotect` guard page to the static alt stack? | Yes — init-time-only cost, real overflow protection | PR 3 (1.6) | +| D4 | Expose per-signal opt-out / alt-stack size / endpoint config now? | No — no requesting integrator; 2.1 covers the conflict case | nothing now | + +--- + +## Suggested PR sequence + +Each PR: conventional-commit title, full validation list from §0, +`./scripts/update_license_3rdparty.sh` if the lockfile moves. + +1. **fix(crashtracker): platform-correct config golden + script exec bit** — + Phase 0. Rebase the `wip`/merge history into clean commits here. +2. **refactor(crashtracker): split signal-safe module and share constants** — + 1.1, 1.3, 1.5, 1.7 (pure refactors; receiver round-trip + golden tests + prove no wire change). +3. **refactor(crashtracker): serde wire-config + alt-stack guard page** — + 1.2, 1.6, 3.2. +4. **feat(crashtracker): visible degradations and capability polish** — 2.1, + 2.2, 2.3, 3.1, 1.4 drift test. +5. **test(crashtracker): e2e matrix, golden fixture, bin_tests integration** — + 4.1, 4.2, 4.3, 5.2. +6. **ci(crashtracker): aarch64 execution, macOS job, guard hardening** — 4.4, + 3.3, 2.5. +7. **build(crashtracker): release wiring, headers, C example** — 5.1 + (D1-gated), 5.4 verification. + +## Key risks + +1. **The wire format is the crown jewel.** Every Phase 1 refactor must run + the receiver round-trip test and (once it exists) the golden fixture diff + in the same commit. A wire regression that reaches a release breaks crash + reporting for every SDK pinned to that version. +2. **aarch64 is untested at runtime** until 4.4 lands — the raw-syscall and + `arch_seed` code there is the most likely place for a silent, shipped bug. +3. **The std-feature re-plumbing has the widest blast radius** — 5.4's checks + are not optional on any PR that touches `Cargo.toml`. +4. **Refactors and behavior changes must not share a commit** (1.1's split is + safe exactly because it moves code verbatim; hold that line in review). From 79d29aac60c3596822d6a1fd6300bd7c603bc910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 03:34:46 +0200 Subject: [PATCH 11/32] feat(crashtracker): implement signal-safe collector --- .../workflows/crashtracker-signal-safe.yml | 20 + builder/src/profiling.rs | 4 +- examples/ffi/CMakeLists.txt | 3 + examples/ffi/signal_safe_crashtracking.c | 64 ++ .../src/collector_signal_safe.rs | 2 + libdd-crashtracker/src/collector/api.rs | 5 +- .../src/collector_signal_safe/capabilities.rs | 53 +- .../src/collector_signal_safe/config.rs | 177 ++-- .../src/collector_signal_safe/emitter.rs | 368 ++++++++ .../src/collector_signal_safe/fmt.rs | 117 +++ .../src/collector_signal_safe/handler.rs | 155 ++-- .../src/collector_signal_safe/mod.rs | 796 ++---------------- .../src/collector_signal_safe/policy.rs | 161 ++++ .../src/collector_signal_safe/report.rs | 105 +++ .../src/collector_signal_safe/signal_names.rs | 132 +++ .../src/collector_signal_safe/state.rs | 8 + .../src/collector_signal_safe/sys.rs | 19 +- libdd-crashtracker/src/lib.rs | 7 +- libdd-crashtracker/src/protocol.rs | 75 +- libdd-crashtracker/src/receiver/mod.rs | 65 ++ libdd-crashtracker/src/shared/constants.rs | 5 +- libdd-crashtracker/src/shared/defaults.rs | 4 + libdd-crashtracker/src/shared/mod.rs | 8 +- libdd-crashtracker/src/shared/signals.rs | 30 + .../tests/collector_signal_safe_e2e.rs | 192 ++++- .../tests/fixtures/signal_safe_report.golden | 26 + libdd-profiling-ffi/Cargo.toml | 2 + libdd-profiling-ffi/src/exporter.rs | 23 +- libdd-profiling-ffi/src/profiles/datatypes.rs | 64 +- libdd-profiling-ffi/src/profiles/mod.rs | 2 + tools/check_signal_safe_symbols.sh | 4 +- tools/src/bin/ffi_test.rs | 28 +- tools/src/lib.rs | 77 +- 33 files changed, 1876 insertions(+), 925 deletions(-) create mode 100644 examples/ffi/signal_safe_crashtracking.c create mode 100644 libdd-crashtracker/src/collector_signal_safe/emitter.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/fmt.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/policy.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/report.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/signal_names.rs create mode 100644 libdd-crashtracker/src/shared/defaults.rs create mode 100644 libdd-crashtracker/src/shared/signals.rs create mode 100644 libdd-crashtracker/tests/fixtures/signal_safe_report.golden mode change 100644 => 100755 tools/check_signal_safe_symbols.sh diff --git a/.github/workflows/crashtracker-signal-safe.yml b/.github/workflows/crashtracker-signal-safe.yml index 9dada418e5..c7ac6bf00c 100644 --- a/.github/workflows/crashtracker-signal-safe.yml +++ b/.github/workflows/crashtracker-signal-safe.yml @@ -8,6 +8,8 @@ on: paths: - "libdd-crashtracker/**" - "libdd-crashtracker-ffi/**" + - "libdd-profiling-ffi/**" + - "builder/**" - "tools/check_signal_safe_symbols.sh" - ".github/workflows/crashtracker-signal-safe.yml" - "Cargo.toml" @@ -42,5 +44,23 @@ jobs: 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@d12e869b89167df346dd0ff65da342d1fb1202fb # v2.57.4 + 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/builder/src/profiling.rs b/builder/src/profiling.rs index 8c8f213aee..4f858fb22c 100644 --- a/builder/src/profiling.rs +++ b/builder/src/profiling.rs @@ -132,7 +132,9 @@ impl Module for Profiling { fn build(&self) -> Result<()> { let features = self.features.to_string() + "," + "cbindgen"; #[cfg(feature = "crashtracker")] - let features = features.add(",crashtracker-collector,crashtracker-receiver,demangler"); + let features = features.add( + ",crashtracker-collector,crashtracker-collector-signal-safe,crashtracker-receiver,demangler", + ); // Using rustc instead of build in order to overcome issues with LTO optimization. let mut cargo_args = vec![ diff --git a/examples/ffi/CMakeLists.txt b/examples/ffi/CMakeLists.txt index c6e1988001..aa9f31c880 100644 --- a/examples/ffi/CMakeLists.txt +++ b/examples/ffi/CMakeLists.txt @@ -76,6 +76,9 @@ if(NOT WIN32) add_executable(crashtracking_unhandled_exception crashtracking_unhandled_exception.c) target_link_libraries(crashtracking_unhandled_exception PRIVATE Datadog::Profiling) + + add_executable(signal_safe_crashtracking signal_safe_crashtracking.c) + target_link_libraries(signal_safe_crashtracking PRIVATE Datadog::Profiling) endif() add_executable(trace_exporter trace_exporter.c) diff --git a/examples/ffi/signal_safe_crashtracking.c b/examples/ffi/signal_safe_crashtracking.c new file mode 100644 index 0000000000..bbbfa31fea --- /dev/null +++ b/examples/ffi/signal_safe_crashtracking.c @@ -0,0 +1,64 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include + +#define MAX_FILE_PATH 512 + +int main(void) { + const char *output_dir = getenv("DDOG_CRASHT_TEST_OUTPUT_DIR"); + if (!output_dir || output_dir[0] == '\0') { + output_dir = "/tmp/crashreports"; + } + + char report_path[MAX_FILE_PATH]; + snprintf(report_path, sizeof(report_path), "%s/signal_safe_crashreport.txt", output_dir); + + int report_fd = open(report_path, O_CREAT | O_TRUNC | O_WRONLY, 0600); + if (report_fd < 0) { + perror("open signal-safe report"); + return EXIT_FAILURE; + } + + struct ddog_crasht_SignalSafeConfig config = { + .receiver_path = "/definitely/missing-signal-safe-receiver", + .service = "signal-safe-ffi-test", + .env = "test", + .app_version = "1", + .runtime_id = "00000000-0000-0000-0000-000000000001", + .platform = "host", + .library_name = "signal-safe-ffi-test", + .library_version = "1.0.0", + .family = "native", + .default_service = "signal-safe-ffi-test", + .force_on_top = false, + .only_bootstrap = false, + .debug_logging = false, + .create_alt_stack = false, + .use_alt_stack = false, + .block_signals = true, + .disarm_on_entry = false, + .report_fd = report_fd, + .collector_reap_ms = 500, + .receiver_timeout_secs = 5, + .max_frames = 32, + .close_fds_on_receiver = true, + .probe_seccomp = false, + }; + + if (ddog_crasht_signal_safe_init(&config) != DDOG_CRASHT_SIGNAL_SAFE_INIT_RESULT_ENABLED) { + fprintf(stderr, "signal-safe crashtracker init failed\n"); + close(report_fd); + return EXIT_FAILURE; + } + + ddog_crasht_signal_safe_bootstrap_complete(); + raise(SIGABRT); + return EXIT_FAILURE; +} diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs index 30717d1599..d3a6a02f17 100644 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -44,6 +44,7 @@ pub struct SignalSafeConfig { pub receiver_timeout_secs: u32, pub max_frames: usize, pub close_fds_on_receiver: bool, + pub probe_seccomp: bool, } #[repr(C)] @@ -114,6 +115,7 @@ pub unsafe extern "C" fn ddog_crasht_signal_safe_init( receiver_timeout_secs: config.receiver_timeout_secs, max_frames: config.max_frames, close_fds_on_receiver: config.close_fds_on_receiver, + probe_seccomp: config.probe_seccomp, })) }) } diff --git a/libdd-crashtracker/src/collector/api.rs b/libdd-crashtracker/src/collector/api.rs index f3a3cd743c..d111c08cbe 100644 --- a/libdd-crashtracker/src/collector/api.rs +++ b/libdd-crashtracker/src/collector/api.rs @@ -9,13 +9,12 @@ use crate::{ collector::signal_handler_manager::register_crash_handlers, crash_info::Metadata, reset_counters, - shared::configuration::CrashtrackerReceiverConfig, + 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) diff --git a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index 617775445e..bf0892deae 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -22,6 +22,9 @@ pub const DEGRADED_FORK_FAILED: u32 = 1 << 6; pub const DEGRADED_RECEIVER_UNAVAILABLE: u32 = 1 << 7; pub const DEGRADED_REPORT_TO_FD: u32 = 1 << 8; pub const DEGRADED_TRUNCATED: u32 = 1 << 9; +pub const DEGRADED_METADATA_TRUNCATED: u32 = 1 << 10; +pub const DEGRADED_APP_HANDLER_PRESENT: u32 = 1 << 11; +pub const DEGRADED_ALT_STACK_GUARD_UNAVAILABLE: u32 = 1 << 12; pub const DEGRADATION_REASONS: &[(u32, &str)] = &[ (DEGRADED_MISSING_RECEIVER, "missing_receiver"), @@ -34,12 +37,18 @@ pub const DEGRADATION_REASONS: &[(u32, &str)] = &[ (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); -pub fn publish(receiver_path: &[u8], report_fd: i32) { +pub fn publish(receiver_path: &[u8], report_fd: i32, probe_seccomp: bool) { let mut caps = 0u32; let mut degraded = 0u32; @@ -49,7 +58,7 @@ pub fn publish(receiver_path: &[u8], report_fd: i32) { degraded |= DEGRADED_MISSING_RECEIVER; } - if probe_process_vm_readv() { + if probe_process_vm_readv() && (!probe_seccomp || probe_process_vm_readv_in_child()) { caps |= PROC_VM_READV; } else { degraded |= DEGRADED_NO_PROC_VM_READV; @@ -108,6 +117,43 @@ fn probe_process_vm_readv() -> bool { 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; + } + + const PROBE_TIMEOUT_MS: i64 = 100; + const PROBE_POLL_MS: i32 = 10; + let start = sys::monotonic_nanos(); + loop { + let mut status = 0i32; + let waited = sys::waitpid_nohang_status(child as i32, &mut status); + if waited == child as i32 { + return libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0; + } + if waited < 0 { + return true; + } + + let elapsed_ms = (sys::monotonic_nanos() - start) / 1_000_000; + if elapsed_ms >= PROBE_TIMEOUT_MS { + let _ = sys::kill(child as i32, libc::SIGKILL); + let mut status = 0i32; + let _ = sys::waitpid_nohang_status(child as i32, &mut status); + return true; + } + sys::poll_sleep_ms(PROBE_POLL_MS); + } +} + #[cfg(test)] mod tests { use super::*; @@ -119,7 +165,7 @@ mod tests { .lock() .expect("test lock poisoned"); - publish(b"/definitely/missing-signal-safe-receiver\0", -1); + publish(b"/definitely/missing-signal-safe-receiver\0", -1, false); assert_eq!(get() & RECEIVER_OK, 0); assert_ne!(degradations() & DEGRADED_MISSING_RECEIVER, 0); @@ -136,6 +182,7 @@ mod tests { publish( b"/definitely/missing-signal-safe-receiver\0", file.as_raw_fd(), + false, ); assert_ne!(get() & REPORT_FD_OK, 0); diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 127b8cc7af..9c70a1bd49 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -1,13 +1,16 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use core::fmt::Write; use core::sync::atomic::Ordering::Relaxed; use heapless::String as HeaplessString; +use serde::Serialize; use super::state::meta_mut; use super::{capabilities, state, sys}; +use crate::shared::{ + defaults::DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS, signals::SIGNAL_SAFE_CRASH_SIGNALS, +}; // Compatibility preset for the existing C-tracer consumer. New integrators should pass // explicit metadata through SignalSafeInitConfig instead of relying on these defaults. @@ -30,20 +33,14 @@ pub const COMPAT_LIBRARY_NAME: &str = "dd-trace-c"; pub const COMPAT_LIBRARY_FAMILY: &str = "native"; pub const COMPAT_DEFAULT_SERVICE: &str = "dd-trace-c"; -pub const RECEIVER_TIMEOUT_SECS: u32 = 5; +pub const RECEIVER_TIMEOUT_SECS: u32 = DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS; pub const RECEIVER_TIMEOUT_SECS_MAX: u32 = 60; pub const COLLECTOR_REAP_MS: i32 = 500; pub const RECEIVER_TIMEOUT_GRACE_MS: i32 = 1000; pub const BACKTRACE_LEVELS_DEFAULT: usize = 32; pub const BACKTRACE_LEVELS_MAX: usize = 64; -pub const CRASH_SIGNALS: [i32; 5] = [ - libc::SIGSEGV, - libc::SIGABRT, - libc::SIGBUS, - libc::SIGILL, - libc::SIGFPE, -]; +pub const CRASH_SIGNALS: [i32; 5] = SIGNAL_SAFE_CRASH_SIGNALS; pub const CONFIG_JSON_BUF_SIZE: usize = 2048; @@ -83,6 +80,7 @@ pub struct SignalSafeInitConfig<'a> { pub receiver_timeout_secs: u32, pub max_frames: usize, pub close_fds_on_receiver: bool, + pub probe_seccomp: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -116,51 +114,58 @@ impl<'a> Default for SignalSafeInitConfig<'a> { receiver_timeout_secs: RECEIVER_TIMEOUT_SECS, 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: &'a str, + 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: &SignalSafeInitConfig<'_>, ) -> bool { out.clear(); - if out - .push_str("{\"additional_files\":[],\"create_alt_stack\":") - .is_err() - { + 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: "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; - } - if write!(out, "{}", config.create_alt_stack).is_err() - || out.push_str(",\"use_alt_stack\":").is_err() - || write!(out, "{}", config.use_alt_stack).is_err() - || out - .push_str( - ",\"demangle_names\":true,\ - \"endpoint\":null,\ - \"resolve_frames\":\"EnabledWithSymbolsInReceiver\",\ - \"signals\":[", - ) - .is_err() - { + }; + let Ok(json) = core::str::from_utf8(&buf[..len]) else { return false; - } - - for (i, sig) in CRASH_SIGNALS.iter().enumerate() { - if i > 0 && out.push(',').is_err() { - return false; - } - if write!(out, "{sig}").is_err() { - return false; - } - } - - writeln!( - out, - "],\"timeout\":{{\"secs\":{},\"nanos\":0}},\"unix_socket_path\":null}}", - normalized_receiver_timeout_secs(config.receiver_timeout_secs) - ) - .is_ok() + }; + out.push_str(json).is_ok() && out.push('\n').is_ok() } pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { @@ -175,30 +180,31 @@ pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareEr return Err(PrepareError::Failed); } - set_str(&mut m.service, config.service); - set_str(&mut m.env, config.env); - set_str(&mut m.app_version, config.app_version); - set_str(&mut m.runtime_id, config.runtime_id); - set_str(&mut m.platform, config.platform); + 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(&mut m.runtime_id, config.runtime_id); + metadata_truncated |= !set_str(&mut m.platform, config.platform); if m.platform.is_empty() { - set_str(&mut m.platform, b"host"); + metadata_truncated |= !set_str(&mut m.platform, b"host"); } - set_str_or( + metadata_truncated |= !set_str_or( &mut m.library_name, config.library_name, COMPAT_LIBRARY_NAME.as_bytes(), ); - set_str_or( + metadata_truncated |= !set_str_or( &mut m.library_version, config.library_version, COMPAT_LIBRARY_VERSION.as_bytes(), ); - set_str_or( + metadata_truncated |= !set_str_or( &mut m.family, config.family, COMPAT_LIBRARY_FAMILY.as_bytes(), ); - set_str_or( + metadata_truncated |= !set_str_or( &mut m.default_service, config.default_service, COMPAT_DEFAULT_SERVICE.as_bytes(), @@ -227,7 +233,14 @@ pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareEr Relaxed, ); state::MAX_FRAMES.store(normalized_max_frames(config.max_frames), Relaxed); - capabilities::publish(m.process_path.as_slice(), config.report_fd); + capabilities::publish( + m.process_path.as_slice(), + config.report_fd, + config.probe_seccomp, + ); + if metadata_truncated { + capabilities::note_degraded(capabilities::DEGRADED_METADATA_TRUNCATED); + } Ok(()) } @@ -261,6 +274,7 @@ pub fn prepare_from_env_result() -> Result<(), PrepareError> { force_on_top: is_true(env_get(b"DD_CRASHTRACKING_ALWAYS_ON_TOP\0")), only_bootstrap: is_true(env_get(b"DD_CRASHTRACKING_ONLY_BOOTSTRAP\0")), debug_logging, + probe_seccomp: is_true(env_get(b"DD_CRASHTRACKING_PROBE_SECCOMP\0")), ..SignalSafeInitConfig::default() }) } @@ -297,22 +311,23 @@ fn normalized_max_frames(value: usize) -> usize { } } -fn set_str(dst: &mut HeaplessString, src: &[u8]) { +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() { - break; + return false; } } } + true } -fn set_str_or(dst: &mut HeaplessString, src: &[u8], default: &[u8]) { +fn set_str_or(dst: &mut HeaplessString, src: &[u8], default: &[u8]) -> bool { if src.is_empty() { - set_str(dst, default); + set_str(dst, default) } else { - set_str(dst, src); + set_str(dst, src) } } @@ -424,6 +439,9 @@ fn parse_log_level(v: Option<&[u8]>) -> i32 { #[cfg(test)] mod tests { use super::*; + use std::format; + use std::string::ToString; + use std::vec::Vec; #[test] fn config_json_contains_receiver_contract() { @@ -432,14 +450,26 @@ mod tests { &mut out, &SignalSafeInitConfig::default() )); + let signals = CRASH_SIGNALS + .iter() + .map(i32::to_string) + .collect::>() + .join(","); assert_eq!( out.as_str(), - "{\"additional_files\":[],\"create_alt_stack\":false,\"use_alt_stack\":false,\ - \"demangle_names\":true,\"endpoint\":null,\ - \"resolve_frames\":\"EnabledWithSymbolsInReceiver\",\ - \"signals\":[11,6,10,4,8],\"timeout\":{\"secs\":5,\"nanos\":0},\ - \"unix_socket_path\":null}\n" + 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] @@ -519,4 +549,23 @@ mod tests { assert!(state::ONLY_BOOTSTRAP.load(Relaxed)); assert!(state::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); + + assert!(prepare(&SignalSafeInitConfig { + receiver_path: b"/definitely/missing-signal-safe-receiver", + service: oversized_service.as_bytes(), + ..SignalSafeInitConfig::default() + })); + + assert_ne!( + capabilities::degradations() & capabilities::DEGRADED_METADATA_TRUNCATED, + 0 + ); + } } 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..f1f73a8319 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -0,0 +1,368 @@ +// 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::fmt::hex_u32; +use super::fmt::write_i32; +use super::report::{ + CrashContext, Frame, Metadata, ProcInfo, Report, Tag, Tags, MESSAGE_CAPACITY, + SECTION_BUF_CAPACITY, +}; +use super::{capabilities, config, protocol, state}; + +pub trait Sink { + fn put(&mut self, bytes: &[u8]) -> bool; +} + +pub struct SliceSink<'a> { + buf: &'a mut [u8], + len: usize, +} + +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] + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } +} + +impl Sink for SliceSink<'_> { + fn put(&mut self, bytes: &[u8]) -> bool { + let Some(end) = self.len.checked_add(bytes.len()) else { + return false; + }; + if end > self.buf.len() { + return false; + } + self.buf[self.len..end].copy_from_slice(bytes); + self.len = end; + true + } +} + +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_config(sink, report.config_json) + || !emit_metadata(sink, report) + || !emit_additional_tags( + sink, + report.stage_name, + report.stackwalk_method, + report.capability_bits, + report.degradation_bits, + ) + || !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) + { + return emit_truncated_tail(sink, report, context); + } + + emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) +} + +pub fn emit_report_with_metadata( + sink: &mut impl Sink, + config_json: &str, + metadata: &Metadata<'_>, + stage_name: &str, + context: &CrashContext<'_>, +) -> bool { + if !emit_config(sink, config_json) + || !emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_METADATA, + metadata, + protocol::DD_CRASHTRACK_END_METADATA, + ) + || !emit_additional_tags(sink, stage_name, "fp_pvr", 0, 0) + || !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) + { + capabilities::note_degraded(capabilities::DEGRADED_TRUNCATED); + let _ = emit_additional_tags( + sink, + stage_name, + "fp_pvr", + 0, + capabilities::DEGRADED_TRUNCATED, + ); + return emit_message(sink, stage_name, &context.signal) && emit_done(sink); + } + + emit_message(sink, stage_name, &context.signal) && emit_done(sink) +} + +pub fn emit_minimal_report( + sink: &mut impl Sink, + config_json: &str, + metadata: &Metadata<'_>, + signal: &super::report::SignalInfo, +) -> bool { + emit_config(sink, config_json) + && emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_METADATA, + metadata, + protocol::DD_CRASHTRACK_END_METADATA, + ) + && emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_SIGINFO, + signal, + protocol::DD_CRASHTRACK_END_SIGINFO, + ) + && emit_done(sink) +} + +pub fn emit_json_section( + sink: &mut impl Sink, + begin: &str, + value: &T, + end: &str, +) -> bool { + let mut buf = [0u8; SECTION_BUF_CAPACITY]; + match serde_json_core::to_slice(value, &mut buf) { + Ok(len) => { + put_marker_line(sink, begin) + && sink.put(&buf[..len]) + && sink.put(b"\n") + && put_marker_line(sink, end) + } + Err(_) => false, + } +} + +fn emit_config(sink: &mut impl Sink, config_json: &str) -> bool { + put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_CONFIG) + && sink.put(config_json.as_bytes()) + && (config_json.ends_with('\n') || sink.put(b"\n")) + && put_marker_line(sink, protocol::DD_CRASHTRACK_END_CONFIG) +} + +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, "language", "native") + && push_tag(&mut metadata.tags, "runtime", "native") + && push_tag(&mut metadata.tags, "is_crash", "true") + && push_tag(&mut metadata.tags, "severity", "crash") + && push_tag(&mut metadata.tags, "service", service) + && push_tag(&mut metadata.tags, "env", report.env) + && push_tag(&mut metadata.tags, "version", report.app_version) + && push_tag(&mut metadata.tags, "runtime_id", report.runtime_id) + && push_tag( + &mut metadata.tags, + "runtime_version", + report.library_version, + ) + && push_tag( + &mut metadata.tags, + "library_version", + report.library_version, + ) + && push_tag(&mut metadata.tags, "platform", report.platform) + && push_tag( + &mut metadata.tags, + "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, + stage: &str, + stackwalk_method: &str, + capability_bits: u32, + degradation_bits: u32, +) -> bool { + let mut tags = Tags::new(); + if !push_tag(&mut tags, "stage", stage) { + return false; + } + if !push_tag(&mut tags, "stackwalk_method", stackwalk_method) { + return false; + } + let capabilities = hex_u32(capability_bits); + if !push_tag(&mut tags, "capabilities", capabilities.as_str()) { + return false; + } + let degradations = hex_u32(degradation_bits); + if !push_tag(&mut tags, "degradations", degradations.as_str()) { + return false; + } + for &(bit, reason) in capabilities::DEGRADATION_REASONS { + if degradation_bits & bit != 0 && !push_tag(&mut tags, "report_degraded", reason) { + return false; + } + } + if degradation_bits & capabilities::DEGRADED_APP_HANDLER_PRESENT != 0 + && !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; 12]; + 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, "report_degraded", value.as_str()) { + return false; + } + } + true +} + +fn emit_kind(sink: &mut impl Sink) -> bool { + put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_KIND) + && sink.put(b"\"UnixSignal\"\n") + && put_marker_line(sink, protocol::DD_CRASHTRACK_END_KIND) +} + +fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { + if !put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_STACKTRACE) { + return false; + } + + let mut buf = [0u8; SECTION_BUF_CAPACITY]; + for ip in frames { + if *ip == 0 { + continue; + } + + let frame = Frame::from_ip(*ip); + let Ok(len) = serde_json_core::to_slice(&frame, &mut buf) else { + return false; + }; + if !(sink.put(&buf[..len]) && sink.put(b"\n")) { + return false; + } + } + + put_marker_line(sink, protocol::DD_CRASHTRACK_END_STACKTRACE) +} + +fn emit_message( + sink: &mut impl Sink, + stage_name: &str, + signal: &super::report::SignalInfo, +) -> bool { + let mut message = HeaplessString::::new(); + message.push_str("Crash during ").is_ok() + && message.push_str(stage_name).is_ok() + && message.push_str(" (").is_ok() + && message.push_str(signal.si_signo_human_readable).is_ok() + && message.push(')').is_ok() + && put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_MESSAGE) + && sink.put(message.as_bytes()) + && sink.put(b"\n") + && put_marker_line(sink, protocol::DD_CRASHTRACK_END_MESSAGE) +} + +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.stage_name, + report.stackwalk_method, + report.capability_bits, + report.degradation_bits | capabilities::DEGRADED_TRUNCATED, + ); + emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) +} + +fn put_marker_line(sink: &mut impl Sink, marker: &str) -> bool { + sink.put(marker.as_bytes()) && sink.put(b"\n") +} + +fn emit_done(sink: &mut impl Sink) -> bool { + put_marker_line(sink, protocol::DD_CRASHTRACK_DONE) +} 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..4d52143627 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/fmt.rs @@ -0,0 +1,117 @@ +// 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 fn hex_addr(value: usize) -> HeaplessString { + let mut out = HeaplessString::new(); + let _ = out.push_str("0x"); + + for shift in (0..core::mem::size_of::() * 2).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 hex_u32(value: u32) -> HeaplessString<10> { + let mut out = HeaplessString::new(); + let _ = out.push_str("0x"); + + for shift in (0..8).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; 12]) -> 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; 12]; + 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.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index c556c777ac..e9196cea6a 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -11,7 +11,7 @@ use super::state::{self, sig_index, BeginInitError, Stage}; use super::sys::{self, FdSink}; use super::{ app_handler_is_real, app_recovered, chain_action, is_genuine_fault, should_run_app_first, - ChainAction, CrashContext, Disposition, Report, SignalInfo, + write_i32, ChainAction, CrashContext, Disposition, Report, SignalInfo, }; use super::{backtrace, capabilities}; use crate::signal_owner::{self, SignalOwner}; @@ -21,12 +21,24 @@ const EXIT_CODE_FAILURE: i32 = 125; const REAP_KILL_TIMEOUT_MS: i64 = 500; const REAP_WAIT_INTERVAL_MS: i32 = 100; const ALT_STACK_SIZE: usize = 64 * 1024; +const ALT_STACK_GUARD_SIZE: usize = 4096; -struct AltStackStorage(UnsafeCell<[u8; ALT_STACK_SIZE]>); +const _: () = assert!(super::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([0; ALT_STACK_SIZE])); +static ALT_STACK: AltStackStorage = AltStackStorage(UnsafeCell::new(AltStackLayout { + guard: [0; ALT_STACK_GUARD_SIZE], + usable: [0; ALT_STACK_SIZE], +})); #[derive(Clone, Copy)] struct Target { @@ -40,6 +52,8 @@ static REPEAT_FAULT_PC: [AtomicUsize; state::NSIG] = [const { AtomicUsize::new(0 static REPEAT_FAULT_ADDR: [AtomicUsize; state::NSIG] = [const { AtomicUsize::new(0) }; state::NSIG]; static REPEAT_FAULT_COUNT: [AtomicUsize; state::NSIG] = [const { AtomicUsize::new(0) }; state::NSIG]; +/// Prevents recursive crash collection. Reset only during explicit shutdown/re-init lifecycle. +static COLLECTING: AtomicBool = AtomicBool::new(false); #[repr(i32)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -129,6 +143,7 @@ pub fn shutdown() { state::set_stage(Stage::CrashtrackerUninstall); state::HANDLERS_ENABLED.store(false, Ordering::Release); uninstall_all_handlers(); + COLLECTING.store(false, Ordering::Relaxed); state::INSTALLED.store(false, Ordering::Release); signal_owner::release(SignalOwner::SignalSafeCollector); state::reset_init(); @@ -241,37 +256,6 @@ fn crash_debug(msg: &[u8], sig: i32) { let _ = super::Sink::put(&mut sink, b"\n"); } -fn write_i32(value: i32, out: &mut [u8; 12]) -> 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 -} - fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> i32 { if (libc::STDIN_FILENO..=libc::STDERR_FILENO).contains(&keep_fd) { let relocated = sys::fcntl_dupfd(keep_fd, libc::STDERR_FILENO + 1); @@ -284,13 +268,19 @@ fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> i32 { reset_handlers_to_default(); - let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); - 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); + if capabilities::has(capabilities::DEV_NULL) { + let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); + 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 close_stdio_without_devnull { + sys::close(libc::STDIN_FILENO); + sys::close(libc::STDOUT_FILENO); + sys::close(libc::STDERR_FILENO); } } else if close_stdio_without_devnull { sys::close(libc::STDIN_FILENO); @@ -334,12 +324,31 @@ fn install_alt_stack_if_requested() -> bool { 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: ALT_STACK.0.get().cast(), + ss_sp: usable, ss_flags: 0, ss_size: ALT_STACK_SIZE, }; - unsafe { libc::sigaltstack(&stack, null_mut()) == 0 } + sigaltstack(&stack) +} + +fn install_sigaltstack(stack: &libc::stack_t) -> bool { + unsafe { libc::sigaltstack(stack, null_mut()) == 0 } } fn strip_loader_injection_env() { @@ -515,8 +524,9 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex let report_fd = state::REPORT_FD.load(Ordering::Relaxed); let direct_report = |reason: u32| { - capabilities::note_degraded(reason | capabilities::DEGRADED_REPORT_TO_FD); - if report_fd >= 0 { + capabilities::note_degraded(reason); + if capabilities::has(capabilities::REPORT_FD_OK) { + capabilities::note_degraded(capabilities::DEGRADED_REPORT_TO_FD); let _ = emit_crash_report( report_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, false, ); @@ -660,11 +670,8 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m 0 }; let genuine_fault = is_genuine_fault(has_info, si_code, si_pid, self_pid); - if genuine_fault { - static COLLECTING: AtomicBool = AtomicBool::new(false); - if !COLLECTING.swap(true, Ordering::Relaxed) { - collect_crash(sig, si_code, has_info, si_addr, ucontext); - } + if genuine_fault && !COLLECTING.swap(true, Ordering::Relaxed) { + collect_crash(sig, si_code, has_info, si_addr, ucontext); } sys::set_errno(saved_errno); @@ -691,7 +698,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m } } ChainAction::Resume => { - if disarmed_on_entry && !genuine_fault { + if disarmed_on_entry { restore_our_handler(sig); } } @@ -761,14 +768,6 @@ fn query_sigaction(sig: c_int, out: *mut libc::sigaction) -> bool { unsafe { libc::sigaction(sig, null_mut(), out) == 0 } } -fn is_default_handler(sig: c_int) -> bool { - let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; - if !query_sigaction(sig, &mut cur) { - return false; - } - cur.sa_sigaction == libc::SIG_DFL -} - fn is_our_handler(sig: c_int) -> bool { let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; if !query_sigaction(sig, &mut cur) { @@ -803,7 +802,18 @@ fn restore_our_handler(sig: c_int) { } fn install_crash_handler(sig: c_int) { - if !is_default_handler(sig) { + let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; + if !query_sigaction(sig, &mut cur) { + 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::APP_HANDLER_PRESENT[i].store(true, Ordering::Relaxed); + } + capabilities::note_degraded(capabilities::DEGRADED_APP_HANDLER_PRESENT); + crash_debug(b"app handler present", sig); + } return; } @@ -858,17 +868,6 @@ fn uninstall_all_handlers() { mod tests { use super::*; - #[test] - fn integer_debug_writer_handles_sign() { - let mut buf = [0u8; 12]; - 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(i32::MIN, &mut buf); - assert_eq!(&buf[..n], b"-2147483648"); - } - #[test] fn app_chain_guard_distinguishes_recursion_from_unwind() { let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK @@ -886,6 +885,20 @@ mod tests { assert_eq!(APP_CHAIN_TID.load(Ordering::Relaxed), 0); } + #[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_ne!( + capabilities::degradations() & capabilities::DEGRADED_ALT_STACK_GUARD_UNAVAILABLE, + 0 + ); + } + #[test] fn repeated_app_return_trips_on_second_same_fault() { let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index 848e33486d..5db1855a27 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -23,18 +23,17 @@ //! crash-signal mask in effect; a nested crash on another managed signal is deferred until the //! app handler returns. -use core::ffi::c_void; -use core::fmt::Write; - -use heapless::{String as HeaplessString, Vec as HeaplessVec}; -use serde::Serialize; - use crate::protocol; mod backtrace; mod capabilities; mod config; +mod emitter; +mod fmt; mod handler; +mod policy; +mod report; +mod signal_names; mod state; mod sys; @@ -42,22 +41,27 @@ mod sys; pub(crate) static TEST_GLOBAL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); pub use config::{build_config_json, prepare, prepare_from_env, SignalSafeInitConfig}; +pub use emitter::{ + emit_json_section, emit_minimal_report, emit_report, emit_report_with_metadata, push_tag, Sink, + SliceSink, +}; +pub use fmt::{hex_addr, hex_u32, write_i32}; pub use handler::{ bootstrap_complete, init, init_from_env, init_from_env_result, init_result, shutdown, InitResult, }; +pub use policy::{ + app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, + should_run_app_first, ChainAction, Disposition, SignalContext, +}; +pub use report::{ + CrashContext, Frame, Metadata, ProcInfo, Report, SignalInfo, Tag, Tags, FRAME_IP_CAPACITY, + MAX_TAGS, MESSAGE_CAPACITY, SECTION_BUF_CAPACITY, TAG_CAPACITY, +}; +pub use signal_names::*; pub use state::{set_stage, Stage}; pub use sys::FdSink; -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; - pub fn capability_bits() -> u32 { capabilities::get() } @@ -74,608 +78,12 @@ pub fn owns_signal(sig: i32) -> bool { state::owns_signal(sig) } -pub trait Sink { - fn put(&mut self, bytes: &[u8]) -> bool; -} - -pub struct SliceSink<'a> { - buf: &'a mut [u8], - len: usize, -} - -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] - } - - pub fn len(&self) -> usize { - self.len - } - - pub fn is_empty(&self) -> bool { - self.len == 0 - } -} - -impl Sink for SliceSink<'_> { - fn put(&mut self, bytes: &[u8]) -> bool { - let Some(end) = self.len.checked_add(bytes.len()) else { - return false; - }; - if end > self.buf.len() { - return false; - } - self.buf[self.len..end].copy_from_slice(bytes); - self.len = end; - true - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Disposition { - Default, - Ignore, - Handler, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ChainAction { - InvokeApp, - RestoreDefaultAndRefault, - RestoreDefaultAndReraise, - Resume, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct SignalContext { - pub has_siginfo: bool, - pub si_code: i32, - pub si_pid: i32, - pub self_pid: i32, -} - -impl SignalContext { - pub fn is_genuine_fault(self) -> bool { - is_genuine_fault(self.has_siginfo, self.si_code, self.si_pid, self.self_pid) - } -} - -pub fn disposition_of(handler: *mut c_void) -> Disposition { - match handler as usize { - SIG_DFL_VALUE => Disposition::Default, - SIG_IGN_VALUE => Disposition::Ignore, - _ => Disposition::Handler, - } -} - -pub fn app_handler_is_real(handler: *mut c_void) -> bool { - matches!(disposition_of(handler), Disposition::Handler) -} - -pub fn should_run_app_first(force_on_top: bool, app_is_real: bool) -> bool { - !force_on_top && app_is_real -} - -pub fn app_recovered(handler_after: *mut c_void) -> bool { - disposition_of(handler_after) != Disposition::Default -} - -pub 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 fn chain_action(disposition: Disposition, has_siginfo: bool, si_code: i32) -> ChainAction { - match disposition { - Disposition::Ignore => ChainAction::Resume, - Disposition::Handler => ChainAction::InvokeApp, - Disposition::Default if has_siginfo && si_code > 0 => ChainAction::RestoreDefaultAndRefault, - Disposition::Default => ChainAction::RestoreDefaultAndReraise, - } -} - -#[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 stage_name: &'a str, - pub stackwalk_method: &'a str, - pub capability_bits: u32, - pub degradation_bits: u32, -} - -pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { - if value.is_empty() { - return true; - } - - let mut tag = 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_config(sink, report.config_json) - || !emit_metadata(sink, report) - || !emit_additional_tags( - sink, - report.stage_name, - report.stackwalk_method, - report.capability_bits, - report.degradation_bits, - ) - || !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) - { - return emit_truncated_tail(sink, report, context); - } - - emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) -} - -pub fn emit_report_with_metadata( - sink: &mut impl Sink, - config_json: &str, - metadata: &Metadata<'_>, - stage_name: &str, - context: &CrashContext<'_>, -) -> bool { - if !emit_config(sink, config_json) - || !emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_METADATA, - metadata, - protocol::DD_CRASHTRACK_END_METADATA, - ) - || !emit_additional_tags(sink, stage_name, "fp_pvr", 0, 0) - || !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) - { - capabilities::note_degraded(capabilities::DEGRADED_TRUNCATED); - let _ = emit_additional_tags( - sink, - stage_name, - "fp_pvr", - 0, - capabilities::DEGRADED_TRUNCATED, - ); - return emit_message(sink, stage_name, &context.signal) && emit_done(sink); - } - - emit_message(sink, stage_name, &context.signal) && emit_done(sink) -} - -pub fn emit_minimal_report( - sink: &mut impl Sink, - config_json: &str, - metadata: &Metadata<'_>, - signal: &SignalInfo, -) -> bool { - emit_config(sink, config_json) - && emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_METADATA, - metadata, - protocol::DD_CRASHTRACK_END_METADATA, - ) - && emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_SIGINFO, - signal, - protocol::DD_CRASHTRACK_END_SIGINFO, - ) - && emit_done(sink) -} - -pub fn emit_json_section( - sink: &mut impl Sink, - begin: &str, - value: &T, - end: &str, -) -> bool { - let mut buf = [0u8; SECTION_BUF_CAPACITY]; - match serde_json_core::to_slice(value, &mut buf) { - Ok(len) => { - put_marker_line(sink, begin) - && sink.put(&buf[..len]) - && sink.put(b"\n") - && put_marker_line(sink, end) - } - Err(_) => false, - } -} - -pub fn rust_signal_name(signal: i32) -> &'static str { - match signal { - libc::SIGABRT => "SIGABRT", - libc::SIGBUS => "SIGBUS", - libc::SIGFPE => "SIGFPE", - libc::SIGILL => "SIGILL", - libc::SIGQUIT => "SIGQUIT", - libc::SIGSEGV => "SIGSEGV", - libc::SIGSYS => "SIGSYS", - libc::SIGTRAP => "SIGTRAP", - _ => "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), - } -} - -pub fn signal_has_address(signal: i32) -> bool { - matches!( - signal, - libc::SIGBUS | libc::SIGFPE | libc::SIGILL | libc::SIGSEGV | libc::SIGTRAP - ) -} - -pub fn hex_addr(value: usize) -> HeaplessString { - let mut out = HeaplessString::new(); - let _ = out.push_str("0x"); - - for shift in (0..core::mem::size_of::() * 2).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 -} - -fn emit_config(sink: &mut impl Sink, config_json: &str) -> bool { - put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_CONFIG) - && sink.put(config_json.as_bytes()) - && (config_json.ends_with('\n') || sink.put(b"\n")) - && put_marker_line(sink, protocol::DD_CRASHTRACK_END_CONFIG) -} - -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, "language", "native") - && push_tag(&mut metadata.tags, "runtime", "native") - && push_tag(&mut metadata.tags, "is_crash", "true") - && push_tag(&mut metadata.tags, "severity", "crash") - && push_tag(&mut metadata.tags, "service", service) - && push_tag(&mut metadata.tags, "env", report.env) - && push_tag(&mut metadata.tags, "version", report.app_version) - && push_tag(&mut metadata.tags, "runtime_id", report.runtime_id) - && push_tag( - &mut metadata.tags, - "runtime_version", - report.library_version, - ) - && push_tag( - &mut metadata.tags, - "library_version", - report.library_version, - ) - && push_tag(&mut metadata.tags, "platform", report.platform) - && push_tag( - &mut metadata.tags, - "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, - stage: &str, - stackwalk_method: &str, - capability_bits: u32, - degradation_bits: u32, -) -> bool { - let mut tags = Tags::new(); - if !push_tag(&mut tags, "stage", stage) { - return false; - } - if !push_tag(&mut tags, "stackwalk_method", stackwalk_method) { - return false; - } - let capabilities = hex_u32(capability_bits); - if !push_tag(&mut tags, "capabilities", capabilities.as_str()) { - return false; - } - let degradations = hex_u32(degradation_bits); - if !push_tag(&mut tags, "degradations", degradations.as_str()) { - return false; - } - for &(bit, reason) in capabilities::DEGRADATION_REASONS { - if degradation_bits & bit != 0 && !push_tag(&mut tags, "report_degraded", reason) { - return false; - } - } - emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_ADDITIONAL_TAGS, - &tags, - protocol::DD_CRASHTRACK_END_ADDITIONAL_TAGS, - ) -} - -fn emit_kind(sink: &mut impl Sink) -> bool { - put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_KIND) - && sink.put(b"\"UnixSignal\"\n") - && put_marker_line(sink, protocol::DD_CRASHTRACK_END_KIND) -} - -fn hex_u32(value: u32) -> HeaplessString<10> { - let mut out = HeaplessString::new(); - let _ = write!(out, "0x{value:08x}"); - out -} - -fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { - if !put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_STACKTRACE) { - return false; - } - - for ip in frames { - if *ip == 0 { - continue; - } - - let frame = Frame::from_ip(*ip); - let mut buf = [0u8; SECTION_BUF_CAPACITY]; - let Ok(len) = serde_json_core::to_slice(&frame, &mut buf) else { - return false; - }; - if !(sink.put(&buf[..len]) && sink.put(b"\n")) { - return false; - } - } - - put_marker_line(sink, protocol::DD_CRASHTRACK_END_STACKTRACE) -} - -fn emit_message(sink: &mut impl Sink, stage_name: &str, signal: &SignalInfo) -> bool { - let mut message = HeaplessString::::new(); - message.push_str("Crash during ").is_ok() - && message.push_str(stage_name).is_ok() - && message.push_str(" (").is_ok() - && message.push_str(signal.si_signo_human_readable).is_ok() - && message.push(')').is_ok() - && put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_MESSAGE) - && sink.put(message.as_bytes()) - && sink.put(b"\n") - && put_marker_line(sink, protocol::DD_CRASHTRACK_END_MESSAGE) -} - -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.stage_name, - report.stackwalk_method, - report.capability_bits, - report.degradation_bits | capabilities::DEGRADED_TRUNCATED, - ); - emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) -} - -fn put_marker_line(sink: &mut impl Sink, marker: &str) -> bool { - sink.put(marker.as_bytes()) && sink.put(b"\n") -} - -fn emit_done(sink: &mut impl Sink) -> bool { - put_marker_line(sink, protocol::DD_CRASHTRACK_DONE) -} - -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", - _ => "UNKNOWN", - }, - libc::SIGBUS => match si_code { - BUS_ADRALN => "BUS_ADRALN", - BUS_ADRERR => "BUS_ADRERR", - BUS_OBJERR => "BUS_OBJERR", - _ => "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", - }, - _ => "UNKNOWN", - } -} - -const SIG_DFL_VALUE: usize = 0; -const SIG_IGN_VALUE: usize = 1; - -pub const SI_USER: i32 = 0; -pub const SI_KERNEL: i32 = 128; -pub const SI_QUEUE: i32 = -1; -pub const SI_TIMER: i32 = -2; -pub const SI_MESGQ: i32 = -3; -pub const SI_ASYNCIO: i32 = -4; -pub const SI_SIGIO: i32 = -5; - -#[cfg(any(target_os = "linux", target_os = "android"))] -pub const SI_TKILL: i32 = -6; - -#[cfg(not(any(target_os = "linux", target_os = "android")))] -// Non-Linux platforms do not define SI_TKILL; use a sentinel that cannot match a real si_code. -pub const SI_TKILL: i32 = i32::MIN; - -pub const SEGV_MAPERR: i32 = 1; -pub const SEGV_ACCERR: i32 = 2; - -pub const BUS_ADRALN: i32 = 1; -pub const BUS_ADRERR: i32 = 2; -pub const BUS_OBJERR: i32 = 3; - -pub const ILL_ILLOPC: i32 = 1; -pub const ILL_ILLOPN: i32 = 2; -pub const ILL_ILLADR: i32 = 3; -pub const ILL_ILLTRP: i32 = 4; -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(test)] mod tests { use super::*; + use std::borrow::ToOwned; use std::str; + use std::string::String; #[test] fn slice_sink_reports_capacity_failure() { @@ -687,116 +95,6 @@ mod tests { assert_eq!(sink.as_slice(), b"abc"); } - #[test] - fn dispositions_match_sigaction_sentinels() { - let dfl = SIG_DFL_VALUE as *mut c_void; - let ign = SIG_IGN_VALUE 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 handler_policy_tracks_application_recovery() { - let dfl = SIG_DFL_VALUE as *mut c_void; - let ign = SIG_IGN_VALUE as *mut c_void; - let handler = 0x1234usize as *mut c_void; - - assert!(should_run_app_first(false, true)); - assert!(!should_run_app_first(true, true)); - assert!(!should_run_app_first(false, false)); - - assert!(app_recovered(handler)); - assert!(app_recovered(ign)); - assert!(!app_recovered(dfl)); - } - - #[test] - fn disposition_based_chain_action_resumes_ignored_signals() { - assert_eq!( - chain_action(Disposition::Ignore, true, SEGV_MAPERR), - ChainAction::Resume - ); - } - - #[test] - fn genuine_fault_filter_ignores_external_async_signal() { - let ctx = SignalContext { - has_siginfo: true, - si_code: SI_USER, - si_pid: 7, - self_pid: 9, - }; - - assert!(!ctx.is_genuine_fault()); - } - - #[test] - fn genuine_fault_filter_accepts_self_sent_async_signal() { - let ctx = SignalContext { - has_siginfo: true, - si_code: SI_USER, - si_pid: 9, - self_pid: 9, - }; - - assert!(ctx.is_genuine_fault()); - } - - #[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::InvokeApp - ); - } - - #[test] - fn signal_names_cover_common_native_faults() { - assert_eq!(rust_signal_name(libc::SIGSEGV), "SIGSEGV"); - 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::SIGSEGV, SI_USER), "SI_USER"); - assert_eq!(rust_si_code_name(libc::SIGSEGV, 999), "UNKNOWN"); - assert_eq!(rust_signal_name(999), "UNKNOWN"); - } - - #[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 minimal_report_emits_json_sections() { let mut metadata = Metadata::new("lib", "1.0.0", "native"); @@ -969,6 +267,58 @@ mod tests { 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", + stage_name: "application", + stackwalk_method: "fp_pvr", + capability_bits: 0x21, + degradation_bits: 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..]; 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..61f9ae6627 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/policy.rs @@ -0,0 +1,161 @@ +// 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_TKILL, SI_USER}; + +const SIG_DFL_VALUE: usize = 0; +const SIG_IGN_VALUE: usize = 1; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Disposition { + Default, + Ignore, + Handler, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChainAction { + InvokeApp, + RestoreDefaultAndRefault, + RestoreDefaultAndReraise, + Resume, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SignalContext { + pub has_siginfo: bool, + pub si_code: i32, + pub si_pid: i32, + pub self_pid: i32, +} + +impl SignalContext { + pub fn is_genuine_fault(self) -> bool { + is_genuine_fault(self.has_siginfo, self.si_code, self.si_pid, self.self_pid) + } +} + +pub fn disposition_of(handler: *mut c_void) -> Disposition { + match handler as usize { + SIG_DFL_VALUE => Disposition::Default, + SIG_IGN_VALUE => Disposition::Ignore, + _ => Disposition::Handler, + } +} + +pub fn app_handler_is_real(handler: *mut c_void) -> bool { + matches!(disposition_of(handler), Disposition::Handler) +} + +pub fn should_run_app_first(force_on_top: bool, app_is_real: bool) -> bool { + !force_on_top && app_is_real +} + +pub fn app_recovered(handler_after: *mut c_void) -> bool { + disposition_of(handler_after) != Disposition::Default +} + +pub 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 fn chain_action(disposition: Disposition, has_siginfo: bool, si_code: i32) -> ChainAction { + match disposition { + Disposition::Ignore => ChainAction::Resume, + Disposition::Handler => ChainAction::InvokeApp, + Disposition::Default if has_siginfo && si_code > 0 => ChainAction::RestoreDefaultAndRefault, + Disposition::Default => ChainAction::RestoreDefaultAndReraise, + } +} + +#[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 = SIG_DFL_VALUE as *mut c_void; + let ign = SIG_IGN_VALUE 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 handler_policy_tracks_application_recovery() { + let dfl = SIG_DFL_VALUE as *mut c_void; + let ign = SIG_IGN_VALUE as *mut c_void; + let handler = 0x1234usize as *mut c_void; + + assert!(should_run_app_first(false, true)); + assert!(!should_run_app_first(true, true)); + assert!(!should_run_app_first(false, false)); + + assert!(app_recovered(handler)); + assert!(app_recovered(ign)); + assert!(!app_recovered(dfl)); + } + + #[test] + fn disposition_based_chain_action_resumes_ignored_signals() { + assert_eq!( + chain_action(Disposition::Ignore, true, SEGV_MAPERR), + ChainAction::Resume + ); + } + + #[test] + fn genuine_fault_filter_ignores_external_async_signal() { + let ctx = SignalContext { + has_siginfo: true, + si_code: SI_USER, + si_pid: 7, + self_pid: 9, + }; + + assert!(!ctx.is_genuine_fault()); + } + + #[test] + fn genuine_fault_filter_accepts_self_sent_async_signal() { + let ctx = SignalContext { + has_siginfo: true, + si_code: SI_USER, + si_pid: 9, + self_pid: 9, + }; + + assert!(ctx.is_genuine_fault()); + } + + #[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::InvokeApp + ); + } +} 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..c9b53d389e --- /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::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 stage_name: &'a str, + pub stackwalk_method: &'a str, + pub capability_bits: u32, + pub degradation_bits: u32, +} diff --git a/libdd-crashtracker/src/collector_signal_safe/signal_names.rs b/libdd-crashtracker/src/collector_signal_safe/signal_names.rs new file mode 100644 index 0000000000..ab3839a2ac --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/signal_names.rs @@ -0,0 +1,132 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +pub fn rust_signal_name(signal: i32) -> &'static str { + match signal { + libc::SIGABRT => "SIGABRT", + libc::SIGBUS => "SIGBUS", + libc::SIGFPE => "SIGFPE", + libc::SIGILL => "SIGILL", + libc::SIGQUIT => "SIGQUIT", + libc::SIGSEGV => "SIGSEGV", + libc::SIGSYS => "SIGSYS", + libc::SIGTRAP => "SIGTRAP", + _ => "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), + } +} + +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", + _ => "UNKNOWN", + }, + libc::SIGBUS => match si_code { + BUS_ADRALN => "BUS_ADRALN", + BUS_ADRERR => "BUS_ADRERR", + BUS_OBJERR => "BUS_OBJERR", + _ => "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", + }, + // FPE_* values are deliberately reported as UNKNOWN until the receiver model has + // corresponding enum variants. + _ => "UNKNOWN", + } +} + +pub const SI_USER: i32 = 0; +pub const SI_KERNEL: i32 = 128; +pub const SI_QUEUE: i32 = -1; +pub const SI_TIMER: i32 = -2; +pub const SI_MESGQ: i32 = -3; +pub const SI_ASYNCIO: i32 = -4; +pub const SI_SIGIO: i32 = -5; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub const SI_TKILL: i32 = -6; + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +// Non-Linux platforms do not define SI_TKILL; use a sentinel that cannot match a real si_code. +pub const SI_TKILL: i32 = i32::MIN; + +pub const SEGV_MAPERR: i32 = 1; +pub const SEGV_ACCERR: i32 = 2; + +pub const BUS_ADRALN: i32 = 1; +pub const BUS_ADRERR: i32 = 2; +pub const BUS_OBJERR: i32 = 3; + +pub const ILL_ILLOPC: i32 = 1; +pub const ILL_ILLOPN: i32 = 2; +pub const ILL_ILLADR: i32 = 3; +pub const ILL_ILLTRP: i32 = 4; +pub const ILL_PRVOPC: i32 = 5; +pub const ILL_PRVREG: i32 = 6; +pub const ILL_COPROC: i32 = 7; +pub const ILL_BADSTK: i32 = 8; + +pub const FPE_INTDIV: i32 = 1; +pub const FPE_INTOVF: i32 = 2; +pub const FPE_FLTDIV: i32 = 3; +pub const FPE_FLTOVF: i32 = 4; +pub const FPE_FLTUND: i32 = 5; +pub const FPE_FLTRES: i32 = 6; +pub const FPE_FLTINV: i32 = 7; +pub const FPE_FLTSUB: 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::SIGSEGV, SI_USER), "SI_USER"); + assert_eq!(rust_si_code_name(libc::SIGFPE, FPE_INTDIV), "UNKNOWN"); + assert_eq!(rust_si_code_name(libc::SIGSEGV, 999), "UNKNOWN"); + } +} diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index 1f2117bda5..1e93019219 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -105,6 +105,7 @@ pub fn meta_mut() -> &'static mut Meta { pub static ORIG_FN: [AtomicPtr; NSIG] = [const { AtomicPtr::new(null_mut()) }; NSIG]; pub static ORIG_FLAGS: [AtomicI32; NSIG] = [const { AtomicI32::new(0) }; NSIG]; pub static OWN_SIGNAL: [AtomicBool; NSIG] = [const { AtomicBool::new(false) }; NSIG]; +pub static APP_HANDLER_PRESENT: [AtomicBool; NSIG] = [const { AtomicBool::new(false) }; NSIG]; struct SigMaskStorage(UnsafeCell<[MaybeUninit; NSIG]>); @@ -180,6 +181,7 @@ pub fn clear_signal_state() { ORIG_FN[i].store(null_mut(), Ordering::Relaxed); ORIG_FLAGS[i].store(0, Ordering::Relaxed); OWN_SIGNAL[i].store(false, Ordering::Relaxed); + APP_HANDLER_PRESENT[i].store(false, Ordering::Relaxed); i += 1; } } @@ -201,3 +203,9 @@ pub fn owned_signal_count() -> u32 { } count } + +pub fn app_handler_present(sig: i32) -> bool { + sig_index(sig) + .map(|i| APP_HANDLER_PRESENT[i].load(Ordering::Acquire)) + .unwrap_or(false) +} diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index c3ff73c1c0..7c168b4cc1 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -350,6 +350,17 @@ mod raw { } } + pub fn mprotect_none(addr: *mut u8, len: usize) -> bool { + unsafe { + syscall3( + libc::SYS_mprotect, + addr as usize, + len, + libc::PROT_NONE as usize, + ) == 0 + } + } + pub fn fork_supported() -> bool { true } @@ -523,6 +534,10 @@ mod raw { unsafe { libc::access(path.cast(), libc::X_OK) == 0 } } + pub fn mprotect_none(addr: *mut u8, len: usize) -> bool { + unsafe { libc::mprotect(addr.cast(), len, libc::PROT_NONE) == 0 } + } + pub fn fork_supported() -> bool { false } @@ -596,8 +611,8 @@ mod raw { pub use raw::{ access_executable, close, close_range_from, dup2, exit_process, fcntl_dupfd, fd_valid, - fork_raw, fork_supported, getpid, gettid, kill, monotonic_nanos, open_readwrite, pipe, - poll_sleep_ms, read_own_mem, waitpid_nohang_status, write, + fork_raw, fork_supported, getpid, gettid, kill, monotonic_nanos, mprotect_none, open_readwrite, + pipe, poll_sleep_ms, read_own_mem, waitpid_nohang_status, write, }; pub fn waitpid_nohang(pid: i32) -> i32 { diff --git a/libdd-crashtracker/src/lib.rs b/libdd-crashtracker/src/lib.rs index 48b39ec123..8562f42d77 100644 --- a/libdd-crashtracker/src/lib.rs +++ b/libdd-crashtracker/src/lib.rs @@ -86,8 +86,11 @@ mod signal_owner; // Keep this module private to avoid exposing blazesym to users of the crate #[cfg(all( unix, - feature = "std", - any(feature = "collector", feature = "receiver") + any( + feature = "collector", + feature = "receiver", + feature = "collector_signal-safe" + ) ))] #[cfg(not(feature = "benchmarking"))] mod shared; diff --git a/libdd-crashtracker/src/protocol.rs b/libdd-crashtracker/src/protocol.rs index d96b85e9c6..d809e653df 100644 --- a/libdd-crashtracker/src/protocol.rs +++ b/libdd-crashtracker/src/protocol.rs @@ -1,39 +1,78 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -#![allow(dead_code)] +macro_rules! protocol_marker { + ($vis:vis const $name:ident: &str = $value:literal;) => { + #[allow(dead_code)] + $vis const $name: &str = $value; + }; +} 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"; +protocol_marker!( + pub const DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE"; +); +protocol_marker!( + pub const DD_CRASHTRACK_BEGIN_COUNTERS: &str = "DD_CRASHTRACK_BEGIN_COUNTERS"; +); +protocol_marker!( + 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"; +protocol_marker!( + pub const DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME: &str = + "DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME"; +); +protocol_marker!( + 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"; +protocol_marker!( + 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"; +protocol_marker!( + pub const DD_CRASHTRACK_BEGIN_TRACE_IDS: &str = "DD_CRASHTRACK_BEGIN_TRACE_IDS"; +); +protocol_marker!( + 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"; +protocol_marker!( + pub const DD_CRASHTRACK_END_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_END_WHOLE_STACKTRACE"; +); +protocol_marker!( + pub const DD_CRASHTRACK_END_COUNTERS: &str = "DD_CRASHTRACK_END_COUNTERS"; +); +protocol_marker!( + 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"; +protocol_marker!( + pub const DD_CRASHTRACK_END_RUNTIME_STACK_FRAME: &str = "DD_CRASHTRACK_END_RUNTIME_STACK_FRAME"; +); +protocol_marker!( + 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"; +protocol_marker!( + 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"; +protocol_marker!( + pub const DD_CRASHTRACK_END_TRACE_IDS: &str = "DD_CRASHTRACK_END_TRACE_IDS"; +); +protocol_marker!( + pub const DD_CRASHTRACK_END_UCONTEXT: &str = "DD_CRASHTRACK_END_UCONTEXT"; +); pub const DD_CRASHTRACK_END_MESSAGE: &str = "DD_CRASHTRACK_END_MESSAGE"; diff --git a/libdd-crashtracker/src/receiver/mod.rs b/libdd-crashtracker/src/receiver/mod.rs index 12d8e794eb..897a3d58c3 100644 --- a/libdd-crashtracker/src/receiver/mod.rs +++ b/libdd-crashtracker/src/receiver/mod.rs @@ -179,4 +179,69 @@ mod tests { 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::UNKNOWN), + ]; + 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/constants.rs b/libdd-crashtracker/src/shared/constants.rs index 3f87c9a80d..1d247b54cb 100644 --- a/libdd-crashtracker/src/shared/constants.rs +++ b/libdd-crashtracker/src/shared/constants.rs @@ -3,6 +3,9 @@ use std::time::Duration; +use super::defaults::DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS; + pub use crate::protocol::*; -pub const DD_CRASHTRACK_DEFAULT_TIMEOUT: Duration = Duration::from_millis(5_000); +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..b95df1aa97 100644 --- a/libdd-crashtracker/src/shared/mod.rs +++ b/libdd-crashtracker/src/shared/mod.rs @@ -3,10 +3,14 @@ //! This module holds constants/structures that are shared between the collector and receiver +pub(crate) mod defaults; +pub(crate) mod signals; + +#[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/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/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index 6a25e4aff2..29770f2873 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -12,7 +12,8 @@ use std::process::Command; #[cfg(any(target_os = "linux", target_os = "android"))] use libdd_crashtracker::collector_signal_safe::init_from_env; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, init, set_stage, SignalSafeInitConfig, Stage, + bootstrap_complete, init, owned_signal_count, owns_signal, set_stage, SignalSafeInitConfig, + Stage, }; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -51,6 +52,62 @@ fn signal_safe_report_fd_child_process() { std::process::abort(); } +#[test] +fn signal_safe_stage_child_process() { + let Some(report) = std::env::var_os("DD_SIGNAL_SAFE_E2E_STAGE_CHILD") else { + return; + }; + let stage = std::env::var("DD_SIGNAL_SAFE_E2E_STAGE").expect("stage"); + + let _report = init_report_fd(report, b"/definitely/missing-signal-safe-receiver", false); + if stage == "application" { + 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(); + set_stage(Stage::Application); + fs::remove_file(receiver).expect("remove receiver"); + 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(); + set_stage(Stage::Application); + std::process::abort(); +} + #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn signal_safe_crash_writes_report_through_receiver() { @@ -112,6 +169,139 @@ fn signal_safe_crash_writes_report_to_fd_when_degraded() { assert!(report.contains("\"report_degraded:report_to_fd\"")); } +#[test] +fn signal_safe_stage_tags_track_bootstrap_completion() { + let init_report = run_stage_child("crashtracker_init"); + assert!(init_report.contains("\"stage:crashtracker_init\"")); + + let application_report = run_stage_child("application"); + assert!(application_report.contains("\"stage:application\"")); +} + +#[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\"")); +} + +#[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 run_stage_child(stage: &str) -> String { + 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_stage_child_process") + .arg("--nocapture") + .env("DD_SIGNAL_SAFE_E2E_STAGE_CHILD", &report) + .env("DD_SIGNAL_SAFE_E2E_STAGE", stage) + .status() + .expect("spawn child"); + + assert!(!status.success(), "child should terminate via signal"); + fs::read_to_string(&report).expect("read crash report") +} + +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!(init(&SignalSafeInitConfig { + 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, + ..SignalSafeInitConfig::default() + })); + report +} + +extern "C" fn noop_handler(_: libc::c_int) {} + +fn install_noop_handler(signal: libc::c_int) { + let mut action: libc::sigaction = unsafe { std::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, std::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")); 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..97e2ee7cfb --- /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 +["stage:application","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 during application (SIGSEGV) +DD_CRASHTRACK_END_MESSAGE +DD_CRASHTRACK_DONE diff --git a/libdd-profiling-ffi/Cargo.toml b/libdd-profiling-ffi/Cargo.toml index e1b40f7063..9606368f24 100644 --- a/libdd-profiling-ffi/Cargo.toml +++ b/libdd-profiling-ffi/Cargo.toml @@ -26,6 +26,8 @@ data-pipeline-ffi = ["dep:libdd-data-pipeline-ffi"] crashtracker-ffi = ["dep:libdd-crashtracker-ffi"] # Enables the in-process collection of crash-info crashtracker-collector = ["crashtracker-ffi", "libdd-crashtracker-ffi/collector"] +# Enables the signal-safe in-process collection of crash-info +crashtracker-collector-signal-safe = ["crashtracker-ffi", "libdd-crashtracker-ffi/collector_signal-safe"] # Enables the use of this library to receiver crash-info from a suitable collector crashtracker-receiver = ["crashtracker-ffi", "libdd-crashtracker-ffi/receiver"] demangler = ["crashtracker-ffi", "libdd-crashtracker-ffi/demangler"] diff --git a/libdd-profiling-ffi/src/exporter.rs b/libdd-profiling-ffi/src/exporter.rs index 80d72ebc1a..27637cad10 100644 --- a/libdd-profiling-ffi/src/exporter.rs +++ b/libdd-profiling-ffi/src/exporter.rs @@ -12,10 +12,13 @@ use libdd_common_ffi::{ }; use libdd_profiling::exporter; use libdd_profiling::exporter::{ExporterManager, ProfileExporter}; -use libdd_profiling::internal::EncodedProfile; +#[cfg(test)] +use libdd_profiling::internal::EncodedProfile as InternalEncodedProfile; use std::borrow::Cow; use std::str::FromStr; +use crate::profiles::EncodedProfile; + type TokioCancellationToken = tokio_util::sync::CancellationToken; #[allow(dead_code)] @@ -280,7 +283,7 @@ pub unsafe extern "C" fn ddog_prof_Exporter_init_runtime( #[named] pub unsafe extern "C" fn ddog_prof_Exporter_send_blocking( mut exporter: *mut Handle, - mut profile: *mut Handle, + profile: *mut EncodedProfile, files_to_compress_and_export: Slice, optional_additional_tags: Option<&libdd_common_ffi::Vec>, optional_process_tags: Option<&CharSlice>, @@ -290,7 +293,9 @@ pub unsafe extern "C" fn ddog_prof_Exporter_send_blocking( ) -> Result { wrap_with_ffi_result!({ let exporter = exporter.to_inner_mut()?; - let profile = *profile.take()?; + let profile = *unsafe { profile.as_mut() } + .context("Null pointer")? + .take()?; let files_to_compress_and_export = try_into_vec_files(files_to_compress_and_export)?; let tags = try_clone_optional_tags(optional_additional_tags)?; let process_tags_str = optional_process_tags @@ -433,7 +438,7 @@ pub unsafe extern "C" fn ddog_prof_ExporterManager_new( #[named] pub unsafe extern "C" fn ddog_prof_ExporterManager_queue( mut manager: *mut Handle, - mut profile: *mut Handle, + profile: *mut EncodedProfile, files_to_compress_and_export: Slice, optional_additional_tags: Option<&libdd_common_ffi::Vec>, optional_process_tags: Option<&CharSlice>, @@ -442,7 +447,9 @@ pub unsafe extern "C" fn ddog_prof_ExporterManager_queue( ) -> VoidResult { wrap_with_void_ffi_result!({ let manager = manager.to_inner_mut()?; - let profile = *profile.take()?; + let profile = *unsafe { profile.as_mut() } + .context("Null pointer")? + .take()?; let files_to_compress_and_export = try_into_vec_files(files_to_compress_and_export)?; let tags = try_clone_optional_tags(optional_additional_tags)?; let process_tags_str = optional_process_tags @@ -606,7 +613,7 @@ mod tests { } .unwrap(); - let profile = &mut EncodedProfile::test_instance().unwrap().into(); + let profile = &mut InternalEncodedProfile::test_instance().unwrap().into(); // This should fail with a connection error since there's no server, // but it validates that the function works end-to-end @@ -675,7 +682,7 @@ mod tests { let mut manager = unsafe { ddog_prof_ExporterManager_new(&mut exporter) }.unwrap(); // Queue a profile - let profile = &mut EncodedProfile::test_instance().unwrap().into(); + let profile = &mut InternalEncodedProfile::test_instance().unwrap().into(); // Should succeed unsafe { ddog_prof_ExporterManager_queue( @@ -740,7 +747,7 @@ mod tests { } .unwrap(); - let profile = &mut EncodedProfile::test_instance().unwrap().into(); + let profile = &mut InternalEncodedProfile::test_instance().unwrap().into(); let raw_internal_metadata = CharSlice::from(r#"{"test": "value"}"#); let raw_info = CharSlice::from(r#"{"runtime": {"engine": "test"}}"#); diff --git a/libdd-profiling-ffi/src/profiles/datatypes.rs b/libdd-profiling-ffi/src/profiles/datatypes.rs index 22d00ef9c0..2d3880a9c0 100644 --- a/libdd-profiling-ffi/src/profiles/datatypes.rs +++ b/libdd-profiling-ffi/src/profiles/datatypes.rs @@ -6,7 +6,7 @@ use crate::{ensure_non_null_out_parameter, ArcHandle, ProfileError, ProfileStatu use anyhow::Context; use function_name::named; use libdd_common_ffi::slice::{AsBytes, ByteSlice, CharSlice, Slice}; -use libdd_common_ffi::{wrap_with_ffi_result, Error, Handle, Timespec, ToInner}; +use libdd_common_ffi::{wrap_with_ffi_result, Error, Timespec}; use libdd_profiling::api::{self, ManagedStringId}; use libdd_profiling::profiles::datatypes::{ProfilesDictionary, StringId2}; use libdd_profiling::{api2, internal}; @@ -49,6 +49,47 @@ impl Drop for Profile { } } +/// Represents a serialized profile. Do not access its member for any reason, only use +/// the C API functions on this struct. +#[repr(C)] +pub struct EncodedProfile { + // This may be null, but if not it will point to a valid EncodedProfile. + inner: *mut internal::EncodedProfile, +} + +impl EncodedProfile { + pub(crate) fn take(&mut self) -> anyhow::Result> { + let raw = std::mem::replace(&mut self.inner, std::ptr::null_mut()); + anyhow::ensure!( + !raw.is_null(), + "inner pointer was null, indicates use after free" + ); + Ok(unsafe { Box::from_raw(raw) }) + } + + fn to_inner_mut(&mut self) -> anyhow::Result<&mut internal::EncodedProfile> { + unsafe { + self.inner + .as_mut() + .context("inner pointer was null, indicates use after free") + } + } +} + +impl From for EncodedProfile { + fn from(value: internal::EncodedProfile) -> Self { + Self { + inner: Box::into_raw(Box::new(value)), + } + } +} + +impl Drop for EncodedProfile { + fn drop(&mut self) { + drop(self.take()) + } +} + /// A generic result type for when a profiling operation may fail, but there's /// nothing to return in the case of success. #[allow(dead_code)] @@ -83,7 +124,7 @@ pub enum ProfileNewResult { #[allow(dead_code)] #[repr(C)] pub enum SerializeResult { - Ok(Handle), + Ok(EncodedProfile), Err(Error), } @@ -829,13 +870,11 @@ unsafe fn add_upscaling_rule( /// valid reference also means that it hasn't already been dropped or exported (do not /// call this twice on the same object). #[no_mangle] -pub unsafe extern "C" fn ddog_prof_EncodedProfile_drop( - profile: *mut Handle, -) { +pub unsafe extern "C" fn ddog_prof_EncodedProfile_drop(profile: *mut EncodedProfile) { // Technically, this function has been designed so if it's double-dropped // then it's okay, but it's not something that should be relied on. - if !profile.is_null() { - drop((*profile).take()) + if let Some(profile) = unsafe { profile.as_mut() } { + drop(profile.take()) } } @@ -848,10 +887,17 @@ pub unsafe extern "C" fn ddog_prof_EncodedProfile_drop( #[must_use] #[named] pub unsafe extern "C" fn ddog_prof_EncodedProfile_bytes<'a>( - mut encoded_profile: *mut Handle, + encoded_profile: *mut EncodedProfile, ) -> libdd_common_ffi::Result> { wrap_with_ffi_result!({ - let slice = encoded_profile.to_inner_mut()?.buffer.as_slice(); + let slice = unsafe { + encoded_profile + .as_mut() + .context("Null pointer")? + .to_inner_mut()? + .buffer + .as_slice() + }; // Rountdtrip through raw pointers to avoid Rust complaining about lifetimes. let byte_slice = ByteSlice::from_raw_parts(slice.as_ptr(), slice.len()); anyhow::Ok(byte_slice) diff --git a/libdd-profiling-ffi/src/profiles/mod.rs b/libdd-profiling-ffi/src/profiles/mod.rs index 077500d41a..69a3598cd7 100644 --- a/libdd-profiling-ffi/src/profiles/mod.rs +++ b/libdd-profiling-ffi/src/profiles/mod.rs @@ -6,6 +6,8 @@ mod interning_api; mod profiles_dictionary; mod utf8; +pub(crate) use datatypes::EncodedProfile; + #[macro_export] macro_rules! ensure_non_null_out_parameter { ($expr:expr) => { diff --git a/tools/check_signal_safe_symbols.sh b/tools/check_signal_safe_symbols.sh old mode 100644 new mode 100755 index 961bdc24cd..6c142235e9 --- a/tools/check_signal_safe_symbols.sh +++ b/tools/check_signal_safe_symbols.sh @@ -4,6 +4,8 @@ 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 @@ -22,7 +24,7 @@ if [[ "${#artifacts[@]}" -eq 0 ]]; then exit 1 fi -banned='(^|[^[:alnum:]_])(malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__libc_[[:alnum:]_]+)([^[:alnum:]_]|$)' +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 diff --git a/tools/src/bin/ffi_test.rs b/tools/src/bin/ffi_test.rs index 4ac1bb9cad..31f1946ee1 100644 --- a/tools/src/bin/ffi_test.rs +++ b/tools/src/bin/ffi_test.rs @@ -139,14 +139,24 @@ struct ExpectedCrash { fn expected_crashes() -> &'static HashMap<&'static str, ExpectedCrash> { static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { - HashMap::from([( - "crashtracking", - ExpectedCrash { - #[cfg(unix)] - signal: 11, // SIGSEGV - output_file: "crashreport.json", - }, - )]) + HashMap::from([ + ( + "crashtracking", + ExpectedCrash { + #[cfg(unix)] + signal: 11, // SIGSEGV + output_file: "crashreport.json", + }, + ), + ( + "signal_safe_crashtracking", + ExpectedCrash { + #[cfg(unix)] + signal: 6, // SIGABRT + output_file: "signal_safe_crashreport.txt", + }, + ), + ]) }) } @@ -218,7 +228,7 @@ fn base_env_vars(_project_root: &Path) -> Vec<(String, String)> { /// receiver binary) can find them without hard-coding paths. fn per_test_env(name: &str, project_root: &Path, work_dir: &Path) -> Vec<(String, String)> { match name { - "crashtracking" => { + "crashtracking" | "signal_safe_crashtracking" => { let (receiver, lib_dir) = find_receiver_paths(project_root); let (search_var, search_val) = library_search_path_env(&lib_dir); vec![ 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" From 4f63bbac6b8d6a0bde36b35fcfa1b8529e4d2739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 03:42:54 +0200 Subject: [PATCH 12/32] rm --- signal_safe_crashtracker_plan.md | 598 ------------------------------- 1 file changed, 598 deletions(-) delete mode 100644 signal_safe_crashtracker_plan.md diff --git a/signal_safe_crashtracker_plan.md b/signal_safe_crashtracker_plan.md deleted file mode 100644 index 9e883da9c5..0000000000 --- a/signal_safe_crashtracker_plan.md +++ /dev/null @@ -1,598 +0,0 @@ -# Signal-Safe Crashtracker — Consolidated Improvement Plan - -Status date: 2026-07-06. Branch: `signal_safe_crashtracker` (HEAD `b59e44071`). - -This document supersedes `signal_safe_collector_improvement_plan.md` (deleted from -the working tree; recoverable at `git show 79f8b2a0d:signal_safe_collector_improvement_plan.md`). -The dd-trace-c parity analysis remains in `crashtracker-work-we-need-to-do.md`; -this plan references it and does not repeat it. - -It is written to be executed by another engineer/LLM with **no prior context**. -Every claim marked *(verified)* was checked against the working tree on the -status date. Section 0 lists what is already done — do not redo it. - ---- - -## Guiding principles - -Apply these to every change (they are also the review bar): - -- **Errors never pass silently.** Every degraded path leaves a trace: a - `report_degraded:*` tag, a distinct `InitResult` variant, or a - `crash_debug` breadcrumb. A silently skipped handler install or a silently - truncated string is a bug even when the behavior is otherwise correct. -- **Explicit is better than implicit.** Env reads happen only in the - `*_from_env` entry points. Failure reasons are enum variants, not booleans. - Interacting options are validated, not auto-corrected. -- **There should be one obvious way — and one source of truth.** Duplicated - constants, duplicated invariants, and hand-rolled serializers that shadow a - serde schema are where wire formats silently diverge. This plan's Phase 1 - exists to remove every such duplication that can be removed, and to pin the - rest with drift tests. -- **Simple is better than complex.** The crash path stays a straight line: - probe at init, one handler, two forked children, fixed buffers. Prefer - deleting code to adding it. Anything genuinely non-obvious (the app-chain - stack-position guard) carries a doc comment explaining why. - ---- - -## 0. Ground truth (verified 2026-07-06) - -### What exists - -`libdd-crashtracker/src/collector_signal_safe/` behind cargo feature -`collector_signal-safe` (note the hyphen), coexisting with the std `collector` -feature via the shared `signal_owner.rs` arbiter: - -| File | Lines | Contents | -|---|---|---| -| `mod.rs` | 978 | Wire emitter (`emit_report`, `Sink`/`SliceSink`), chain-policy pure functions, signal/si_code name tables, report data types, capacity constants, ~300 lines of tests | -| `handler.rs` | 926 | `init`/`init_from_env`/`bootstrap_complete`/`shutdown`, `InitResult`, the `crash_handler`, double-fork collect path, app-chain guard, repeat-fault detector, alt-stack, reap logic | -| `config.rs` | 522 | `SignalSafeInitConfig`, `validate`, `prepare[_from_env]_result`, hand-rolled config-JSON builder, env parsing, clamps | -| `sys.rs` | 728 | Raw syscall layer: rustix + inline asm on Linux x86_64/aarch64 (`fork_raw` via `clone(SIGCHLD)`, `process_vm_readv`, `wait4`, `close_range`), libc fallback elsewhere, `environ` walk (no `getenv`) | -| `state.rs` | 203 | Static `Meta` (heapless), init-state machine, per-signal orig-handler/mask atomics, runtime option atomics, `Stage` | -| `backtrace.rs` | 183 | Frame-pointer walk seeded from `ucontext`, probing frames with `process_vm_readv` so corrupt stacks fail instead of faulting | -| `capabilities.rs` | 143 | Capability bits (`RECEIVER_OK, PROC_VM_READV, FORK_OK, DEV_NULL, PIPE_OK, REPORT_FD_OK`), degradation bits + `DEGRADATION_REASONS` tag table, init-time `publish()` probes | - -Plus: FFI surface `libdd-crashtracker-ffi/src/collector_signal_safe.rs` -(`ddog_crasht_signal_safe_*`, all `catch_unwind`-wrapped, `#[repr(C)]` enums -with lib↔FFI value-equality tests); shared `src/protocol.rs` (wire markers, -used by std emitter, signal-safe emitter, and receiver — `shared/constants.rs` -now re-exports it); e2e tests `tests/collector_signal_safe_e2e.rs`; receiver -round-trip test `receiver/mod.rs:112` feeding the signal-safe emitter's bytes -through the real receiver parser; CI workflow -`.github/workflows/crashtracker-signal-safe.yml`; symbol guard -`tools/check_signal_safe_symbols.sh`. - -### Already done — do not redo - -The previous plan's Phases 0–3 were largely implemented by commits `5da67273c` -and `b59e44071` *(all verified in the tree)*: - -- `getenv` replaced by a direct `environ` walk (`sys.rs:614`); symbol guard - **passes** (`bash tools/check_signal_safe_symbols.sh` → exit 0). -- App-chain recursion guard is tid + stack-position based - (`handler.rs:37-38,180-209`), surviving `siglongjmp` recovery. -- Repeat-fault detector for app handlers that return without fixing the fault - (`handler.rs:39-41,211-226`). -- `shutdown()`/`fail_init()` reset `INIT_STATE` to `UNINIT` → re-init works - (`state.rs:89-95`). -- Original `sa_mask` saved and restored (`handler.rs:819,837`). -- `InitResult` has `DisabledByConfig/AlreadyInitialized/OwnerConflict/InvalidConfig` - variants plus a central `validate()` (`config.rs:336`). -- `close_fds_on_receiver` implemented via raw `SYS_close_range` - (`handler.rs:377-378`, `sys.rs:307`). -- Receiver exit-125 detection with re-emit to `report_fd` - (`handler.rs:592`). -- Truncation degrades loudly: `emit_truncated_tail` always terminates the - stream and tags `report_degraded:truncated` (`mod.rs:589`). -- Loader-env scrubbing (`LD_PRELOAD`/`LD_AUDIT`) before receiver exec - (`handler.rs:345`). -- FFI `catch_unwind` on every entry point; support-matrix doc in `mod.rs:10-17`. - -### What is red right now - -Exactly one failure *(verified)*: - -**`collector_signal_safe::config::tests::config_json_contains_receiver_contract` -fails on Linux.** The golden string at `config.rs:435-443` hardcodes -`"signals":[11,6,10,4,8]` — `10` is SIGBUS on BSD/macOS, but -`CRASH_SIGNALS` (`config.rs:40`) uses `libc::SIGBUS`, which is `7` on Linux. -The test encodes one platform's numbers; the code is portable. Fix in Phase 0. - -Also *(verified)*: `tools/check_signal_safe_symbols.sh` is mode `100644` — -`./tools/...` fails with *Permission denied*; CI survives only because the -workflow says `bash tools/...` (`crashtracker-signal-safe.yml:46`). - -### Validation commands (run after every phase) - -```bash -cargo check -p libdd-crashtracker --features collector_signal-safe -cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe -cargo check -p libdd-crashtracker-ffi --no-default-features --features collector_signal-safe -cargo build --workspace --exclude builder -cargo +nightly-2026-02-08 fmt --all -- --check -cargo +stable clippy --workspace --all-targets --all-features -- -D warnings -cargo nextest run -p libdd-crashtracker --features collector_signal-safe # or cargo test if nextest absent -cargo nextest run --workspace --no-fail-fast -cargo test --doc -bash tools/check_signal_safe_symbols.sh -``` - -If `Cargo.lock` changes: `./scripts/update_license_3rdparty.sh && cargo deny check`. -If FFI is touched: `cargo ffi-test`. New files need Apache-2.0 headers -(`./scripts/reformat_copyright.sh`). Signal-state-mutating tests must hold -`TEST_GLOBAL_LOCK` (`mod.rs:42`) — nextest's process-per-test isolation is why -CI uses it. - ---- - -## Phase 0 — Green branch (P0, do first) - -### 0.1 Fix the platform-dependent golden test - -Replace the hardcoded signal numbers in -`config_json_contains_receiver_contract` (`config.rs:429-443`) with an -expectation built from `CRASH_SIGNALS` itself (format the array the same way -`build_config_json` does), or assert the full string with the signal segment -spliced in. The golden test's job is to pin the *shape* of the receiver -contract; the numeric values are `libc`'s to define per-platform. Keep one -additional assertion that `CRASH_SIGNALS` contains SIGSEGV/SIGABRT/SIGBUS/ -SIGILL/SIGFPE by symbolic name so the set itself is still pinned. - -### 0.2 Housekeeping - -- `chmod +x tools/check_signal_safe_symbols.sh` (`git update-index --chmod=+x`). -- Branch history contains `wip` commits and merges — rebase/squash into - conventional commits before opening PRs (sequence at the end). -- `cargo nextest` is not installed in the local dev environment; either - install it or use `cargo test` locally (the `TEST_GLOBAL_LOCK` makes plain - `cargo test` safe). - ---- - -## Phase 1 — DRY and simplification - -This is the headline phase. Each item removes a duplication or shrinks the -code; none changes wire behavior (the receiver round-trip test at -`receiver/mod.rs:112` and the config golden test are the guardrails — run them -after every step). - -### 1.1 Split `mod.rs` (978 lines) into cohesive submodules - -`mod.rs` currently mixes four concerns. Split, keeping `mod.rs` as the façade -with the same `pub use` surface (zero API change): - -| New file | Moves from `mod.rs` | -|---|---| -| `emitter.rs` | `Sink`, `SliceSink`, `emit_report[_with_metadata]`, `emit_minimal_report`, `emit_json_section`, `emit_config/metadata/additional_tags/kind/stacktrace/message/truncated_tail/done`, `put_marker_line`, capacity constants, `push_tag` | -| `policy.rs` | `Disposition`, `ChainAction`, `SignalContext`, `disposition_of`, `app_handler_is_real`, `should_run_app_first`, `app_recovered`, `is_genuine_fault`, `chain_action` | -| `signal_names.rs` | `rust_signal_name`, `rust_si_code_name`, `signal_specific_si_code_name`, `signal_has_address`, the `SI_*`/`SEGV_*`/`BUS_*`/`ILL_*`/`FPE_*` consts | -| `report.rs` | `Metadata`, `SignalInfo`, `ProcInfo`, `Frame`, `Report`, `CrashContext`, `Tag`/`Tags` aliases | - -Move each block's tests with it. Pure mechanical refactor; do it in one commit -with no logic changes so review is trivial. - -### 1.2 Replace the hand-rolled config-JSON builder with a serde struct - -`build_config_json` (`config.rs:123-164`) is 40 lines of `push_str`/`write!` -chains hand-assembling JSON that must match `CrashtrackerConfiguration`'s -serde schema — the classic shadow-serializer. The module already depends on -`serde` + `serde-json-core` and already serializes every report section that -way (`emit_json_section`, `mod.rs:389`). Replace with: - -```rust -#[derive(Serialize)] -struct WireConfig<'a> { - additional_files: [&'a str; 0], - create_alt_stack: bool, - use_alt_stack: bool, - demangle_names: bool, - endpoint: Option<()>, // serializes as null - resolve_frames: &'a str, // "EnabledWithSymbolsInReceiver" - signals: &'a [i32], - timeout: WireTimeout, // { secs: u32, nanos: u32 } - unix_socket_path: Option<()>, -} -``` - -serialized with `serde_json_core::to_slice` into the existing -`CONFIG_JSON_BUF_SIZE` buffer (plus the trailing `\n`). This deletes the -manual escaping/ordering logic entirely; field order in the struct pins the -byte-exact output, and the receiver round-trip test proves -`CrashtrackerConfiguration` still deserializes it. Keep the (now -platform-correct, per 0.1) golden test as the wire contract. - -*Rejected alternative*: reusing `CrashtrackerConfiguration` itself — it is -std-only (`Vec`, `Endpoint`) and its serde output is the *target*, not a tool -usable in a no-std crate. The drift risk is covered by the round-trip test. - -### 1.3 One source of truth for wire/receiver-shared constants - -Three small duplications *(verified)* to collapse into the shared layer: - -- **Crash-signal list.** `config::CRASH_SIGNALS = [SIGSEGV, SIGABRT, SIGBUS, - SIGILL, SIGFPE]` (`config.rs:40`) vs legacy `DEFAULT_SIGNALS = [SIGBUS, - SIGABRT, SIGSEGV, SIGILL]` (`collector/api.rs:17-22`) — the legacy set omits - SIGFPE. These are semantically different (fixed set vs configurable - default), so don't force one list; instead move both to a tiny shared - `crate::shared::signals` (or extend `protocol.rs`) as named consts with a - comment stating the intended difference, and add a test asserting the - signal-safe set is a superset of the legacy default. If the difference is - *not* intended, that surfaces immediately (decision D2 below). -- **Default receiver timeout.** `DD_CRASHTRACK_DEFAULT_TIMEOUT = 5000 ms` - (`shared/constants.rs:8`) vs `RECEIVER_TIMEOUT_SECS = 5` (`config.rs:33`). - Derive the latter from the former. -- **`#![allow(dead_code)]` at `protocol.rs:4`** — with three consumers - (std emitter, signal-safe emitter, receiver) most constants are live; scope - the allow to the actually-unused items or remove it so dead protocol - constants become visible again. - -### 1.4 si_code / signal-name drift-proofing - -`signal_names.rs` (post-1.1) deliberately reimplements the receiver's -`SignalNames`/`SiCodes` tables allocation-free — that duplication stays (the -legacy path uses `num_derive` + a C shim, unusable in-handler). Pin it instead -of merging it: add a `#[cfg(all(test, feature = "receiver"))]` test that, for -every signal in `CRASH_SIGNALS` × every named si_code, feeds -`rust_signal_name`/`rust_si_code_name` output through the receiver's serde -deserialization of `SigInfo` and asserts it produces the corresponding enum -variant (and that the `""` fallback maps to the receiver's UNKNOWN -handling rather than an error). Wire the combined-features invocation into the -CI workflow. This turns silent table drift into a red test. - -### 1.5 Crash-path micro-simplifications - -- **`emit_stacktrace` allocates a fresh `[0u8; 4096]` per frame inside its - loop** (`mod.rs:564`, up to 64 iterations). Hoist the buffer out of the - loop. A frame line needs ~40 bytes; also consider a dedicated small buffer - (e.g. 256 B) — the 64 KiB alt stack is the budget, spend it deliberately. -- **Consolidate the hand-rolled formatters** — `hex_addr` (`mod.rs:442`), - `hex_u32` (`mod.rs:547`), `write_i32` (`handler.rs:244`) — into one - `fmt.rs` with unit tests (`i32::MIN`, `usize::MAX`, `0`). `hex_u32` is the - only `core::fmt` user on the crash path; rewriting it in the same style as - `hex_addr` makes the crash path fmt-free, which also shrinks the code the - symbol guard has to trust. -- **`cstr_bytes` in the FFI** (`collector_signal_safe.rs:191-201`) and - `set_str` truncation (`config.rs:300-309`): truncating `service` or - `receiver_path` silently is an errors-pass-silently violation. Receiver-path - truncation already returns `InvalidConfig` *(verified)*; make metadata - truncation observable with a `crash_debug` breadcrumb or an init-time - degradation note. - -### 1.6 Alt-stack: one documented policy - -Two divergent implementations *(verified)*: legacy mmaps -`max(SIGSTKSZ, 16 × page)` plus a `PROT_NONE` guard page -(`collector/signal_handler_manager.rs:139-173`); signal-safe uses a static -64 KiB array with no guard page (`handler.rs:23-29,332-343`). The static -approach is right for the no-std path (no mmap at init required), but: - -- Document both choices side by side in a comment in each file referencing - the other ("static because no-std/no-mmap; no guard page — overflow past the - alt stack corrupts adjacent statics rather than faulting; acceptable because - the process is already crashing"). -- Optional improvement (small, worth it): place the alt stack in its own - page-aligned static and `mprotect` its first page `PROT_NONE` *at init time* - (init may syscall freely). That restores guard-page semantics without any - crash-path cost. Fold the decision into D3. - -### 1.7 Uniform capability gating - -`collect_crash` checks `report_fd >= 0` directly (`handler.rs:519`) while -`REPORT_FD_OK` exists as a capability bit; `DEV_NULL` is probed and published -but never gated on *(verified)*. Make the handler consult capabilities -uniformly: gate the fd fallback on `REPORT_FD_OK`, and have `sanitize_clone` -branch on `DEV_NULL` (it already has the no-devnull path, -`close_stdio_without_devnull`). One idiom for one concept — and the -capability bits reported via FFI then truthfully describe what the handler -will actually do. - ---- - -## Phase 2 — Missing capabilities and degraded modes - -### 2.1 Silent handler-install skip must become visible - -`install_crash_handler` returns silently when a signal's disposition is not -`SIG_DFL` (`handler.rs:805-806`) — an app that installed a SIGSEGV handler -before us means we never collect for that signal, observable only by polling -`owned_signal_count`. Add a degradation bit + reason -(`DEGRADED_HANDLER_PRESENT` / `report_degraded:app_handler_present:` — or -one bit plus a per-signal mask exposed through a new -`ddog_crasht_signal_safe_unowned_signals()` FFI getter). Init still succeeds -(partial coverage is better than none) but the fact is now on the record at -init time and in any report produced by the signals we do own. - -Note: full Mode-A fidelity for *late*-registering runtimes needs the -sigaction/PLT virtualization deferred to Phase 6 — this item only makes the -install-time case loud. - -### 2.2 Seccomp sacrificial-child probe (opt-in) - -`probe_process_vm_readv` (`capabilities.rs:105`) catches errno-returning -denials (`EPERM`) but not `SECCOMP_RET_KILL` policies, which would kill the -collector child mid-crash and silently lose the stack. Add -`SignalSafeInitConfig::probe_seccomp: bool` (default `false` — forking at init -is a global effect and must be opt-in per repo conventions): -`fork_raw()`; child calls `read_own_mem` on itself and `_exit(0)`; parent -waits ≤100 ms. Killed by signal → clear `PROC_VM_READV`, set -`DEGRADED_NO_PROC_VM_READV`; timeout → kill, keep the capability (status -quo). The `seed_only` stackwalk fallback already exists downstream. - -### 2.3 Make the one-shot `COLLECTING` semantics explicit - -`COLLECTING` is set once and never reset (`handler.rs:663-667`): exactly one -collection per process lifetime, by design (a second concurrent crash must not -re-enter, and a second sequential crash is almost always the same fault). Keep -the behavior, but (a) doc-comment it at the static, (b) reset it in -`shutdown()` so the re-init lifecycle is coherent, and (c) add an e2e assert -that a second crash after a first report chains straight to default -disposition without forking. - -### 2.4 Config surface gaps — deliberate scope decision - -Currently not configurable *(verified)*: the signal set, alt-stack size, -`endpoint`/`unix_socket_path` (hardcoded null → receiver decides), and -stackwalk mode (always `EnabledWithSymbolsInReceiver`). Recommendation: -**add none of them now.** Each is a knob without a requesting integrator, and -the config JSON's hardcoded nulls are what keep the receiver contract simple. -Record this as decided (table below, D4); revisit per-signal opt-out first if -a runtime conflict report arrives (e.g. a JVM that must own SIGFPE — today -that case is handled by the app-handler-present skip from 2.1). - -### 2.5 Non-Linux coverage - -macOS/other-Unix code paths (`fork_supported() == false` → minimal report to -`report_fd`) compile but never run in CI *(verified: only ubuntu runners)*. -Add a `macos-latest` job running -`cargo test -p libdd-crashtracker --features collector_signal-safe` — the -degraded-fd e2e test is already portable (`collector_signal_safe_e2e.rs:93`). -This is cheap and pins the entire libc-fallback half of `sys.rs`, which today -is check-only dead weight that could regress freely. - ---- - -## Phase 3 — Safety-option hardening - -### 3.1 `disarm_on_entry` interaction matrix - -`DISARM_ON_ENTRY` resets the signal to `SIG_DFL` on entry (`handler.rs:606`), -then the tail chain logic reinstalls/raises per `chain_action`. Unit-test the -matrix (disarm × {genuine fault, external async → `Resume`, ignored -disposition}) and fix the known gap: after disarm + `Resume`, the pre-entry -disposition (our handler) must be restored before returning, else the next -occurrence terminates with no report. - -### 3.2 Stack-budget audit - -Document the crash-path stack requirement next to `ALT_STACK_SIZE` -(`handler.rs:23`): handler frame + `collect_crash` locals + -(degraded path only) `emit_crash_report`'s section buffer ≈ 8–12 KiB, vs the -64 KiB alt stack. `direct_report` runs the emitter **in the signal-handler -frame** — with 1.5's per-frame-buffer hoist this is one 4 KiB buffer, fine, -but write the number down and add a `const _: () = assert!(...)` relating -`SECTION_BUF_CAPACITY` to `ALT_STACK_SIZE` so a future capacity bump can't -silently outgrow the stack. - -### 3.3 Symbol guard hardening - -The guard *(currently green, verified)* scans `nm -u` of the -no-default-features rlibs for -`malloc|free|pthread_mutex_lock|__rust_alloc|getenv|dlsym|getauxval|fork|posix_spawn|pthread_atfork|__libc_*` -(`tools/check_signal_safe_symbols.sh:25`). Extend: - -- Add `calloc|realloc|posix_memalign|mmap|pthread_mutex_unlock|pthread_cond_[a-z]+|syslog|abort` — - note `abort` will require auditing that nothing on the crash path links it - (panics are already banned by clippy config). -- Build into a dedicated `CARGO_TARGET_DIR=target/signal-safe-guard` inside - the script so stale dev artifacts can't produce false results in either - direction. -- Known limitation, document in the script header: it scans the whole rlib, - so init-time code is held to crash-path standards. That is the right - trade-off; exceptions must be explicit regex changes reviewed in the - script, never attributes in code. -- Longer term (with 5.1's release wiring): run `nm -u` on the released - staticlib/cdylib too — rlib scanning can miss symbols introduced at final - link. - ---- - -## Phase 4 — Test strategy - -### 4.1 E2E matrix (extend `tests/collector_signal_safe_e2e.rs`) - -The self-exec pattern (env-gated child test fns + orchestrating asserts) is -good. Current coverage is exactly two scenarios *(verified)*: SIGABRT through -a shell receiver, and degraded report-to-fd. Highest-value additions, in -order: - -| Scenario | Child behavior | Assert | -|---|---|---| -| **SIGSEGV with real stackwalk** | deref null | report, `SEGV_MAPERR`, >1 frame, `stackwalk_method:fp_pvr` — this is the only test that exercises `arch_seed`/`walk_fp`/`process_vm_readv` for real | -| App handler recovers (Mode A) | `siglongjmp` handler; crash; continue | no report; exit 0 | -| Recover, then genuine crash | as above, then handler removed, crash | exactly one report (pins the app-chain guard) | -| App handler gives up | handler restores `SIG_DFL`, returns | one report; process dies by SIGSEGV re-fault (check termination signal) | -| Mode B (`force_on_top`) | recovering handler + `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` | report **and** exit 0 | -| External async signal | parent `kill -SEGV` child | **no** report; default termination | -| Self-sent async | child `raise(SIGSEGV)` | report | -| Stuck receiver | receiver script sleeps forever | process terminates within timeout+grace+ε; no zombie | -| Receiver deleted post-init | unlink receiver after init | fd fallback with `receiver_unavailable` + `report_to_fd` tags | -| Bootstrap-only | `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true`, crash after `bootstrap_complete()` | no report | -| Stage tags | crash before vs after `bootstrap_complete` | `stage:crashtracker_init` vs `stage:application` | -| Loader-env scrub | init under `LD_PRELOAD=` | receiver env lacks `LD_PRELOAD`/`LD_AUDIT` (receiver script dumps env) | -| fd hygiene | parent opens marker fd without `O_CLOEXEC` | receiver's `/proc/self/fd` lacks it (pins `close_range`) | -| SIGFPE | integer div-by-zero | report; si_code string accepted by receiver (with 1.4's drift test) | -| Re-init lifecycle | init → shutdown → init → crash | one report (pins 2.3's `COLLECTING` reset) | - -Gate Linux-only scenarios `#[cfg(target_os = "linux")]`; use generous -timeouts (CI is slow). - -### 4.2 Plug into `bin_tests` (the big reuse win) - -The `bin_tests` harness is producer-agnostic: it asserts on the `CrashInfo` -JSON that the receiver writes to a `file://` endpoint -(`bin_tests/src/test_runner.rs:121-160`). Add: - -- a new bin artifact (alongside `crashtracker_bin_test`) that calls - `ddog_crasht_signal_safe_init` (through the FFI, so the C surface is what's - tested) and crashes per a mode argument; -- a `TestMode`/validator asserting the signal-safe-specific fields: `stage:*`, - `stackwalk_method:*`, `report_degraded:*` tags, and frames non-empty for - SIGSEGV; -- reuse `test_crashtracker_receiver` unchanged — same protocol, same receiver - *(already proven by the `receiver/mod.rs:112` round-trip test)*. - -This buys the full validation pipeline (real receiver binary, real JSON -schema, telemetry file checks) for free and is the compatibility proof that -the two collectors remain interchangeable in front of one receiver. - -### 4.3 Golden wire fixture - -Check in the emitted byte stream for a fixed `Report`/`CrashContext` -(`tests/fixtures/signal_safe_report.golden`) and diff against regenerated -output in a test. The receiver round-trip test proves *parseability*; the -golden file makes any wire change *visible in review*. Regeneration path: -a `#[ignore]`d test that rewrites the fixture when run explicitly. - -### 4.4 CI upgrades (`.github/workflows/crashtracker-signal-safe.yml`) - -- aarch64 is check-only today *(verified)* — the inline-asm syscall stubs and - `arch_seed` LR fallback are exactly the code that passes `check` and fails - at runtime. Add an execution job (`cross test` or - `taiki-e/setup-cross-toolchain-action` + qemu). -- macOS job (2.5). -- Combined-features run for the drift/round-trip tests: - `cargo nextest run -p libdd-crashtracker --features "collector_signal-safe,receiver"`. -- Optional but cheap: an ASan job for the e2e tests (fork + raw syscalls + - signal handlers is ASan's home turf; it will not understand the crash - itself, so scope it to the lifecycle/init tests). - ---- - -## Phase 5 — Reuse and compatibility with the rest of libdatadog - -### 5.1 Ship it: release wiring (currently a dead end, verified) - -The feature exists end-to-end in the crashtracker crates but **cannot reach -the released artifact**: `libdd-profiling-ffi/Cargo.toml` has passthroughs for -`crashtracker-collector`/`crashtracker-receiver` but none for -`collector_signal-safe`, and the builder's feature string -(`builder/src/profiling.rs:135`) doesn't mention it either. To ship: - -1. Add `crashtracker-collector-signal-safe = ["libdd-crashtracker-ffi/collector_signal-safe"]` - passthrough in `libdd-profiling-ffi/Cargo.toml`. -2. Add it to the builder's crashtracker feature set in - `builder/src/profiling.rs` (gated on decision D1 below). -3. Remove the `libdd_crashtracker::collector_signal_safe` exclusion from - `libdd-crashtracker-ffi/cbindgen.toml:75` **only if** cbindgen needs to - chase lib types; the current design keeps all `#[repr(C)]` types in the - FFI crate, so instead just verify the generated `crashtracker.h` contains - the `ddog_crasht_signal_safe_*` functions and structs (build with the - `cbindgen` feature and inspect). -4. Add a C example under `examples/ffi/` exercising init + abort + a receiver - script, wired into `cargo ffi-test` (AGENTS.md step 4). -5. Smoke: `cargo run --bin release -- --out /tmp/out` and `nm -u` the - produced staticlib for the banned-symbol list (closes the 3.3 gap). - -### 5.2 Signal-owner interplay tests (both directions) - -`signal_owner.rs` arbitration exists *(verified)*; test it explicitly with -both features enabled: std `init` after signal-safe `init` → error naming the -conflict; signal-safe `init_result` after std `init` → `OwnerConflict`; std -teardown releasing ownership → signal-safe init then succeeds (if the std -collector has no full uninstall, document that switching collectors requires -a process restart). Remember `signal_owner::acquire` is reentrant for the -same owner — single-init is enforced by `state::begin_init`'s CAS, not the -owner gate; don't weaken that CAS. - -### 5.3 Explicit non-reuse decisions (recorded so nobody relitigates) - -Surveyed the workspace for reuse; these are **deliberate no's** — the DRY -principle applies to sources of truth, not to forcing shared code across the -std/no-std boundary: - -| Candidate | Verdict | Why | -|---|---|---| -| `libdd-capabilities`(-impl) | No | Trait-DI abstractions for HTTP/sleep/spawn (wasm portability), unrelated to runtime capability probing despite the name | -| `libdd-alloc::LinearAllocator` | No (for now) | Genuinely signal-safe bump allocator, but the collector's buffers are fixed-size by design; heapless capacities *are* the report size contract. Revisit only if variable-size scratch becomes necessary | -| `spawn_worker` | No | Trampoline-binary process spawner; allocates, memfd/tempfiles — not signal-safe, wrong tool | -| `libdd_common::unix_utils` (`alt_fork`, `PreparedExecve`…) | No in-handler | std/nix-based; the legacy collector's tools. `sys.rs` is the signal-safe equivalent and must stay independent | -| `blazesym`/symbolication | No in-collector | Symbolication stays in the receiver (`EnabledWithSymbolsInReceiver`); collector emits raw IPs only | -| Receiver, `CrashInfo` model, telemetry/errors-intake uploaders, `protocol.rs` | **Yes — already reused** | Collector-agnostic; the whole design hinges on one receiver parsing both collectors | - -### 5.4 Don't regress the std path - -The `std`-feature re-plumbing in `libdd-crashtracker/Cargo.toml` touches every -consumer. Non-optional checks: `cargo build --workspace --exclude builder`; -`cargo nextest run --workspace --all-features --exclude builder --exclude test_spawn_from_lib`; -`cargo nextest run -p bin_tests` (std collector e2e); -`cargo nextest run -p libdd-crashtracker --features generate-unit-test-files` -(per AGENTS.md). Verify no downstream crate relied on the removed -`staticlib` crate-type (grep `builder/` before assuming). - ---- - -## Phase 6 — Deferred parity work (tracked, out of execution scope) - -From `crashtracker-work-we-need-to-do.md`, in priority order; do after -Phases 0–5: - -1. `sigaction`/`signal` PLT interposition + virtualized per-signal state - (§3 there) — prerequisite for full Mode-A fidelity with late-registering - runtimes; today's `ORIG_FN` snapshot sees only install-time handlers. -2. Receiver path discovery beside the loaded `.so` (dladdr glue, - arch-suffixed names) (§9). -3. Packaging: musl receiver sidecar, size guard (§16). -4. Remainder of the dd-trace-c `crashtracker_preload_test.go` matrix (§18) — - Phase 4.1 covers the highest-value half. - ---- - -## Open decisions - -Everything else in this plan is decided; these need a human call before the -affected PR: - -| # | Decision | Recommendation | Blocks | -|---|---|---|---| -| D1 | Ship `collector_signal-safe` in the released FFI artifact now, or keep opt-in? | Team/product call | PR 7 (5.1 steps 2–5) | -| D2 | Legacy default signal set omits SIGFPE — intended divergence or historical accident? | Keep the divergence, document it (changing legacy defaults is a behavior change for existing SDKs) | PR 2 (1.3) | -| D3 | Add init-time `mprotect` guard page to the static alt stack? | Yes — init-time-only cost, real overflow protection | PR 3 (1.6) | -| D4 | Expose per-signal opt-out / alt-stack size / endpoint config now? | No — no requesting integrator; 2.1 covers the conflict case | nothing now | - ---- - -## Suggested PR sequence - -Each PR: conventional-commit title, full validation list from §0, -`./scripts/update_license_3rdparty.sh` if the lockfile moves. - -1. **fix(crashtracker): platform-correct config golden + script exec bit** — - Phase 0. Rebase the `wip`/merge history into clean commits here. -2. **refactor(crashtracker): split signal-safe module and share constants** — - 1.1, 1.3, 1.5, 1.7 (pure refactors; receiver round-trip + golden tests - prove no wire change). -3. **refactor(crashtracker): serde wire-config + alt-stack guard page** — - 1.2, 1.6, 3.2. -4. **feat(crashtracker): visible degradations and capability polish** — 2.1, - 2.2, 2.3, 3.1, 1.4 drift test. -5. **test(crashtracker): e2e matrix, golden fixture, bin_tests integration** — - 4.1, 4.2, 4.3, 5.2. -6. **ci(crashtracker): aarch64 execution, macOS job, guard hardening** — 4.4, - 3.3, 2.5. -7. **build(crashtracker): release wiring, headers, C example** — 5.1 - (D1-gated), 5.4 verification. - -## Key risks - -1. **The wire format is the crown jewel.** Every Phase 1 refactor must run - the receiver round-trip test and (once it exists) the golden fixture diff - in the same commit. A wire regression that reaches a release breaks crash - reporting for every SDK pinned to that version. -2. **aarch64 is untested at runtime** until 4.4 lands — the raw-syscall and - `arch_seed` code there is the most likely place for a silent, shipped bug. -3. **The std-feature re-plumbing has the widest blast radius** — 5.4's checks - are not optional on any PR that touches `Cargo.toml`. -4. **Refactors and behavior changes must not share a commit** (1.1's split is - safe exactly because it moves code verbatim; hold that line in review). From 3ae9e999baea149cd5e35b9511f91b9597bebd4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 03:51:50 +0200 Subject: [PATCH 13/32] docs(crashtracker): add signal-safe collector cleanup plan Co-Authored-By: Claude Fable 5 --- plan.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..467a7bf21d --- /dev/null +++ b/plan.md @@ -0,0 +1,105 @@ +# Plan: simplify and clean up the signal-safe crashtracker + +Theme: **code reuse and sharing**. The signal-safe collector (`libdd-crashtracker/src/collector_signal_safe/`, ~4,000 lines) currently shares almost nothing with the rest of the workspace except the wire protocol constants (`protocol.rs`) and two freshly split shared modules (`shared/signals.rs`, `shared/defaults.rs`). Everything else — syscall wrappers, signal-name tables, config wire schema, metadata tag sets, fork/reap logic — exists twice. This plan removes internal duplication first, then makes the signal-safe primitives the single source of truth that the std collector and `libdd-common` reuse, making other code more no_std-compatible where needed. + +What is already right and should stay as-is (the model to follow): + +- `protocol.rs` is the single source of the `DD_CRASHTRACK_*` markers; both emitters and the receiver parser consume it, verified by the round-trip test in `receiver/mod.rs:109-181`. +- `signal_owner.rs` is a minimal, no_std-clean arbiter shared by both collectors. +- The two **emitters** and two **backtrace strategies** stay separate by design (see Non-goals). + +--- + +## Phase 1 — In-module cleanup (no cross-module churn, no behavior change) + +Quick deletions and consolidations inside `collector_signal_safe/` and the FFI crate. + +1. **`fmt.rs`**: collapse `hex_addr` and `hex_u32` (`fmt.rs:8-40`) — byte-identical nibble loops — into one generic helper (`fn hex(value: u64, digits: usize)` or two thin wrappers over a shared core). +2. **`config.rs`**: delete `eq()` (`config.rs:372`) — `&[u8] == &[u8]` works in core. Keep only `eq_ic` (case-insensitive), which is genuinely needed. +3. **`policy.rs`**: remove the dead `SignalContext` struct and its `is_genuine_fault` method (`policy.rs:27-38`) plus the `mod.rs:55` re-export. Production only calls the free functions; port its tests to the free function. +4. **`sys.rs`**: remove the never-called `syscall0`, and remove the blanket `#![allow(dead_code)]` at `sys.rs:4` so future dead code is caught by the compiler instead of hidden. +5. **`handler.rs`**: factor the shared body of `init_result` / `init_from_env_result` (`handler.rs:73-132`) — they differ only in the `prepare_*` call; take the prepared config as a parameter. Merge the overlapping `reset_handlers_to_default` / `reset_signal_to_default` (`handler.rs:293-306`) into one function over a slice. +6. **`emitter.rs`**: consolidate the three near-parallel entry points (`emit_report`, `emit_minimal_report`, `emit_report_with_metadata`, `emitter.rs:102-147`) into one section-sequence driver parameterized by what is available (frames present or not, metadata source). One place defines section order; the variants become thin wrappers. +7. **CSTR reader dedup**: `sys.rs:651 cstr_bytes_bounded` and `libdd-crashtracker-ffi/src/collector_signal_safe.rs:193 cstr_bytes` are the same bounded loop (same `CSTR_MAX_LEN = 4096`). Export one from the library crate and use it from FFI. + +Estimated deletion: ~150–200 lines, plus a compiler-enforced dead-code guarantee going forward. + +## Phase 2 — Shrink `sys.rs` by finishing the rustix migration + +`sys.rs` (743 lines) is inconsistent: `write/close/pipe/getpid/gettid/nanosleep/clock_gettime` already go through **rustix** (`sys.rs:272-457`), while `dup3, fcntl, close_range, openat, faccessat, mprotect, kill, wait4, process_vm_readv` still use hand-written `asm!` wrappers `syscall1..syscall6` (`sys.rs:67-270`). rustix's linux-raw backend issues raw syscalls (no libc, async-signal-safe), so the split buys nothing. + +1. Route all remaining wrappers through rustix (already a dependency with the right feature set, `Cargo.toml:96`). Verify each call is available in the pinned rustix version and covered by the enabled features (`event,fs,pipe,process,stdio,thread,time` — may need `mm` for mprotect and `process` for pidfd/wait variants). +2. **Keep exactly one raw-asm path**: `fork_raw` via raw `clone(SIGCHLD)` (`sys.rs:368-401`) — libc `fork()` runs atfork handlers, and rustix deliberately doesn't wrap clone-as-fork. Document why it is the sole exception. +3. Delete the now-unused `syscallN` layer and its per-arch `asm!` blocks. +4. **Replace the hand-rolled `environ` walker** (`sys.rs:623-690`, consumed by `config.rs:251-283`): `prepare_from_env` runs at **config time**, not in the signal handler, so the async-signal-safe raw-`environ` machinery is over-built. Use `libc::getenv` (still no_std-friendly, no alloc) or `std::env::var_os` behind the existing std test gate. Keep `strip_loader_injection_env` (`handler.rs:354-374`) as-is — that one genuinely runs in the fork child and must stay raw. +5. Extend `tools/check_signal_safe_symbols.sh` to the post-migration symbol set so the rustix migration is proven not to pull in banned symbols (malloc, pthread locks, stdio) on the crash path. Run it before and after as the acceptance gate for this phase. + +Estimated deletion: ~300+ lines of asm and env machinery; `sys.rs` becomes mostly a thin, documented rustix facade plus `fork_raw`. + +## Phase 3 — Promote `sys.rs` to the shared signal-safe primitive layer + +This is the biggest overlap: the old collector and `libdd-common/unix_utils` maintain a second, *less safe* implementation of fork/exec/reap that the improvement doc (`crashtracker-work-we-need-to-do.md:82-116`) already flags as "audit or replace". + +1. **Placement**: keep the primitives inside `libdd-crashtracker` initially — move `collector_signal_safe/sys.rs` (post Phase 2) to `libdd-crashtracker/src/signal_safe_sys/` (crate-visible, compiled whenever either collector feature is on). Promoting to a standalone crate (`libdd-signal-safe`) is deferred until a second crate actually needs it; don't create a crate on speculation. `libdd-common` is the wrong home — it is std/tokio-heavy and the dependency arrow should point the other way. +2. **Retire crash-path usage of `libdd-common/unix_utils` from the std collector**, replacing with the shared primitives. This kills three known bugs for free: + - `collector_manager.rs:100-102` closes fds 0/1/2 before writing to `uds_fd`, destroying the crash socket if it landed on a low fd → use `sanitize_clone`-style `F_DUPFD` relocation (`handler.rs:259-291`). + - `run_collector_child` never resets crash signals to `SIG_DFL` → reuse the shared reset helper. + - `eprintln!` on signal/fork-child paths (`signal_handler_manager.rs`, `collector_manager.rs`, `process_handle.rs`) → reuse the fixed-buffer `crash_debug` raw-write path (`handler.rs:243-257`). + - `fork.rs::is_being_traced` (std::fs::File + UTF-8 parse on the signal path) → raw read via the shared primitives or drop from the crash path. +3. **Reap semantics**: replace `libdd-common/process.rs` busy-wait reap on the crash path with the bounded `reap_or_kill` (`handler.rs:490-515`). +4. `libdd-common/unix_utils/{fork,execve,process}.rs` keep their std API for non-crash users, but their crash-path callers are gone; mark them accordingly (doc comment: not async-signal-safe, must not be called from signal context). + +Note: this phase changes old-collector behavior (fixes bugs, changes reap timing). It needs its own PR with the e2e suites of *both* collectors green, and is the natural place to add the doc's missing child-sanitation tests for the std collector. + +## Phase 4 — Single source of truth for shared data (tables, schemas, mirrors) + +Each of these is currently two definitions kept in sync only by a test. Invert that: one definition, and the test becomes a compatibility check against the receiver, not a sync check between twins. + +1. **Signal/si_code names**: `collector_signal_safe/signal_names.rs` (pure const matches, no_std) vs `crash_info/sig_info.rs` (`SignalNames`/`SiCodes` serde enums backed by C code in `emit_sicodes.c`), synced only by `receiver/mod.rs:183-246`. Make `signal_names.rs` the canonical no_std table (move to `shared/`), and have `crash_info`'s human-readable strings derive from it — ideally letting the C `translate_si_code_impl` path retire. Keep the receiver round-trip test. +2. **Wire config schema**: `config.rs:122-139 WireConfig` is a no_std mirror of `CrashtrackerConfiguration`'s serialized form, synced by golden-bytes tests. Define the wire schema once as a no_std serializable struct in `shared/`, have `CrashtrackerConfiguration` convert into it, and serialize the same struct with `serde_json` (std path) and `serde_json_core` (signal-safe path). The golden test then guards the wire contract, not twin drift. +3. **Metadata tag set**: `emitter.rs:196-234` hardcodes the dd-trace-c native tag list; `crash_info::Metadata` has the overlapping semantics. Extract a shared tag-name/order definition (const slice of tag keys in `shared/`) that both the signal-safe emitter and any future std-side preload-metadata builder consume (this also pre-builds doc item 10). +4. **FFI mirror enums**: `InitResult`/`Stage` vs `SignalSafeInitResult`/`SignalSafeStage` are field-for-field mirrors synced by a value-equality test (`collector_signal_safe.rs:209`). Since the C FFI explicitly has no ABI-stability guarantee (per AGENTS.md), make the library enums `#[repr(C)]` and re-export them through cbindgen directly, deleting the mirrors and the sync test. Same consideration for `SignalSafeConfig` vs `SignalSafeInitConfig` — the lifetime-carrying `&[u8]` fields likely force keeping the config mirror, but the enums don't need one. Regenerate cbindgen headers and run `cargo ffi-test`. +5. **`protocol.rs` dead-marker hygiene**: the `#[allow(dead_code)]` on unused markers (WHOLE_STACKTRACE, COUNTERS, …) is fine — they're the shared protocol used by the std emitter — but move the allow to the macro call sites that need it rather than blanket, so genuinely dead markers surface. + +## Phase 5 (optional, behavior change) — std collector adopts `policy.rs` + +`policy.rs` is pure, no_std, heavily tested chaining/genuine-fault logic (re-fault vs re-raise, `SI_USER`/`SI_TKILL` filtering, Mode A/B). The old `signal_handler_manager.rs:113-130` chaining is strictly weaker (always `raise`, no genuine-fault filter — risk #5 in the improvement doc). Having the std collector call into `policy.rs` closes that gap with shared, already-tested code. + +This changes what the std collector reports (external `kill` no longer becomes crash telemetry) and how it chains (re-fault on synchronous faults). It's the right direction per the improvement doc, but it is a **product behavior change**, not a cleanup — do it as its own PR, opt-in or with explicit sign-off, after Phases 1–4. + +## Non-goals (explicitly out of scope) + +- **Do not merge the two emitters.** The std emitter (`collector/emitters.rs`: 15 sections, `/proc` reads, serde_json) and the signal-safe emitter (fixed minimal section list, serde-json-core, heapless) serve different contracts; they already share `protocol.rs`, which is the correct amount of sharing. +- **Do not make the libunwind backtrace path signal-safe** or unify it with the frame-pointer/`process_vm_readv` walk. Different strategies by design (improvement doc item 11). +- **Do not port `saguard.rs`'s RAII pattern into the signal-safe path** — its `Drop`-based restore is exactly the `siglongjmp` hazard the new code avoids with explicit enter/leave markers. +- **No new crate yet** (see Phase 3 placement). +- Feature work from `crashtracker-work-we-need-to-do.md` (sigaction PLT interposition, receiver path discovery, seccomp probing, packaging) is tracked there, not here. + +## Sequencing and validation + +Suggested PR split (each independently green): + +1. Phase 1 (pure cleanup) — one PR. +2. Phase 2 (rustix migration + env walker removal) — one PR, gated on `tools/check_signal_safe_symbols.sh` before/after plus the golden fixture test (`tests/fixtures/signal_safe_report.golden`) staying byte-identical. +3. Phase 3 (shared primitives + std-collector adoption) — one PR, both collectors' e2e suites green (`collector_signal_safe_e2e.rs` and the std collector tests). +4. Phase 4 — can be split per item (4.1 signal names, 4.2 wire config, 4.4 FFI enums are independent). +5. Phase 5 — separate, explicit sign-off. + +Per-change validation (per AGENTS.md): + +```bash +cargo check -p libdd-crashtracker +cargo +nightly-2026-02-08 fmt --all -- --check +cargo +stable clippy --workspace --all-targets --all-features -- -D warnings +cargo nextest run -p libdd-crashtracker --features libdd-crashtracker/generate-unit-test-files +cargo nextest run --workspace --no-fail-fast +cargo test --doc +cargo ffi-test # Phases 3–4 touch FFI +./tools/check_signal_safe_symbols.sh # every phase — the crash-path symbol guard is the safety net +``` + +Invariants that must hold at every step: + +- The emitted wire bytes stay identical (golden fixture) unless a phase explicitly says otherwise — none of Phases 1–4 should change the wire format. +- No new symbols on the crash path: no alloc, no locks, no stdio, no `Drop`-bearing state across the app-first call. +- The signal-safe module keeps compiling with only its declared no_std-friendly deps (`heapless`, `libc`, `rustix`, `serde`, `serde-json-core`); anything moved into `shared/` for reuse must itself stay no_std-clean (core-only, no `std::` imports) so the sharing direction is std-code-depends-on-no_std-code, never the reverse. From 21744823184f4a506131ad42d6a695bbeee4a8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 04:19:32 +0200 Subject: [PATCH 14/32] refactor(crashtracker): clean up signal-safe collector --- .../src/collector_signal_safe.rs | 17 +- .../src/collector_signal_safe/config.rs | 20 +- .../src/collector_signal_safe/emitter.rs | 191 ++++++++++++------ .../src/collector_signal_safe/fmt.rs | 21 +- .../src/collector_signal_safe/handler.rs | 56 ++--- .../src/collector_signal_safe/mod.rs | 4 +- .../src/collector_signal_safe/policy.rs | 32 +-- .../src/collector_signal_safe/sys.rs | 45 +---- plan.md | 105 ---------- 9 files changed, 167 insertions(+), 324 deletions(-) delete mode 100644 plan.md diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs index d3a6a02f17..d21cf7bc65 100644 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -5,12 +5,11 @@ use std::ffi::c_char; use std::panic::{catch_unwind, AssertUnwindSafe}; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, capability_bits, degradation_bits, init_from_env_result, init_result, - owned_signal_count, owns_signal, set_stage, shutdown, InitResult, SignalSafeInitConfig, Stage, + bootstrap_complete, capability_bits, cstr_bytes_bounded, degradation_bits, + init_from_env_result, init_result, owned_signal_count, owns_signal, set_stage, shutdown, + InitResult, SignalSafeInitConfig, Stage, }; -const CSTR_MAX_LEN: usize = 4096; - #[repr(C)] pub struct SignalSafeConfig { pub receiver_path: *const c_char, @@ -191,15 +190,7 @@ fn ffi_u32(f: impl FnOnce() -> u32) -> u32 { } unsafe fn cstr_bytes<'a>(ptr: *const c_char) -> &'a [u8] { - if ptr.is_null() { - &[] - } else { - let mut len = 0usize; - while len < CSTR_MAX_LEN && *ptr.add(len) != 0 { - len += 1; - } - std::slice::from_raw_parts(ptr.cast(), len) - } + unsafe { cstr_bytes_bounded(ptr) } } #[cfg(test)] diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 9c70a1bd49..444962e305 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -369,20 +369,6 @@ fn validate(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { Ok(()) } -fn eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - let mut i = 0usize; - while i < a.len() { - if a[i] != b[i] { - return false; - } - i += 1; - } - true -} - fn eq_ic(a: &[u8], lower: &[u8]) -> bool { if a.len() != lower.len() { return false; @@ -399,14 +385,14 @@ fn eq_ic(a: &[u8], lower: &[u8]) -> bool { fn is_false(v: Option<&[u8]>) -> bool { match v { - Some(s) => eq(s, b"0") || eq_ic(s, b"false") || eq_ic(s, b"f"), + Some(s) => s == b"0" || eq_ic(s, b"false") || eq_ic(s, b"f"), None => false, } } fn is_true(v: Option<&[u8]>) -> bool { match v { - Some(s) => eq(s, b"1") || eq_ic(s, b"true") || eq_ic(s, b"t"), + Some(s) => s == b"1" || eq_ic(s, b"true") || eq_ic(s, b"t"), None => false, } } @@ -427,7 +413,7 @@ fn parse_log_level(v: Option<&[u8]>) -> i32 { (b"trace", 5), ]; for (name, level) in LEVELS { - if eq(s, name) { + if s == name { return level; } } diff --git a/libdd-crashtracker/src/collector_signal_safe/emitter.rs b/libdd-crashtracker/src/collector_signal_safe/emitter.rs index f1f73a8319..4260598a5f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/emitter.rs +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -7,7 +7,7 @@ use serde::Serialize; use super::fmt::hex_u32; use super::fmt::write_i32; use super::report::{ - CrashContext, Frame, Metadata, ProcInfo, Report, Tag, Tags, MESSAGE_CAPACITY, + CrashContext, Frame, Metadata, ProcInfo, Report, SignalInfo, Tag, Tags, MESSAGE_CAPACITY, SECTION_BUF_CAPACITY, }; use super::{capabilities, config, protocol, state}; @@ -53,6 +53,28 @@ impl Sink for SliceSink<'_> { } } +struct AdditionalTags<'a> { + stage: &'a str, + stackwalk_method: &'a str, + capability_bits: u32, + degradation_bits: u32, +} + +enum MetadataSource<'a> { + Report(&'a Report<'a>), + Provided(&'a Metadata<'a>), +} + +struct SectionSequence<'a> { + config_json: &'a str, + metadata: MetadataSource<'a>, + additional_tags: Option>, + emit_kind: bool, + signal: &'a SignalInfo, + procinfo: Option, + frames: Option<&'a [usize]>, +} + pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { if value.is_empty() { return true; @@ -66,33 +88,26 @@ pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { } pub fn emit_report(sink: &mut impl Sink, report: &Report<'_>, context: &CrashContext<'_>) -> bool { - if !emit_config(sink, report.config_json) - || !emit_metadata(sink, report) - || !emit_additional_tags( - sink, - report.stage_name, - report.stackwalk_method, - report.capability_bits, - report.degradation_bits, - ) - || !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 { + if !emit_section_sequence( + sink, + SectionSequence { + config_json: report.config_json, + metadata: MetadataSource::Report(report), + additional_tags: Some(AdditionalTags { + stage: report.stage_name, + stackwalk_method: report.stackwalk_method, + capability_bits: report.capability_bits, + degradation_bits: report.degradation_bits, + }), + emit_kind: true, + signal: &context.signal, + procinfo: Some(ProcInfo { pid: context.pid, tid: context.tid, - }, - protocol::DD_CRASHTRACK_END_PROCINFO, - ) - || !emit_stacktrace(sink, context.frames) - { + }), + frames: Some(context.frames), + }, + ) { return emit_truncated_tail(sink, report, context); } @@ -106,32 +121,26 @@ pub fn emit_report_with_metadata( stage_name: &str, context: &CrashContext<'_>, ) -> bool { - if !emit_config(sink, config_json) - || !emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_METADATA, - metadata, - protocol::DD_CRASHTRACK_END_METADATA, - ) - || !emit_additional_tags(sink, stage_name, "fp_pvr", 0, 0) - || !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 { + if !emit_section_sequence( + sink, + SectionSequence { + config_json, + metadata: MetadataSource::Provided(metadata), + additional_tags: Some(AdditionalTags { + stage: stage_name, + stackwalk_method: "fp_pvr", + capability_bits: 0, + degradation_bits: 0, + }), + emit_kind: true, + signal: &context.signal, + procinfo: Some(ProcInfo { pid: context.pid, tid: context.tid, - }, - protocol::DD_CRASHTRACK_END_PROCINFO, - ) - || !emit_stacktrace(sink, context.frames) - { + }), + frames: Some(context.frames), + }, + ) { capabilities::note_degraded(capabilities::DEGRADED_TRUNCATED); let _ = emit_additional_tags( sink, @@ -150,22 +159,82 @@ pub fn emit_minimal_report( sink: &mut impl Sink, config_json: &str, metadata: &Metadata<'_>, - signal: &super::report::SignalInfo, + signal: &SignalInfo, ) -> bool { - emit_config(sink, config_json) - && emit_json_section( + emit_section_sequence( + sink, + SectionSequence { + config_json, + metadata: MetadataSource::Provided(metadata), + additional_tags: None, + emit_kind: false, + signal, + procinfo: None, + frames: None, + }, + ) && emit_done(sink) +} + +fn emit_section_sequence(sink: &mut impl Sink, sequence: SectionSequence<'_>) -> bool { + if !emit_config(sink, sequence.config_json) || !emit_metadata_source(sink, sequence.metadata) { + return false; + } + + if let Some(tags) = sequence.additional_tags { + if !emit_additional_tags( + sink, + tags.stage, + tags.stackwalk_method, + tags.capability_bits, + tags.degradation_bits, + ) { + return false; + } + } + + if sequence.emit_kind && !emit_kind(sink) { + return false; + } + + if !emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_SIGINFO, + sequence.signal, + protocol::DD_CRASHTRACK_END_SIGINFO, + ) { + return false; + } + + if let Some(procinfo) = sequence.procinfo { + if !emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_PROCINFO, + &procinfo, + protocol::DD_CRASHTRACK_END_PROCINFO, + ) { + return false; + } + } + + if let Some(frames) = sequence.frames { + if !emit_stacktrace(sink, frames) { + return false; + } + } + + true +} + +fn emit_metadata_source(sink: &mut impl Sink, metadata: MetadataSource<'_>) -> bool { + match metadata { + MetadataSource::Report(report) => emit_metadata(sink, report), + MetadataSource::Provided(metadata) => emit_json_section( sink, protocol::DD_CRASHTRACK_BEGIN_METADATA, metadata, protocol::DD_CRASHTRACK_END_METADATA, - ) - && emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_SIGINFO, - signal, - protocol::DD_CRASHTRACK_END_SIGINFO, - ) - && emit_done(sink) + ), + } } pub fn emit_json_section( diff --git a/libdd-crashtracker/src/collector_signal_safe/fmt.rs b/libdd-crashtracker/src/collector_signal_safe/fmt.rs index 4d52143627..4fd6af6fe9 100644 --- a/libdd-crashtracker/src/collector_signal_safe/fmt.rs +++ b/libdd-crashtracker/src/collector_signal_safe/fmt.rs @@ -6,27 +6,18 @@ use heapless::String as HeaplessString; use super::report::FRAME_IP_CAPACITY; pub fn hex_addr(value: usize) -> HeaplessString { - let mut out = HeaplessString::new(); - let _ = out.push_str("0x"); - - for shift in (0..core::mem::size_of::() * 2).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 + hex(value as u64, core::mem::size_of::() * 2) } pub fn hex_u32(value: u32) -> HeaplessString<10> { + 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..8).rev() { + for shift in (0..digits).rev() { let nibble = ((value >> (shift * 4)) & 0xf) as u8; let ch = if nibble < 10 { b'0' + nibble diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index e9196cea6a..ecbe003837 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -71,30 +71,7 @@ pub fn init(config: &SignalSafeInitConfig<'_>) -> bool { } pub fn init_result(config: &SignalSafeInitConfig<'_>) -> InitResult { - let begin = state::begin_init(); - if let Err(err) = begin { - return begin_init_error(err); - } - if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { - state::fail_init(); - return InitResult::OwnerConflict; - } - if let Err(err) = config::prepare_result(config) { - signal_owner::release(SignalOwner::SignalSafeCollector); - state::fail_init(); - return prepare_error(err); - } - if !install_alt_stack_if_requested() { - signal_owner::release(SignalOwner::SignalSafeCollector); - state::fail_init(); - return InitResult::Failed; - } - install_all_handlers(); - state::set_stage(Stage::CrashtrackerInit); - state::INSTALLED.store(true, Ordering::Release); - state::finish_init(); - state::HANDLERS_ENABLED.store(true, Ordering::Release); - InitResult::Enabled + init_with_prepare(|| config::prepare_result(config)) } pub fn init_from_env() -> bool { @@ -105,6 +82,10 @@ pub fn init_from_env_result() -> InitResult { if config::disabled_by_env() { return InitResult::DisabledByConfig; } + init_with_prepare(config::prepare_from_env_result) +} + +fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> InitResult { let begin = state::begin_init(); if let Err(err) = begin { return begin_init_error(err); @@ -113,7 +94,7 @@ pub fn init_from_env_result() -> InitResult { state::fail_init(); return InitResult::OwnerConflict; } - if let Err(err) = config::prepare_from_env_result() { + if let Err(err) = prepare() { signal_owner::release(SignalOwner::SignalSafeCollector); state::fail_init(); return prepare_error(err); @@ -266,7 +247,7 @@ fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> i32 { keep_fd = relocated; } - reset_handlers_to_default(); + let _ = reset_signals_to_default(&config::CRASH_SIGNALS); if capabilities::has(capabilities::DEV_NULL) { let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); @@ -290,26 +271,17 @@ fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> i32 { keep_fd } -fn reset_handlers_to_default() { +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); } - for &sig in &config::CRASH_SIGNALS { - unsafe { - let _ = libc::sigaction(sig, &dfl, null_mut()); - } - } -} - -fn reset_signal_to_default(sig: 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); - libc::sigaction(sig, &dfl, null_mut()) == 0 + let mut ok = true; + for &sig in signals { + ok &= unsafe { libc::sigaction(sig, &dfl, null_mut()) == 0 }; } + ok } unsafe fn unblock_signal(sig: c_int) { @@ -614,7 +586,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m let saved_errno = sys::errno(); crash_debug(b"handler entered", sig); let disarmed_on_entry = - state::DISARM_ON_ENTRY.load(Ordering::Relaxed) && reset_signal_to_default(sig); + state::DISARM_ON_ENTRY.load(Ordering::Relaxed) && reset_signals_to_default(&[sig]); let idx = sig_index(sig); let has_info = !info.is_null(); @@ -686,7 +658,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m let action = chain_action(disposition_of_target(target.fn_ptr), has_info, si_code); match action { ChainAction::RestoreDefaultAndRefault | ChainAction::RestoreDefaultAndReraise => { - if !reset_signal_to_default(sig) { + if !reset_signals_to_default(&[sig]) { sys::exit_process(EXIT_CODE_FAILURE); } unsafe { diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index 5db1855a27..3c59a2eb2f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -52,7 +52,7 @@ pub use handler::{ }; pub use policy::{ app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, - should_run_app_first, ChainAction, Disposition, SignalContext, + should_run_app_first, ChainAction, Disposition, }; pub use report::{ CrashContext, Frame, Metadata, ProcInfo, Report, SignalInfo, Tag, Tags, FRAME_IP_CAPACITY, @@ -60,6 +60,8 @@ pub use report::{ }; pub use signal_names::*; pub use state::{set_stage, Stage}; +#[doc(hidden)] +pub use sys::cstr_bytes_bounded; pub use sys::FdSink; pub fn capability_bits() -> u32 { diff --git a/libdd-crashtracker/src/collector_signal_safe/policy.rs b/libdd-crashtracker/src/collector_signal_safe/policy.rs index 61f9ae6627..f176eb86a7 100644 --- a/libdd-crashtracker/src/collector_signal_safe/policy.rs +++ b/libdd-crashtracker/src/collector_signal_safe/policy.rs @@ -23,20 +23,6 @@ pub enum ChainAction { Resume, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct SignalContext { - pub has_siginfo: bool, - pub si_code: i32, - pub si_pid: i32, - pub self_pid: i32, -} - -impl SignalContext { - pub fn is_genuine_fault(self) -> bool { - is_genuine_fault(self.has_siginfo, self.si_code, self.si_pid, self.self_pid) - } -} - pub fn disposition_of(handler: *mut c_void) -> Disposition { match handler as usize { SIG_DFL_VALUE => Disposition::Default, @@ -121,26 +107,12 @@ mod tests { #[test] fn genuine_fault_filter_ignores_external_async_signal() { - let ctx = SignalContext { - has_siginfo: true, - si_code: SI_USER, - si_pid: 7, - self_pid: 9, - }; - - assert!(!ctx.is_genuine_fault()); + assert!(!is_genuine_fault(true, SI_USER, 7, 9)); } #[test] fn genuine_fault_filter_accepts_self_sent_async_signal() { - let ctx = SignalContext { - has_siginfo: true, - si_code: SI_USER, - si_pid: 9, - self_pid: 9, - }; - - assert!(ctx.is_genuine_fault()); + assert!(is_genuine_fault(true, SI_USER, 9, 9)); } #[test] diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index 7c168b4cc1..aeaed0b099 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -1,8 +1,6 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -#![allow(dead_code)] - use core::ffi::c_char; use super::Sink; @@ -64,20 +62,6 @@ mod raw { BorrowedFd::borrow_raw(fd) } - #[cfg(target_arch = "x86_64")] - #[inline] - unsafe fn syscall0(nr: i64) -> isize { - let ret: isize; - asm!( - "syscall", - inlateout("rax") nr as isize => ret, - lateout("rcx") _, - lateout("r11") _, - options(nostack, preserves_flags), - ); - ret - } - #[cfg(target_arch = "x86_64")] #[inline] unsafe fn syscall1(nr: i64, a0: usize) -> isize { @@ -172,19 +156,6 @@ mod raw { ret } - #[cfg(target_arch = "aarch64")] - #[inline] - unsafe fn syscall0(nr: i64) -> isize { - let ret: isize; - asm!( - "svc 0", - in("x8") nr, - lateout("x0") ret, - options(nostack), - ); - ret - } - #[cfg(target_arch = "aarch64")] #[inline] unsafe fn syscall1(nr: i64, a0: usize) -> isize { @@ -443,12 +414,6 @@ mod raw { let _ = rustix::thread::nanosleep(&ts); } - #[repr(C)] - struct KernelTimespec { - tv_sec: i64, - tv_nsec: i64, - } - pub fn monotonic_nanos() -> i64 { let ts = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic); ts.tv_sec @@ -615,11 +580,6 @@ pub use raw::{ pipe, poll_sleep_ms, read_own_mem, waitpid_nohang_status, write, }; -pub fn waitpid_nohang(pid: i32) -> i32 { - let mut status = 0i32; - waitpid_nohang_status(pid, &mut status) -} - pub fn env_get(name_nul: &[u8]) -> Option<&'static [u8]> { if name_nul.is_empty() || name_nul[name_nul.len() - 1] != 0 { return None; @@ -648,6 +608,11 @@ pub fn environ_ptr() -> *mut *mut c_char { unsafe { environ } } +/// Reads bytes from a NUL-terminated C string, capped at the signal-safe maximum length. +/// +/// # Safety +/// `p` must be null or point to readable memory containing a NUL byte within `CSTR_MAX_LEN` +/// bytes. The returned slice borrows from `p` and must not outlive that memory. pub unsafe fn cstr_bytes_bounded<'a>(p: *const c_char) -> &'a [u8] { if p.is_null() { return &[]; diff --git a/plan.md b/plan.md deleted file mode 100644 index 467a7bf21d..0000000000 --- a/plan.md +++ /dev/null @@ -1,105 +0,0 @@ -# Plan: simplify and clean up the signal-safe crashtracker - -Theme: **code reuse and sharing**. The signal-safe collector (`libdd-crashtracker/src/collector_signal_safe/`, ~4,000 lines) currently shares almost nothing with the rest of the workspace except the wire protocol constants (`protocol.rs`) and two freshly split shared modules (`shared/signals.rs`, `shared/defaults.rs`). Everything else — syscall wrappers, signal-name tables, config wire schema, metadata tag sets, fork/reap logic — exists twice. This plan removes internal duplication first, then makes the signal-safe primitives the single source of truth that the std collector and `libdd-common` reuse, making other code more no_std-compatible where needed. - -What is already right and should stay as-is (the model to follow): - -- `protocol.rs` is the single source of the `DD_CRASHTRACK_*` markers; both emitters and the receiver parser consume it, verified by the round-trip test in `receiver/mod.rs:109-181`. -- `signal_owner.rs` is a minimal, no_std-clean arbiter shared by both collectors. -- The two **emitters** and two **backtrace strategies** stay separate by design (see Non-goals). - ---- - -## Phase 1 — In-module cleanup (no cross-module churn, no behavior change) - -Quick deletions and consolidations inside `collector_signal_safe/` and the FFI crate. - -1. **`fmt.rs`**: collapse `hex_addr` and `hex_u32` (`fmt.rs:8-40`) — byte-identical nibble loops — into one generic helper (`fn hex(value: u64, digits: usize)` or two thin wrappers over a shared core). -2. **`config.rs`**: delete `eq()` (`config.rs:372`) — `&[u8] == &[u8]` works in core. Keep only `eq_ic` (case-insensitive), which is genuinely needed. -3. **`policy.rs`**: remove the dead `SignalContext` struct and its `is_genuine_fault` method (`policy.rs:27-38`) plus the `mod.rs:55` re-export. Production only calls the free functions; port its tests to the free function. -4. **`sys.rs`**: remove the never-called `syscall0`, and remove the blanket `#![allow(dead_code)]` at `sys.rs:4` so future dead code is caught by the compiler instead of hidden. -5. **`handler.rs`**: factor the shared body of `init_result` / `init_from_env_result` (`handler.rs:73-132`) — they differ only in the `prepare_*` call; take the prepared config as a parameter. Merge the overlapping `reset_handlers_to_default` / `reset_signal_to_default` (`handler.rs:293-306`) into one function over a slice. -6. **`emitter.rs`**: consolidate the three near-parallel entry points (`emit_report`, `emit_minimal_report`, `emit_report_with_metadata`, `emitter.rs:102-147`) into one section-sequence driver parameterized by what is available (frames present or not, metadata source). One place defines section order; the variants become thin wrappers. -7. **CSTR reader dedup**: `sys.rs:651 cstr_bytes_bounded` and `libdd-crashtracker-ffi/src/collector_signal_safe.rs:193 cstr_bytes` are the same bounded loop (same `CSTR_MAX_LEN = 4096`). Export one from the library crate and use it from FFI. - -Estimated deletion: ~150–200 lines, plus a compiler-enforced dead-code guarantee going forward. - -## Phase 2 — Shrink `sys.rs` by finishing the rustix migration - -`sys.rs` (743 lines) is inconsistent: `write/close/pipe/getpid/gettid/nanosleep/clock_gettime` already go through **rustix** (`sys.rs:272-457`), while `dup3, fcntl, close_range, openat, faccessat, mprotect, kill, wait4, process_vm_readv` still use hand-written `asm!` wrappers `syscall1..syscall6` (`sys.rs:67-270`). rustix's linux-raw backend issues raw syscalls (no libc, async-signal-safe), so the split buys nothing. - -1. Route all remaining wrappers through rustix (already a dependency with the right feature set, `Cargo.toml:96`). Verify each call is available in the pinned rustix version and covered by the enabled features (`event,fs,pipe,process,stdio,thread,time` — may need `mm` for mprotect and `process` for pidfd/wait variants). -2. **Keep exactly one raw-asm path**: `fork_raw` via raw `clone(SIGCHLD)` (`sys.rs:368-401`) — libc `fork()` runs atfork handlers, and rustix deliberately doesn't wrap clone-as-fork. Document why it is the sole exception. -3. Delete the now-unused `syscallN` layer and its per-arch `asm!` blocks. -4. **Replace the hand-rolled `environ` walker** (`sys.rs:623-690`, consumed by `config.rs:251-283`): `prepare_from_env` runs at **config time**, not in the signal handler, so the async-signal-safe raw-`environ` machinery is over-built. Use `libc::getenv` (still no_std-friendly, no alloc) or `std::env::var_os` behind the existing std test gate. Keep `strip_loader_injection_env` (`handler.rs:354-374`) as-is — that one genuinely runs in the fork child and must stay raw. -5. Extend `tools/check_signal_safe_symbols.sh` to the post-migration symbol set so the rustix migration is proven not to pull in banned symbols (malloc, pthread locks, stdio) on the crash path. Run it before and after as the acceptance gate for this phase. - -Estimated deletion: ~300+ lines of asm and env machinery; `sys.rs` becomes mostly a thin, documented rustix facade plus `fork_raw`. - -## Phase 3 — Promote `sys.rs` to the shared signal-safe primitive layer - -This is the biggest overlap: the old collector and `libdd-common/unix_utils` maintain a second, *less safe* implementation of fork/exec/reap that the improvement doc (`crashtracker-work-we-need-to-do.md:82-116`) already flags as "audit or replace". - -1. **Placement**: keep the primitives inside `libdd-crashtracker` initially — move `collector_signal_safe/sys.rs` (post Phase 2) to `libdd-crashtracker/src/signal_safe_sys/` (crate-visible, compiled whenever either collector feature is on). Promoting to a standalone crate (`libdd-signal-safe`) is deferred until a second crate actually needs it; don't create a crate on speculation. `libdd-common` is the wrong home — it is std/tokio-heavy and the dependency arrow should point the other way. -2. **Retire crash-path usage of `libdd-common/unix_utils` from the std collector**, replacing with the shared primitives. This kills three known bugs for free: - - `collector_manager.rs:100-102` closes fds 0/1/2 before writing to `uds_fd`, destroying the crash socket if it landed on a low fd → use `sanitize_clone`-style `F_DUPFD` relocation (`handler.rs:259-291`). - - `run_collector_child` never resets crash signals to `SIG_DFL` → reuse the shared reset helper. - - `eprintln!` on signal/fork-child paths (`signal_handler_manager.rs`, `collector_manager.rs`, `process_handle.rs`) → reuse the fixed-buffer `crash_debug` raw-write path (`handler.rs:243-257`). - - `fork.rs::is_being_traced` (std::fs::File + UTF-8 parse on the signal path) → raw read via the shared primitives or drop from the crash path. -3. **Reap semantics**: replace `libdd-common/process.rs` busy-wait reap on the crash path with the bounded `reap_or_kill` (`handler.rs:490-515`). -4. `libdd-common/unix_utils/{fork,execve,process}.rs` keep their std API for non-crash users, but their crash-path callers are gone; mark them accordingly (doc comment: not async-signal-safe, must not be called from signal context). - -Note: this phase changes old-collector behavior (fixes bugs, changes reap timing). It needs its own PR with the e2e suites of *both* collectors green, and is the natural place to add the doc's missing child-sanitation tests for the std collector. - -## Phase 4 — Single source of truth for shared data (tables, schemas, mirrors) - -Each of these is currently two definitions kept in sync only by a test. Invert that: one definition, and the test becomes a compatibility check against the receiver, not a sync check between twins. - -1. **Signal/si_code names**: `collector_signal_safe/signal_names.rs` (pure const matches, no_std) vs `crash_info/sig_info.rs` (`SignalNames`/`SiCodes` serde enums backed by C code in `emit_sicodes.c`), synced only by `receiver/mod.rs:183-246`. Make `signal_names.rs` the canonical no_std table (move to `shared/`), and have `crash_info`'s human-readable strings derive from it — ideally letting the C `translate_si_code_impl` path retire. Keep the receiver round-trip test. -2. **Wire config schema**: `config.rs:122-139 WireConfig` is a no_std mirror of `CrashtrackerConfiguration`'s serialized form, synced by golden-bytes tests. Define the wire schema once as a no_std serializable struct in `shared/`, have `CrashtrackerConfiguration` convert into it, and serialize the same struct with `serde_json` (std path) and `serde_json_core` (signal-safe path). The golden test then guards the wire contract, not twin drift. -3. **Metadata tag set**: `emitter.rs:196-234` hardcodes the dd-trace-c native tag list; `crash_info::Metadata` has the overlapping semantics. Extract a shared tag-name/order definition (const slice of tag keys in `shared/`) that both the signal-safe emitter and any future std-side preload-metadata builder consume (this also pre-builds doc item 10). -4. **FFI mirror enums**: `InitResult`/`Stage` vs `SignalSafeInitResult`/`SignalSafeStage` are field-for-field mirrors synced by a value-equality test (`collector_signal_safe.rs:209`). Since the C FFI explicitly has no ABI-stability guarantee (per AGENTS.md), make the library enums `#[repr(C)]` and re-export them through cbindgen directly, deleting the mirrors and the sync test. Same consideration for `SignalSafeConfig` vs `SignalSafeInitConfig` — the lifetime-carrying `&[u8]` fields likely force keeping the config mirror, but the enums don't need one. Regenerate cbindgen headers and run `cargo ffi-test`. -5. **`protocol.rs` dead-marker hygiene**: the `#[allow(dead_code)]` on unused markers (WHOLE_STACKTRACE, COUNTERS, …) is fine — they're the shared protocol used by the std emitter — but move the allow to the macro call sites that need it rather than blanket, so genuinely dead markers surface. - -## Phase 5 (optional, behavior change) — std collector adopts `policy.rs` - -`policy.rs` is pure, no_std, heavily tested chaining/genuine-fault logic (re-fault vs re-raise, `SI_USER`/`SI_TKILL` filtering, Mode A/B). The old `signal_handler_manager.rs:113-130` chaining is strictly weaker (always `raise`, no genuine-fault filter — risk #5 in the improvement doc). Having the std collector call into `policy.rs` closes that gap with shared, already-tested code. - -This changes what the std collector reports (external `kill` no longer becomes crash telemetry) and how it chains (re-fault on synchronous faults). It's the right direction per the improvement doc, but it is a **product behavior change**, not a cleanup — do it as its own PR, opt-in or with explicit sign-off, after Phases 1–4. - -## Non-goals (explicitly out of scope) - -- **Do not merge the two emitters.** The std emitter (`collector/emitters.rs`: 15 sections, `/proc` reads, serde_json) and the signal-safe emitter (fixed minimal section list, serde-json-core, heapless) serve different contracts; they already share `protocol.rs`, which is the correct amount of sharing. -- **Do not make the libunwind backtrace path signal-safe** or unify it with the frame-pointer/`process_vm_readv` walk. Different strategies by design (improvement doc item 11). -- **Do not port `saguard.rs`'s RAII pattern into the signal-safe path** — its `Drop`-based restore is exactly the `siglongjmp` hazard the new code avoids with explicit enter/leave markers. -- **No new crate yet** (see Phase 3 placement). -- Feature work from `crashtracker-work-we-need-to-do.md` (sigaction PLT interposition, receiver path discovery, seccomp probing, packaging) is tracked there, not here. - -## Sequencing and validation - -Suggested PR split (each independently green): - -1. Phase 1 (pure cleanup) — one PR. -2. Phase 2 (rustix migration + env walker removal) — one PR, gated on `tools/check_signal_safe_symbols.sh` before/after plus the golden fixture test (`tests/fixtures/signal_safe_report.golden`) staying byte-identical. -3. Phase 3 (shared primitives + std-collector adoption) — one PR, both collectors' e2e suites green (`collector_signal_safe_e2e.rs` and the std collector tests). -4. Phase 4 — can be split per item (4.1 signal names, 4.2 wire config, 4.4 FFI enums are independent). -5. Phase 5 — separate, explicit sign-off. - -Per-change validation (per AGENTS.md): - -```bash -cargo check -p libdd-crashtracker -cargo +nightly-2026-02-08 fmt --all -- --check -cargo +stable clippy --workspace --all-targets --all-features -- -D warnings -cargo nextest run -p libdd-crashtracker --features libdd-crashtracker/generate-unit-test-files -cargo nextest run --workspace --no-fail-fast -cargo test --doc -cargo ffi-test # Phases 3–4 touch FFI -./tools/check_signal_safe_symbols.sh # every phase — the crash-path symbol guard is the safety net -``` - -Invariants that must hold at every step: - -- The emitted wire bytes stay identical (golden fixture) unless a phase explicitly says otherwise — none of Phases 1–4 should change the wire format. -- No new symbols on the crash path: no alloc, no locks, no stdio, no `Drop`-bearing state across the app-first call. -- The signal-safe module keeps compiling with only its declared no_std-friendly deps (`heapless`, `libc`, `rustix`, `serde`, `serde-json-core`); anything moved into `shared/` for reuse must itself stay no_std-clean (core-only, no `std::` imports) so the sharing direction is std-code-depends-on-no_std-code, never the reverse. From eb2276079d3bef98b6ef56b60dac43c3d1ff2c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 04:29:37 +0200 Subject: [PATCH 15/32] docs(crashtracker): add signal-safe simplification plan Co-Authored-By: Claude Fable 5 --- signal_safe_cleanup_plan.md | 208 ++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 signal_safe_cleanup_plan.md diff --git a/signal_safe_cleanup_plan.md b/signal_safe_cleanup_plan.md new file mode 100644 index 0000000000..88b20a357b --- /dev/null +++ b/signal_safe_cleanup_plan.md @@ -0,0 +1,208 @@ +# Signal-safe crashtracker: simplification & code-reuse plan + +Theme: shrink `collector_signal_safe` (~4,000 lines / 12 files) by deleting dead +surface, replacing hand-rolled code with crates already in the dependency tree +(`rustix`, `libc`), and moving genuinely shared logic into the crate's existing +shared no_std layer (`protocol.rs`, `shared/`, `signal_owner.rs`) so both +collectors consume one copy. Where sharing requires it, the *other* code (std +collector, `crash_info`) is made more no_std — not the reverse. + +## Ground rules + +- The `collector_signal-safe` feature must keep working **without** `std` + (Cargo.toml:61 pulls only `heapless`, `libc`, `rustix`, `serde`, + `serde-json-core`). Anything shared with it must be no_std. +- Code reachable from `crash_handler` (handler.rs), `backtrace.rs`, + `emitter.rs`, `fmt.rs`, `sys.rs::raw`, and `capabilities::has/note_degraded` + runs in the signal handler or the forked child: replacements must be + async-signal-safe. rustix's `linux_raw` backend (direct syscalls, no libc + PLT, no locks) qualifies — the module already uses rustix in the handler for + `write`/`close`/`pipe`/`getpid`/`gettid`/`nanosleep`/`clock_gettime`, which + is the precedent. +- Wire format is the contract. The guards already exist and must stay green + throughout: the golden fixture test (`mod.rs::emitted_wire_matches_golden_fixture`), + the receiver round-trip test (`receiver/mod.rs:109`), the signal-name + compatibility test (`receiver/mod.rs:183`), and the e2e test + (`tests/collector_signal_safe_e2e.rs`). +- **"Dead" is per-target.** The support matrix (mod.rs:10-17) has three tiers + (Linux x86_64/aarch64, other-Linux, macOS/iOS). Before deleting anything, + confirm it is unused under every cfg: `cargo check` for + `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, an "other-arch" + Linux target, and `aarch64-apple-darwin`. In particular + `emit_minimal_report` is the macOS fallback per the support matrix — verify + before touching it. + +--- + +## Phase 1 — Shrink the public surface, delete dead code (low risk, do first) + +The real external surface (used by `libdd-crashtracker-ffi/src/collector_signal_safe.rs`, +`lib.rs`, and production `receiver/`) is only ~15 symbols: +`SignalSafeInitConfig`, `init`, `init_result`, `init_from_env`, +`init_from_env_result`, `bootstrap_complete`, `shutdown`, `InitResult`, +`set_stage`, `Stage`, `cstr_bytes_bounded`, `capability_bits`, +`degradation_bits`, `owned_signal_count`, `owns_signal`. + +1. Trim the `pub use` block in `collector_signal_safe/mod.rs:43-65` to that + list. Demote everything else to `pub(crate)` (receiver tests and mod.rs + tests are in-crate, so `pub(crate)` suffices for: `emit_report`, `Sink`, + `SliceSink`, `push_tag`, the `report.rs` structs, `fmt.rs` helpers, + `policy.rs` items, `rust_signal_name`/`rust_si_code_name`). +2. Delete outright (after the per-target cfg check above): + - `config::prepare` and `config::prepare_from_env` bool wrappers + (config.rs:171, 247) — all callers use the `_result` variants. + - The 7 unused `FPE_*` constants (signal_names.rs:102-107, all but + `FPE_INTDIV`) — `signal_specific_si_code_name` deliberately maps FPE to + `UNKNOWN`. (Superseded by Phase 3 if the constants move to libc anyway.) + - `emit_report_with_metadata` (emitter.rs) — no callers found on any + target; its truncation tail duplicates `emit_truncated_tail` + (emitter.rs:415-429). + - `emit_minimal_report` **only if** the cfg sweep shows the macOS path + doesn't call it. +3. After (2), collapse the `SectionSequence` / `MetadataSource` / + `AdditionalTags` indirection (emitter.rs:56-76, 178-226) into the remaining + live driver(s). This machinery only exists to serve three entry points; with + one or two left it should be straight-line code. +4. Micro-dedup in `handler.rs` / `capabilities.rs`: + - Fold the copy-pasted close-stdio arms in `sanitize_clone` + (handler.rs:261-270) into one block. + - Extract one "waitpid(WNOHANG) + monotonic deadline + SIGKILL + reap" + helper and use it from both `reap_or_kill` (handler.rs:462-487) and + `probe_process_vm_readv_in_child` (capabilities.rs:120-155); replace the + tail recursion in `reap_or_kill` with a loop. + - Inline the `disposition_of_target` pass-through (handler.rs:686-688). + - Funnel the repeated `mem::zeroed::()` + query pattern + (handler.rs:690, 743, 777, …) through the existing `query_sigaction`. + +Estimated effect: several hundred lines gone, emitter.rs substantially +simpler, public API honest. + +## Phase 2 — Replace hand-rolled sys.rs with rustix/libc (the big reuse win) + +`sys.rs` (708 lines) contains hand-written inline-asm `syscall1..6` for +x86_64/aarch64 plus a second, libc-based portable copy of the same API. rustix +is already a dependency and already used in the handler; the asm duplicates it. + +1. Migrate these wrappers to rustix (both the Linux-asm and portable copies + collapse into one implementation each): + + | sys.rs item | replacement | + |---|---| + | `dup2`/`dup3` (sys.rs:256) | `rustix::io::dup2` | + | `fcntl_dupfd` (sys.rs:263) | `rustix::io::fcntl_dupfd` | + | `fd_valid` (sys.rs:274) | `rustix::io::fcntl_getfd` | + | `close_range_from` (sys.rs:278) | `rustix::io::close_range` (`fs` feature, enabled) | + | `open_readwrite` (sys.rs:301) | `rustix::fs::openat` | + | `access_executable` (sys.rs:313) | `rustix::fs::accessat` | + | `kill` (sys.rs:390) | `rustix::process::kill_process` | + | `waitpid_nohang_status` (sys.rs:394) | `rustix::process::waitpid` | + | `exit_process` (sys.rs:374) | keep `libc::_exit` / `exit_group` raw (trivial) | + +2. `mprotect_none` (guard page): add the rustix `mm` feature to the + crashtracker's rustix dependency and use `rustix::mm::mprotect`, removing + another raw syscall. (Check feature-unification cost across the workspace; + if `mm` is unacceptable, keep this one raw.) +3. Keep genuinely uncovered syscalls raw, but shrink the asm layer to exactly + what they need: `fork_raw`/`clone(SIGCHLD)` (rustix `runtime` is unstable) + and `read_own_mem`/`process_vm_readv` (no rustix wrapper). That means one + or two `syscallN` helpers instead of six, or `libc::syscall` where + acceptable. +4. Delete the `errno`/`set_errno`/`__errno_location` shim (sys.rs:657-688) — + rustix returns `Errno` values directly; the only consumer is the portable + `raw::write` fallback, which moves to rustix too. +5. Replace the custom `IoVec` struct (sys.rs:424-428) with `libc::iovec`. +6. Verification for this phase, beyond the test suite: + - `tools/check_signal_safe_symbols.sh` must stay clean (this is the guard + that no libc PLT/alloc symbols crept into the handler path). + - e2e test on Linux; cross-`cargo check` for the target matrix. + +Estimated effect: sys.rs drops from ~700 to roughly 250-300 lines, the entire +per-arch `syscall2`/`syscall4` asm and the duplicated portable API copy go +away, and the module stops maintaining its own syscall ABI knowledge. + +## Phase 3 — One source of truth for signal/si_code naming (sharing theme) + +Today there are three parallel implementations, held together only by tests: +- std collector/receiver: `SignalNames`/`SiCodes` enums + C-backed + `translate_si_code` (`crash_info/sig_info.rs:63-256` + `emit_sicodes.c`); +- signal-safe: `rust_signal_name`/`rust_si_code_name` + ~35 hand-defined + numeric constants (`collector_signal_safe/signal_names.rs`); +- the compatibility test `receiver/mod.rs:183-246` asserting the two agree. + +Steps: +1. Source the numeric constants from `libc` (`libc::SEGV_MAPERR`, + `libc::SI_USER`, `libc::BUS_ADRALN`, …) instead of hand-defining them. + Keep the existing non-Linux `SI_TKILL` sentinel (signal_names.rs:80-82). + `libc` with `default-features = false` is no_std, so this costs nothing. +2. Move the name-mapping functions (`rust_signal_name`, `rust_si_code_name`, + `signal_has_address`) to the shared no_std layer — `shared/signal_names.rs`, + gated like `shared/signals.rs` so both `collector_signal-safe` and + `std`/`receiver` builds compile it. +3. Make the std side consume it ("make the other code more no_std"): replace + the C `translate_si_code_impl` FFI (`emit_sicodes.c`) with the shared Rust + mapping — `SiCodes`/`SignalNames` parse the shared strings or are generated + from the same table. This deletes a C file, a build-script compilation + unit, and the cross-language duplication. The existing compat test flips + from "two copies agree" to a plain unit test of the one copy. +4. Close the FPE gap while here: add the `FPE_*` variants to the receiver's + `SiCodes` model, then drop the "FPE reported as UNKNOWN" carve-out + (signal_names.rs:63-65). + +Step 3 is the largest item in this phase and can ship separately after 1-2; +it touches receiver-side data models, so the round-trip and errors_intake +tests are the guards. + +## Phase 4 — Optional sharing, only if it pays (evaluate, don't force) + +Ranked by value; each is skippable without hurting the phases above. + +1. **macOS frame-pointer walk**: the std collector's + `emit_macos_backtrace_from_ucontext` (collector/emitters.rs:306-368) + hand-rolls the same FP-chain walk as `collector_signal_safe/backtrace.rs` + (which additionally reads memory safely via `process_vm_readv`). Move the + walk into a shared no_std module and have the std collector call it — + deletes the weaker duplicate. +2. **fmt.rs**: `write_i32` is a hand-rolled `itoa`; the crate isn't in the + tree. 30 lines of tested code vs. a new dependency — recommend **keep** + as-is, just `pub(crate)`. +3. **Alt-stack + sigaction machinery**: the std versions + (signal_handler_manager.rs, saguard.rs) are nix/std-based by design; the + signal-safe versions exist precisely because those aren't + async-signal-safe. **Do not unify** — document the split instead. +4. **`probe_process_vm_readv_in_child`** (capabilities.rs:120): forks a child + at init to detect seccomp filtering, behind an off-by-default flag. If no + SDK asks for it, delete; otherwise leave (it's init-time only). + +## Sequencing & validation + +Order: Phase 1 → Phase 2 → Phase 3 (steps 1-2, then 3-4) → Phase 4 cherry-picks. +Each phase is a separate conventional commit +(`refactor(crashtracker): …` / `feat(crashtracker): …` for the FPE addition). + +After every phase: + +```bash +cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe +cargo check -p libdd-crashtracker # default features +cargo +nightly-2026-02-08 fmt --all -- --check +cargo +stable clippy --workspace --all-targets --all-features -- -D warnings +cargo nextest run -p libdd-crashtracker --features libdd-crashtracker/generate-unit-test-files +cargo nextest run --workspace --no-fail-fast +./tools/check_signal_safe_symbols.sh +``` + +Cross-target cfg sweep (needed for the "dead per-target" checks in Phases 1-2): +`cargo check` for `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, +`aarch64-apple-darwin`, and one non-FP-walk Linux arch. If FFI files are +touched (they shouldn't be — the ~15-symbol surface is preserved), run +`cargo ffi-test`. If `Cargo.lock` changes (rustix `mm` feature does not add +crates, but verify), regenerate `LICENSE-3rdparty.csv`. + +## Out of scope + +- Changing the wire protocol or receiver parsing (beyond the additive FPE + enum variants in Phase 3.4). +- Unifying the fork/exec/reap machinery with `libdd_common::unix_utils` — the + std versions allocate and are not async-signal-safe; that is the reason the + signal-safe module exists. +- Any change to the `signal_owner.rs` arbitration or the FFI ABI. From c523de6c8b44479dbff345d286aff94122a2badb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 14:22:56 +0200 Subject: [PATCH 16/32] refactor(crashtracker): simplify signal-safe collector Trim the collector_signal_safe public surface, remove dead emitter/config paths, reuse rustix/system helpers, and move signal/si-code names into shared Rust code. --- Cargo.lock | 13 - LICENSE-3rdparty.csv | 1 - ...ker-unified-runtime-stack-schema-v1_8.json | 10 +- libdd-crashtracker/Cargo.toml | 6 +- libdd-crashtracker/build.rs | 5 - .../src/collector_signal_safe/capabilities.rs | 24 +- .../src/collector_signal_safe/config.rs | 18 +- .../src/collector_signal_safe/emitter.rs | 183 ++-------- .../src/collector_signal_safe/handler.rs | 82 ++--- .../src/collector_signal_safe/mod.rs | 66 +--- .../src/collector_signal_safe/policy.rs | 17 +- .../src/collector_signal_safe/signal_names.rs | 130 +------ .../src/collector_signal_safe/sys.rs | 337 ++++++++++-------- .../src/crash_info/emit_sicodes.c | 121 ------- libdd-crashtracker/src/crash_info/sig_info.rs | 77 +++- libdd-crashtracker/src/receiver/mod.rs | 2 +- libdd-crashtracker/src/shared/mod.rs | 1 + libdd-crashtracker/src/shared/signal_names.rs | 257 +++++++++++++ 18 files changed, 617 insertions(+), 733 deletions(-) delete mode 100644 libdd-crashtracker/src/crash_info/emit_sicodes.c create mode 100644 libdd-crashtracker/src/shared/signal_names.rs diff --git a/Cargo.lock b/Cargo.lock index 9036a7d253..9331cdd55b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2968,8 +2968,6 @@ dependencies = [ "libdd-libunwind-sys", "libdd-telemetry", "nix 0.29.0", - "num-derive", - "num-traits", "os_info", "page_size", "portable-atomic", @@ -3882,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" diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 5718aef37b..ef84ef658f 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -257,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 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/libdd-crashtracker/Cargo.toml b/libdd-crashtracker/Cargo.toml index 688e40863d..342d8217b5 100644 --- a/libdd-crashtracker/Cargo.toml +++ b/libdd-crashtracker/Cargo.toml @@ -38,8 +38,6 @@ std = [ "dep:libdd-libunwind-sys", "dep:libdd-telemetry", "dep:nix", - "dep:num-derive", - "dep:num-traits", "dep:os_info", "dep:page_size", "dep:portable-atomic", @@ -87,13 +85,11 @@ libdd-telemetry = { version = "5.0.1", path = "../libdd-telemetry", optional = t 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 } -num-derive = { version = "0.4.2", optional = true } -num-traits = { version = "0.2.19", 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.3", default-features = false, features = ["event", "fs", "pipe", "process", "stdio", "thread", "time"], optional = true } +rustix = { version = "=1.1.3", default-features = false, features = ["event", "fs", "mm", "pipe", "process", "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 } diff --git a/libdd-crashtracker/build.rs b/libdd-crashtracker/build.rs index 1830af678e..b08230c2f3 100644 --- a/libdd-crashtracker/build.rs +++ b/libdd-crashtracker/build.rs @@ -119,11 +119,6 @@ fn main() { std::env::var("TARGET").unwrap() ); - #[cfg(feature = "std")] - 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_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index bf0892deae..9d4c96cb50 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -130,27 +130,9 @@ fn probe_process_vm_readv_in_child() -> bool { return true; } - const PROBE_TIMEOUT_MS: i64 = 100; - const PROBE_POLL_MS: i32 = 10; - let start = sys::monotonic_nanos(); - loop { - let mut status = 0i32; - let waited = sys::waitpid_nohang_status(child as i32, &mut status); - if waited == child as i32 { - return libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0; - } - if waited < 0 { - return true; - } - - let elapsed_ms = (sys::monotonic_nanos() - start) / 1_000_000; - if elapsed_ms >= PROBE_TIMEOUT_MS { - let _ = sys::kill(child as i32, libc::SIGKILL); - let mut status = 0i32; - let _ = sys::waitpid_nohang_status(child as i32, &mut status); - return true; - } - sys::poll_sleep_ms(PROBE_POLL_MS); + match sys::reap_child(child as i32, 100, 10, true, 10) { + sys::ChildReap::Reaped(status) => libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, + sys::ChildReap::NoChild | sys::ChildReap::WaitFailed(_) | sys::ChildReap::TimedOut => true, } } diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 444962e305..22f0d8e2b4 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -168,10 +168,6 @@ pub fn build_config_json( out.push_str(json).is_ok() && out.push('\n').is_ok() } -pub fn prepare(config: &SignalSafeInitConfig<'_>) -> bool { - prepare_result(config).is_ok() -} - pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { validate(config)?; @@ -244,10 +240,6 @@ pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareEr Ok(()) } -pub fn prepare_from_env() -> bool { - prepare_from_env_result().is_ok() -} - pub fn prepare_from_env_result() -> Result<(), PrepareError> { if disabled_by_env() { return Err(PrepareError::InvalidConfig); @@ -511,7 +503,7 @@ mod tests { .lock() .expect("test lock poisoned"); - assert!(prepare(&SignalSafeInitConfig { + assert!(prepare_result(&SignalSafeInitConfig { receiver_path: b"/tmp/receiver", service: b"svc", env: b"prod", @@ -522,7 +514,8 @@ mod tests { only_bootstrap: true, debug_logging: true, ..SignalSafeInitConfig::default() - })); + }) + .is_ok()); let meta = state::meta(); assert_eq!(meta.service.as_str(), "svc"); @@ -543,11 +536,12 @@ mod tests { .expect("test lock poisoned"); let oversized_service = "s".repeat(300); - assert!(prepare(&SignalSafeInitConfig { + assert!(prepare_result(&SignalSafeInitConfig { receiver_path: b"/definitely/missing-signal-safe-receiver", service: oversized_service.as_bytes(), ..SignalSafeInitConfig::default() - })); + }) + .is_ok()); assert_ne!( capabilities::degradations() & capabilities::DEGRADED_METADATA_TRUNCATED, diff --git a/libdd-crashtracker/src/collector_signal_safe/emitter.rs b/libdd-crashtracker/src/collector_signal_safe/emitter.rs index 4260598a5f..6cfad93a39 100644 --- a/libdd-crashtracker/src/collector_signal_safe/emitter.rs +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -7,7 +7,7 @@ use serde::Serialize; use super::fmt::hex_u32; use super::fmt::write_i32; use super::report::{ - CrashContext, Frame, Metadata, ProcInfo, Report, SignalInfo, Tag, Tags, MESSAGE_CAPACITY, + CrashContext, Frame, Metadata, ProcInfo, Report, Tag, Tags, MESSAGE_CAPACITY, SECTION_BUF_CAPACITY, }; use super::{capabilities, config, protocol, state}; @@ -16,11 +16,13 @@ pub trait Sink { fn put(&mut self, bytes: &[u8]) -> bool; } +#[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 } @@ -29,16 +31,9 @@ impl<'a> SliceSink<'a> { pub fn as_slice(&self) -> &[u8] { &self.buf[..self.len] } - - pub fn len(&self) -> usize { - self.len - } - - pub fn is_empty(&self) -> bool { - self.len == 0 - } } +#[cfg(test)] impl Sink for SliceSink<'_> { fn put(&mut self, bytes: &[u8]) -> bool { let Some(end) = self.len.checked_add(bytes.len()) else { @@ -53,28 +48,6 @@ impl Sink for SliceSink<'_> { } } -struct AdditionalTags<'a> { - stage: &'a str, - stackwalk_method: &'a str, - capability_bits: u32, - degradation_bits: u32, -} - -enum MetadataSource<'a> { - Report(&'a Report<'a>), - Provided(&'a Metadata<'a>), -} - -struct SectionSequence<'a> { - config_json: &'a str, - metadata: MetadataSource<'a>, - additional_tags: Option>, - emit_kind: bool, - signal: &'a SignalInfo, - procinfo: Option, - frames: Option<&'a [usize]>, -} - pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { if value.is_empty() { return true; @@ -88,153 +61,53 @@ pub fn push_tag(tags: &mut Tags, key: &str, value: &str) -> bool { } pub fn emit_report(sink: &mut impl Sink, report: &Report<'_>, context: &CrashContext<'_>) -> bool { - if !emit_section_sequence( - sink, - SectionSequence { - config_json: report.config_json, - metadata: MetadataSource::Report(report), - additional_tags: Some(AdditionalTags { - stage: report.stage_name, - stackwalk_method: report.stackwalk_method, - capability_bits: report.capability_bits, - degradation_bits: report.degradation_bits, - }), - emit_kind: true, - signal: &context.signal, - procinfo: Some(ProcInfo { - pid: context.pid, - tid: context.tid, - }), - frames: Some(context.frames), - }, - ) { + if !emit_report_sections(sink, report, context) { return emit_truncated_tail(sink, report, context); } emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) } -pub fn emit_report_with_metadata( +fn emit_report_sections( sink: &mut impl Sink, - config_json: &str, - metadata: &Metadata<'_>, - stage_name: &str, + report: &Report<'_>, context: &CrashContext<'_>, ) -> bool { - if !emit_section_sequence( - sink, - SectionSequence { - config_json, - metadata: MetadataSource::Provided(metadata), - additional_tags: Some(AdditionalTags { - stage: stage_name, - stackwalk_method: "fp_pvr", - capability_bits: 0, - degradation_bits: 0, - }), - emit_kind: true, - signal: &context.signal, - procinfo: Some(ProcInfo { - pid: context.pid, - tid: context.tid, - }), - frames: Some(context.frames), - }, - ) { - capabilities::note_degraded(capabilities::DEGRADED_TRUNCATED); - let _ = emit_additional_tags( - sink, - stage_name, - "fp_pvr", - 0, - capabilities::DEGRADED_TRUNCATED, - ); - return emit_message(sink, stage_name, &context.signal) && emit_done(sink); + if !emit_config(sink, report.config_json) || !emit_metadata(sink, report) { + return false; } - - emit_message(sink, stage_name, &context.signal) && emit_done(sink) -} - -pub fn emit_minimal_report( - sink: &mut impl Sink, - config_json: &str, - metadata: &Metadata<'_>, - signal: &SignalInfo, -) -> bool { - emit_section_sequence( + if !emit_additional_tags( sink, - SectionSequence { - config_json, - metadata: MetadataSource::Provided(metadata), - additional_tags: None, - emit_kind: false, - signal, - procinfo: None, - frames: None, - }, - ) && emit_done(sink) -} - -fn emit_section_sequence(sink: &mut impl Sink, sequence: SectionSequence<'_>) -> bool { - if !emit_config(sink, sequence.config_json) || !emit_metadata_source(sink, sequence.metadata) { + report.stage_name, + report.stackwalk_method, + report.capability_bits, + report.degradation_bits, + ) { return false; } - - if let Some(tags) = sequence.additional_tags { - if !emit_additional_tags( - sink, - tags.stage, - tags.stackwalk_method, - tags.capability_bits, - tags.degradation_bits, - ) { - return false; - } - } - - if sequence.emit_kind && !emit_kind(sink) { + if !emit_kind(sink) { return false; } - if !emit_json_section( sink, protocol::DD_CRASHTRACK_BEGIN_SIGINFO, - sequence.signal, + &context.signal, protocol::DD_CRASHTRACK_END_SIGINFO, ) { return false; } - - if let Some(procinfo) = sequence.procinfo { - if !emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_PROCINFO, - &procinfo, - protocol::DD_CRASHTRACK_END_PROCINFO, - ) { - return false; - } - } - - if let Some(frames) = sequence.frames { - if !emit_stacktrace(sink, frames) { - return false; - } - } - - true -} - -fn emit_metadata_source(sink: &mut impl Sink, metadata: MetadataSource<'_>) -> bool { - match metadata { - MetadataSource::Report(report) => emit_metadata(sink, report), - MetadataSource::Provided(metadata) => emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_METADATA, - metadata, - protocol::DD_CRASHTRACK_END_METADATA, - ), + if !emit_json_section( + sink, + protocol::DD_CRASHTRACK_BEGIN_PROCINFO, + &ProcInfo { + pid: context.pid, + tid: context.tid, + }, + protocol::DD_CRASHTRACK_END_PROCINFO, + ) { + return false; } + emit_stacktrace(sink, context.frames) } pub fn emit_json_section( diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index ecbe003837..36d43b6bc6 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -11,7 +11,7 @@ use super::state::{self, sig_index, BeginInitError, Stage}; use super::sys::{self, FdSink}; use super::{ app_handler_is_real, app_recovered, chain_action, is_genuine_fault, should_run_app_first, - write_i32, ChainAction, CrashContext, Disposition, Report, SignalInfo, + write_i32, ChainAction, CrashContext, Report, SignalInfo, }; use super::{backtrace, capabilities}; use crate::signal_owner::{self, SignalOwner}; @@ -259,18 +259,20 @@ fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> i32 { sys::close(devnull); } } else if close_stdio_without_devnull { - sys::close(libc::STDIN_FILENO); - sys::close(libc::STDOUT_FILENO); - sys::close(libc::STDERR_FILENO); + close_stdio(); } } else if close_stdio_without_devnull { - sys::close(libc::STDIN_FILENO); - sys::close(libc::STDOUT_FILENO); - sys::close(libc::STDERR_FILENO); + close_stdio(); } keep_fd } +fn close_stdio() { + sys::close(libc::STDIN_FILENO); + sys::close(libc::STDOUT_FILENO); + sys::close(libc::STDERR_FILENO); +} + fn reset_signals_to_default(signals: &[c_int]) -> bool { let mut dfl: libc::sigaction = unsafe { core::mem::zeroed() }; dfl.sa_sigaction = libc::SIG_DFL; @@ -460,29 +462,19 @@ fn emit_crash_report( } fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) -> Option { - let start = sys::monotonic_nanos(); - loop { - let mut status = 0i32; - let waited = sys::waitpid_nohang_status(pid, &mut status); - if waited == pid { - return Some(status); - } - if waited < 0 { - if waited != -libc::ECHILD { - crash_debug(b"waitpid failed", -1); - } - return None; - } - - sys::poll_sleep_ms(REAP_WAIT_INTERVAL_MS); - let elapsed_ms = (sys::monotonic_nanos() - start) / 1_000_000; - if elapsed_ms >= timeout_ms { - if kill_process { - let _ = sys::kill(pid, libc::SIGKILL); - return reap_or_kill(pid, REAP_KILL_TIMEOUT_MS, false); - } - return None; + match sys::reap_child( + pid, + timeout_ms, + REAP_WAIT_INTERVAL_MS, + kill_process, + 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, } } @@ -655,7 +647,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m flags: 0, }, }; - let action = chain_action(disposition_of_target(target.fn_ptr), has_info, si_code); + let action = chain_action(super::disposition_of(target.fn_ptr), has_info, si_code); match action { ChainAction::RestoreDefaultAndRefault | ChainAction::RestoreDefaultAndReraise => { if !reset_signals_to_default(&[sig]) { @@ -683,17 +675,8 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m } } -fn disposition_of_target(handler: *mut c_void) -> Disposition { - super::disposition_of(handler) -} - fn live_handler_for_recovery(sig: c_int) -> Option<*mut c_void> { - let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; - if query_sigaction(sig, &mut cur) { - Some(cur.sa_sigaction as *mut c_void) - } else { - None - } + query_sigaction(sig).map(|cur| cur.sa_sigaction as *mut c_void) } #[cfg(any( @@ -736,15 +719,19 @@ unsafe fn siginfo_addr(_info: *mut libc::siginfo_t) -> usize { 0 } -fn query_sigaction(sig: c_int, out: *mut libc::sigaction) -> bool { - unsafe { libc::sigaction(sig, null_mut(), out) == 0 } +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 mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; - if !query_sigaction(sig, &mut cur) { + 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 } @@ -774,10 +761,9 @@ fn restore_our_handler(sig: c_int) { } fn install_crash_handler(sig: c_int) { - let mut cur: libc::sigaction = unsafe { core::mem::zeroed() }; - if !query_sigaction(sig, &mut cur) { + 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) { diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index 3c59a2eb2f..eef2a771c5 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -40,29 +40,27 @@ mod sys; #[cfg(test)] pub(crate) static TEST_GLOBAL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -pub use config::{build_config_json, prepare, prepare_from_env, SignalSafeInitConfig}; -pub use emitter::{ - emit_json_section, emit_minimal_report, emit_report, emit_report_with_metadata, push_tag, Sink, - SliceSink, -}; -pub use fmt::{hex_addr, hex_u32, write_i32}; +pub use config::SignalSafeInitConfig; +#[cfg(test)] +pub(crate) use emitter::SliceSink; +pub(crate) use emitter::{emit_report, Sink}; +#[cfg(test)] +pub(crate) use fmt::hex_addr; +pub(crate) use fmt::write_i32; pub use handler::{ bootstrap_complete, init, init_from_env, init_from_env_result, init_result, shutdown, InitResult, }; -pub use policy::{ +pub(crate) use policy::{ app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, - should_run_app_first, ChainAction, Disposition, -}; -pub use report::{ - CrashContext, Frame, Metadata, ProcInfo, Report, SignalInfo, Tag, Tags, FRAME_IP_CAPACITY, - MAX_TAGS, MESSAGE_CAPACITY, SECTION_BUF_CAPACITY, TAG_CAPACITY, + should_run_app_first, ChainAction, }; -pub use signal_names::*; +pub(crate) use report::{CrashContext, Report, SignalInfo, SECTION_BUF_CAPACITY}; +#[cfg(test)] +pub(crate) use signal_names::*; pub use state::{set_stage, Stage}; #[doc(hidden)] pub use sys::cstr_bytes_bounded; -pub use sys::FdSink; pub fn capability_bits() -> u32 { capabilities::get() @@ -97,46 +95,6 @@ mod tests { assert_eq!(sink.as_slice(), b"abc"); } - #[test] - fn minimal_report_emits_json_sections() { - let mut metadata = Metadata::new("lib", "1.0.0", "native"); - assert!(push_tag(&mut metadata.tags, "stage", "application")); - - let signal = SignalInfo::new(libc::SIGSEGV, SEGV_MAPERR, 0x1234, true); - - let mut buf = [0u8; 1024]; - let mut sink = SliceSink::new(&mut buf); - assert!(emit_minimal_report( - &mut sink, - "{\"resolve_frames\":\"Disabled\"}", - &metadata, - &signal - )); - - let report = str::from_utf8(sink.as_slice()).unwrap(); - let metadata = section( - report, - "DD_CRASHTRACK_BEGIN_METADATA\n", - "DD_CRASHTRACK_END_METADATA\n", - ); - let signal = section( - report, - "DD_CRASHTRACK_BEGIN_SIGINFO\n", - "DD_CRASHTRACK_END_SIGINFO\n", - ); - - let metadata: serde_json::Value = serde_json::from_str(metadata.trim()).unwrap(); - let signal: serde_json::Value = serde_json::from_str(signal.trim()).unwrap(); - - assert_eq!(metadata["library_name"], "lib"); - assert_eq!(metadata["tags"][0], "stage:application"); - assert_eq!(signal["si_signo"], libc::SIGSEGV); - assert_eq!(signal["si_signo_human_readable"], "SIGSEGV"); - assert_eq!(signal["si_code_human_readable"], "SEGV_MAPERR"); - assert_eq!(signal["si_addr"], hex_addr(0x1234).as_str()); - assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); - } - #[test] fn oversized_metadata_still_terminates_report_with_degradation() { let signal = SignalInfo::new(libc::SIGSEGV, SEGV_ACCERR, 0x4321, true); diff --git a/libdd-crashtracker/src/collector_signal_safe/policy.rs b/libdd-crashtracker/src/collector_signal_safe/policy.rs index f176eb86a7..44b2c8d8de 100644 --- a/libdd-crashtracker/src/collector_signal_safe/policy.rs +++ b/libdd-crashtracker/src/collector_signal_safe/policy.rs @@ -3,7 +3,7 @@ use core::ffi::c_void; -use super::signal_names::{SI_TKILL, SI_USER}; +use super::signal_names::{SI_ASYNCIO, SI_MESGQ, SI_QUEUE, SI_SIGIO, SI_TIMER, SI_TKILL, SI_USER}; const SIG_DFL_VALUE: usize = 0; const SIG_IGN_VALUE: usize = 1; @@ -57,11 +57,24 @@ pub fn chain_action(disposition: Disposition, has_siginfo: bool, si_code: i32) - match disposition { Disposition::Ignore => ChainAction::Resume, Disposition::Handler => ChainAction::InvokeApp, - Disposition::Default if has_siginfo && si_code > 0 => ChainAction::RestoreDefaultAndRefault, + Disposition::Default if should_refault(has_siginfo, si_code) => { + ChainAction::RestoreDefaultAndRefault + } Disposition::Default => 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::*; diff --git a/libdd-crashtracker/src/collector_signal_safe/signal_names.rs b/libdd-crashtracker/src/collector_signal_safe/signal_names.rs index ab3839a2ac..2dd6c0c8c8 100644 --- a/libdd-crashtracker/src/collector_signal_safe/signal_names.rs +++ b/libdd-crashtracker/src/collector_signal_safe/signal_names.rs @@ -1,132 +1,4 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -pub fn rust_signal_name(signal: i32) -> &'static str { - match signal { - libc::SIGABRT => "SIGABRT", - libc::SIGBUS => "SIGBUS", - libc::SIGFPE => "SIGFPE", - libc::SIGILL => "SIGILL", - libc::SIGQUIT => "SIGQUIT", - libc::SIGSEGV => "SIGSEGV", - libc::SIGSYS => "SIGSYS", - libc::SIGTRAP => "SIGTRAP", - _ => "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), - } -} - -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", - _ => "UNKNOWN", - }, - libc::SIGBUS => match si_code { - BUS_ADRALN => "BUS_ADRALN", - BUS_ADRERR => "BUS_ADRERR", - BUS_OBJERR => "BUS_OBJERR", - _ => "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", - }, - // FPE_* values are deliberately reported as UNKNOWN until the receiver model has - // corresponding enum variants. - _ => "UNKNOWN", - } -} - -pub const SI_USER: i32 = 0; -pub const SI_KERNEL: i32 = 128; -pub const SI_QUEUE: i32 = -1; -pub const SI_TIMER: i32 = -2; -pub const SI_MESGQ: i32 = -3; -pub const SI_ASYNCIO: i32 = -4; -pub const SI_SIGIO: i32 = -5; - -#[cfg(any(target_os = "linux", target_os = "android"))] -pub const SI_TKILL: i32 = -6; - -#[cfg(not(any(target_os = "linux", target_os = "android")))] -// Non-Linux platforms do not define SI_TKILL; use a sentinel that cannot match a real si_code. -pub const SI_TKILL: i32 = i32::MIN; - -pub const SEGV_MAPERR: i32 = 1; -pub const SEGV_ACCERR: i32 = 2; - -pub const BUS_ADRALN: i32 = 1; -pub const BUS_ADRERR: i32 = 2; -pub const BUS_OBJERR: i32 = 3; - -pub const ILL_ILLOPC: i32 = 1; -pub const ILL_ILLOPN: i32 = 2; -pub const ILL_ILLADR: i32 = 3; -pub const ILL_ILLTRP: i32 = 4; -pub const ILL_PRVOPC: i32 = 5; -pub const ILL_PRVREG: i32 = 6; -pub const ILL_COPROC: i32 = 7; -pub const ILL_BADSTK: i32 = 8; - -pub const FPE_INTDIV: i32 = 1; -pub const FPE_INTOVF: i32 = 2; -pub const FPE_FLTDIV: i32 = 3; -pub const FPE_FLTOVF: i32 = 4; -pub const FPE_FLTUND: i32 = 5; -pub const FPE_FLTRES: i32 = 6; -pub const FPE_FLTINV: i32 = 7; -pub const FPE_FLTSUB: 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::SIGSEGV, SI_USER), "SI_USER"); - assert_eq!(rust_si_code_name(libc::SIGFPE, FPE_INTDIV), "UNKNOWN"); - assert_eq!(rust_si_code_name(libc::SIGSEGV, 999), "UNKNOWN"); - } -} +pub(crate) use crate::shared::signal_names::*; diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index aeaed0b099..fa389f8bc3 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -49,7 +49,8 @@ impl Sink for FdSink { ))] mod raw { use core::arch::asm; - use core::ffi::c_void; + use core::ffi::{c_void, CStr}; + use core::num::NonZeroI32; use rustix::fd::{BorrowedFd, IntoRawFd}; #[inline] @@ -77,22 +78,6 @@ mod raw { ret } - #[cfg(target_arch = "x86_64")] - #[inline] - unsafe fn syscall2(nr: i64, a0: usize, a1: usize) -> isize { - let ret: isize; - asm!( - "syscall", - inlateout("rax") nr as isize => ret, - in("rdi") a0, - in("rsi") a1, - lateout("rcx") _, - lateout("r11") _, - options(nostack, preserves_flags), - ); - ret - } - #[cfg(target_arch = "x86_64")] #[inline] unsafe fn syscall3(nr: i64, a0: usize, a1: usize, a2: usize) -> isize { @@ -110,24 +95,6 @@ mod raw { ret } - #[cfg(target_arch = "x86_64")] - #[inline] - unsafe fn syscall4(nr: i64, a0: usize, a1: usize, a2: usize, a3: 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, - lateout("rcx") _, - lateout("r11") _, - options(nostack, preserves_flags), - ); - ret - } - #[cfg(target_arch = "x86_64")] #[inline] unsafe fn syscall6( @@ -169,20 +136,6 @@ mod raw { ret } - #[cfg(target_arch = "aarch64")] - #[inline] - unsafe fn syscall2(nr: i64, a0: usize, a1: usize) -> isize { - let ret: isize; - asm!( - "svc 0", - in("x8") nr, - inlateout("x0") a0 => ret, - in("x1") a1, - options(nostack), - ); - ret - } - #[cfg(target_arch = "aarch64")] #[inline] unsafe fn syscall3(nr: i64, a0: usize, a1: usize, a2: usize) -> isize { @@ -198,22 +151,6 @@ mod raw { ret } - #[cfg(target_arch = "aarch64")] - #[inline] - unsafe fn syscall4(nr: i64, a0: usize, a1: usize, a2: usize, a3: usize) -> isize { - let ret: isize; - asm!( - "svc 0", - in("x8") nr, - inlateout("x0") a0 => ret, - in("x1") a1, - in("x2") a2, - in("x3") a3, - options(nostack), - ); - ret - } - #[cfg(target_arch = "aarch64")] #[inline] unsafe fn syscall6( @@ -261,18 +198,17 @@ mod raw { } pub fn fcntl_dupfd(fd: i32, min_fd: i32) -> i32 { - unsafe { - syscall3( - libc::SYS_fcntl, - fd as usize, - libc::F_DUPFD as usize, - min_fd as usize, - ) as 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) as i32, + }, + Err(err) => neg_errno(err) as i32, } } pub fn fd_valid(fd: i32) -> bool { - fd >= 0 && unsafe { syscall3(libc::SYS_fcntl, fd as usize, libc::F_GETFD as usize, 0) >= 0 } + fd >= 0 && rustix::io::fcntl_getfd(unsafe { borrowed_fd(fd) }).is_ok() } pub fn close_range_from(first_fd: i32) -> bool { @@ -299,37 +235,32 @@ mod raw { } pub fn open_readwrite(path: *const u8) -> i32 { - unsafe { - syscall4( - libc::SYS_openat, - libc::AT_FDCWD as usize, - path as usize, - libc::O_RDWR as usize, - 0, - ) as 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) as i32, } } pub fn access_executable(path: *const u8) -> bool { - unsafe { - syscall3( - libc::SYS_faccessat, - libc::AT_FDCWD as usize, - path as usize, - libc::X_OK as usize, - ) == 0 - } + 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 { - syscall3( - libc::SYS_mprotect, - addr as usize, - len, - libc::PROT_NONE as usize, - ) == 0 - } + unsafe { rustix::mm::mprotect(addr.cast(), len, rustix::mm::MprotectFlags::empty()) } + .is_ok() } pub fn fork_supported() -> bool { @@ -388,18 +319,30 @@ mod raw { } pub fn kill(pid: i32, sig: i32) -> i32 { - unsafe { syscall2(libc::SYS_kill, pid as usize, sig as usize) as 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) as i32, + } } pub fn waitpid_nohang_status(pid: i32, status: &mut i32) -> i32 { - unsafe { - syscall4( - libc::SYS_wait4, - pid as usize, - (status as *mut i32) as usize, - libc::WNOHANG as usize, - 0, - ) as 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) as i32, } } @@ -421,18 +364,12 @@ mod raw { .wrapping_add(ts.tv_nsec as i64) } - #[repr(C)] - struct IoVec { - iov_base: *mut c_void, - iov_len: usize, - } - pub fn read_own_mem(pid: i32, src: usize, dst: &mut [u8]) -> bool { - let local = IoVec { + let local = libc::iovec { iov_base: dst.as_mut_ptr() as *mut c_void, iov_len: dst.len(), }; - let remote = IoVec { + let remote = libc::iovec { iov_base: src as *mut c_void, iov_len: dst.len(), }; @@ -440,9 +377,9 @@ mod raw { syscall6( libc::SYS_process_vm_readv, pid as usize, - &local as *const IoVec as usize, + &local as *const libc::iovec as usize, 1, - &remote as *const IoVec as usize, + &remote as *const libc::iovec as usize, 1, 0, ) @@ -456,18 +393,30 @@ mod raw { any(target_arch = "x86_64", target_arch = "aarch64") )))] mod raw { + use core::ffi::CStr; + use core::num::NonZeroI32; + use rustix::fd::{BorrowedFd, IntoRawFd}; + + #[inline] + fn neg_errno(err: rustix::io::Errno) -> i32 { + -err.raw_os_error() + } + + #[inline] + unsafe fn borrowed_fd(fd: i32) -> BorrowedFd<'static> { + BorrowedFd::borrow_raw(fd) + } + pub fn write(fd: i32, bytes: &[u8]) -> isize { - let ret = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) }; - if ret < 0 { - -(super::errno() as isize) - } else { - ret + match rustix::io::write(unsafe { borrowed_fd(fd) }, bytes) { + Ok(n) => n as isize, + Err(err) => -(err.raw_os_error() as isize), } } pub fn close(fd: i32) { unsafe { - let _ = libc::close(fd); + rustix::io::close(fd); } } @@ -476,11 +425,17 @@ mod raw { } pub fn fcntl_dupfd(fd: i32, min_fd: i32) -> i32 { - unsafe { libc::fcntl(fd, libc::F_DUPFD, min_fd) } + 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 && unsafe { libc::fcntl(fd, libc::F_GETFD) >= 0 } + fd >= 0 && rustix::io::fcntl_getfd(unsafe { borrowed_fd(fd) }).is_ok() } pub fn close_range_from(_first_fd: i32) -> bool { @@ -488,19 +443,43 @@ mod raw { } pub fn pipe(fds: &mut [i32; 2]) -> bool { - unsafe { libc::pipe(fds.as_mut_ptr()) == 0 } + match rustix::pipe::pipe() { + Ok((read_fd, write_fd)) => { + fds[0] = read_fd.into_raw_fd(); + fds[1] = write_fd.into_raw_fd(); + true + } + Err(_) => false, + } } pub fn open_readwrite(path: *const u8) -> i32 { - unsafe { libc::open(path.cast(), libc::O_RDWR) } + 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 { - unsafe { libc::access(path.cast(), libc::X_OK) == 0 } + 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 { libc::mprotect(addr.cast(), len, libc::PROT_NONE) == 0 } + unsafe { rustix::mm::mprotect(addr.cast(), len, rustix::mm::MprotectFlags::empty()) } + .is_ok() } pub fn fork_supported() -> bool { @@ -518,7 +497,7 @@ mod raw { } pub fn getpid() -> i32 { - unsafe { libc::getpid() } + rustix::process::getpid().as_raw_pid() } pub fn gettid() -> i32 { @@ -537,36 +516,49 @@ mod raw { } pub fn kill(pid: i32, sig: i32) -> i32 { - unsafe { libc::kill(pid, sig) } + 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 ret = unsafe { libc::waitpid(pid, status, libc::WNOHANG) }; - if ret < 0 { - -super::errno() - } else { - ret + 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) { - unsafe { - let _ = libc::poll(core::ptr::null_mut(), 0, timeout_ms); + 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 mut ts = libc::timespec { - tv_sec: 0, - tv_nsec: 0, - }; - let rc = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; - if rc != 0 { - return 0; - } + let ts = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic); ts.tv_sec .wrapping_mul(1_000_000_000) - .wrapping_add(ts.tv_nsec) + .wrapping_add(ts.tv_nsec as i64) } pub fn read_own_mem(_pid: i32, _src: usize, _dst: &mut [u8]) -> bool { @@ -580,6 +572,59 @@ pub use raw::{ pipe, poll_sleep_ms, read_own_mem, 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_on_timeout: bool, + kill_timeout_ms: i64, +) -> ChildReap { + let mut remaining_timeout_ms = timeout_ms; + let mut should_kill = kill_on_timeout; + 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 env_get(name_nul: &[u8]) -> Option<&'static [u8]> { if name_nul.is_empty() || name_nul[name_nul.len() - 1] != 0 { return None; 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/sig_info.rs b/libdd-crashtracker/src/crash_info/sig_info.rs index 861de4ad39..1146cff1ea 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}; @@ -116,27 +115,20 @@ mod unix { } } - 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 +247,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 +289,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/receiver/mod.rs b/libdd-crashtracker/src/receiver/mod.rs index 897a3d58c3..f8640072de 100644 --- a/libdd-crashtracker/src/receiver/mod.rs +++ b/libdd-crashtracker/src/receiver/mod.rs @@ -220,7 +220,7 @@ mod tests { (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::UNKNOWN), + (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)?; diff --git a/libdd-crashtracker/src/shared/mod.rs b/libdd-crashtracker/src/shared/mod.rs index b95df1aa97..856e37a01f 100644 --- a/libdd-crashtracker/src/shared/mod.rs +++ b/libdd-crashtracker/src/shared/mod.rs @@ -4,6 +4,7 @@ //! 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; #[cfg(feature = "std")] diff --git a/libdd-crashtracker/src/shared/signal_names.rs b/libdd-crashtracker/src/shared/signal_names.rs new file mode 100644 index 0000000000..66af635528 --- /dev/null +++ b/libdd-crashtracker/src/shared/signal_names.rs @@ -0,0 +1,257 @@ +// 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::SIGABRT => "SIGABRT", + libc::SIGBUS => "SIGBUS", + libc::SIGFPE => "SIGFPE", + libc::SIGILL => "SIGILL", + libc::SIGQUIT => "SIGQUIT", + libc::SIGSEGV => "SIGSEGV", + libc::SIGSYS => "SIGSYS", + libc::SIGTRAP => "SIGTRAP", + _ => "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"); + } +} From 111d9651258468530d35c5ae76c1b52dcddca301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 15:26:22 +0200 Subject: [PATCH 17/32] refactor(crashtracker): derive signal-safe errors Use thiserror's no-std-capable derive for signal-safe initialization errors while keeping the std feature explicit for normal crashtracker builds. --- Cargo.lock | 2 +- libdd-crashtracker/Cargo.toml | 5 +++-- libdd-crashtracker/src/collector_signal_safe/config.rs | 5 ++++- libdd-crashtracker/src/collector_signal_safe/state.rs | 5 ++++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9331cdd55b..baf0b0208e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2980,7 +2980,7 @@ dependencies = [ "symbolic-common", "symbolic-demangle", "tempfile", - "thiserror 1.0.68", + "thiserror 2.0.17", "tokio", "uuid", "windows 0.59.0", diff --git a/libdd-crashtracker/Cargo.toml b/libdd-crashtracker/Cargo.toml index 342d8217b5..e93939d16e 100644 --- a/libdd-crashtracker/Cargo.toml +++ b/libdd-crashtracker/Cargo.toml @@ -48,6 +48,7 @@ std = [ "dep:symbolic-common", "dep:symbolic-demangle", "dep:thiserror", + "thiserror/std", "dep:tokio", "dep:uuid", "dep:windows", @@ -56,7 +57,7 @@ std = [ # Enables the in-process collection of crash-info 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"] +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 = ["std"] # Enables the collection of crash-info on Windows @@ -98,7 +99,7 @@ symbolic-demangle = { version = "12.8.0", default-features = false, features = [ 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 = "1.0", 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"], optional = true } diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 22f0d8e2b4..2a2fab19b8 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -5,6 +5,7 @@ use core::sync::atomic::Ordering::Relaxed; use heapless::String as HeaplessString; use serde::Serialize; +use thiserror::Error; use super::state::meta_mut; use super::{capabilities, state, sys}; @@ -83,9 +84,11 @@ pub struct SignalSafeInitConfig<'a> { pub probe_seccomp: bool, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[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, } diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index 1e93019219..b44fe001ee 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -8,6 +8,7 @@ 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::CONFIG_JSON_BUF_SIZE; @@ -63,9 +64,11 @@ const INIT_READY: i32 = 2; static INIT_STATE: AtomicI32 = AtomicI32::new(INIT_UNINIT); -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[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, } From bfaa12b8afd06b60b80443a99a5935ac2a89ec2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 15:46:39 +0200 Subject: [PATCH 18/32] docs(crashtracker): remove signal-safe cleanup plan --- signal_safe_cleanup_plan.md | 208 ------------------------------------ 1 file changed, 208 deletions(-) delete mode 100644 signal_safe_cleanup_plan.md diff --git a/signal_safe_cleanup_plan.md b/signal_safe_cleanup_plan.md deleted file mode 100644 index 88b20a357b..0000000000 --- a/signal_safe_cleanup_plan.md +++ /dev/null @@ -1,208 +0,0 @@ -# Signal-safe crashtracker: simplification & code-reuse plan - -Theme: shrink `collector_signal_safe` (~4,000 lines / 12 files) by deleting dead -surface, replacing hand-rolled code with crates already in the dependency tree -(`rustix`, `libc`), and moving genuinely shared logic into the crate's existing -shared no_std layer (`protocol.rs`, `shared/`, `signal_owner.rs`) so both -collectors consume one copy. Where sharing requires it, the *other* code (std -collector, `crash_info`) is made more no_std — not the reverse. - -## Ground rules - -- The `collector_signal-safe` feature must keep working **without** `std` - (Cargo.toml:61 pulls only `heapless`, `libc`, `rustix`, `serde`, - `serde-json-core`). Anything shared with it must be no_std. -- Code reachable from `crash_handler` (handler.rs), `backtrace.rs`, - `emitter.rs`, `fmt.rs`, `sys.rs::raw`, and `capabilities::has/note_degraded` - runs in the signal handler or the forked child: replacements must be - async-signal-safe. rustix's `linux_raw` backend (direct syscalls, no libc - PLT, no locks) qualifies — the module already uses rustix in the handler for - `write`/`close`/`pipe`/`getpid`/`gettid`/`nanosleep`/`clock_gettime`, which - is the precedent. -- Wire format is the contract. The guards already exist and must stay green - throughout: the golden fixture test (`mod.rs::emitted_wire_matches_golden_fixture`), - the receiver round-trip test (`receiver/mod.rs:109`), the signal-name - compatibility test (`receiver/mod.rs:183`), and the e2e test - (`tests/collector_signal_safe_e2e.rs`). -- **"Dead" is per-target.** The support matrix (mod.rs:10-17) has three tiers - (Linux x86_64/aarch64, other-Linux, macOS/iOS). Before deleting anything, - confirm it is unused under every cfg: `cargo check` for - `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, an "other-arch" - Linux target, and `aarch64-apple-darwin`. In particular - `emit_minimal_report` is the macOS fallback per the support matrix — verify - before touching it. - ---- - -## Phase 1 — Shrink the public surface, delete dead code (low risk, do first) - -The real external surface (used by `libdd-crashtracker-ffi/src/collector_signal_safe.rs`, -`lib.rs`, and production `receiver/`) is only ~15 symbols: -`SignalSafeInitConfig`, `init`, `init_result`, `init_from_env`, -`init_from_env_result`, `bootstrap_complete`, `shutdown`, `InitResult`, -`set_stage`, `Stage`, `cstr_bytes_bounded`, `capability_bits`, -`degradation_bits`, `owned_signal_count`, `owns_signal`. - -1. Trim the `pub use` block in `collector_signal_safe/mod.rs:43-65` to that - list. Demote everything else to `pub(crate)` (receiver tests and mod.rs - tests are in-crate, so `pub(crate)` suffices for: `emit_report`, `Sink`, - `SliceSink`, `push_tag`, the `report.rs` structs, `fmt.rs` helpers, - `policy.rs` items, `rust_signal_name`/`rust_si_code_name`). -2. Delete outright (after the per-target cfg check above): - - `config::prepare` and `config::prepare_from_env` bool wrappers - (config.rs:171, 247) — all callers use the `_result` variants. - - The 7 unused `FPE_*` constants (signal_names.rs:102-107, all but - `FPE_INTDIV`) — `signal_specific_si_code_name` deliberately maps FPE to - `UNKNOWN`. (Superseded by Phase 3 if the constants move to libc anyway.) - - `emit_report_with_metadata` (emitter.rs) — no callers found on any - target; its truncation tail duplicates `emit_truncated_tail` - (emitter.rs:415-429). - - `emit_minimal_report` **only if** the cfg sweep shows the macOS path - doesn't call it. -3. After (2), collapse the `SectionSequence` / `MetadataSource` / - `AdditionalTags` indirection (emitter.rs:56-76, 178-226) into the remaining - live driver(s). This machinery only exists to serve three entry points; with - one or two left it should be straight-line code. -4. Micro-dedup in `handler.rs` / `capabilities.rs`: - - Fold the copy-pasted close-stdio arms in `sanitize_clone` - (handler.rs:261-270) into one block. - - Extract one "waitpid(WNOHANG) + monotonic deadline + SIGKILL + reap" - helper and use it from both `reap_or_kill` (handler.rs:462-487) and - `probe_process_vm_readv_in_child` (capabilities.rs:120-155); replace the - tail recursion in `reap_or_kill` with a loop. - - Inline the `disposition_of_target` pass-through (handler.rs:686-688). - - Funnel the repeated `mem::zeroed::()` + query pattern - (handler.rs:690, 743, 777, …) through the existing `query_sigaction`. - -Estimated effect: several hundred lines gone, emitter.rs substantially -simpler, public API honest. - -## Phase 2 — Replace hand-rolled sys.rs with rustix/libc (the big reuse win) - -`sys.rs` (708 lines) contains hand-written inline-asm `syscall1..6` for -x86_64/aarch64 plus a second, libc-based portable copy of the same API. rustix -is already a dependency and already used in the handler; the asm duplicates it. - -1. Migrate these wrappers to rustix (both the Linux-asm and portable copies - collapse into one implementation each): - - | sys.rs item | replacement | - |---|---| - | `dup2`/`dup3` (sys.rs:256) | `rustix::io::dup2` | - | `fcntl_dupfd` (sys.rs:263) | `rustix::io::fcntl_dupfd` | - | `fd_valid` (sys.rs:274) | `rustix::io::fcntl_getfd` | - | `close_range_from` (sys.rs:278) | `rustix::io::close_range` (`fs` feature, enabled) | - | `open_readwrite` (sys.rs:301) | `rustix::fs::openat` | - | `access_executable` (sys.rs:313) | `rustix::fs::accessat` | - | `kill` (sys.rs:390) | `rustix::process::kill_process` | - | `waitpid_nohang_status` (sys.rs:394) | `rustix::process::waitpid` | - | `exit_process` (sys.rs:374) | keep `libc::_exit` / `exit_group` raw (trivial) | - -2. `mprotect_none` (guard page): add the rustix `mm` feature to the - crashtracker's rustix dependency and use `rustix::mm::mprotect`, removing - another raw syscall. (Check feature-unification cost across the workspace; - if `mm` is unacceptable, keep this one raw.) -3. Keep genuinely uncovered syscalls raw, but shrink the asm layer to exactly - what they need: `fork_raw`/`clone(SIGCHLD)` (rustix `runtime` is unstable) - and `read_own_mem`/`process_vm_readv` (no rustix wrapper). That means one - or two `syscallN` helpers instead of six, or `libc::syscall` where - acceptable. -4. Delete the `errno`/`set_errno`/`__errno_location` shim (sys.rs:657-688) — - rustix returns `Errno` values directly; the only consumer is the portable - `raw::write` fallback, which moves to rustix too. -5. Replace the custom `IoVec` struct (sys.rs:424-428) with `libc::iovec`. -6. Verification for this phase, beyond the test suite: - - `tools/check_signal_safe_symbols.sh` must stay clean (this is the guard - that no libc PLT/alloc symbols crept into the handler path). - - e2e test on Linux; cross-`cargo check` for the target matrix. - -Estimated effect: sys.rs drops from ~700 to roughly 250-300 lines, the entire -per-arch `syscall2`/`syscall4` asm and the duplicated portable API copy go -away, and the module stops maintaining its own syscall ABI knowledge. - -## Phase 3 — One source of truth for signal/si_code naming (sharing theme) - -Today there are three parallel implementations, held together only by tests: -- std collector/receiver: `SignalNames`/`SiCodes` enums + C-backed - `translate_si_code` (`crash_info/sig_info.rs:63-256` + `emit_sicodes.c`); -- signal-safe: `rust_signal_name`/`rust_si_code_name` + ~35 hand-defined - numeric constants (`collector_signal_safe/signal_names.rs`); -- the compatibility test `receiver/mod.rs:183-246` asserting the two agree. - -Steps: -1. Source the numeric constants from `libc` (`libc::SEGV_MAPERR`, - `libc::SI_USER`, `libc::BUS_ADRALN`, …) instead of hand-defining them. - Keep the existing non-Linux `SI_TKILL` sentinel (signal_names.rs:80-82). - `libc` with `default-features = false` is no_std, so this costs nothing. -2. Move the name-mapping functions (`rust_signal_name`, `rust_si_code_name`, - `signal_has_address`) to the shared no_std layer — `shared/signal_names.rs`, - gated like `shared/signals.rs` so both `collector_signal-safe` and - `std`/`receiver` builds compile it. -3. Make the std side consume it ("make the other code more no_std"): replace - the C `translate_si_code_impl` FFI (`emit_sicodes.c`) with the shared Rust - mapping — `SiCodes`/`SignalNames` parse the shared strings or are generated - from the same table. This deletes a C file, a build-script compilation - unit, and the cross-language duplication. The existing compat test flips - from "two copies agree" to a plain unit test of the one copy. -4. Close the FPE gap while here: add the `FPE_*` variants to the receiver's - `SiCodes` model, then drop the "FPE reported as UNKNOWN" carve-out - (signal_names.rs:63-65). - -Step 3 is the largest item in this phase and can ship separately after 1-2; -it touches receiver-side data models, so the round-trip and errors_intake -tests are the guards. - -## Phase 4 — Optional sharing, only if it pays (evaluate, don't force) - -Ranked by value; each is skippable without hurting the phases above. - -1. **macOS frame-pointer walk**: the std collector's - `emit_macos_backtrace_from_ucontext` (collector/emitters.rs:306-368) - hand-rolls the same FP-chain walk as `collector_signal_safe/backtrace.rs` - (which additionally reads memory safely via `process_vm_readv`). Move the - walk into a shared no_std module and have the std collector call it — - deletes the weaker duplicate. -2. **fmt.rs**: `write_i32` is a hand-rolled `itoa`; the crate isn't in the - tree. 30 lines of tested code vs. a new dependency — recommend **keep** - as-is, just `pub(crate)`. -3. **Alt-stack + sigaction machinery**: the std versions - (signal_handler_manager.rs, saguard.rs) are nix/std-based by design; the - signal-safe versions exist precisely because those aren't - async-signal-safe. **Do not unify** — document the split instead. -4. **`probe_process_vm_readv_in_child`** (capabilities.rs:120): forks a child - at init to detect seccomp filtering, behind an off-by-default flag. If no - SDK asks for it, delete; otherwise leave (it's init-time only). - -## Sequencing & validation - -Order: Phase 1 → Phase 2 → Phase 3 (steps 1-2, then 3-4) → Phase 4 cherry-picks. -Each phase is a separate conventional commit -(`refactor(crashtracker): …` / `feat(crashtracker): …` for the FPE addition). - -After every phase: - -```bash -cargo check -p libdd-crashtracker --no-default-features --features collector_signal-safe -cargo check -p libdd-crashtracker # default features -cargo +nightly-2026-02-08 fmt --all -- --check -cargo +stable clippy --workspace --all-targets --all-features -- -D warnings -cargo nextest run -p libdd-crashtracker --features libdd-crashtracker/generate-unit-test-files -cargo nextest run --workspace --no-fail-fast -./tools/check_signal_safe_symbols.sh -``` - -Cross-target cfg sweep (needed for the "dead per-target" checks in Phases 1-2): -`cargo check` for `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, -`aarch64-apple-darwin`, and one non-FP-walk Linux arch. If FFI files are -touched (they shouldn't be — the ~15-symbol surface is preserved), run -`cargo ffi-test`. If `Cargo.lock` changes (rustix `mm` feature does not add -crates, but verify), regenerate `LICENSE-3rdparty.csv`. - -## Out of scope - -- Changing the wire protocol or receiver parsing (beyond the additive FPE - enum variants in Phase 3.4). -- Unifying the fork/exec/reap machinery with `libdd_common::unix_utils` — the - std versions allocate and are not async-signal-safe; that is the reason the - signal-safe module exists. -- Any change to the `signal_owner.rs` arbitration or the FFI ABI. From 839902988bf8a59d815d22ab46def259fd1e6995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 17:42:13 +0200 Subject: [PATCH 19/32] docs(crashtracker): add signal-safe cleanup plan Co-Authored-By: Claude Sonnet 5 --- plan.md | 280 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..9cc348d69e --- /dev/null +++ b/plan.md @@ -0,0 +1,280 @@ +# Signal-safe crashtracker — simplification & reuse plan + +## Goal + +Make `libdd-crashtracker/src/collector_signal_safe/` maximally idiomatic Rust and +share as much as possible with the rest of the crate. Where the existing (std) +collector and shared modules hold logic the signal-safe path re-implements, prefer +extracting a **no_std / async-signal-safe** shared piece over keeping two copies — +even if that means making the existing code more `no_std`-friendly. + +## PR scoping (decided up front) + +The feature branch is already ~6k insertions ahead of `main`. To keep review surface +sane, the work is split across PRs as follows — the phase numbers below map onto this: + +- **This branch/PR**: the feature + **Phase 0 only**. Phase 0 is net-deletion + (surface trims, dead code), so it *shrinks* the reviewed diff. +- **Follow-up PR(s) after merge**: Phases 1–2 (internal typing + `sys.rs` + consolidation). Confined to `collector_signal_safe/`. +- **Strictly post-merge, one extraction per PR**: all of Theme A (Phase 3) and + Phase 4. Every A-item reaches outside `collector_signal_safe/` into the shipping + std collector — that must not be coupled to the feature landing. + +## Hard constraints (do not "simplify" these away) + +These bound what "reuse" can mean. Every proposal below respects them. + +1. **Async-signal-safety.** The crash path (handler → collector child → emitter) may + run in a signal handler after memory corruption. No heap allocation, no locks, no + `getenv`, no `std::io`, no panics. This is why `env_get` walks `environ` by hand + (`sys.rs:628`), why the emitter uses `heapless` + a `Sink` byte trait instead of + `std::io::Write`, and why `fork_raw` uses inline `asm!`. These stay bespoke. +2. **no_std under the `collector_signal-safe` feature.** Non-test code is already + `core`/`heapless`/`serde-json-core`/`rustix` only (verified). Any shared code we + pull in must compile without `std`/`alloc`. This is the lever: several existing + modules can be made `no_std` so both collectors use them. +3. **FFI ABI is version-pinned but not back-compat.** `#[repr(C)]`/`#[repr(i32)]` + layouts and discriminants may change between releases, but the `SignalSafeInitResult` + ⇔ `InitResult` numeric mapping must stay tested (`collector_signal_safe.rs:201`). + Wire-format (`DD_CRASHTRACK_*` sections + golden fixture) must stay byte-identical + unless we deliberately regenerate the golden. + +## Theme A — Reuse & sharing across modules (headline work) + +### A1. Extract a marker-framing helper over a byte sink *(highest value)* +The `BEGIN-marker / body / newline / END-marker` pattern is written twice: the +signal-safe `emit_json_section`/`put_marker_line` (`emitter.rs:113-136,304-310`) and, +by hand at every section, the std collector (`collector/emitters.rs` `emit_config` +423, `emit_metadata` 440, `emit_kind` 431, `emit_procinfo` 505, `emit_stacktrace` +144…). Both already share the marker *constants* via `protocol.rs`. + +- Define one framing helper generic over a minimal byte-push trait + (`fn put(&mut self, &[u8]) -> bool`, i.e. today's `Sink`). +- Provide two adapters: the heapless `Sink` (signal path) and a thin + `std::io::Write` shim (std path). `core::fmt::Write` may serve as the idiomatic + bridge **for the std adapter only** — the signal-safe side keeps the bool `Sink` + (see B5: `core::fmt` is alloc-free but not panic-free, and must not enter the + crash path unless `check_signal_safe_symbols.sh` proves no panic machinery is + pulled in). +- Result: ~15 duplicated begin/end pairs collapse to `section(sink, BEGIN, END, |w| …)`. +- **Split into two PRs**: (1) introduce the helper + adopt in the signal-safe + emitter, guarded by the golden fixture; (2) rewrite the ~15 call sites in + `collector/emitters.rs`, guarded by a new std-side output snapshot added first. + +### A2. Share `StacktraceCollection` + kill the `WireConfig` shadow +`config.rs:125-142` `WireConfig`/`WireTimeout` hand-reproduces the serde shape of +`CrashtrackerConfiguration` (`shared/configuration/mod.rs:29-46`) field-for-field, and +hardcodes the literal `"EnabledWithSymbolsInReceiver"` (`config.rs:155`) which is a +`StacktraceCollection` variant. This silently drifts if the wire schema changes. + +- Make `StacktraceCollection` (already a fieldless `#[repr(C)]` enum) `no_std`-safe and + reference the variant through its `Serialize` impl instead of a string literal. This + first bullet delivers most of the de-drift value and is the committed scope. +- **Deferred (own PR, only if the first bullet proves insufficient)**: a shared + `Serialize` struct rendered by both `serde_json` and `serde-json-core` is *not* + automatically byte-identical (float formatting, map-key limits differ). If pursued, + it needs a cross-serializer equality test, and making `shared/configuration` + `no_std` may cascade through its `Vec`/`String` fields. + +### A3. Canonical crash-tag key table (config/metadata de-drift) +The metadata tag set is hardcoded in `emitter.rs:138-176` (`language:native`, +`runtime:native`, `is_crash:true`, `severity:crash`, `service`, `env`, `version`, +`runtime_id`, `runtime_version`, `library_version`, `platform`, `injector_version`) +and assembled independently in the std path via `crash_info/metadata.rs` + the `tag!` +macro + `collector/additional_tags.rs`. Two `Metadata` structs also exist (owned +`crash_info/metadata.rs:9` vs borrowed `report.rs:19`) with two tag builders +(`push_tag` `emitter.rs:51` vs `tag!`). + +- Define the canonical tag **keys** once (a shared `const` table) and drive both + builders from it, so a new/renamed tag can't appear in one path only. + +### A4. Single signal-number→name + `signal_has_address` source +si_code naming is already shared correctly (`sig_info.rs:118` delegates to +`shared::signal_names::rust_si_code_name`, and signal-safe re-exports the shared module +verbatim — `collector_signal_safe/signal_names.rs:4`, which is a 1-line pass-through). +Still duplicated: +- signal number→name: `shared/signal_names.rs:5` `rust_signal_name` (9 signals) vs + `sig_info.rs:62` `SignalNames::from` (32 variants). +- `signal_has_address` (`shared/signal_names.rs:34`) vs the `si_addr` match in + `collector/emitters.rs:711`. + +Have `SignalNames::from` and the std emitter's address logic delegate to the shared +`no_std` functions (add a number→enum mapping in `shared`). Then delete +`collector_signal_safe/signal_names.rs` (fold the re-export into `mod.rs`). + +Direction matters: unify by **expanding the shared table to the 32-variant set**, +never by shrinking the signal-safe path to the shared 9. Note this changes the *std* +collector's output for uncommon signals, and the golden fixture only guards the +signal-safe wire format — add a std-side output snapshot first, or scope the first PR +to the signal-safe side only. + +### A5. Shared `ucontext` → `(ip, sp, fp)` register extraction +`backtrace.rs:56-95` (`arch_seed`, RIP/RBP / x29 seeding) duplicates the register reads +in `collector/emitters.rs:532-547` (`REG_RIP`/`REG_RBP`) and the macOS fp-walk at +`emitters.rs:306-370`. Extract a `no_std` `ucontext_registers(uc) -> (ip, sp, fp)` +helper (near `crash_info/ucontext.rs`) used by both; consider sharing the fp-walk itself +as the non-libunwind fallback. + +## Theme B — Internal idiomatic-Rust cleanups + +### B1. Replace hand-rolled bitmasks with typed flags (`capabilities.rs`) +`capabilities.rs:8-27` hand-defines 6 capability + 13 degradation `const u32` masks with +`& x != 0` idioms scattered across `handler.rs`, plus a parallel `DEGRADATION_REASONS` +`(mask,&str)` table (`:29-46`) kept in sync by hand. Convert to `bitflags` (already a +transitive workspace dep) or two `#[repr(u32)]` flag types; derive `has`/`note_degraded`/ +`get`/`degradations` as methods and attach reason strings to the flag definition. Collapse +the repetitive `if ok { caps|=X } else { degraded|=Y }` in `publish` (`:51-96`) to a small +`probe(cond, CAP, DEG)` helper. + +### B2. Collapse the struct-of-arrays statics (`state.rs`, `handler.rs`) +Five lock-step `[_; NSIG]` statics in `state.rs:108-118` (`ORIG_FN`, `ORIG_FLAGS`, +`OWN_SIGNAL`, `APP_HANDLER_PRESENT`, `ORIG_MASKS`) and three more in `handler.rs:51-54` +(`REPEAT_FAULT_PC/ADDR/COUNT`) are indexed together. Fold each group into one +`[SignalSlot; NSIG]` (a struct of atomics), centralizing the `unsafe impl Sync` +boilerplate (`StaticMeta`, `SigMaskStorage`) behind one typed wrapper. Invariant: +every `SignalSlot` field stays an *individual* atomic — readers must tolerate torn +reads across fields exactly as today; do not "simplify" the slot into anything +lock-guarded. + +### B3. `Stage` name lookup via the enum, not raw ints +`state.rs:167-179` `current_stage_name` re-matches raw `1..8` ints, duplicating the +`Stage` enum (`:147-159`). Add `Stage::try_from(i32)` + `Stage::name(&self)` and load the +enum, removing the drift risk. The FFI `SignalSafeStage`→`Stage` remap +(`collector_signal_safe.rs:135-145`) and the parallel `SignalSafeStage` enum are a 1:1 +shadow — collapse via a match-based `From` impl or a single shared enum. **No +transmute**: int→enum transmute is UB on any out-of-range value and a test only +catches drift you thought to test; a match over contiguous `#[repr(i32)]` variants +compiles to the same identity code anyway. + +### B4. Idiomatic loops & duplicate helpers +- C-style `while i < NSIG` loops in `state.rs:181-190,198-208` → `for`/iterator + (`.iter().filter(..).count()`). +- `fail_init` and `reset_init` (`state.rs:92-98`) are byte-identical → one function. +- `word_at` (`backtrace.rs:18`) → `usize::from_ne_bytes` on an `array_chunks` slice. +- `instruction_pointer` (`backtrace.rs:97`) duplicates the `n==1` seed of + `backtrace_from_ucontext` (`:112`) → reuse. + +### B5. Formatting: drop hand-rolled itoa/hex where safe (`fmt.rs`) +`hex`/`hex_addr`/`hex_u32`/`write_i32` (`fmt.rs:8-62`) manually build digits. +Caution: `core::fmt` is alloc-free but **not panic-free** — the `Formatter` +padding/width machinery has panicking branches, and `write!` on the crash path can +pull `core::panicking::panic_fmt` into code that runs after memory corruption. The +hand-rolled digits exist precisely to avoid that. Preference order: +1. the alloc-free, panic-free `itoa` crate (already a transitive dep) for decimals; +2. keep the hand-rolled hex (or `itoa`-style tables) for `hex`/`hex_addr`; +3. `core::fmt::Write` only if `tools/check_signal_safe_symbols.sh` passing — with no + new panic/fmt symbols — is a hard gate *on that specific commit*. +Keep a named capacity const for `hex_u32` instead of the bare `10` (`fmt.rs:12`). + +### B6. De-duplicate the two `raw` syscall modules (`sys.rs`) *(large win)* +The two `mod raw` blocks (`sys.rs:50-389` vs `395-567`) are ~90% identical — `write`, +`close`, `fcntl_dupfd`, `fd_valid`, `pipe`, `open_readwrite`, `access_executable`, +`mprotect_none`, `getpid`, `gettid`, `kill`, `waitpid_nohang_status`, `poll_sleep_ms`, +`monotonic_nanos` are byte-identical `rustix` calls. Only ~5 functions genuinely differ +per cfg. Move the common ones into one shared module (~170 lines removed). Shrink the +inline-`asm!` `syscall1/3/6` block (`:66-178`) to only what has no stable `rustix` +equivalent: keep `fork_raw` (clone), `read_own_mem` (`process_vm_readv`), +`close_range`, **and `exit_group`** — `rustix::runtime` is experimental, +linux_raw-backend-only, and not in our pinned feature set (`Cargo.toml:93`); five +lines of stable asm beat an unstable feature flag. Route `dup2`→`rustix::io::dup2` +only after confirming its typed-fd signature (`&mut OwnedFd` target) fits the raw-fd +usage in the handler without `OwnedFd` construction gymnastics — otherwise keep the +raw syscall. Replace the manual EINTR loop in `FdSink::put` (`sys.rs:29-41`) with +`rustix::io::retry_on_intr` (confirm it exists in the pinned `=1.1.3` first). +Merge the near-identical `cstr_starts_with`/`env_entry_value` prefix scanners +(`sys.rs:673-700`) into one helper; base `cstr_bytes_bounded` (`:661`) on +`CStr::from_ptr(..).to_bytes()` with the length cap layered on top. + +Platform matrix: this file is cfg-dense (the second `raw` module is the +macOS/fallback path; the asm block is per-arch). Require green builds on x86_64 *and* +aarch64, Linux *and* macOS, before and after — the Linux e2e test alone does not +exercise the fallback module. + +### B7. Pass structs, not scalar bundles (`handler.rs`) +Two `#[allow(clippy::too_many_arguments)]` sites (`handler.rs:377,401`, +`collector_child`/`emit_crash_report`) thread 8-9 scalars (sig/si_code/has_info/si_addr/ +pid/tid/ucontext) that already have homes in `SignalInfo`/`CrashContext` +(`report.rs:38,83`). Build the struct once and pass it. Fold the three repeat-fault +arrays (B2) into the same context. Replace the trivial enum→enum maps `begin_init_error`/ +`prepare_error` (`handler.rs:133-145`) with `From` impls. + +## Theme C — Public-API surface reduction + +`mod.rs` re-exports ~20 items (`mod.rs:43-63`); trim to what the FFI and tests actually +need. + +- **Drop the bool wrappers** `init`/`init_from_env` (`handler.rs:69-79`) — the FFI only + calls the `_result` forms (`collector_signal_safe.rs:76,94`), but + `tests/collector_signal_safe_e2e.rs:13,26` calls `init_from_env()` directly: switch + the e2e to `init_from_env_result()` in the same commit. Keep the `InitResult` + status enum (needed for stable FFI codes); do **not** collapse to `Result`. +- The policy one-liners `app_handler_is_real`, `should_run_app_first`, `app_recovered` + (`policy.rs:34-44`) are re-exported (`mod.rs:55-56`) but consumed only in `handler.rs` + — inline or make `pub(super)`. +- Reconsider `SIG_DFL_VALUE`/`SIG_IGN_VALUE` (`policy.rs:8-9`) reimplementing + `libc::SIG_DFL`/`SIG_IGN`; compare against libc directly. +- Remove the unused `use crate::protocol;` at `mod.rs:26` (protocol is used as + `protocol::` inside `emitter.rs`, not `mod.rs`). +- Audit `owns_signal`/`owned_signal_count`/`write_i32`/`cstr_bytes_bounded` re-exports — + keep only those crossing the crate boundary (FFI uses `cstr_bytes_bounded`, + `owns_signal`, `owned_signal_count`). +- The 24-field manual copy (`collector_signal_safe.rs:94-118`) and the 1:1 + `init_result_to_ffi`/`set_stage` remaps **stay as explicit code** — a mapping macro + would save ~30 boring lines at the cost of hiding the FFI surface from grep and + reviewers. Explicit copies are the right idiom at an ABI boundary. Revisit only if + the enum count grows materially. + +## Suggested sequencing + +Each phase compiles + tests green before the next (`cargo check -p libdd-crashtracker +--features collector_signal-safe`, then the full validation from AGENTS.md; the golden +fixture and `collector_signal_safe_e2e` guard behavior). + +Phases map onto the PR scoping at the top: Phase 0 in this branch; Phases 1–2 as +follow-up PRs; Phases 3–4 strictly post-merge, one extraction per PR. + +1. **Phase 0 — dead weight & surface** (low risk, this branch): C-theme trims, + `mod.rs:26` dead import, `fail_init`/`reset_init` merge, idiomatic loops (B4), + e2e switch to `init_from_env_result()`. No behavior change; net deletion. +2. **Phase 1 — internal typing**: bitflags (B1), SoA→struct arrays (B2), `Stage` name via + enum (B3, match-based — no transmute), pass-structs (B7). Local, well-tested by + existing unit tests. +3. **Phase 2 — sys.rs consolidation** (B6): merge `raw` modules, trim `asm!` (keeping + `exit_group`), adopt `rustix` helpers. Isolated; guarded by e2e **plus** the + four-way platform matrix (x86_64/aarch64 × Linux/macOS). +4. **Phase 3 — cross-module reuse** (Theme A): framing helper (A1, split signal-side / + std-side), `StacktraceCollection` share (A2, first bullet only), tag-key table (A3), + signal-name unification (A4, expand shared table + std snapshot first), ucontext + extraction (A5). Requires making a few existing modules `no_std`; one extraction per + PR; golden fixture byte-stable. +5. **Phase 4 — formatting** (B5): only after A1 lands, so `fmt.rs` is deleted rather + than rewritten. `itoa`/hand-rolled preferred; `core::fmt` only behind the symbol-check + gate. + +## Validation checklist + +- `cargo check -p libdd-crashtracker --features collector_signal-safe` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` + (target: remove the two `too_many_arguments` allows and any `dead_code` allows made + obsolete) +- `cargo nextest run -p libdd-crashtracker --features + libdd-crashtracker/generate-unit-test-files` incl. `collector_signal_safe_e2e` +- Golden fixture `tests/fixtures/signal_safe_report.golden` unchanged (or regenerated + intentionally via the `#[ignore]` regenerate test with the diff reviewed). +- `tools/check_signal_safe_symbols.sh` — run **per phase**, not just at the end; it is + the guard that catches `core::fmt`/panic machinery leaking into the crash path + (B5/A1) and non-signal-safe symbols from any newly-shared dependency. +- Phase 2 additionally: green builds on x86_64 and aarch64, Linux and macOS. +- If FFI touched: `cargo ffi-test` + `examples/ffi/signal_safe_crashtracking.c`. + +## Non-goals / explicitly out of scope + +- Rewriting `fork_raw`/`read_own_mem` inline `asm!` — inherent to signal-safety. +- Reusing the std `from_env`/telemetry config machinery — it allocates and calls + `getenv`; the bespoke `environ` walk stays. (The bespoke bool/log-level/case-insensitive + parsers in `config.rs:367-418` may be factored into a shared *no_std* helper if a second + signal-safe consumer appears, but not merged with the std parsers.) +- Changing the wire protocol or the `InitResult` numeric contract. + + From 82218c2c27d60c69ee96dcfe2d5a7590de25580b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 18:59:39 +0200 Subject: [PATCH 20/32] refactor(crashtracker): simplify signal-safe collector --- examples/ffi/crashtracking.c | 6 + .../src/collector_signal_safe.rs | 30 +- .../src/collector/additional_tags.rs | 12 +- libdd-crashtracker/src/collector/counters.rs | 26 +- libdd-crashtracker/src/collector/emitters.rs | 467 ++++++++++-------- libdd-crashtracker/src/collector/spans.rs | 24 +- .../src/collector_signal_safe/backtrace.rs | 46 +- .../src/collector_signal_safe/capabilities.rs | 154 ++++-- .../src/collector_signal_safe/config.rs | 10 +- .../src/collector_signal_safe/emitter.rs | 181 ++++--- .../src/collector_signal_safe/fmt.rs | 9 +- .../src/collector_signal_safe/handler.rs | 239 +++++---- .../src/collector_signal_safe/mod.rs | 33 +- .../src/collector_signal_safe/policy.rs | 36 +- .../src/collector_signal_safe/report.rs | 5 +- .../src/collector_signal_safe/signal_names.rs | 4 - .../src/collector_signal_safe/state.rs | 162 ++++-- .../src/collector_signal_safe/sys.rs | 439 ++++++---------- .../src/crash_info/errors_intake.rs | 14 +- libdd-crashtracker/src/crash_info/sig_info.rs | 91 ++-- .../src/crash_info/telemetry.rs | 59 +-- libdd-crashtracker/src/protocol.rs | 39 ++ libdd-crashtracker/src/receiver/mod.rs | 5 +- .../src/shared/configuration/mod.rs | 19 +- libdd-crashtracker/src/shared/mod.rs | 3 + libdd-crashtracker/src/shared/signal_names.rs | 46 +- .../src/shared/stacktrace_collection.rs | 18 + libdd-crashtracker/src/shared/tag_keys.rs | 27 + libdd-crashtracker/src/shared/ucontext.rs | 80 +++ .../tests/collector_signal_safe_e2e.rs | 52 +- 30 files changed, 1310 insertions(+), 1026 deletions(-) delete mode 100644 libdd-crashtracker/src/collector_signal_safe/signal_names.rs create mode 100644 libdd-crashtracker/src/shared/stacktrace_collection.rs create mode 100644 libdd-crashtracker/src/shared/tag_keys.rs create mode 100644 libdd-crashtracker/src/shared/ucontext.rs 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/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs index d21cf7bc65..9a899dc90a 100644 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -71,6 +71,22 @@ pub enum SignalSafeStage { CrashtrackerUninstall = 8, } +impl From for Stage { + fn from(stage: SignalSafeStage) -> Self { + match stage { + SignalSafeStage::Uninitialized => Stage::Uninitialized, + SignalSafeStage::CrashtrackerInit => Stage::CrashtrackerInit, + SignalSafeStage::PlatformInit => Stage::PlatformInit, + SignalSafeStage::LanguageInit => Stage::LanguageInit, + SignalSafeStage::PluginLoading => Stage::PluginLoading, + SignalSafeStage::InjectionMetadataSend => Stage::InjectionMetadataSend, + SignalSafeStage::HttpClientSend => Stage::HttpClientSend, + SignalSafeStage::Application => Stage::Application, + SignalSafeStage::CrashtrackerUninstall => Stage::CrashtrackerUninstall, + } + } +} + #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> SignalSafeInitResult { ffi_result(|| init_result_to_ffi(init_from_env_result())) @@ -131,19 +147,7 @@ pub extern "C" fn ddog_crasht_signal_safe_shutdown() { #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_set_stage(stage: SignalSafeStage) { - ffi_void(|| { - set_stage(match stage { - SignalSafeStage::Uninitialized => Stage::Uninitialized, - SignalSafeStage::CrashtrackerInit => Stage::CrashtrackerInit, - SignalSafeStage::PlatformInit => Stage::PlatformInit, - SignalSafeStage::LanguageInit => Stage::LanguageInit, - SignalSafeStage::PluginLoading => Stage::PluginLoading, - SignalSafeStage::InjectionMetadataSend => Stage::InjectionMetadataSend, - SignalSafeStage::HttpClientSend => Stage::HttpClientSend, - SignalSafeStage::Application => Stage::Application, - SignalSafeStage::CrashtrackerUninstall => Stage::CrashtrackerUninstall, - }); - }); + ffi_void(|| set_stage(stage.into())); } #[no_mangle] 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/counters.rs b/libdd-crashtracker/src/collector/counters.rs index f1de6bb5a1..dd93de934f 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 4d7dda8f8a..b6e1ec6b7f 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 3b26ffc33c..690b5e5d9c 100644 --- a/libdd-crashtracker/src/collector/spans.rs +++ b/libdd-crashtracker/src/collector/spans.rs @@ -14,9 +14,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(()) } @@ -37,9 +43,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 index a8f5292e48..46494f0ff8 100644 --- a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs +++ b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs @@ -4,6 +4,7 @@ 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; @@ -49,49 +50,22 @@ fn walk_fp(out: &mut [usize], mut n: usize, self_pid: i32, mut fp: usize) -> usi n } -#[cfg(all( - any(target_os = "linux", target_os = "android"), - target_arch = "x86_64" -))] fn arch_seed(uc: &libc::ucontext_t, out: &mut [usize]) -> (usize, usize) { - let ip = uc.uc_mcontext.gregs[libc::REG_RIP as usize] as usize; - let fp = uc.uc_mcontext.gregs[libc::REG_RBP as usize] as usize; + let Some(registers) = ucontext_registers(uc) else { + return (0, 0); + }; let mut n = 0; - if ip != 0 { - out[0] = ip; - n = 1; - } - (n, fp) -} - -#[cfg(all( - any(target_os = "linux", target_os = "android"), - target_arch = "aarch64" -))] -fn arch_seed(uc: &libc::ucontext_t, out: &mut [usize]) -> (usize, usize) { - let pc = uc.uc_mcontext.pc as usize; - let fp = uc.uc_mcontext.regs[29] as usize; - let lr = uc.uc_mcontext.regs[30] as usize; - let mut n = 0; - if pc != 0 { - out[0] = pc; + if registers.ip != 0 { + out[0] = registers.ip; n = 1; } - let fp_walkable = fp != 0 && aligned_8(fp) && fp >= MMAP_MIN_ADDR; - if !fp_walkable && lr != 0 && n < out.len() { - out[n] = lr; + 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, fp) -} - -#[cfg(not(all( - any(target_os = "linux", target_os = "android"), - any(target_arch = "x86_64", target_arch = "aarch64") -)))] -fn arch_seed(_uc: &libc::ucontext_t, _out: &mut [usize]) -> (usize, usize) { - (0, 0) + (n, registers.fp) } pub fn instruction_pointer(ucontext: *const c_void) -> usize { diff --git a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index 9d4c96cb50..824ac1d09e 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -5,28 +5,84 @@ use core::sync::atomic::{AtomicU32, Ordering}; use super::sys; -pub const RECEIVER_OK: u32 = 1 << 0; -pub const PROC_VM_READV: u32 = 1 << 1; -pub const FORK_OK: u32 = 1 << 2; -pub const DEV_NULL: u32 = 1 << 3; -pub const PIPE_OK: u32 = 1 << 4; -pub const REPORT_FD_OK: u32 = 1 << 5; - -pub const DEGRADED_MISSING_RECEIVER: u32 = 1 << 0; -pub const DEGRADED_NO_PROC_VM_READV: u32 = 1 << 1; -pub const DEGRADED_NO_FORK: u32 = 1 << 2; -pub const DEGRADED_NO_DEV_NULL: u32 = 1 << 3; -pub const DEGRADED_NO_PIPE: u32 = 1 << 4; -pub const DEGRADED_PIPE_FAILED: u32 = 1 << 5; -pub const DEGRADED_FORK_FAILED: u32 = 1 << 6; -pub const DEGRADED_RECEIVER_UNAVAILABLE: u32 = 1 << 7; -pub const DEGRADED_REPORT_TO_FD: u32 = 1 << 8; -pub const DEGRADED_TRUNCATED: u32 = 1 << 9; -pub const DEGRADED_METADATA_TRUNCATED: u32 = 1 << 10; -pub const DEGRADED_APP_HANDLER_PRESENT: u32 = 1 << 11; -pub const DEGRADED_ALT_STACK_GUARD_UNAVAILABLE: u32 = 1 << 12; - -pub const DEGRADATION_REASONS: &[(u32, &str)] = &[ +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Capabilities(u32); + +impl Capabilities { + 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; + } +} + +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Degradations(u32); + +impl Degradations { + 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 + } + + pub const fn with(self, flag: Self) -> Self { + Self(self.0 | flag.0) + } + + fn insert(&mut self, flag: 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"), @@ -49,66 +105,66 @@ static CAPABILITIES: AtomicU32 = AtomicU32::new(0); static DEGRADATIONS: AtomicU32 = AtomicU32::new(0); pub fn publish(receiver_path: &[u8], report_fd: i32, probe_seccomp: bool) { - let mut caps = 0u32; - let mut degraded = 0u32; + let mut caps = Capabilities::empty(); + let mut degraded = Degradations::empty(); if sys::access_executable(receiver_path.as_ptr()) { - caps |= RECEIVER_OK; + caps.insert(RECEIVER_OK); } else { - degraded |= DEGRADED_MISSING_RECEIVER; + degraded.insert(DEGRADED_MISSING_RECEIVER); } if probe_process_vm_readv() && (!probe_seccomp || probe_process_vm_readv_in_child()) { - caps |= PROC_VM_READV; + caps.insert(PROC_VM_READV); } else { - degraded |= DEGRADED_NO_PROC_VM_READV; + degraded.insert(DEGRADED_NO_PROC_VM_READV); } if sys::fork_supported() { - caps |= FORK_OK; + caps.insert(FORK_OK); } else { - degraded |= DEGRADED_NO_FORK; + degraded.insert(DEGRADED_NO_FORK); } let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); if devnull >= 0 { - caps |= DEV_NULL; + caps.insert(DEV_NULL); sys::close(devnull); } else { - degraded |= DEGRADED_NO_DEV_NULL; + degraded.insert(DEGRADED_NO_DEV_NULL); } let mut fds = [0i32; 2]; if sys::pipe(&mut fds) { - caps |= PIPE_OK; + caps.insert(PIPE_OK); sys::close(fds[0]); sys::close(fds[1]); } else { - degraded |= DEGRADED_NO_PIPE; + degraded.insert(DEGRADED_NO_PIPE); } if sys::fd_valid(report_fd) { - caps |= REPORT_FD_OK; + caps.insert(REPORT_FD_OK); } - CAPABILITIES.store(caps, Ordering::Release); - DEGRADATIONS.store(degraded, Ordering::Release); + CAPABILITIES.store(caps.bits(), Ordering::Release); + DEGRADATIONS.store(degraded.bits(), Ordering::Release); } -pub fn get() -> u32 { - CAPABILITIES.load(Ordering::Acquire) +pub fn get() -> Capabilities { + Capabilities::from_bits(CAPABILITIES.load(Ordering::Acquire)) } -pub fn has(capability: u32) -> bool { - get() & capability != 0 +pub fn has(capability: Capabilities) -> bool { + get().contains(capability) } -pub fn degradations() -> u32 { - DEGRADATIONS.load(Ordering::Acquire) +pub fn degradations() -> Degradations { + Degradations::from_bits(DEGRADATIONS.load(Ordering::Acquire)) } -pub fn note_degraded(reason: u32) { - DEGRADATIONS.fetch_or(reason, Ordering::AcqRel); +pub fn note_degraded(reason: Degradations) { + DEGRADATIONS.fetch_or(reason.bits(), Ordering::AcqRel); } fn probe_process_vm_readv() -> bool { @@ -149,9 +205,9 @@ mod tests { publish(b"/definitely/missing-signal-safe-receiver\0", -1, false); - assert_eq!(get() & RECEIVER_OK, 0); - assert_ne!(degradations() & DEGRADED_MISSING_RECEIVER, 0); - assert_eq!(get() & REPORT_FD_OK, 0); + assert!(!get().contains(RECEIVER_OK)); + assert!(degradations().contains(DEGRADED_MISSING_RECEIVER)); + assert!(!get().contains(REPORT_FD_OK)); } #[test] @@ -167,6 +223,6 @@ mod tests { false, ); - assert_ne!(get() & REPORT_FD_OK, 0); + 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 index 2a2fab19b8..ce4b227246 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -11,6 +11,7 @@ use super::state::meta_mut; use super::{capabilities, state, sys}; use crate::shared::{ defaults::DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS, signals::SIGNAL_SAFE_CRASH_SIGNALS, + stacktrace_collection::StacktraceCollection, }; // Compatibility preset for the existing C-tracer consumer. New integrators should pass @@ -129,7 +130,7 @@ struct WireConfig<'a> { use_alt_stack: bool, demangle_names: bool, endpoint: Option<()>, - resolve_frames: &'a str, + resolve_frames: StacktraceCollection, signals: &'a [i32], timeout: WireTimeout, unix_socket_path: Option<()>, @@ -152,7 +153,7 @@ pub fn build_config_json( use_alt_stack: config.use_alt_stack, demangle_names: true, endpoint: None, - resolve_frames: "EnabledWithSymbolsInReceiver", + resolve_frames: StacktraceCollection::EnabledWithSymbolsInReceiver, signals: &CRASH_SIGNALS, timeout: WireTimeout { secs: normalized_receiver_timeout_secs(config.receiver_timeout_secs), @@ -546,9 +547,6 @@ mod tests { }) .is_ok()); - assert_ne!( - capabilities::degradations() & capabilities::DEGRADED_METADATA_TRUNCATED, - 0 - ); + 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 index 6cfad93a39..0f3b5550a2 100644 --- a/libdd-crashtracker/src/collector_signal_safe/emitter.rs +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -4,18 +4,25 @@ use heapless::String as HeaplessString; use serde::Serialize; +use super::capabilities::{Capabilities, Degradations}; use super::fmt::hex_u32; -use super::fmt::write_i32; +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, protocol, state}; +use super::{capabilities, config, state}; +use crate::protocol; +use crate::shared::tag_keys; -pub trait Sink { - fn put(&mut self, bytes: &[u8]) -> bool; +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], @@ -34,17 +41,19 @@ impl<'a> SliceSink<'a> { } #[cfg(test)] -impl Sink for SliceSink<'_> { - fn put(&mut self, bytes: &[u8]) -> bool { +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 false; + return Err(()); }; if end > self.buf.len() { - return false; + return Err(()); } self.buf[self.len..end].copy_from_slice(bytes); self.len = end; - true + Ok(()) } } @@ -80,8 +89,8 @@ fn emit_report_sections( sink, report.stage_name, report.stackwalk_method, - report.capability_bits, - report.degradation_bits, + report.capabilities, + report.degradations, ) { return false; } @@ -117,22 +126,29 @@ pub fn emit_json_section( end: &str, ) -> bool { let mut buf = [0u8; SECTION_BUF_CAPACITY]; - match serde_json_core::to_slice(value, &mut buf) { - Ok(len) => { - put_marker_line(sink, begin) - && sink.put(&buf[..len]) - && sink.put(b"\n") - && put_marker_line(sink, end) - } - Err(_) => false, - } + 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 { - put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_CONFIG) - && sink.put(config_json.as_bytes()) - && (config_json.ends_with('\n') || sink.put(b"\n")) - && put_marker_line(sink, protocol::DD_CRASHTRACK_END_CONFIG) + 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 { @@ -143,28 +159,28 @@ fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { }; let mut metadata = Metadata::new(report.library_name, report.library_version, report.family); - push_tag(&mut metadata.tags, "language", "native") - && push_tag(&mut metadata.tags, "runtime", "native") - && push_tag(&mut metadata.tags, "is_crash", "true") - && push_tag(&mut metadata.tags, "severity", "crash") - && push_tag(&mut metadata.tags, "service", service) - && push_tag(&mut metadata.tags, "env", report.env) - && push_tag(&mut metadata.tags, "version", report.app_version) - && push_tag(&mut metadata.tags, "runtime_id", report.runtime_id) + 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, - "runtime_version", + tag_keys::RUNTIME_VERSION, report.library_version, ) && push_tag( &mut metadata.tags, - "library_version", + tag_keys::LIBRARY_VERSION, report.library_version, ) - && push_tag(&mut metadata.tags, "platform", report.platform) + && push_tag(&mut metadata.tags, tag_keys::PLATFORM, report.platform) && push_tag( &mut metadata.tags, - "injector_version", + tag_keys::INJECTOR_VERSION, report.library_version, ) && emit_json_section( @@ -179,30 +195,31 @@ fn emit_additional_tags( sink: &mut impl Sink, stage: &str, stackwalk_method: &str, - capability_bits: u32, - degradation_bits: u32, + capability_bits: Capabilities, + degradation_bits: Degradations, ) -> bool { let mut tags = Tags::new(); - if !push_tag(&mut tags, "stage", stage) { + if !push_tag(&mut tags, tag_keys::STAGE, stage) { return false; } - if !push_tag(&mut tags, "stackwalk_method", stackwalk_method) { + if !push_tag(&mut tags, tag_keys::STACKWALK_METHOD, stackwalk_method) { return false; } - let capabilities = hex_u32(capability_bits); - if !push_tag(&mut tags, "capabilities", capabilities.as_str()) { + 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); - if !push_tag(&mut tags, "degradations", degradations.as_str()) { + 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 & bit != 0 && !push_tag(&mut tags, "report_degraded", reason) { + if degradation_bits.contains(bit) && !push_tag(&mut tags, tag_keys::REPORT_DEGRADED, reason) + { return false; } } - if degradation_bits & capabilities::DEGRADED_APP_HANDLER_PRESENT != 0 + if degradation_bits.contains(capabilities::DEGRADED_APP_HANDLER_PRESENT) && !push_app_handler_present_tags(&mut tags) { return false; @@ -224,7 +241,7 @@ fn push_app_handler_present_tags(tags: &mut Tags) -> bool { if value.push_str("app_handler_present:").is_err() { return false; } - let mut buf = [0u8; 12]; + 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; @@ -232,7 +249,7 @@ fn push_app_handler_present_tags(tags: &mut Tags) -> bool { if value.push_str(sig).is_err() { return false; } - if !push_tag(tags, "report_degraded", value.as_str()) { + if !push_tag(tags, tag_keys::REPORT_DEGRADED, value.as_str()) { return false; } } @@ -240,32 +257,36 @@ fn push_app_handler_present_tags(tags: &mut Tags) -> bool { } fn emit_kind(sink: &mut impl Sink) -> bool { - put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_KIND) - && sink.put(b"\"UnixSignal\"\n") - && put_marker_line(sink, protocol::DD_CRASHTRACK_END_KIND) + 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 { - if !put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_STACKTRACE) { - return false; - } - - let mut buf = [0u8; SECTION_BUF_CAPACITY]; - for ip in frames { - if *ip == 0 { - continue; - } - - let frame = Frame::from_ip(*ip); - let Ok(len) = serde_json_core::to_slice(&frame, &mut buf) else { - return false; - }; - if !(sink.put(&buf[..len]) && sink.put(b"\n")) { - return false; - } - } + 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; + } - put_marker_line(sink, protocol::DD_CRASHTRACK_END_STACKTRACE) + 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( @@ -279,10 +300,16 @@ fn emit_message( && message.push_str(" (").is_ok() && message.push_str(signal.si_signo_human_readable).is_ok() && message.push(')').is_ok() - && put_marker_line(sink, protocol::DD_CRASHTRACK_BEGIN_MESSAGE) - && sink.put(message.as_bytes()) - && sink.put(b"\n") - && put_marker_line(sink, protocol::DD_CRASHTRACK_END_MESSAGE) + && 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( @@ -295,14 +322,14 @@ fn emit_truncated_tail( sink, report.stage_name, report.stackwalk_method, - report.capability_bits, - report.degradation_bits | capabilities::DEGRADED_TRUNCATED, + report.capabilities, + report.degradations.with(capabilities::DEGRADED_TRUNCATED), ); emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) } fn put_marker_line(sink: &mut impl Sink, marker: &str) -> bool { - sink.put(marker.as_bytes()) && sink.put(b"\n") + protocol::marker_line::<_, ()>(sink, marker).is_ok() } fn emit_done(sink: &mut impl Sink) -> bool { diff --git a/libdd-crashtracker/src/collector_signal_safe/fmt.rs b/libdd-crashtracker/src/collector_signal_safe/fmt.rs index 4fd6af6fe9..06e100bfe8 100644 --- a/libdd-crashtracker/src/collector_signal_safe/fmt.rs +++ b/libdd-crashtracker/src/collector_signal_safe/fmt.rs @@ -5,11 +5,14 @@ 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<10> { +pub fn hex_u32(value: u32) -> HeaplessString { hex(value as u64, 8) } @@ -30,7 +33,7 @@ fn hex(value: u64, digits: usize) -> HeaplessString { out } -pub fn write_i32(value: i32, out: &mut [u8; 12]) -> usize { +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 { @@ -95,7 +98,7 @@ mod tests { #[test] fn integer_debug_writer_handles_sign() { - let mut buf = [0u8; 12]; + 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); diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index 36d43b6bc6..fc5ba8a4d2 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -7,13 +7,15 @@ use core::ptr::null_mut; use core::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; use super::config::{self, PrepareError, SignalSafeInitConfig}; +use super::fmt::{write_i32, I32_BUF_CAPACITY}; +use super::policy::{ + app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, + should_run_app_first, ChainAction, +}; use super::state::{self, sig_index, BeginInitError, Stage}; use super::sys::{self, FdSink}; -use super::{ - app_handler_is_real, app_recovered, chain_action, is_genuine_fault, should_run_app_first, - write_i32, ChainAction, CrashContext, Report, SignalInfo, -}; use super::{backtrace, capabilities}; +use super::{CrashContext, Report, SignalInfo}; use crate::signal_owner::{self, SignalOwner}; // Used only by forked children; 125 matches the existing shell-like "cannot exec" convention. @@ -46,12 +48,73 @@ struct Target { flags: i32, } +#[derive(Clone, Copy)] +struct CrashEvent { + sig: i32, + si_code: i32, + has_info: bool, + si_addr: usize, + pid: i32, + tid: i32, + ucontext: *mut c_void, +} + +impl CrashEvent { + 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, + } + } +} + static APP_CHAIN_TID: AtomicI32 = AtomicI32::new(0); static APP_CHAIN_STACK: AtomicUsize = AtomicUsize::new(0); -static REPEAT_FAULT_PC: [AtomicUsize; state::NSIG] = [const { AtomicUsize::new(0) }; state::NSIG]; -static REPEAT_FAULT_ADDR: [AtomicUsize; state::NSIG] = [const { AtomicUsize::new(0) }; state::NSIG]; -static REPEAT_FAULT_COUNT: [AtomicUsize; state::NSIG] = - [const { AtomicUsize::new(0) }; state::NSIG]; + +struct RepeatFaultSlot { + pc: AtomicUsize, + addr: AtomicUsize, + count: AtomicUsize, +} + +impl RepeatFaultSlot { + const fn new() -> Self { + Self { + pc: AtomicUsize::new(0), + addr: AtomicUsize::new(0), + count: AtomicUsize::new(0), + } + } + + fn tripped(&self, pc: usize, addr: usize) -> bool { + if pc == 0 { + return false; + } + + let last_pc = self.pc.load(Ordering::Relaxed); + let last_addr = self.addr.load(Ordering::Relaxed); + if last_pc == pc && last_addr == addr { + self.count.fetch_add(1, Ordering::Relaxed) + 1 >= 2 + } else { + self.addr.store(addr, Ordering::Relaxed); + self.count.store(1, Ordering::Relaxed); + self.pc.store(pc, Ordering::Relaxed); + false + } + } + + #[cfg(test)] + fn reset(&self) { + self.pc.store(0, Ordering::Relaxed); + self.addr.store(0, Ordering::Relaxed); + self.count.store(0, Ordering::Relaxed); + } +} + +static REPEAT_FAULT: [RepeatFaultSlot; state::NSIG] = + [const { RepeatFaultSlot::new() }; state::NSIG]; /// Prevents recursive crash collection. Reset only during explicit shutdown/re-init lifecycle. static COLLECTING: AtomicBool = AtomicBool::new(false); @@ -66,18 +129,10 @@ pub enum InitResult { InvalidConfig = 5, } -pub fn init(config: &SignalSafeInitConfig<'_>) -> bool { - matches!(init_result(config), InitResult::Enabled) -} - pub fn init_result(config: &SignalSafeInitConfig<'_>) -> InitResult { init_with_prepare(|| config::prepare_result(config)) } -pub fn init_from_env() -> bool { - matches!(init_from_env_result(), InitResult::Enabled) -} - pub fn init_from_env_result() -> InitResult { if config::disabled_by_env() { return InitResult::DisabledByConfig; @@ -88,20 +143,20 @@ pub fn init_from_env_result() -> InitResult { fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> InitResult { let begin = state::begin_init(); if let Err(err) = begin { - return begin_init_error(err); + return err.into(); } if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { - state::fail_init(); + state::reset_init(); return InitResult::OwnerConflict; } if let Err(err) = prepare() { signal_owner::release(SignalOwner::SignalSafeCollector); - state::fail_init(); - return prepare_error(err); + state::reset_init(); + return err.into(); } if !install_alt_stack_if_requested() { signal_owner::release(SignalOwner::SignalSafeCollector); - state::fail_init(); + state::reset_init(); return InitResult::Failed; } install_all_handlers(); @@ -130,25 +185,27 @@ pub fn shutdown() { state::reset_init(); } -fn begin_init_error(err: BeginInitError) -> InitResult { - match err { - BeginInitError::AlreadyInitialized => InitResult::AlreadyInitialized, - BeginInitError::Busy => InitResult::Failed, +impl From for InitResult { + fn from(err: BeginInitError) -> Self { + match err { + BeginInitError::AlreadyInitialized => Self::AlreadyInitialized, + BeginInitError::Busy => Self::Failed, + } } } -fn prepare_error(err: PrepareError) -> InitResult { - match err { - PrepareError::InvalidConfig => InitResult::InvalidConfig, - PrepareError::Failed => InitResult::Failed, +impl From for InitResult { + fn from(err: PrepareError) -> Self { + match err { + PrepareError::InvalidConfig => Self::InvalidConfig, + PrepareError::Failed => Self::Failed, + } } } fn effective_target(idx: usize) -> Target { - Target { - fn_ptr: state::ORIG_FN[idx].load(Ordering::Relaxed), - flags: state::ORIG_FLAGS[idx].load(Ordering::Relaxed), - } + let (fn_ptr, flags) = state::signal_slot(idx).original_handler(); + Target { fn_ptr, flags } } unsafe fn invoke_handler( @@ -205,20 +262,7 @@ fn leave_app_chain(tid: i32, stack_pos: usize) { } fn app_return_repeated_fault(idx: usize, pc: usize, addr: usize) -> bool { - if pc == 0 { - return false; - } - - let last_pc = REPEAT_FAULT_PC[idx].load(Ordering::Relaxed); - let last_addr = REPEAT_FAULT_ADDR[idx].load(Ordering::Relaxed); - if last_pc == pc && last_addr == addr { - REPEAT_FAULT_COUNT[idx].fetch_add(1, Ordering::Relaxed) + 1 >= 2 - } else { - REPEAT_FAULT_ADDR[idx].store(addr, Ordering::Relaxed); - REPEAT_FAULT_COUNT[idx].store(1, Ordering::Relaxed); - REPEAT_FAULT_PC[idx].store(pc, Ordering::Relaxed); - false - } + REPEAT_FAULT[idx].tripped(pc, addr) } fn crash_debug(msg: &[u8], sig: i32) { @@ -230,7 +274,7 @@ fn crash_debug(msg: &[u8], sig: i32) { let _ = super::Sink::put(&mut sink, msg); if sig >= 0 { let _ = super::Sink::put(&mut sink, b" "); - let mut buf = [0u8; 12]; + let mut buf = [0u8; I32_BUF_CAPACITY]; let written = write_i32(sig, &mut buf); let _ = super::Sink::put(&mut sink, &buf[..written]); } @@ -374,48 +418,29 @@ fn receiver_child(read_fd: i32, write_fd: i32) -> ! { sys::exit_process(EXIT_CODE_FAILURE); } -#[allow(clippy::too_many_arguments)] -fn collector_child( - read_fd: i32, - write_fd: i32, - sig: i32, - si_code: i32, - has_info: bool, - si_addr: usize, - pid: i32, - tid: i32, - ucontext: *mut c_void, -) -> ! { +fn collector_child(read_fd: i32, write_fd: i32, event: CrashEvent) -> ! { sys::close(read_fd); let write_fd = sanitize_clone(write_fd, false); if write_fd < 0 { sys::exit_process(EXIT_CODE_FAILURE); } - let _ = emit_crash_report( - write_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, true, - ); + let _ = emit_crash_report(write_fd, event, true); sys::exit_process(0); } -#[allow(clippy::too_many_arguments)] -fn emit_crash_report( - write_fd: i32, - sig: i32, - si_code: i32, - has_info: bool, - si_addr: usize, - pid: i32, - tid: i32, - ucontext: *mut c_void, - close_when_done: bool, -) -> bool { +fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> bool { let mut frames = [0usize; config::BACKTRACE_LEVELS_MAX]; let max_frames = state::MAX_FRAMES .load(Ordering::Relaxed) .min(config::BACKTRACE_LEVELS_MAX); let can_walk = capabilities::has(capabilities::PROC_VM_READV); - let n = backtrace::backtrace_from_ucontext(&mut frames[..max_frames], ucontext, pid, can_walk); + let n = backtrace::backtrace_from_ucontext( + &mut frames[..max_frames], + event.ucontext, + event.pid, + can_walk, + ); let stackwalk_method = if n == 0 { "none" } else if can_walk { @@ -443,15 +468,10 @@ fn emit_crash_report( platform: meta.platform.as_str(), stage_name: state::current_stage_name(), stackwalk_method, - capability_bits: capabilities::get(), - degradation_bits: capabilities::degradations(), - }; - let context = CrashContext { - signal: SignalInfo::new(sig, si_code, si_addr, has_info), - pid, - tid, - frames: &frames[..n], + capabilities: capabilities::get(), + degradations: capabilities::degradations(), }; + let context = event.context(&frames[..n]); let mut sink = FdSink::new(write_fd); let emitted = super::emit_report(&mut sink, &report, &context); @@ -486,14 +506,21 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex let pid = sys::getpid(); let tid = sys::gettid(); let report_fd = state::REPORT_FD.load(Ordering::Relaxed); + let event = CrashEvent { + sig, + si_code, + has_info, + si_addr, + pid, + tid, + ucontext, + }; - let direct_report = |reason: u32| { + let direct_report = |reason: capabilities::Degradations| { capabilities::note_degraded(reason); if capabilities::has(capabilities::REPORT_FD_OK) { capabilities::note_degraded(capabilities::DEGRADED_REPORT_TO_FD); - let _ = emit_crash_report( - report_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, false, - ); + let _ = emit_crash_report(report_fd, event, false); } }; @@ -532,9 +559,7 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex if receiver > 0 { collector = unsafe { sys::fork_raw() }; if collector == 0 { - collector_child( - read_fd, write_fd, sig, si_code, has_info, si_addr, pid, tid, ucontext, - ); + collector_child(read_fd, write_fd, event); } } else { crash_debug(b"receiver fork failed", sig); @@ -647,7 +672,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m flags: 0, }, }; - let action = chain_action(super::disposition_of(target.fn_ptr), has_info, si_code); + let action = chain_action(disposition_of(target.fn_ptr), has_info, si_code); match action { ChainAction::RestoreDefaultAndRefault | ChainAction::RestoreDefaultAndReraise => { if !reset_signals_to_default(&[sig]) { @@ -767,7 +792,7 @@ fn install_crash_handler(sig: c_int) { 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::APP_HANDLER_PRESENT[i].store(true, Ordering::Relaxed); + state::signal_slot(i).set_app_handler_present(); } capabilities::note_degraded(capabilities::DEGRADED_APP_HANDLER_PRESENT); crash_debug(b"app handler present", sig); @@ -782,10 +807,12 @@ fn install_crash_handler(sig: c_int) { } if let Some(i) = sig_index(sig) { - state::ORIG_FN[i].store(old.sa_sigaction as *mut c_void, Ordering::Relaxed); - state::ORIG_FLAGS[i].store(old.sa_flags, Ordering::Relaxed); - state::store_orig_mask(i, &old.sa_mask); - state::OWN_SIGNAL[i].store(true, Ordering::Relaxed); + 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); } } @@ -802,9 +829,9 @@ fn uninstall_crash_handler(sig: c_int) { restore.sa_sigaction = target.fn_ptr as usize; restore.sa_flags = target.flags; unsafe { - state::load_orig_mask(i, &mut restore.sa_mask); + state::signal_slot(i).load_original_mask(&mut restore.sa_mask); if libc::sigaction(sig, &restore, null_mut()) == 0 { - state::OWN_SIGNAL[i].store(false, Ordering::Relaxed); + state::signal_slot(i).set_owned(false); } } } @@ -851,10 +878,8 @@ mod tests { capabilities::publish(b"/definitely/missing-signal-safe-receiver\0", -1, false); assert!(install_alt_stack_with(|_, _| false, |_| true)); - assert_ne!( - capabilities::degradations() & capabilities::DEGRADED_ALT_STACK_GUARD_UNAVAILABLE, - 0 - ); + assert!(capabilities::degradations() + .contains(capabilities::DEGRADED_ALT_STACK_GUARD_UNAVAILABLE)); } #[test] @@ -864,9 +889,7 @@ mod tests { .expect("test lock poisoned"); let idx = sig_index(libc::SIGSEGV).expect("SIGSEGV index"); - REPEAT_FAULT_PC[idx].store(0, Ordering::Relaxed); - REPEAT_FAULT_ADDR[idx].store(0, Ordering::Relaxed); - REPEAT_FAULT_COUNT[idx].store(0, Ordering::Relaxed); + REPEAT_FAULT[idx].reset(); assert!(!app_return_repeated_fault(idx, 0x1234, 0)); assert!(app_return_repeated_fault(idx, 0x1234, 0)); @@ -884,12 +907,12 @@ mod tests { receiver_path: b"/bin/cat", ..SignalSafeInitConfig::default() }; - assert!(init(&config)); + assert_eq!(init_result(&config), InitResult::Enabled); assert!(state::INSTALLED.load(Ordering::Acquire)); assert_eq!(init_result(&config), InitResult::AlreadyInitialized); shutdown(); assert!(!state::INSTALLED.load(Ordering::Acquire)); - assert!(init(&config)); + assert_eq!(init_result(&config), InitResult::Enabled); assert!(state::INSTALLED.load(Ordering::Acquire)); shutdown(); assert!(!state::INSTALLED.load(Ordering::Acquire)); diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index eef2a771c5..9dda3cef6c 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -23,20 +23,19 @@ //! crash-signal mask in effect; a nested crash on another managed signal is deferred until the //! app handler returns. -use crate::protocol; - mod backtrace; -mod capabilities; +pub(crate) mod capabilities; mod config; mod emitter; mod fmt; mod handler; mod policy; mod report; -mod signal_names; mod state; mod sys; +use crate::shared::signal_names; + #[cfg(test)] pub(crate) static TEST_GLOBAL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -46,15 +45,7 @@ pub(crate) use emitter::SliceSink; pub(crate) use emitter::{emit_report, Sink}; #[cfg(test)] pub(crate) use fmt::hex_addr; -pub(crate) use fmt::write_i32; -pub use handler::{ - bootstrap_complete, init, init_from_env, init_from_env_result, init_result, shutdown, - InitResult, -}; -pub(crate) use policy::{ - app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, - should_run_app_first, ChainAction, -}; +pub use handler::{bootstrap_complete, init_from_env_result, init_result, shutdown, InitResult}; pub(crate) use report::{CrashContext, Report, SignalInfo, SECTION_BUF_CAPACITY}; #[cfg(test)] pub(crate) use signal_names::*; @@ -63,11 +54,11 @@ pub use state::{set_stage, Stage}; pub use sys::cstr_bytes_bounded; pub fn capability_bits() -> u32 { - capabilities::get() + capabilities::get().bits() } pub fn degradation_bits() -> u32 { - capabilities::degradations() + capabilities::degradations().bits() } pub fn owned_signal_count() -> u32 { @@ -119,8 +110,8 @@ mod tests { platform: "linux", stage_name: "application", stackwalk_method: "fp_pvr", - capability_bits: 0x21, - degradation_bits: 0, + capabilities: capabilities::Capabilities::from_bits(0x21), + degradations: capabilities::Degradations::empty(), }; let mut buf = [0u8; 4096]; @@ -156,8 +147,8 @@ mod tests { platform: "linux", stage_name: "application", stackwalk_method: "fp_pvr", - capability_bits: 0x21, - degradation_bits: 0, + capabilities: capabilities::Capabilities::from_bits(0x21), + degradations: capabilities::Degradations::empty(), }; let mut buf = [0u8; 4096]; @@ -269,8 +260,8 @@ mod tests { platform: "linux", stage_name: "application", stackwalk_method: "fp_pvr", - capability_bits: 0x21, - degradation_bits: capabilities::DEGRADED_REPORT_TO_FD, + capabilities: capabilities::Capabilities::from_bits(0x21), + degradations: capabilities::DEGRADED_REPORT_TO_FD, }; let mut buf = [0u8; 8192]; diff --git a/libdd-crashtracker/src/collector_signal_safe/policy.rs b/libdd-crashtracker/src/collector_signal_safe/policy.rs index 44b2c8d8de..b60c0e17d0 100644 --- a/libdd-crashtracker/src/collector_signal_safe/policy.rs +++ b/libdd-crashtracker/src/collector_signal_safe/policy.rs @@ -5,9 +5,6 @@ use core::ffi::c_void; use super::signal_names::{SI_ASYNCIO, SI_MESGQ, SI_QUEUE, SI_SIGIO, SI_TIMER, SI_TKILL, SI_USER}; -const SIG_DFL_VALUE: usize = 0; -const SIG_IGN_VALUE: usize = 1; - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Disposition { Default, @@ -23,27 +20,32 @@ pub enum ChainAction { Resume, } -pub fn disposition_of(handler: *mut c_void) -> Disposition { +pub(super) fn disposition_of(handler: *mut c_void) -> Disposition { match handler as usize { - SIG_DFL_VALUE => Disposition::Default, - SIG_IGN_VALUE => Disposition::Ignore, + value if value == libc::SIG_DFL => Disposition::Default, + value if value == libc::SIG_IGN => Disposition::Ignore, _ => Disposition::Handler, } } -pub fn app_handler_is_real(handler: *mut c_void) -> bool { +pub(super) fn app_handler_is_real(handler: *mut c_void) -> bool { matches!(disposition_of(handler), Disposition::Handler) } -pub fn should_run_app_first(force_on_top: bool, app_is_real: bool) -> bool { +pub(super) fn should_run_app_first(force_on_top: bool, app_is_real: bool) -> bool { !force_on_top && app_is_real } -pub fn app_recovered(handler_after: *mut c_void) -> bool { +pub(super) fn app_recovered(handler_after: *mut c_void) -> bool { disposition_of(handler_after) != Disposition::Default } -pub fn is_genuine_fault(has_siginfo: bool, si_code: i32, si_pid: i32, self_pid: i32) -> bool { +pub(super) fn is_genuine_fault( + has_siginfo: bool, + si_code: i32, + si_pid: i32, + self_pid: i32, +) -> bool { if !has_siginfo { return false; } @@ -53,7 +55,11 @@ pub fn is_genuine_fault(has_siginfo: bool, si_code: i32, si_pid: i32, self_pid: si_pid == self_pid } -pub fn chain_action(disposition: Disposition, has_siginfo: bool, si_code: i32) -> ChainAction { +pub(super) fn chain_action( + disposition: Disposition, + has_siginfo: bool, + si_code: i32, +) -> ChainAction { match disposition { Disposition::Ignore => ChainAction::Resume, Disposition::Handler => ChainAction::InvokeApp, @@ -82,8 +88,8 @@ mod tests { #[test] fn dispositions_match_sigaction_sentinels() { - let dfl = SIG_DFL_VALUE as *mut c_void; - let ign = SIG_IGN_VALUE as *mut c_void; + 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); @@ -97,8 +103,8 @@ mod tests { #[test] fn handler_policy_tracks_application_recovery() { - let dfl = SIG_DFL_VALUE as *mut c_void; - let ign = SIG_IGN_VALUE as *mut c_void; + 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!(should_run_app_first(false, true)); diff --git a/libdd-crashtracker/src/collector_signal_safe/report.rs b/libdd-crashtracker/src/collector_signal_safe/report.rs index c9b53d389e..3460d87393 100644 --- a/libdd-crashtracker/src/collector_signal_safe/report.rs +++ b/libdd-crashtracker/src/collector_signal_safe/report.rs @@ -4,6 +4,7 @@ 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}; @@ -100,6 +101,6 @@ pub struct Report<'a> { pub platform: &'a str, pub stage_name: &'a str, pub stackwalk_method: &'a str, - pub capability_bits: u32, - pub degradation_bits: u32, + pub capabilities: Capabilities, + pub degradations: Degradations, } diff --git a/libdd-crashtracker/src/collector_signal_safe/signal_names.rs b/libdd-crashtracker/src/collector_signal_safe/signal_names.rs deleted file mode 100644 index 2dd6c0c8c8..0000000000 --- a/libdd-crashtracker/src/collector_signal_safe/signal_names.rs +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -pub(crate) use crate::shared::signal_names::*; diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index b44fe001ee..59765946c2 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -89,10 +89,6 @@ pub fn finish_init() { INIT_STATE.store(INIT_READY, Ordering::Release); } -pub fn fail_init() { - INIT_STATE.store(INIT_UNINIT, Ordering::Release); -} - pub fn reset_init() { INIT_STATE.store(INIT_UNINIT, Ordering::Release); } @@ -105,30 +101,83 @@ pub fn meta_mut() -> &'static mut Meta { unsafe { &mut *META.0.get() } } -pub static ORIG_FN: [AtomicPtr; NSIG] = [const { AtomicPtr::new(null_mut()) }; NSIG]; -pub static ORIG_FLAGS: [AtomicI32; NSIG] = [const { AtomicI32::new(0) }; NSIG]; -pub static OWN_SIGNAL: [AtomicBool; NSIG] = [const { AtomicBool::new(false) }; NSIG]; -pub static APP_HANDLER_PRESENT: [AtomicBool; NSIG] = [const { AtomicBool::new(false) }; NSIG]; +pub(super) struct SignalSlot { + orig_fn: AtomicPtr, + orig_flags: AtomicI32, + own_signal: AtomicBool, + app_handler_present: AtomicBool, + orig_mask: UnsafeCell>, +} -struct SigMaskStorage(UnsafeCell<[MaybeUninit; NSIG]>); +unsafe impl Sync for SignalSlot {} -unsafe impl Sync for SigMaskStorage {} +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()), + } + } -static ORIG_MASKS: SigMaskStorage = - SigMaskStorage(UnsafeCell::new([const { MaybeUninit::uninit() }; NSIG])); + pub(super) fn original_handler(&self) -> (*mut c_void, i32) { + ( + self.orig_fn.load(Ordering::Relaxed), + self.orig_flags.load(Ordering::Relaxed), + ) + } -pub fn store_orig_mask(idx: usize, mask: &libc::sigset_t) { - unsafe { - (*ORIG_MASKS.0.get())[idx].as_mut_ptr().write(*mask); + 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::Acquire) } -} -pub fn load_orig_mask(idx: usize, out: &mut libc::sigset_t) { - unsafe { - out.clone_from(&*(*ORIG_MASKS.0.get())[idx].as_ptr()); + 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::Acquire) + } + + 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); pub static FORCE_ON_TOP: AtomicBool = AtomicBool::new(false); pub static ONLY_BOOTSTRAP: AtomicBool = AtomicBool::new(false); @@ -158,6 +207,41 @@ pub enum Stage { CrashtrackerUninstall = 8, } +impl Stage { + pub const fn name(self) -> &'static str { + match self { + Self::Uninitialized => "uninitialized", + Self::CrashtrackerInit => "crashtracker_init", + Self::PlatformInit => "platform_init", + Self::LanguageInit => "language_init", + Self::PluginLoading => "plugin_loading", + Self::InjectionMetadataSend => "injection_metadata_send", + Self::HttpClientSend => "http_client_send", + Self::Application => "application", + Self::CrashtrackerUninstall => "crashtracker_uninstall", + } + } +} + +impl TryFrom for Stage { + type Error = (); + + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(Self::Uninitialized), + 1 => Ok(Self::CrashtrackerInit), + 2 => Ok(Self::PlatformInit), + 3 => Ok(Self::LanguageInit), + 4 => Ok(Self::PluginLoading), + 5 => Ok(Self::InjectionMetadataSend), + 6 => Ok(Self::HttpClientSend), + 7 => Ok(Self::Application), + 8 => Ok(Self::CrashtrackerUninstall), + _ => Err(()), + } + } +} + static STAGE: AtomicI32 = AtomicI32::new(Stage::Uninitialized as i32); pub fn set_stage(stage: Stage) { @@ -165,50 +249,32 @@ pub fn set_stage(stage: Stage) { } pub fn current_stage_name() -> &'static str { - match STAGE.load(Ordering::Relaxed) { - 1 => "crashtracker_init", - 2 => "platform_init", - 3 => "language_init", - 4 => "plugin_loading", - 5 => "injection_metadata_send", - 6 => "http_client_send", - 7 => "application", - 8 => "crashtracker_uninstall", - _ => "uninitialized", - } + Stage::try_from(STAGE.load(Ordering::Relaxed)) + .unwrap_or(Stage::Uninitialized) + .name() } pub fn clear_signal_state() { - let mut i = 0usize; - while i < NSIG { - ORIG_FN[i].store(null_mut(), Ordering::Relaxed); - ORIG_FLAGS[i].store(0, Ordering::Relaxed); - OWN_SIGNAL[i].store(false, Ordering::Relaxed); - APP_HANDLER_PRESENT[i].store(false, Ordering::Relaxed); - i += 1; + for slot in &SIGNAL_SLOTS { + slot.clear(); } } pub fn owns_signal(sig: i32) -> bool { sig_index(sig) - .map(|i| OWN_SIGNAL[i].load(Ordering::Acquire)) + .map(|i| signal_slot(i).owns_signal()) .unwrap_or(false) } pub fn owned_signal_count() -> u32 { - let mut count = 0u32; - let mut i = 0usize; - while i < NSIG { - if OWN_SIGNAL[i].load(Ordering::Acquire) { - count += 1; - } - i += 1; - } - count + SIGNAL_SLOTS + .iter() + .filter(|slot| slot.owns_signal()) + .count() as u32 } pub fn app_handler_present(sig: i32) -> bool { sig_index(sig) - .map(|i| APP_HANDLER_PRESENT[i].load(Ordering::Acquire)) + .map(|i| signal_slot(i).app_handler_present()) .unwrap_or(false) } diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index fa389f8bc3..9ea61c2d37 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -1,11 +1,8 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use core::ffi::c_char; +use core::ffi::{c_char, CStr}; -use super::Sink; - -const MAX_WRITE_RETRIES: u32 = 10; const CSTR_MAX_LEN: usize = 4096; unsafe extern "C" { @@ -22,47 +19,169 @@ impl FdSink { } } -impl Sink for FdSink { - fn put(&mut self, bytes: &[u8]) -> bool { +impl crate::protocol::ByteSink for FdSink { + type Error = (); + + fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error> { let mut off = 0usize; - let mut retries = 0u32; while off < bytes.len() { let n = write(self.fd, &bytes[off..]); if n > 0 { off += n as usize; - retries = 0; - continue; - } - if n == -(libc::EINTR as isize) && retries < MAX_WRITE_RETRIES { - retries += 1; continue; } - return false; + return Err(()); } - true + Ok(()) } } -#[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, CStr}; +mod raw_common { + use core::ffi::CStr; use core::num::NonZeroI32; use rustix::fd::{BorrowedFd, IntoRawFd}; #[inline] - fn neg_errno(err: rustix::io::Errno) -> isize { - -(err.raw_os_error() as isize) + fn neg_errno(err: rustix::io::Errno) -> i32 { + -err.raw_os_error() } #[inline] - unsafe fn borrowed_fd(fd: i32) -> BorrowedFd<'static> { + 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, + } + } + + 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(fds: &mut [i32; 2]) -> bool { + match rustix::pipe::pipe() { + Ok((read_fd, write_fd)) => { + fds[0] = read_fd.into_raw_fd(); + fds[1] = write_fd.into_raw_fd(); + true + } + Err(_) => false, + } + } + + 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, close, fcntl_dupfd, fd_valid, getpid, kill, monotonic_nanos, + mprotect_none, open_readwrite, pipe, poll_sleep_ms, waitpid_nohang_status, write, + }; + #[cfg(target_arch = "x86_64")] #[inline] unsafe fn syscall1(nr: i64, a0: usize) -> isize { @@ -177,19 +296,6 @@ mod raw { ret } - pub fn write(fd: i32, bytes: &[u8]) -> isize { - match rustix::io::write(unsafe { borrowed_fd(fd) }, bytes) { - Ok(n) => n as isize, - Err(err) => neg_errno(err), - } - } - - pub fn close(fd: i32) { - unsafe { - rustix::io::close(fd); - } - } - pub fn dup2(oldfd: i32, newfd: i32) -> i32 { if oldfd == newfd { return newfd; @@ -197,20 +303,6 @@ mod raw { unsafe { syscall3(libc::SYS_dup3, oldfd as usize, newfd as usize, 0) as i32 } } - 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) as i32, - }, - Err(err) => neg_errno(err) as i32, - } - } - - pub fn fd_valid(fd: i32) -> bool { - fd >= 0 && rustix::io::fcntl_getfd(unsafe { borrowed_fd(fd) }).is_ok() - } - pub fn close_range_from(first_fd: i32) -> bool { first_fd >= 0 && unsafe { @@ -223,46 +315,6 @@ mod raw { } } - pub fn pipe(fds: &mut [i32; 2]) -> bool { - match rustix::pipe::pipe() { - Ok((read_fd, write_fd)) => { - fds[0] = read_fd.into_raw_fd(); - fds[1] = write_fd.into_raw_fd(); - true - } - Err(_) => false, - } - } - - 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) as i32, - } - } - - 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 fork_supported() -> bool { true } @@ -310,60 +362,10 @@ mod raw { } } - pub fn getpid() -> i32 { - rustix::process::getpid().as_raw_pid() - } - pub fn gettid() -> i32 { rustix::thread::gettid().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) as i32, - } - } - - 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) as i32, - } - } - - 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) - } - 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, @@ -393,95 +395,19 @@ mod raw { any(target_arch = "x86_64", target_arch = "aarch64") )))] mod raw { - use core::ffi::CStr; - use core::num::NonZeroI32; - use rustix::fd::{BorrowedFd, IntoRawFd}; - - #[inline] - fn neg_errno(err: rustix::io::Errno) -> i32 { - -err.raw_os_error() - } - - #[inline] - unsafe fn borrowed_fd(fd: i32) -> BorrowedFd<'static> { - BorrowedFd::borrow_raw(fd) - } - - pub fn write(fd: i32, bytes: &[u8]) -> isize { - match rustix::io::write(unsafe { borrowed_fd(fd) }, bytes) { - Ok(n) => n as isize, - Err(err) => -(err.raw_os_error() as isize), - } - } - - pub fn close(fd: i32) { - unsafe { - rustix::io::close(fd); - } - } + 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 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 close_range_from(_first_fd: i32) -> bool { false } - pub fn pipe(fds: &mut [i32; 2]) -> bool { - match rustix::pipe::pipe() { - Ok((read_fd, write_fd)) => { - fds[0] = read_fd.into_raw_fd(); - fds[1] = write_fd.into_raw_fd(); - true - } - Err(_) => false, - } - } - - 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 fork_supported() -> bool { false } @@ -496,10 +422,6 @@ mod raw { } } - pub fn getpid() -> i32 { - rustix::process::getpid().as_raw_pid() - } - pub fn gettid() -> i32 { #[cfg(any(target_os = "macos", target_os = "ios"))] { @@ -515,52 +437,6 @@ mod raw { } } - 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) - } - pub fn read_own_mem(_pid: i32, _src: usize, _dst: &mut [u8]) -> bool { false } @@ -667,10 +543,19 @@ pub unsafe fn cstr_bytes_bounded<'a>(p: *const c_char) -> &'a [u8] { while len < CSTR_MAX_LEN && *p.add(len) != 0 { len += 1; } - core::slice::from_raw_parts(p.cast(), len) + if len == CSTR_MAX_LEN { + return core::slice::from_raw_parts(p.cast(), len); + } + + let bytes = core::slice::from_raw_parts(p.cast(), len + 1); + CStr::from_bytes_with_nul_unchecked(bytes).to_bytes() } pub unsafe fn cstr_starts_with(s: *const c_char, prefix: &[u8]) -> bool { + cstr_has_prefix(s, prefix) +} + +unsafe fn cstr_has_prefix(s: *const c_char, prefix: &[u8]) -> bool { let mut i = 0usize; while i < prefix.len() { let c = *s.add(i); @@ -683,13 +568,8 @@ pub unsafe fn cstr_starts_with(s: *const c_char, prefix: &[u8]) -> bool { } unsafe fn env_entry_value(entry: *const c_char, name: &[u8]) -> Option<*const c_char> { - let mut i = 0usize; - while i < name.len() { - let c = *entry.add(i); - if c == 0 || c as u8 != name[i] { - return None; - } - i += 1; + if !cstr_has_prefix(entry, name) { + return None; } if *entry.add(name.len()) as u8 == b'=' { @@ -735,6 +615,7 @@ unsafe fn errno_location() -> *mut i32 { #[cfg(test)] mod tests { use super::*; + use crate::collector_signal_safe::Sink; #[test] fn fd_sink_writes_to_pipe() { diff --git a/libdd-crashtracker/src/crash_info/errors_intake.rs b/libdd-crashtracker/src/crash_info/errors_intake.rs index 3117ec33e2..10b0dc770c 100644 --- a/libdd-crashtracker/src/crash_info/errors_intake.rs +++ b/libdd-crashtracker/src/crash_info/errors_intake.rs @@ -3,7 +3,7 @@ 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, @@ -296,16 +296,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 1146cff1ea..199fd0efad 100644 --- a/libdd-crashtracker/src/crash_info/sig_info.rs +++ b/libdd-crashtracker/src/crash_info/sig_info.rs @@ -52,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::*; @@ -61,57 +100,7 @@ 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)) } } diff --git a/libdd-crashtracker/src/crash_info/telemetry.rs b/libdd-crashtracker/src/crash_info/telemetry.rs index 3d92b6efa8..b25366ef73 100644 --- a/libdd-crashtracker/src/crash_info/telemetry.rs +++ b/libdd-crashtracker/src/crash_info/telemetry.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use std::{fmt::Write, time::SystemTime}; -use crate::{ErrorKind, SigInfo}; +use crate::{shared::tag_keys, ErrorKind, SigInfo}; use super::{CrashInfo, Metadata, TARGET_TRIPLE}; use anyhow::Context; @@ -159,25 +159,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, @@ -216,18 +197,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/protocol.rs b/libdd-crashtracker/src/protocol.rs index d809e653df..b44214ff85 100644 --- a/libdd-crashtracker/src/protocol.rs +++ b/libdd-crashtracker/src/protocol.rs @@ -76,3 +76,42 @@ protocol_marker!( 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 f8640072de..e1b7915d7a 100644 --- a/libdd-crashtracker/src/receiver/mod.rs +++ b/libdd-crashtracker/src/receiver/mod.rs @@ -112,6 +112,7 @@ mod tests { 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()) @@ -140,8 +141,8 @@ mod tests { platform: "linux", stage_name: "application", stackwalk_method: "fp_pvr", - capability_bits: 0x21, - degradation_bits: 1 << 8, // DEGRADED_REPORT_TO_FD + capabilities: Capabilities::from_bits(0x21), + degradations: Degradations::from_bits(1 << 8), // DEGRADED_REPORT_TO_FD }; let mut buf = [0u8; 8192]; diff --git a/libdd-crashtracker/src/shared/configuration/mod.rs b/libdd-crashtracker/src/shared/configuration/mod.rs index 96a075f79f..010c423faf 100644 --- a/libdd-crashtracker/src/shared/configuration/mod.rs +++ b/libdd-crashtracker/src/shared/configuration/mod.rs @@ -7,24 +7,7 @@ use libdd_common::Endpoint; use serde::{Deserialize, Serialize}; use std::time::Duration; -/// 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/mod.rs b/libdd-crashtracker/src/shared/mod.rs index 856e37a01f..e2d0e10df7 100644 --- a/libdd-crashtracker/src/shared/mod.rs +++ b/libdd-crashtracker/src/shared/mod.rs @@ -6,6 +6,9 @@ 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; diff --git a/libdd-crashtracker/src/shared/signal_names.rs b/libdd-crashtracker/src/shared/signal_names.rs index 66af635528..56e9af2533 100644 --- a/libdd-crashtracker/src/shared/signal_names.rs +++ b/libdd-crashtracker/src/shared/signal_names.rs @@ -4,14 +4,54 @@ #[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::SIGILL => "SIGILL", - libc::SIGQUIT => "SIGQUIT", + 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", - libc::SIGTRAP => "SIGTRAP", + #[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", } } 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..33f0aedc3b --- /dev/null +++ b/libdd-crashtracker/src/shared/tag_keys.rs @@ -0,0 +1,27 @@ +// 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 STAGE: &str = "stage"; +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/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index 29770f2873..a08f85e222 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -10,10 +10,10 @@ use std::os::unix::fs::PermissionsExt; use std::process::Command; #[cfg(any(target_os = "linux", target_os = "android"))] -use libdd_crashtracker::collector_signal_safe::init_from_env; +use libdd_crashtracker::collector_signal_safe::init_from_env_result; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, init, owned_signal_count, owns_signal, set_stage, SignalSafeInitConfig, - Stage, + bootstrap_complete, init_result, owned_signal_count, owns_signal, set_stage, InitResult, + SignalSafeInitConfig, Stage, }; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -23,7 +23,7 @@ fn signal_safe_receiver_child_process() { return; } - assert!(init_from_env()); + assert_eq!(init_from_env_result(), InitResult::Enabled); bootstrap_complete(); set_stage(Stage::Application); @@ -37,15 +37,18 @@ fn signal_safe_report_fd_child_process() { }; let report = fs::File::create(report).expect("create report"); - assert!(init(&SignalSafeInitConfig { - 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(), - ..SignalSafeInitConfig::default() - })); + assert_eq!( + init_result(&SignalSafeInitConfig { + 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(), + ..SignalSafeInitConfig::default() + }), + InitResult::Enabled + ); bootstrap_complete(); set_stage(Stage::Application); @@ -277,16 +280,19 @@ fn init_report_fd( only_bootstrap: bool, ) -> fs::File { let report = fs::File::create(report_path).expect("create report"); - assert!(init(&SignalSafeInitConfig { - 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, - ..SignalSafeInitConfig::default() - })); + assert_eq!( + init_result(&SignalSafeInitConfig { + 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, + ..SignalSafeInitConfig::default() + }), + InitResult::Enabled + ); report } From f19e6876c8cf22bfdc49b112b067326b1e6110ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 19:00:30 +0200 Subject: [PATCH 21/32] docs(crashtracker): remove cleanup plan --- plan.md | 280 -------------------------------------------------------- 1 file changed, 280 deletions(-) delete mode 100644 plan.md diff --git a/plan.md b/plan.md deleted file mode 100644 index 9cc348d69e..0000000000 --- a/plan.md +++ /dev/null @@ -1,280 +0,0 @@ -# Signal-safe crashtracker — simplification & reuse plan - -## Goal - -Make `libdd-crashtracker/src/collector_signal_safe/` maximally idiomatic Rust and -share as much as possible with the rest of the crate. Where the existing (std) -collector and shared modules hold logic the signal-safe path re-implements, prefer -extracting a **no_std / async-signal-safe** shared piece over keeping two copies — -even if that means making the existing code more `no_std`-friendly. - -## PR scoping (decided up front) - -The feature branch is already ~6k insertions ahead of `main`. To keep review surface -sane, the work is split across PRs as follows — the phase numbers below map onto this: - -- **This branch/PR**: the feature + **Phase 0 only**. Phase 0 is net-deletion - (surface trims, dead code), so it *shrinks* the reviewed diff. -- **Follow-up PR(s) after merge**: Phases 1–2 (internal typing + `sys.rs` - consolidation). Confined to `collector_signal_safe/`. -- **Strictly post-merge, one extraction per PR**: all of Theme A (Phase 3) and - Phase 4. Every A-item reaches outside `collector_signal_safe/` into the shipping - std collector — that must not be coupled to the feature landing. - -## Hard constraints (do not "simplify" these away) - -These bound what "reuse" can mean. Every proposal below respects them. - -1. **Async-signal-safety.** The crash path (handler → collector child → emitter) may - run in a signal handler after memory corruption. No heap allocation, no locks, no - `getenv`, no `std::io`, no panics. This is why `env_get` walks `environ` by hand - (`sys.rs:628`), why the emitter uses `heapless` + a `Sink` byte trait instead of - `std::io::Write`, and why `fork_raw` uses inline `asm!`. These stay bespoke. -2. **no_std under the `collector_signal-safe` feature.** Non-test code is already - `core`/`heapless`/`serde-json-core`/`rustix` only (verified). Any shared code we - pull in must compile without `std`/`alloc`. This is the lever: several existing - modules can be made `no_std` so both collectors use them. -3. **FFI ABI is version-pinned but not back-compat.** `#[repr(C)]`/`#[repr(i32)]` - layouts and discriminants may change between releases, but the `SignalSafeInitResult` - ⇔ `InitResult` numeric mapping must stay tested (`collector_signal_safe.rs:201`). - Wire-format (`DD_CRASHTRACK_*` sections + golden fixture) must stay byte-identical - unless we deliberately regenerate the golden. - -## Theme A — Reuse & sharing across modules (headline work) - -### A1. Extract a marker-framing helper over a byte sink *(highest value)* -The `BEGIN-marker / body / newline / END-marker` pattern is written twice: the -signal-safe `emit_json_section`/`put_marker_line` (`emitter.rs:113-136,304-310`) and, -by hand at every section, the std collector (`collector/emitters.rs` `emit_config` -423, `emit_metadata` 440, `emit_kind` 431, `emit_procinfo` 505, `emit_stacktrace` -144…). Both already share the marker *constants* via `protocol.rs`. - -- Define one framing helper generic over a minimal byte-push trait - (`fn put(&mut self, &[u8]) -> bool`, i.e. today's `Sink`). -- Provide two adapters: the heapless `Sink` (signal path) and a thin - `std::io::Write` shim (std path). `core::fmt::Write` may serve as the idiomatic - bridge **for the std adapter only** — the signal-safe side keeps the bool `Sink` - (see B5: `core::fmt` is alloc-free but not panic-free, and must not enter the - crash path unless `check_signal_safe_symbols.sh` proves no panic machinery is - pulled in). -- Result: ~15 duplicated begin/end pairs collapse to `section(sink, BEGIN, END, |w| …)`. -- **Split into two PRs**: (1) introduce the helper + adopt in the signal-safe - emitter, guarded by the golden fixture; (2) rewrite the ~15 call sites in - `collector/emitters.rs`, guarded by a new std-side output snapshot added first. - -### A2. Share `StacktraceCollection` + kill the `WireConfig` shadow -`config.rs:125-142` `WireConfig`/`WireTimeout` hand-reproduces the serde shape of -`CrashtrackerConfiguration` (`shared/configuration/mod.rs:29-46`) field-for-field, and -hardcodes the literal `"EnabledWithSymbolsInReceiver"` (`config.rs:155`) which is a -`StacktraceCollection` variant. This silently drifts if the wire schema changes. - -- Make `StacktraceCollection` (already a fieldless `#[repr(C)]` enum) `no_std`-safe and - reference the variant through its `Serialize` impl instead of a string literal. This - first bullet delivers most of the de-drift value and is the committed scope. -- **Deferred (own PR, only if the first bullet proves insufficient)**: a shared - `Serialize` struct rendered by both `serde_json` and `serde-json-core` is *not* - automatically byte-identical (float formatting, map-key limits differ). If pursued, - it needs a cross-serializer equality test, and making `shared/configuration` - `no_std` may cascade through its `Vec`/`String` fields. - -### A3. Canonical crash-tag key table (config/metadata de-drift) -The metadata tag set is hardcoded in `emitter.rs:138-176` (`language:native`, -`runtime:native`, `is_crash:true`, `severity:crash`, `service`, `env`, `version`, -`runtime_id`, `runtime_version`, `library_version`, `platform`, `injector_version`) -and assembled independently in the std path via `crash_info/metadata.rs` + the `tag!` -macro + `collector/additional_tags.rs`. Two `Metadata` structs also exist (owned -`crash_info/metadata.rs:9` vs borrowed `report.rs:19`) with two tag builders -(`push_tag` `emitter.rs:51` vs `tag!`). - -- Define the canonical tag **keys** once (a shared `const` table) and drive both - builders from it, so a new/renamed tag can't appear in one path only. - -### A4. Single signal-number→name + `signal_has_address` source -si_code naming is already shared correctly (`sig_info.rs:118` delegates to -`shared::signal_names::rust_si_code_name`, and signal-safe re-exports the shared module -verbatim — `collector_signal_safe/signal_names.rs:4`, which is a 1-line pass-through). -Still duplicated: -- signal number→name: `shared/signal_names.rs:5` `rust_signal_name` (9 signals) vs - `sig_info.rs:62` `SignalNames::from` (32 variants). -- `signal_has_address` (`shared/signal_names.rs:34`) vs the `si_addr` match in - `collector/emitters.rs:711`. - -Have `SignalNames::from` and the std emitter's address logic delegate to the shared -`no_std` functions (add a number→enum mapping in `shared`). Then delete -`collector_signal_safe/signal_names.rs` (fold the re-export into `mod.rs`). - -Direction matters: unify by **expanding the shared table to the 32-variant set**, -never by shrinking the signal-safe path to the shared 9. Note this changes the *std* -collector's output for uncommon signals, and the golden fixture only guards the -signal-safe wire format — add a std-side output snapshot first, or scope the first PR -to the signal-safe side only. - -### A5. Shared `ucontext` → `(ip, sp, fp)` register extraction -`backtrace.rs:56-95` (`arch_seed`, RIP/RBP / x29 seeding) duplicates the register reads -in `collector/emitters.rs:532-547` (`REG_RIP`/`REG_RBP`) and the macOS fp-walk at -`emitters.rs:306-370`. Extract a `no_std` `ucontext_registers(uc) -> (ip, sp, fp)` -helper (near `crash_info/ucontext.rs`) used by both; consider sharing the fp-walk itself -as the non-libunwind fallback. - -## Theme B — Internal idiomatic-Rust cleanups - -### B1. Replace hand-rolled bitmasks with typed flags (`capabilities.rs`) -`capabilities.rs:8-27` hand-defines 6 capability + 13 degradation `const u32` masks with -`& x != 0` idioms scattered across `handler.rs`, plus a parallel `DEGRADATION_REASONS` -`(mask,&str)` table (`:29-46`) kept in sync by hand. Convert to `bitflags` (already a -transitive workspace dep) or two `#[repr(u32)]` flag types; derive `has`/`note_degraded`/ -`get`/`degradations` as methods and attach reason strings to the flag definition. Collapse -the repetitive `if ok { caps|=X } else { degraded|=Y }` in `publish` (`:51-96`) to a small -`probe(cond, CAP, DEG)` helper. - -### B2. Collapse the struct-of-arrays statics (`state.rs`, `handler.rs`) -Five lock-step `[_; NSIG]` statics in `state.rs:108-118` (`ORIG_FN`, `ORIG_FLAGS`, -`OWN_SIGNAL`, `APP_HANDLER_PRESENT`, `ORIG_MASKS`) and three more in `handler.rs:51-54` -(`REPEAT_FAULT_PC/ADDR/COUNT`) are indexed together. Fold each group into one -`[SignalSlot; NSIG]` (a struct of atomics), centralizing the `unsafe impl Sync` -boilerplate (`StaticMeta`, `SigMaskStorage`) behind one typed wrapper. Invariant: -every `SignalSlot` field stays an *individual* atomic — readers must tolerate torn -reads across fields exactly as today; do not "simplify" the slot into anything -lock-guarded. - -### B3. `Stage` name lookup via the enum, not raw ints -`state.rs:167-179` `current_stage_name` re-matches raw `1..8` ints, duplicating the -`Stage` enum (`:147-159`). Add `Stage::try_from(i32)` + `Stage::name(&self)` and load the -enum, removing the drift risk. The FFI `SignalSafeStage`→`Stage` remap -(`collector_signal_safe.rs:135-145`) and the parallel `SignalSafeStage` enum are a 1:1 -shadow — collapse via a match-based `From` impl or a single shared enum. **No -transmute**: int→enum transmute is UB on any out-of-range value and a test only -catches drift you thought to test; a match over contiguous `#[repr(i32)]` variants -compiles to the same identity code anyway. - -### B4. Idiomatic loops & duplicate helpers -- C-style `while i < NSIG` loops in `state.rs:181-190,198-208` → `for`/iterator - (`.iter().filter(..).count()`). -- `fail_init` and `reset_init` (`state.rs:92-98`) are byte-identical → one function. -- `word_at` (`backtrace.rs:18`) → `usize::from_ne_bytes` on an `array_chunks` slice. -- `instruction_pointer` (`backtrace.rs:97`) duplicates the `n==1` seed of - `backtrace_from_ucontext` (`:112`) → reuse. - -### B5. Formatting: drop hand-rolled itoa/hex where safe (`fmt.rs`) -`hex`/`hex_addr`/`hex_u32`/`write_i32` (`fmt.rs:8-62`) manually build digits. -Caution: `core::fmt` is alloc-free but **not panic-free** — the `Formatter` -padding/width machinery has panicking branches, and `write!` on the crash path can -pull `core::panicking::panic_fmt` into code that runs after memory corruption. The -hand-rolled digits exist precisely to avoid that. Preference order: -1. the alloc-free, panic-free `itoa` crate (already a transitive dep) for decimals; -2. keep the hand-rolled hex (or `itoa`-style tables) for `hex`/`hex_addr`; -3. `core::fmt::Write` only if `tools/check_signal_safe_symbols.sh` passing — with no - new panic/fmt symbols — is a hard gate *on that specific commit*. -Keep a named capacity const for `hex_u32` instead of the bare `10` (`fmt.rs:12`). - -### B6. De-duplicate the two `raw` syscall modules (`sys.rs`) *(large win)* -The two `mod raw` blocks (`sys.rs:50-389` vs `395-567`) are ~90% identical — `write`, -`close`, `fcntl_dupfd`, `fd_valid`, `pipe`, `open_readwrite`, `access_executable`, -`mprotect_none`, `getpid`, `gettid`, `kill`, `waitpid_nohang_status`, `poll_sleep_ms`, -`monotonic_nanos` are byte-identical `rustix` calls. Only ~5 functions genuinely differ -per cfg. Move the common ones into one shared module (~170 lines removed). Shrink the -inline-`asm!` `syscall1/3/6` block (`:66-178`) to only what has no stable `rustix` -equivalent: keep `fork_raw` (clone), `read_own_mem` (`process_vm_readv`), -`close_range`, **and `exit_group`** — `rustix::runtime` is experimental, -linux_raw-backend-only, and not in our pinned feature set (`Cargo.toml:93`); five -lines of stable asm beat an unstable feature flag. Route `dup2`→`rustix::io::dup2` -only after confirming its typed-fd signature (`&mut OwnedFd` target) fits the raw-fd -usage in the handler without `OwnedFd` construction gymnastics — otherwise keep the -raw syscall. Replace the manual EINTR loop in `FdSink::put` (`sys.rs:29-41`) with -`rustix::io::retry_on_intr` (confirm it exists in the pinned `=1.1.3` first). -Merge the near-identical `cstr_starts_with`/`env_entry_value` prefix scanners -(`sys.rs:673-700`) into one helper; base `cstr_bytes_bounded` (`:661`) on -`CStr::from_ptr(..).to_bytes()` with the length cap layered on top. - -Platform matrix: this file is cfg-dense (the second `raw` module is the -macOS/fallback path; the asm block is per-arch). Require green builds on x86_64 *and* -aarch64, Linux *and* macOS, before and after — the Linux e2e test alone does not -exercise the fallback module. - -### B7. Pass structs, not scalar bundles (`handler.rs`) -Two `#[allow(clippy::too_many_arguments)]` sites (`handler.rs:377,401`, -`collector_child`/`emit_crash_report`) thread 8-9 scalars (sig/si_code/has_info/si_addr/ -pid/tid/ucontext) that already have homes in `SignalInfo`/`CrashContext` -(`report.rs:38,83`). Build the struct once and pass it. Fold the three repeat-fault -arrays (B2) into the same context. Replace the trivial enum→enum maps `begin_init_error`/ -`prepare_error` (`handler.rs:133-145`) with `From` impls. - -## Theme C — Public-API surface reduction - -`mod.rs` re-exports ~20 items (`mod.rs:43-63`); trim to what the FFI and tests actually -need. - -- **Drop the bool wrappers** `init`/`init_from_env` (`handler.rs:69-79`) — the FFI only - calls the `_result` forms (`collector_signal_safe.rs:76,94`), but - `tests/collector_signal_safe_e2e.rs:13,26` calls `init_from_env()` directly: switch - the e2e to `init_from_env_result()` in the same commit. Keep the `InitResult` - status enum (needed for stable FFI codes); do **not** collapse to `Result`. -- The policy one-liners `app_handler_is_real`, `should_run_app_first`, `app_recovered` - (`policy.rs:34-44`) are re-exported (`mod.rs:55-56`) but consumed only in `handler.rs` - — inline or make `pub(super)`. -- Reconsider `SIG_DFL_VALUE`/`SIG_IGN_VALUE` (`policy.rs:8-9`) reimplementing - `libc::SIG_DFL`/`SIG_IGN`; compare against libc directly. -- Remove the unused `use crate::protocol;` at `mod.rs:26` (protocol is used as - `protocol::` inside `emitter.rs`, not `mod.rs`). -- Audit `owns_signal`/`owned_signal_count`/`write_i32`/`cstr_bytes_bounded` re-exports — - keep only those crossing the crate boundary (FFI uses `cstr_bytes_bounded`, - `owns_signal`, `owned_signal_count`). -- The 24-field manual copy (`collector_signal_safe.rs:94-118`) and the 1:1 - `init_result_to_ffi`/`set_stage` remaps **stay as explicit code** — a mapping macro - would save ~30 boring lines at the cost of hiding the FFI surface from grep and - reviewers. Explicit copies are the right idiom at an ABI boundary. Revisit only if - the enum count grows materially. - -## Suggested sequencing - -Each phase compiles + tests green before the next (`cargo check -p libdd-crashtracker ---features collector_signal-safe`, then the full validation from AGENTS.md; the golden -fixture and `collector_signal_safe_e2e` guard behavior). - -Phases map onto the PR scoping at the top: Phase 0 in this branch; Phases 1–2 as -follow-up PRs; Phases 3–4 strictly post-merge, one extraction per PR. - -1. **Phase 0 — dead weight & surface** (low risk, this branch): C-theme trims, - `mod.rs:26` dead import, `fail_init`/`reset_init` merge, idiomatic loops (B4), - e2e switch to `init_from_env_result()`. No behavior change; net deletion. -2. **Phase 1 — internal typing**: bitflags (B1), SoA→struct arrays (B2), `Stage` name via - enum (B3, match-based — no transmute), pass-structs (B7). Local, well-tested by - existing unit tests. -3. **Phase 2 — sys.rs consolidation** (B6): merge `raw` modules, trim `asm!` (keeping - `exit_group`), adopt `rustix` helpers. Isolated; guarded by e2e **plus** the - four-way platform matrix (x86_64/aarch64 × Linux/macOS). -4. **Phase 3 — cross-module reuse** (Theme A): framing helper (A1, split signal-side / - std-side), `StacktraceCollection` share (A2, first bullet only), tag-key table (A3), - signal-name unification (A4, expand shared table + std snapshot first), ucontext - extraction (A5). Requires making a few existing modules `no_std`; one extraction per - PR; golden fixture byte-stable. -5. **Phase 4 — formatting** (B5): only after A1 lands, so `fmt.rs` is deleted rather - than rewritten. `itoa`/hand-rolled preferred; `core::fmt` only behind the symbol-check - gate. - -## Validation checklist - -- `cargo check -p libdd-crashtracker --features collector_signal-safe` -- `cargo clippy --workspace --all-targets --all-features -- -D warnings` - (target: remove the two `too_many_arguments` allows and any `dead_code` allows made - obsolete) -- `cargo nextest run -p libdd-crashtracker --features - libdd-crashtracker/generate-unit-test-files` incl. `collector_signal_safe_e2e` -- Golden fixture `tests/fixtures/signal_safe_report.golden` unchanged (or regenerated - intentionally via the `#[ignore]` regenerate test with the diff reviewed). -- `tools/check_signal_safe_symbols.sh` — run **per phase**, not just at the end; it is - the guard that catches `core::fmt`/panic machinery leaking into the crash path - (B5/A1) and non-signal-safe symbols from any newly-shared dependency. -- Phase 2 additionally: green builds on x86_64 and aarch64, Linux and macOS. -- If FFI touched: `cargo ffi-test` + `examples/ffi/signal_safe_crashtracking.c`. - -## Non-goals / explicitly out of scope - -- Rewriting `fork_raw`/`read_own_mem` inline `asm!` — inherent to signal-safety. -- Reusing the std `from_env`/telemetry config machinery — it allocates and calls - `getenv`; the bespoke `environ` walk stays. (The bespoke bool/log-level/case-insensitive - parsers in `config.rs:367-418` may be factored into a shared *no_std* helper if a second - signal-safe consumer appears, but not merged with the std parsers.) -- Changing the wire protocol or the `InitResult` numeric contract. - - From 4de97b5c8ec941e1dbed38d5ef2aef047f54aba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Mon, 6 Jul 2026 21:19:38 +0200 Subject: [PATCH 22/32] refactor(crashtracker): remove signal-safe stage tracking --- crashtracker-work-we-need-to-do.md | 63 ++++++------------- .../src/collector_signal_safe.rs | 39 +----------- .../src/collector_signal_safe/config.rs | 4 +- .../src/collector_signal_safe/emitter.rs | 20 ++---- .../src/collector_signal_safe/handler.rs | 7 +-- .../src/collector_signal_safe/mod.rs | 19 +++--- .../src/collector_signal_safe/report.rs | 1 - .../src/collector_signal_safe/state.rs | 61 ------------------ libdd-crashtracker/src/receiver/mod.rs | 2 - libdd-crashtracker/src/shared/tag_keys.rs | 1 - .../tests/collector_signal_safe_e2e.rs | 53 ++-------------- .../tests/fixtures/signal_safe_report.golden | 4 +- 12 files changed, 40 insertions(+), 234 deletions(-) diff --git a/crashtracker-work-we-need-to-do.md b/crashtracker-work-we-need-to-do.md index 5582bda0b3..30e155c5b2 100644 --- a/crashtracker-work-we-need-to-do.md +++ b/crashtracker-work-we-need-to-do.md @@ -42,7 +42,7 @@ Current libdatadog still has `std`, `nix`, `UnixStream`, `File`, `serde_json`, ` Status as of 2026-07-06: - Implemented a separate `collector_signal-safe` path that can coexist with the standard collector feature, with a runtime signal-owner guard so only one collector arms crash signal handlers. -- Added single-shot signal-safe initialization, release/acquire publication for handler enablement, fixed metadata snapshots, stage tags, and configurable integrator metadata. The C-tracer defaults are now a compatibility preset rather than hardcoded report fields. +- Added single-shot signal-safe initialization, release/acquire publication for handler enablement, fixed metadata snapshots, and configurable integrator metadata. The C-tracer defaults are now a compatibility preset rather than hardcoded report fields. - Fixed the app-first recovery check by re-reading the live handler after the app handler returns, and kept the app-handler call path free of `Drop`-dependent state. - Added init-time capability probes and report degradation tags for missing receiver, missing `process_vm_readv`, no fork support, `/dev/null`, pipe availability, and report-to-fd fallback. - Replaced broad non-Linux forking with an explicit degraded no-fork policy. Linux x86_64/aarch64 keeps raw `clone(SIGCHLD)` for fork-based collection; other Unix targets can emit a minimal report to a pre-opened fd. @@ -186,9 +186,9 @@ Work needed: ### 6. Add whole-process lifetime and teardown semantics -dd-trace-c keeps crashtracking armed for the whole process by default. `crashtracker_uninstall()` runs at the end of bootstrap, but unless `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true`, it only changes the stage to `application`. The destructor calls `crashtracker_shutdown()` and force-restores handlers so unloaded code is not left installed as a signal handler. +dd-trace-c keeps crashtracking armed for the whole process by default. Bootstrap-only configuration tears it down at the end of bootstrap; otherwise the destructor calls `crashtracker_shutdown()` and force-restores handlers so unloaded code is not left installed as a signal handler. -Current libdatadog has `enable()`/`disable()`, but `disable()` does not restore old handlers and there is no equivalent stage-aware uninstall/shutdown contract. +Current libdatadog has `enable()`/`disable()`, but `disable()` does not restore old handlers and there is no equivalent preload uninstall/shutdown contract. Without interposition, a disabled libdatadog handler can only chain to the handler displaced at install time. It cannot see a handler registered later by the application. That caveat belongs with the lifecycle work and reinforces the need for `sigaction`/`signal` virtualization. @@ -201,34 +201,7 @@ Work needed: - Leave interposition transparent after teardown, or provide safe hook removal if available. - Ensure no handler can dangle into unloaded code. -### 7. Add stage tracking and crash stage tags - -dd-trace-c tracks crash stages in an atomic `sig_atomic_t`: - -- `uninitialized` -- `crashtracker_init` -- `platform_init` -- `language_init` -- `plugin_loading` -- `injection_metadata_send` -- `http_client_send` -- `application` -- `crashtracker_uninstall` - -The crash report includes: - -- message: `Crash during ()` -- additional tag: `stage:` - -Work needed: - -- Add a libdatadog-owned or integration-owned stage API. -- Make stage reads signal-safe and race-free. -- Add scoped stage guards for normal code paths. -- Include the stage in the emitted message and additional tags. -- Add tests that crashes during init, HTTP send, and application runtime are labeled correctly. - -### 8. Add dd-trace-c env-driven preload configuration +### 7. Add dd-trace-c env-driven preload configuration dd-trace-c crashtracking can initialize from env without a language tracer constructing a `CrashtrackerConfiguration`. @@ -262,7 +235,7 @@ Work needed: - Keep the config JSON stable and test it as a wire contract. - Decide how to preserve libdatadog's general rule against hidden env reads for normal library callers while still supporting preload bootstrap. -### 9. Add receiver path discovery and loader-env scrubbing +### 8. Add receiver path discovery and loader-env scrubbing dd-trace-c receiver path lookup order: @@ -283,7 +256,7 @@ Work needed: - Ensure the receiver process does not inherit `LD_PRELOAD` or `LD_AUDIT`: strip them in the child if inheriting `environ`, or exclude them while constructing the explicit receiver env list. - Add tests for explicit override, sibling suffixed receiver, sibling plain receiver, baked default, and preload recursion prevention. -### 10. Align metadata tags with dd-trace-c/dd-trace-py +### 9. Align metadata tags with dd-trace-c/dd-trace-py dd-trace-c emits metadata with: @@ -313,7 +286,7 @@ Work needed: - Derive the default service name without doing signal-path work. - Test telemetry/log tags and crash-report metadata against dd-trace-c expectations. -### 11. Make Linux unwinding optional and add a signal-safe fallback +### 10. Make Linux unwinding optional and add a signal-safe fallback Current libdatadog has Linux local libunwind in the forked collector and remote libunwind in the receiver. dd-trace-c's current `crashtracker_native` does not use libunwind in the collector; it walks frame pointers from `ucontext` and probes frame records with `process_vm_readv` on itself. The old C implementation used a `sigsetjmp`/`siglongjmp` recovery handler around raw frame-pointer reads; the Rust port replaced that with `process_vm_readv` so corrupt frame pointers return `EFAULT` instead of crashing the collector. @@ -330,7 +303,7 @@ Work needed: - Decide whether `EnabledWithInprocessSymbols` is allowed in the signal/fork-child path for preload use. It likely should not be the default there. - Add tests for null ucontext, corrupt frame pointer, seccomp-denied memory probe if practical, and fallback without libunwind. -### 12. Align signal set and si_code wire behavior +### 11. Align signal set and si_code wire behavior dd-trace-c manages `SIGSEGV`, `SIGABRT`, `SIGBUS`, `SIGILL`, and `SIGFPE`. Current libdatadog defaults are `SIGBUS`, `SIGABRT`, `SIGSEGV`, and `SIGILL`; `SIGFPE` is not included by default. @@ -343,7 +316,7 @@ Work needed: - Add tests for `SIGFPE` reporting and receiver parsing. - Keep emitted signal and si_code strings compatible with receiver enum names. -### 13. Add signal-safe debug logging +### 12. Add signal-safe debug logging dd-trace-c has a debug path that formats into a fixed stack buffer and calls `dd_trace_log_write_signal` exactly once. It is gated by a cached `DD_TRACE_LOG_LEVEL` read from init. @@ -356,7 +329,7 @@ Work needed: ## P1 work -### 14. Make the wire emitter deterministic and allocation-free for preload +### 13. Make the wire emitter deterministic and allocation-free for preload dd-trace-c's preload collector emits a minimal stream: @@ -380,7 +353,7 @@ Work needed: - Keep section names compatible. The receiver already accepts dd-trace-c's `PROCESSINFO` spelling; add a golden round-trip test instead of treating this as parser work. - Decide how to preserve crash-ping enrichment if metadata is emitted before message. -### 15. Preserve and test fd/socket protocol choices +### 14. Preserve and test fd/socket protocol choices dd-trace-c uses `pipe()` and execs the receiver with the read end on stdin. libdatadog's general collector uses `socketpair()` and wraps it in `UnixStream`. @@ -391,7 +364,7 @@ Work needed: - Ensure EOF signaling and receiver completion are deterministic. - Add tests for closed stdio descriptors, low-numbered pipe/socket fds, `EINTR` write retries, short writes, and receiver EOF. -### 16. Packaging and build integration +### 15. Packaging and build integration dd-trace-c builds and ships: @@ -409,11 +382,11 @@ Work needed: - Add receiver size checks if libdatadog owns the sidecar packaging. - Keep old-glibc load behavior intact; avoid hard `libdl` symbols. -### 17. API boundaries and ownership +### 16. API boundaries and ownership There are two plausible designs: -- Put the generic signal-safe collector in libdatadog and let integrations provide metadata, stage, receiver-path, and hook-engine adapters. +- Put the generic signal-safe collector in libdatadog and let integrations provide metadata, receiver-path, and hook-engine adapters. - Put only reusable primitives in libdatadog and keep dd-trace-c's preload-specific policy in dd-trace-c. Either way, libdatadog needs a clearer split between: @@ -434,7 +407,7 @@ Work needed: ## P2 work -### 18. Backfill integration tests from dd-trace-c +### 17. Backfill integration tests from dd-trace-c Port or mirror these dd-trace-c `test/agentapi/crashtracker_preload_test.go` scenarios: @@ -463,7 +436,7 @@ Add new libdatadog-specific tests for: - golden preload wire round-trip through the receiver, including `PROCESSINFO` - forked collector has no allocator/logging/std calls on the hot path, including no `eprintln!`, no `std::fs::File`, and no `dlsym` -### 19. Keep existing libdatadog strengths +### 18. Keep existing libdatadog strengths While adding dd-trace-c parity, avoid regressing current libdatadog functionality: @@ -485,7 +458,7 @@ Preload mode can be smaller and stricter than general mode, but the two should s 3. Add allocation-free minimal emitter and golden wire tests. 4. Add frame-pointer/process_vm_readv fallback and libunwind feature/config split. 5. Add receiver child env/fd sanitation and bounded reap semantics. -6. Add stage and metadata builders. +6. Add preload metadata builders. 7. Add sigaction/signal virtualization and Mode A/Mode B. Do not implement Mode A before the virtualized effective-handler state exists. 8. Add preload env init and receiver path discovery. 9. Port dd-trace-c integration tests. @@ -501,4 +474,4 @@ Highest risk missing pieces: 4. No Mode A app-first policy for managed-runtime recovery. 5. Default-disposition chaining via `raise` instead of re-fault for synchronous kernel faults. 6. Receiver exec environment can recurse under `LD_PRELOAD`/`LD_AUDIT` unless the receiver env is sanitized or explicitly built without loader vars. -7. Preload metadata/stage/report shape is not available as a libdatadog helper. +7. Preload metadata/report shape is not available as a libdatadog helper. diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs index 9a899dc90a..27f49b4a64 100644 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ b/libdd-crashtracker-ffi/src/collector_signal_safe.rs @@ -6,8 +6,8 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use libdd_crashtracker::collector_signal_safe::{ bootstrap_complete, capability_bits, cstr_bytes_bounded, degradation_bits, - init_from_env_result, init_result, owned_signal_count, owns_signal, set_stage, shutdown, - InitResult, SignalSafeInitConfig, Stage, + init_from_env_result, init_result, owned_signal_count, owns_signal, shutdown, InitResult, + SignalSafeInitConfig, }; #[repr(C)] @@ -57,36 +57,6 @@ pub enum SignalSafeInitResult { InvalidConfig = 5, } -#[repr(C)] -#[derive(Clone, Copy)] -pub enum SignalSafeStage { - Uninitialized = 0, - CrashtrackerInit = 1, - PlatformInit = 2, - LanguageInit = 3, - PluginLoading = 4, - InjectionMetadataSend = 5, - HttpClientSend = 6, - Application = 7, - CrashtrackerUninstall = 8, -} - -impl From for Stage { - fn from(stage: SignalSafeStage) -> Self { - match stage { - SignalSafeStage::Uninitialized => Stage::Uninitialized, - SignalSafeStage::CrashtrackerInit => Stage::CrashtrackerInit, - SignalSafeStage::PlatformInit => Stage::PlatformInit, - SignalSafeStage::LanguageInit => Stage::LanguageInit, - SignalSafeStage::PluginLoading => Stage::PluginLoading, - SignalSafeStage::InjectionMetadataSend => Stage::InjectionMetadataSend, - SignalSafeStage::HttpClientSend => Stage::HttpClientSend, - SignalSafeStage::Application => Stage::Application, - SignalSafeStage::CrashtrackerUninstall => Stage::CrashtrackerUninstall, - } - } -} - #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> SignalSafeInitResult { ffi_result(|| init_result_to_ffi(init_from_env_result())) @@ -145,11 +115,6 @@ pub extern "C" fn ddog_crasht_signal_safe_shutdown() { ffi_void(shutdown); } -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_set_stage(stage: SignalSafeStage) { - ffi_void(|| set_stage(stage.into())); -} - #[no_mangle] pub extern "C" fn ddog_crasht_signal_safe_capabilities() -> u32 { ffi_u32(capability_bits) diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index ce4b227246..c79ae40936 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -421,9 +421,9 @@ fn parse_log_level(v: Option<&[u8]>) -> i32 { #[cfg(test)] mod tests { use super::*; + use alloc::string::ToString; + use alloc::vec::Vec; use std::format; - use std::string::ToString; - use std::vec::Vec; #[test] fn config_json_contains_receiver_contract() { diff --git a/libdd-crashtracker/src/collector_signal_safe/emitter.rs b/libdd-crashtracker/src/collector_signal_safe/emitter.rs index 0f3b5550a2..ae90053ef6 100644 --- a/libdd-crashtracker/src/collector_signal_safe/emitter.rs +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -74,7 +74,7 @@ pub fn emit_report(sink: &mut impl Sink, report: &Report<'_>, context: &CrashCon return emit_truncated_tail(sink, report, context); } - emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) + emit_message(sink, &context.signal) && emit_done(sink) } fn emit_report_sections( @@ -87,7 +87,6 @@ fn emit_report_sections( } if !emit_additional_tags( sink, - report.stage_name, report.stackwalk_method, report.capabilities, report.degradations, @@ -193,15 +192,11 @@ fn emit_metadata(sink: &mut impl Sink, report: &Report<'_>) -> bool { fn emit_additional_tags( sink: &mut impl Sink, - stage: &str, stackwalk_method: &str, capability_bits: Capabilities, degradation_bits: Degradations, ) -> bool { let mut tags = Tags::new(); - if !push_tag(&mut tags, tag_keys::STAGE, stage) { - return false; - } if !push_tag(&mut tags, tag_keys::STACKWALK_METHOD, stackwalk_method) { return false; } @@ -289,15 +284,9 @@ fn emit_stacktrace(sink: &mut impl Sink, frames: &[usize]) -> bool { .is_ok() } -fn emit_message( - sink: &mut impl Sink, - stage_name: &str, - signal: &super::report::SignalInfo, -) -> bool { +fn emit_message(sink: &mut impl Sink, signal: &super::report::SignalInfo) -> bool { let mut message = HeaplessString::::new(); - message.push_str("Crash during ").is_ok() - && message.push_str(stage_name).is_ok() - && message.push_str(" (").is_ok() + message.push_str("Crash (").is_ok() && message.push_str(signal.si_signo_human_readable).is_ok() && message.push(')').is_ok() && protocol::section::<_, ()>( @@ -320,12 +309,11 @@ fn emit_truncated_tail( capabilities::note_degraded(capabilities::DEGRADED_TRUNCATED); let _ = emit_additional_tags( sink, - report.stage_name, report.stackwalk_method, report.capabilities, report.degradations.with(capabilities::DEGRADED_TRUNCATED), ); - emit_message(sink, report.stage_name, &context.signal) && emit_done(sink) + emit_message(sink, &context.signal) && emit_done(sink) } fn put_marker_line(sink: &mut impl Sink, marker: &str) -> bool { diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index fc5ba8a4d2..1154547d9f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -12,7 +12,7 @@ use super::policy::{ app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, should_run_app_first, ChainAction, }; -use super::state::{self, sig_index, BeginInitError, Stage}; +use super::state::{self, sig_index, BeginInitError}; use super::sys::{self, FdSink}; use super::{backtrace, capabilities}; use super::{CrashContext, Report, SignalInfo}; @@ -160,7 +160,6 @@ fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> Init return InitResult::Failed; } install_all_handlers(); - state::set_stage(Stage::CrashtrackerInit); state::INSTALLED.store(true, Ordering::Release); state::finish_init(); state::HANDLERS_ENABLED.store(true, Ordering::Release); @@ -170,13 +169,10 @@ fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> Init pub fn bootstrap_complete() { if state::ONLY_BOOTSTRAP.load(Ordering::Relaxed) { shutdown(); - } else { - state::set_stage(Stage::Application); } } pub fn shutdown() { - state::set_stage(Stage::CrashtrackerUninstall); state::HANDLERS_ENABLED.store(false, Ordering::Release); uninstall_all_handlers(); COLLECTING.store(false, Ordering::Relaxed); @@ -466,7 +462,6 @@ fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> app_version: meta.app_version.as_str(), runtime_id, platform: meta.platform.as_str(), - stage_name: state::current_stage_name(), stackwalk_method, capabilities: capabilities::get(), degradations: capabilities::degradations(), diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index 9dda3cef6c..9a4fe9e2e9 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -49,7 +49,6 @@ pub use handler::{bootstrap_complete, init_from_env_result, init_result, shutdow pub(crate) use report::{CrashContext, Report, SignalInfo, SECTION_BUF_CAPACITY}; #[cfg(test)] pub(crate) use signal_names::*; -pub use state::{set_stage, Stage}; #[doc(hidden)] pub use sys::cstr_bytes_bounded; @@ -72,9 +71,9 @@ pub fn owns_signal(sig: i32) -> bool { #[cfg(test)] mod tests { use super::*; - use std::borrow::ToOwned; - use std::str; - use std::string::String; + use alloc::borrow::ToOwned; + use alloc::string::String; + use core::str; #[test] fn slice_sink_reports_capacity_failure() { @@ -108,7 +107,6 @@ mod tests { app_version: "v1", runtime_id: "rid", platform: "linux", - stage_name: "application", stackwalk_method: "fp_pvr", capabilities: capabilities::Capabilities::from_bits(0x21), degradations: capabilities::Degradations::empty(), @@ -145,7 +143,6 @@ mod tests { app_version: "v1", runtime_id: "rid", platform: "linux", - stage_name: "application", stackwalk_method: "fp_pvr", capabilities: capabilities::Capabilities::from_bits(0x21), degradations: capabilities::Degradations::empty(), @@ -204,17 +201,16 @@ mod tests { ); assert_eq!(metadata["tags"][5], "env:prod"); assert_eq!(metadata["tags"][6], "version:v1"); - assert_eq!(tags[0], "stage:application"); - assert_eq!(tags[1], "stackwalk_method:fp_pvr"); - assert_eq!(tags[2], "capabilities:0x00000021"); - assert_eq!(tags[3], "degradations:0x00000000"); + 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 during application (SIGSEGV)"); + assert_eq!(message.trim(), "Crash (SIGSEGV)"); assert!(report.ends_with("DD_CRASHTRACK_DONE\n")); } @@ -258,7 +254,6 @@ mod tests { app_version: "v1", runtime_id: "rid", platform: "linux", - stage_name: "application", stackwalk_method: "fp_pvr", capabilities: capabilities::Capabilities::from_bits(0x21), degradations: capabilities::DEGRADED_REPORT_TO_FD, diff --git a/libdd-crashtracker/src/collector_signal_safe/report.rs b/libdd-crashtracker/src/collector_signal_safe/report.rs index 3460d87393..b702b51906 100644 --- a/libdd-crashtracker/src/collector_signal_safe/report.rs +++ b/libdd-crashtracker/src/collector_signal_safe/report.rs @@ -99,7 +99,6 @@ pub struct Report<'a> { pub app_version: &'a str, pub runtime_id: &'a str, pub platform: &'a str, - pub stage_name: &'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 index 59765946c2..8703d830fd 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -193,67 +193,6 @@ pub static COLLECTOR_REAP_MS: AtomicI32 = AtomicI32::new(500); pub static RECEIVER_TIMEOUT_MS: AtomicI32 = AtomicI32::new(6_000); pub static MAX_FRAMES: AtomicUsize = AtomicUsize::new(32); -#[repr(i32)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Stage { - Uninitialized = 0, - CrashtrackerInit = 1, - PlatformInit = 2, - LanguageInit = 3, - PluginLoading = 4, - InjectionMetadataSend = 5, - HttpClientSend = 6, - Application = 7, - CrashtrackerUninstall = 8, -} - -impl Stage { - pub const fn name(self) -> &'static str { - match self { - Self::Uninitialized => "uninitialized", - Self::CrashtrackerInit => "crashtracker_init", - Self::PlatformInit => "platform_init", - Self::LanguageInit => "language_init", - Self::PluginLoading => "plugin_loading", - Self::InjectionMetadataSend => "injection_metadata_send", - Self::HttpClientSend => "http_client_send", - Self::Application => "application", - Self::CrashtrackerUninstall => "crashtracker_uninstall", - } - } -} - -impl TryFrom for Stage { - type Error = (); - - fn try_from(value: i32) -> Result { - match value { - 0 => Ok(Self::Uninitialized), - 1 => Ok(Self::CrashtrackerInit), - 2 => Ok(Self::PlatformInit), - 3 => Ok(Self::LanguageInit), - 4 => Ok(Self::PluginLoading), - 5 => Ok(Self::InjectionMetadataSend), - 6 => Ok(Self::HttpClientSend), - 7 => Ok(Self::Application), - 8 => Ok(Self::CrashtrackerUninstall), - _ => Err(()), - } - } -} - -static STAGE: AtomicI32 = AtomicI32::new(Stage::Uninitialized as i32); - -pub fn set_stage(stage: Stage) { - STAGE.store(stage as i32, Ordering::Relaxed); -} - -pub fn current_stage_name() -> &'static str { - Stage::try_from(STAGE.load(Ordering::Relaxed)) - .unwrap_or(Stage::Uninitialized) - .name() -} - pub fn clear_signal_state() { for slot in &SIGNAL_SLOTS { slot.clear(); diff --git a/libdd-crashtracker/src/receiver/mod.rs b/libdd-crashtracker/src/receiver/mod.rs index 5b00baf5e8..83ea2d8dd2 100644 --- a/libdd-crashtracker/src/receiver/mod.rs +++ b/libdd-crashtracker/src/receiver/mod.rs @@ -140,7 +140,6 @@ mod tests { app_version: "v1", runtime_id: "rid", platform: "linux", - stage_name: "application", stackwalk_method: "fp_pvr", capabilities: Capabilities::from_bits(0x21), degradations: Degradations::from_bits(1 << 8), // DEGRADED_REPORT_TO_FD @@ -175,7 +174,6 @@ mod tests { .experimental .expect("additional tags parsed") .additional_tags; - assert!(tags.iter().any(|tag| tag == "stage:application")); assert!(tags.iter().any(|tag| tag == "stackwalk_method:fp_pvr")); assert!(tags.iter().any(|tag| tag == "report_degraded:report_to_fd")); diff --git a/libdd-crashtracker/src/shared/tag_keys.rs b/libdd-crashtracker/src/shared/tag_keys.rs index 33f0aedc3b..0e8d2f4efc 100644 --- a/libdd-crashtracker/src/shared/tag_keys.rs +++ b/libdd-crashtracker/src/shared/tag_keys.rs @@ -20,7 +20,6 @@ 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 STAGE: &str = "stage"; pub const STACKWALK_METHOD: &str = "stackwalk_method"; pub const CAPABILITIES: &str = "capabilities"; pub const DEGRADATIONS: &str = "degradations"; diff --git a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index a08f85e222..a8284989db 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -12,8 +12,8 @@ use std::process::Command; #[cfg(any(target_os = "linux", target_os = "android"))] use libdd_crashtracker::collector_signal_safe::init_from_env_result; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, init_result, owned_signal_count, owns_signal, set_stage, InitResult, - SignalSafeInitConfig, Stage, + bootstrap_complete, init_result, owned_signal_count, owns_signal, InitResult, + SignalSafeInitConfig, }; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -25,7 +25,6 @@ fn signal_safe_receiver_child_process() { assert_eq!(init_from_env_result(), InitResult::Enabled); bootstrap_complete(); - set_stage(Stage::Application); std::process::abort(); } @@ -50,25 +49,10 @@ fn signal_safe_report_fd_child_process() { InitResult::Enabled ); bootstrap_complete(); - set_stage(Stage::Application); std::process::abort(); } -#[test] -fn signal_safe_stage_child_process() { - let Some(report) = std::env::var_os("DD_SIGNAL_SAFE_E2E_STAGE_CHILD") else { - return; - }; - let stage = std::env::var("DD_SIGNAL_SAFE_E2E_STAGE").expect("stage"); - - let _report = init_report_fd(report, b"/definitely/missing-signal-safe-receiver", false); - if stage == "application" { - 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 { @@ -90,7 +74,6 @@ fn signal_safe_receiver_deleted_child_process() { let _report = init_report_fd(report, receiver.as_encoded_bytes(), false); bootstrap_complete(); - set_stage(Stage::Application); fs::remove_file(receiver).expect("remove receiver"); std::process::abort(); } @@ -107,7 +90,6 @@ fn signal_safe_preexisting_app_handler_child_process() { assert!(owns_signal(libc::SIGABRT)); assert!(owned_signal_count() < 5); bootstrap_complete(); - set_stage(Stage::Application); std::process::abort(); } @@ -172,15 +154,6 @@ fn signal_safe_crash_writes_report_to_fd_when_degraded() { assert!(report.contains("\"report_degraded:report_to_fd\"")); } -#[test] -fn signal_safe_stage_tags_track_bootstrap_completion() { - let init_report = run_stage_child("crashtracker_init"); - assert!(init_report.contains("\"stage:crashtracker_init\"")); - - let application_report = run_stage_child("application"); - assert!(application_report.contains("\"stage:application\"")); -} - #[test] fn signal_safe_bootstrap_only_shutdown_suppresses_later_report() { let temp = tempfile::tempdir().expect("tempdir"); @@ -256,24 +229,6 @@ fn signal_safe_preexisting_app_handler_is_reported_without_internal_state_setup( ))); } -fn run_stage_child(stage: &str) -> String { - 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_stage_child_process") - .arg("--nocapture") - .env("DD_SIGNAL_SAFE_E2E_STAGE_CHILD", &report) - .env("DD_SIGNAL_SAFE_E2E_STAGE", stage) - .status() - .expect("spawn child"); - - assert!(!status.success(), "child should terminate via signal"); - fs::read_to_string(&report).expect("read crash report") -} - fn init_report_fd( report_path: impl AsRef, receiver_path: &[u8], @@ -299,12 +254,12 @@ fn init_report_fd( extern "C" fn noop_handler(_: libc::c_int) {} fn install_noop_handler(signal: libc::c_int) { - let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + 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, std::ptr::null_mut()), 0); + assert_eq!(libc::sigaction(signal, &action, core::ptr::null_mut()), 0); } } diff --git a/libdd-crashtracker/tests/fixtures/signal_safe_report.golden b/libdd-crashtracker/tests/fixtures/signal_safe_report.golden index 97e2ee7cfb..d1b76730e2 100644 --- a/libdd-crashtracker/tests/fixtures/signal_safe_report.golden +++ b/libdd-crashtracker/tests/fixtures/signal_safe_report.golden @@ -5,7 +5,7 @@ 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 -["stage:application","stackwalk_method:fp_pvr","capabilities:0x00000021","degradations:0x00000100","report_degraded:report_to_fd"] +["stackwalk_method:fp_pvr","capabilities:0x00000021","degradations:0x00000100","report_degraded:report_to_fd"] DD_CRASHTRACK_END_ADDITIONAL_TAGS DD_CRASHTRACK_BEGIN_KIND "UnixSignal" @@ -21,6 +21,6 @@ DD_CRASHTRACK_BEGIN_STACKTRACE {"ip":"0x0000000000000020"} DD_CRASHTRACK_END_STACKTRACE DD_CRASHTRACK_BEGIN_MESSAGE -Crash during application (SIGSEGV) +Crash (SIGSEGV) DD_CRASHTRACK_END_MESSAGE DD_CRASHTRACK_DONE From a4c012f383923fcbdbfa32cd9c6ffa2662707a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Tue, 7 Jul 2026 02:42:39 +0200 Subject: [PATCH 23/32] refactor(crashtracker): simplify signal-safe collector cleanups - Drop protocol_marker! macro in favor of a module-level allow(dead_code) - Collapse duplicate errno_location cfg branches - Remove cstr_starts_with pass-through wrapper - Factor Capabilities/Degradations into a shared bitset_u32! macro - Remove scratch planning doc --- crashtracker-work-we-need-to-do.md | 477 ------------------ .../src/collector_signal_safe/capabilities.rs | 79 ++- .../src/collector_signal_safe/handler.rs | 2 +- .../src/collector_signal_safe/sys.rs | 21 +- libdd-crashtracker/src/protocol.rs | 77 +-- 5 files changed, 55 insertions(+), 601 deletions(-) delete mode 100644 crashtracker-work-we-need-to-do.md diff --git a/crashtracker-work-we-need-to-do.md b/crashtracker-work-we-need-to-do.md deleted file mode 100644 index 30e155c5b2..0000000000 --- a/crashtracker-work-we-need-to-do.md +++ /dev/null @@ -1,477 +0,0 @@ -# Crashtracker Work We Need To Do - -## Scope - -This compares current libdatadog `libdd-crashtracker` against the crashtracking implementation in `/Users/pawel.chojnacki/work/dd-trace-c/crashtracker`. - -Sources inspected: - -- dd-trace-c branch `crashtracker` at `608537b3`. -- dd-trace-c commit `114f4a08`, which ports the in-process collector from `libdd_autoinstrument/crashtracker_linux.c` to `crashtracker_native`. -- dd-trace-c original C crashtracker from `origin/main:libdd_autoinstrument/crashtracker_linux.c`. -- libdatadog current `origin/main` at `d7b2aad37`. - -Important framing: dd-trace-c already uses libdatadog for the receiver sidecar (`crashtracker_rust` is a tiny `libdd_crashtracker::receiver_entry_point_stdin()` wrapper). Most of the missing work is the in-process/preload-grade collector, signal policy, initialization, and packaging behavior. - -## What libdatadog already has - -Do not rebuild these unless needed to support the missing in-process work: - -- Receiver-side stdin protocol parsing and crash upload. -- Telemetry and errors-intake payload generation. -- Configurable receiver process and Unix socket receiver modes. -- Panic hook and unhandled-exception reporting. -- Linux crashing-thread unwinding with bundled `libdd-libunwind-sys`. -- Linux all-thread collection in the receiver via `/proc//task`, `PTRACE_SEIZE`, `PTRACE_INTERRUPT`, and remote libunwind. -- `PR_SET_PTRACER` scoping and sidecar peer PID verification for all-thread collection. -- macOS crashing-thread frame-pointer collection. -- Existing crash protocol sections for counters, spans, traces, additional tags, `/proc/self/maps`, ucontext, runtime stack callbacks, and whole-stack unhandled exceptions. -- Receiver compatibility with dd-trace-c's `PROCESSINFO` wire section. This needs golden coverage, not new parser work. -- Errno preservation in the signal handler. - -## Main gap - -libdatadog has a feature-rich crashtracker, but its current Unix collector is not yet equivalent to dd-trace-c's preload-grade implementation. - -The dd-trace-c implementation assumes the signal handler and forked children are constrained environments. After a `fork`/raw `clone` from a signal handler in a potentially multi-threaded process, the child must be treated as async-signal-safe until `execve` or `_exit`. For the collector child there is no `execve`, so the entire collector child path must stay async-signal-safe. For the receiver child, every setup step before `execve` must stay async-signal-safe. - -Current libdatadog still has `std`, `nix`, `UnixStream`, `File`, `serde_json`, `Box`-backed globals, formatting, and RAII/drop patterns reachable from the signal-handler/fork-child path. Some individual syscalls underneath are safe, but the Rust wrappers and destructors are not enough of a contract for the dd-trace-c preload use case. - -## Progress on the signal-safe collector branch - -Status as of 2026-07-06: - -- Implemented a separate `collector_signal-safe` path that can coexist with the standard collector feature, with a runtime signal-owner guard so only one collector arms crash signal handlers. -- Added single-shot signal-safe initialization, release/acquire publication for handler enablement, fixed metadata snapshots, and configurable integrator metadata. The C-tracer defaults are now a compatibility preset rather than hardcoded report fields. -- Fixed the app-first recovery check by re-reading the live handler after the app handler returns, and kept the app-handler call path free of `Drop`-dependent state. -- Added init-time capability probes and report degradation tags for missing receiver, missing `process_vm_readv`, no fork support, `/dev/null`, pipe availability, and report-to-fd fallback. -- Replaced broad non-Linux forking with an explicit degraded no-fork policy. Linux x86_64/aarch64 keeps raw `clone(SIGCHLD)` for fork-based collection; other Unix targets can emit a minimal report to a pre-opened fd. -- Adopted `rustix` for ordinary fd/process/time wrappers where it fits, kept raw asm only where needed, normalized fallback errno handling, and removed libc `fork()` from the fallback path. -- Added optional alt-stack, signal-mask, disarm-on-entry, report-fd, timeout, max-frame, and receiver-fd cleanup config fields through Rust and FFI. -- Added Linux receiver e2e coverage, portable report-to-fd degraded e2e coverage, an aarch64 Linux check, and a symbol guard for banned crash-path symbols. - -Deferred follow-up work: - -- Full `sigaction`/`signal` virtualization and PLT interposition for late app/runtime handler registration. -- Receiver path discovery beside the loaded integration library, including architecture-suffixed receiver names. -- Sacrificial-child probing for seccomp policies that kill `process_vm_readv`. -- `close_range`/fd sweep behavior in the receiver child. -- Regenerating and reviewing cbindgen headers for the expanded FFI structs. -- Porting the complete dd-trace-c preload integration matrix, including app-handler recovery by `siglongjmp`, report-first policy tests, stuck receiver timeout tests, and receiver recursion prevention tests. -- Packaging decisions for the sidecar receiver and any preload-owned release artifacts. - -## P0 work - -### 1. Add a signal-safe in-process collector surface - -dd-trace-c has `crashtracker_native`, a `no_std`, no-allocator staticlib linked into `libdd_autoinstrument.so`. It uses fixed-capacity buffers, `heapless`, `serde-json-core`, raw syscalls or `rustix` linux-raw calls, `panic = abort`, and a panic handler that exits immediately. - -libdatadog needs an equivalent path or crate for Unix preload use: - -- No allocation in the signal handler. -- No allocation in the forked collector child. -- No allocation in the receiver child before `execve`. -- No mutexes, stdio, lazy init, `pthread_once`, or logging paths that take locks. -- No `Drop`-bearing values live across calls to app signal handlers, because those handlers may recover with `siglongjmp`. -- No `std::io::Write` trait objects or `serde_json` in the crash path. -- No panics or unwinding through signal-handler or fork-child frames. -- Raw syscall wrappers for `clone`/`fork`, `_exit`, `write`, `close`, `dup2`, `fcntl`, `pipe`, `poll`, `waitpid`, `kill`, `getpid`, `gettid`, `clock_gettime`, and stack-memory probes. - -Concrete libdatadog areas to audit or replace: - -- `libdd-crashtracker/src/collector/crash_handler.rs` -- `libdd-crashtracker/src/collector/collector_manager.rs` -- `libdd-crashtracker/src/collector/receiver_manager.rs` -- `libdd-crashtracker/src/collector/emitters.rs` -- `libdd-common/src/unix_utils/fork.rs` -- `libdd-common/src/unix_utils/process.rs` -- `libdd-common/src/unix_utils/execve.rs` - -Concrete current violations to remove first: - -- `eprintln!` is reachable from signal or fork-child paths and is not async-signal-safe: `signal_handler_manager.rs` in `chain_signal_handler`, `collector_manager.rs` in `run_collector_child`, and `process_handle.rs` in `finish`. Replace with a fixed-buffer raw `write(2)` path or remove from crash paths. -- `libdd-common/src/unix_utils/fork.rs::is_being_traced()` uses `std::fs::File`, `Read`, UTF-8 parsing, and `Drop`-closed fds from `alt_fork()`, which is reachable from `Collector::spawn` and `Receiver::spawn_from_config` on the signal path. The underlying syscalls are not the problem; the std/RAII surface is. -- `collector/api.rs::mark_preload_logger_collector()` is a best-effort preload test hook using `dlsym` from the signal path on Linux. The preload design needs to remove it from the hot path, make it signal-safe, or explicitly scope it away from production crash handling. - -### 2. Treat forked children as async-signal-safe - -This is not optional. The dd-trace-c implementation deliberately treats both children as constrained: - -- Receiver child: reset crash handlers, preserve/relocate the report fd if it is `0`, `1`, or `2`, redirect stdio to `/dev/null`, strip loader env, then `execv`. -- Collector child: reset crash handlers, preserve/relocate the write fd, redirect stdio, collect stack frames, emit the protocol stream, close the fd, then `_exit`. -- Parent: close both pipe ends, wait with bounded budgets, kill only after timeout, then reap. - -Work needed in libdatadog: - -- Remove `UnixStream::from_raw_fd`, `File`, `OwnedFd`, `PreparedExecve::exec()` wrappers, and destructor-dependent cleanup from the signal/fork-child path. -- Add explicit fd relocation before stdio redirection. -- Add child stdio redirection to `/dev/null` without clobbering the crash pipe/socket. -- Reset managed crash signals to `SIG_DFL` in both children so a crash in the child does not recurse into crashtracker. -- Fix the current concrete child gaps: - - `collector_manager.rs::run_collector_child()` closes `0`, `1`, and `2` before writing to `uds_fd`; if `uds_fd` is one of those fds, the crash socket is destroyed. - - `receiver_manager.rs::run_receiver_child()` uses naive `dup2` setup without collision handling for low-numbered report fds. - - `collector_manager.rs::run_collector_child()` does not reset the managed crash signals to `SIG_DFL`; a collector fault while unwinding can re-enter the inherited crash handler. -- Use `_exit`, not Rust process exit or panic paths. -- Replace busy wait/reap behavior with the dd-trace-c bounded wait loop: collector around 500 ms, receiver timeout plus grace, then `SIGKILL` and reap. -- Treat libdatadog's current `ProcessHandle::finish()` semantics as different, not automatically broken: it waits for `POLLHUP`, then `SIGKILL`s and reaps. Matching dd-trace-c's wait-for-exit and kill-only-after-timeout behavior is a robustness/parity upgrade. - -### 3. Add sigaction/signal virtualization - -dd-trace-c installs its handler only on crash signals still set to `SIG_DFL`. It then PLT-interposes libc `sigaction` and `signal` so late app/runtime registrations cannot displace the crashtracker handler. The wrappers record the app's requested disposition in per-signal atomics and answer `oldact` from that virtual state. - -libdatadog currently installs handlers and stores the previous handlers, but it does not keep itself on top if an app registers a handler later. That misses dd-trace-c's core preload behavior. - -Work needed: - -- Add a way to interpose or otherwise mediate `sigaction` and `signal` for owned crash signals. -- Track per-signal state: - - handler pointer (`SIG_DFL`, `SIG_IGN`, or function pointer) - - `SA_SIGINFO` flags - - whether the app has set a handler - - original handler displaced at install - - whether crashtracker owns the kernel handler for this signal -- Virtualize `oldact` for app calls. -- Ensure crashtracker's own sigaction calls bypass the wrapper and hit the real libc function. -- Keep wrappers transparent when crashtracker is disabled or does not own the signal. -- Preserve dd-trace-c limitations explicitly: raw `rt_sigaction` syscalls and later `dlopen` PLT slots are not covered. - -### 4. Port Mode A and Mode B crash policy - -dd-trace-c has two on-crash policies: - -- Mode A, default: managed-runtime-safe. If the app installed a real handler, run it first. If it recovers, do not report. If it restores `SIG_DFL`, report and terminate. -- Mode B, `DD_CRASHTRACKING_ALWAYS_ON_TOP=true`: report first, then chain to the app/runtime handler. - -This matters for HotSpot, V8/Node, .NET, sanitizers, Python faulthandler, and other runtimes that use `SIGSEGV` non-fatally for null checks, safepoints, write barriers, or recovery. - -Work needed: - -- Add policy state and configuration. -- Land this with, or after, `sigaction`/`signal` virtualization. Mode A needs the virtualized effective app disposition to know whether there is a real app handler to call. -- Make the app-first call safe with respect to `siglongjmp`. This is the return-twice hazard: the app handler may jump through crashtracker frames, so no `Drop`-bearing guard or cleanup-required state can be live across the app-first call. -- Account for `SA_NODEFER`, guard splitting, and errno preservation as explicit invariants. -- Do not count a recovered runtime signal as the one crash for the process. -- Add a re-entry guard for crashes inside the app handler. -- Preserve errno across the app-first path and final chain path. -- Add tests for: - - app handler gives up by restoring `SIG_DFL` - - app handler recovers by `siglongjmp` - - Mode B reports before recovery - - registration through `sigaction` - - registration through `signal` - -### 5. Match dd-trace-c chaining and genuine-fault decisions - -dd-trace-c does not report every delivered configured signal. It reports a genuine fault when siginfo is present and either: - -- `si_code` is not `SI_USER` or `SI_TKILL`, or -- the async signal was sent by the process itself. - -External `kill/tgkill` should not become crash telemetry by default. - -After reporting, dd-trace-c chains carefully: - -- `SIG_DFL` plus kernel-raised synchronous fault (`si_code > 0`): restore default and return, letting the faulting instruction re-execute so the kernel terminates with the original address and `si_code`. -- `SIG_DFL` plus async signal: restore default and `raise(sig)`. -- `SIG_IGN`: resume. -- function handler: invoke with the correct `SA_SIGINFO` calling convention. - -Work needed: - -- Add the genuine-fault filter. -- Fix default-disposition chaining to re-fault on synchronous faults rather than always calling `raise`. -- Preserve the original core-dump signal context. -- Keep current errno-preservation behavior. -- Add tests for external async signal, self-sent async signal, default sync re-fault, ignored disposition, and function handler chain. - -### 6. Add whole-process lifetime and teardown semantics - -dd-trace-c keeps crashtracking armed for the whole process by default. Bootstrap-only configuration tears it down at the end of bootstrap; otherwise the destructor calls `crashtracker_shutdown()` and force-restores handlers so unloaded code is not left installed as a signal handler. - -Current libdatadog has `enable()`/`disable()`, but `disable()` does not restore old handlers and there is no equivalent preload uninstall/shutdown contract. - -Without interposition, a disabled libdatadog handler can only chain to the handler displaced at install time. It cannot see a handler registered later by the application. That caveat belongs with the lifecycle work and reinforces the need for `sigaction`/`signal` virtualization. - -Work needed: - -- Add explicit init, bootstrap-end, and shutdown APIs for preload users. -- Default to whole-process lifetime. -- Support bootstrap-only lifetime. -- Restore effective app handlers on forced shutdown. -- Leave interposition transparent after teardown, or provide safe hook removal if available. -- Ensure no handler can dangle into unloaded code. - -### 7. Add dd-trace-c env-driven preload configuration - -dd-trace-c crashtracking can initialize from env without a language tracer constructing a `CrashtrackerConfiguration`. - -Environment inputs used by the implementation: - -- `DD_CRASHTRACKING_ENABLED=false` disables install. -- `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` enables Mode B. -- `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true` enables bootstrap-only lifetime. -- `DD_TRACE_LOG_LEVEL=debug` enables signal-safe debug breadcrumbs. -- `DD_TRACE_C_CRASHTRACKER_PROCESS` overrides the receiver path. -- `DD_SERVICE`, `DD_ENV`, `DD_VERSION`, `DD_INJECT_SENDER_TYPE` feed metadata. -- runtime id is snapshotted from dd-trace-c process metadata during init. -- `DD_TRACE_AGENT_URL` is intentionally resolved by the receiver through libdatadog config, not parsed in the in-process collector. - -The fixed collector config emitted by dd-trace-c is: - -- `additional_files: []` -- `create_alt_stack: false` -- `use_alt_stack: false` -- `demangle_names: true` -- `endpoint: null` -- `resolve_frames: EnabledWithSymbolsInReceiver` -- signals: `SIGSEGV`, `SIGABRT`, `SIGBUS`, `SIGILL`, `SIGFPE` -- timeout: 5 seconds -- `unix_socket_path: null` - -Work needed: - -- Add a preload-oriented config builder or C ABI that caches this state before arming the handler. -- Snapshot env-derived strings during init; do not call `getenv` from the signal path. -- Keep the config JSON stable and test it as a wire contract. -- Decide how to preserve libdatadog's general rule against hidden env reads for normal library callers while still supporting preload bootstrap. - -### 8. Add receiver path discovery and loader-env scrubbing - -dd-trace-c receiver path lookup order: - -1. `DD_TRACE_C_CRASHTRACKER_PROCESS` -2. sibling of the loaded `libdd_autoinstrument.so`, preferring an architecture-suffixed receiver name such as `process-crash-receiver-linux-amd64` -3. sibling plain `process-crash-receiver` -4. baked default install path - -The Rust port keeps a small C glue file because stable Rust cannot express weak `dladdr`/`dl_iterate_phdr` linkage the same way. It also canonicalizes when possible and checks executability. - -Before `execv`, dd-trace-c strips `LD_PRELOAD` and `LD_AUDIT` from `environ` so the receiver does not re-run the preload constructor and recurse. If libdatadog uses an explicit `execve` environment list instead of inheriting `environ`, the equivalent fix is to construct the receiver environment without loader-injection variables while still preserving Datadog config needed by the receiver, such as agent URL/API-key settings. - -Work needed: - -- Add receiver path discovery usable by preload integrations. -- Support non-UTF-8 paths or explicitly document a UTF-8-only limitation. -- Add weak `dladdr`/`dl_iterate_phdr` C glue or another glibc-old-safe resolver. -- Ensure the receiver process does not inherit `LD_PRELOAD` or `LD_AUDIT`: strip them in the child if inheriting `environ`, or exclude them while constructing the explicit receiver env list. -- Add tests for explicit override, sibling suffixed receiver, sibling plain receiver, baked default, and preload recursion prevention. - -### 9. Align metadata tags with dd-trace-c/dd-trace-py - -dd-trace-c emits metadata with: - -- `library_name: dd-trace-c` -- `library_version: ` -- `family: native` -- tags: - - `language:native` - - `runtime:native` - - `is_crash:true` - - `severity:crash` - - `service:` - - `env:` when set - - `version:` when set - - `runtime_id:` - - `runtime_version:` - - `library_version:` - - `platform:` - - `injector_version:` - -Current libdatadog accepts caller-provided metadata; it does not provide this dd-trace-c preload metadata builder. - -Work needed: - -- Add a helper to build native preload metadata with this exact tag set. -- Snapshot runtime id before crash handling. -- Derive the default service name without doing signal-path work. -- Test telemetry/log tags and crash-report metadata against dd-trace-c expectations. - -### 10. Make Linux unwinding optional and add a signal-safe fallback - -Current libdatadog has Linux local libunwind in the forked collector and remote libunwind in the receiver. dd-trace-c's current `crashtracker_native` does not use libunwind in the collector; it walks frame pointers from `ucontext` and probes frame records with `process_vm_readv` on itself. The old C implementation used a `sigsetjmp`/`siglongjmp` recovery handler around raw frame-pointer reads; the Rust port replaced that with `process_vm_readv` so corrupt frame pointers return `EFAULT` instead of crashing the collector. - -This is not just dependency cleanup. Current libdatadog's collector child walks the crashing thread stack with local libunwind. A corrupt frame can fault in the collector child; combined with the current lack of child crash-signal reset, that can re-enter the inherited crash handler. The frame-pointer plus no-fault memory-read path is a crash-safety requirement for preload mode. - -Work needed: - -- Feature-gate or config-gate local libunwind in the collector. -- Provide a frame-pointer fallback for x86_64 and aarch64. -- Use `process_vm_readv` probing, or another explicit no-fault memory-read strategy, for frame records. -- Treat errno-returning `process_vm_readv` failures, such as `EPERM`, as graceful stacktrace loss. Document that `SECCOMP_RET_KILL` is different: it kills the collector clone and may lose the report entirely. -- Consider a later preflight probe for `process_vm_readv`, for example a sacrificial child at init that calls `process_vm_readv` on itself and records whether the syscall returns normally, returns `EPERM`, or is killed by seccomp. This is useful follow-up work, not required for the first signal-safe collector cut. -- Keep remote libunwind for receiver all-thread collection separate from local collector unwinding. -- Decide whether `EnabledWithInprocessSymbols` is allowed in the signal/fork-child path for preload use. It likely should not be the default there. -- Add tests for null ucontext, corrupt frame pointer, seccomp-denied memory probe if practical, and fallback without libunwind. - -### 11. Align signal set and si_code wire behavior - -dd-trace-c manages `SIGSEGV`, `SIGABRT`, `SIGBUS`, `SIGILL`, and `SIGFPE`. Current libdatadog defaults are `SIGBUS`, `SIGABRT`, `SIGSEGV`, and `SIGILL`; `SIGFPE` is not included by default. - -dd-trace-c deliberately maps `SIGFPE` `FPE_*` si_codes to `UNKNOWN` because libdatadog's `SiCodes` enum has no `FPE_*` variants and the receiver deserializes `si_code_human_readable`. - -Work needed: - -- Decide whether libdatadog default signals should include `SIGFPE`. -- If `SIGFPE` is enabled, keep `FPE_*` as `UNKNOWN` until `SiCodes` and receiver deserialization support those variants. -- Add tests for `SIGFPE` reporting and receiver parsing. -- Keep emitted signal and si_code strings compatible with receiver enum names. - -### 12. Add signal-safe debug logging - -dd-trace-c has a debug path that formats into a fixed stack buffer and calls `dd_trace_log_write_signal` exactly once. It is gated by a cached `DD_TRACE_LOG_LEVEL` read from init. - -Work needed: - -- Add a fixed-buffer signal-safe debug writer for the crash path. -- Do not call normal logger paths from signal context. -- Keep normal init/teardown logs separate from crash-path logs. -- Add debug breadcrumbs for install, app-first, genuine-fault decision, collector spawn, duplicate collection, and chain action. - -## P1 work - -### 13. Make the wire emitter deterministic and allocation-free for preload - -dd-trace-c's preload collector emits a minimal stream: - -1. config -2. metadata -3. additional tags -4. kind -5. siginfo -6. procinfo -7. stacktrace -8. message -9. done - -Current libdatadog's full emitter emits more sections and uses std formatting/serialization. The receiver can parse flexible order, but preload should have a small deterministic emitter that cannot allocate. - -Work needed: - -- Add a preload/minimal emitter, or make current emitters swappable by crash mode. -- Use fixed scratch buffers for each JSON section. -- Avoid protocol injection in message and tags. -- Keep section names compatible. The receiver already accepts dd-trace-c's `PROCESSINFO` spelling; add a golden round-trip test instead of treating this as parser work. -- Decide how to preserve crash-ping enrichment if metadata is emitted before message. - -### 14. Preserve and test fd/socket protocol choices - -dd-trace-c uses `pipe()` and execs the receiver with the read end on stdin. libdatadog's general collector uses `socketpair()` and wraps it in `UnixStream`. - -Work needed: - -- Decide whether preload mode should use dd-trace-c's pipe+stdin model for simplicity and safety. -- If socketpair remains, implement a raw-fd sink and raw poll/reap path without `UnixStream`. -- Ensure EOF signaling and receiver completion are deterministic. -- Add tests for closed stdio descriptors, low-numbered pipe/socket fds, `EINTR` write retries, short writes, and receiver EOF. - -### 15. Packaging and build integration - -dd-trace-c builds and ships: - -- `libdd_autoinstrument.so` with the in-process crashtracker staticlib linked in. -- `process-crash-receiver` as a musl/self-contained receiver sidecar. -- Size guard for receiver sidecar, currently 5 MiB in dd-trace-c. -- Build-time `DD_TRACE_C_VERSION` and install path injection. -- `crashtracker_glue.c` for weak self `.so` path resolution and log gate helpers. - -Work needed: - -- Decide whether the signal-safe collector lives inside `libdd-crashtracker`, a new crate, or an integration crate consumed by dd-trace-c. -- Add builder/release artifact support if it becomes a libdatadog-owned artifact. -- Add musl receiver build knobs and self-contained libunwind/libc link handling. -- Add receiver size checks if libdatadog owns the sidecar packaging. -- Keep old-glibc load behavior intact; avoid hard `libdl` symbols. - -### 16. API boundaries and ownership - -There are two plausible designs: - -- Put the generic signal-safe collector in libdatadog and let integrations provide metadata, receiver-path, and hook-engine adapters. -- Put only reusable primitives in libdatadog and keep dd-trace-c's preload-specific policy in dd-trace-c. - -Either way, libdatadog needs a clearer split between: - -- general crashtracker API for language runtimes -- preload/constructor API -- receiver-only API -- signal-safe low-level primitives -- std/async receiver and upload code - -Work needed: - -- Define which API reads env vars and which never does. -- Define which API is allowed to install global signal handlers. -- Define which API is allowed to interpose `sigaction`/`signal`. -- Define teardown ownership for signal handlers and hook tables. -- Document all async-signal-safety contracts on public callbacks. - -## P2 work - -### 17. Backfill integration tests from dd-trace-c - -Port or mirror these dd-trace-c `test/agentapi/crashtracker_preload_test.go` scenarios: - -- genuine crash during injector init uploads a crash report -- crash in dd-trace-c's own HTTP client is labeled `http_client_send` -- `DD_CRASHTRACKING_ENABLED=false` skips install -- stuck receiver is killed and reaped -- app handler gives up, crashtracker reports -- app handler recovers, crashtracker does not report in Mode A -- `DD_CRASHTRACKING_ALWAYS_ON_TOP=true` reports before recovery -- app uses `signal()` instead of `sigaction()` -- whole-process default reports application crash -- `DD_CRASHTRACKING_ONLY_BOOTSTRAP=true` does not report application crash - -Add new libdatadog-specific tests for: - -- late handler registration after crashtracker install -- raw `rt_sigaction` limitation documented and tested if practical -- receiver exec environment excludes `LD_PRELOAD` and `LD_AUDIT` -- receiver path beside `.so`, including suffixed artifacts -- closed stdio fd preservation -- `SIGFPE` and `UNKNOWN` si_code parsing -- external async signal ignored -- self-sent async signal reported -- default sync re-fault preserves crash signal context -- golden preload wire round-trip through the receiver, including `PROCESSINFO` -- forked collector has no allocator/logging/std calls on the hot path, including no `eprintln!`, no `std::fs::File`, and no `dlsym` - -### 18. Keep existing libdatadog strengths - -While adding dd-trace-c parity, avoid regressing current libdatadog functionality: - -- all-thread collection -- unhandled exception reporting -- runtime callback stacks -- counters, spans, traces, and additional files -- Windows collector behavior -- macOS collector behavior -- receiver telemetry debug logs -- errors-intake output - -Preload mode can be smaller and stricter than general mode, but the two should share receiver data models where practical. - -## Suggested PR split - -1. Document the signal-safety model and split the collector path into `std` and signal-safe modules. -2. Add raw syscall/fd/sink primitives with tests. -3. Add allocation-free minimal emitter and golden wire tests. -4. Add frame-pointer/process_vm_readv fallback and libunwind feature/config split. -5. Add receiver child env/fd sanitation and bounded reap semantics. -6. Add preload metadata builders. -7. Add sigaction/signal virtualization and Mode A/Mode B. Do not implement Mode A before the virtualized effective-handler state exists. -8. Add preload env init and receiver path discovery. -9. Port dd-trace-c integration tests. -10. Decide packaging ownership and wire into builder/release artifacts. - -## Quick risk ranking - -Highest risk missing pieces: - -1. Forked collector child not being treated as async-signal-safe. -2. Linux local unwinding in the collector can re-fault on a corrupt crashing stack and lacks the dd-trace-c frame-pointer/process_vm_readv fallback. -3. No late `sigaction`/`signal` virtualization. -4. No Mode A app-first policy for managed-runtime recovery. -5. Default-disposition chaining via `raise` instead of re-fault for synchronous kernel faults. -6. Receiver exec environment can recurse under `LD_PRELOAD`/`LD_AUDIT` unless the receiver env is sanitized or explicitly built without loader vars. -7. Preload metadata/report shape is not available as a libdatadog helper. diff --git a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index 824ac1d09e..71f50bce03 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -5,60 +5,45 @@ use core::sync::atomic::{AtomicU32, Ordering}; use super::sys; -#[repr(transparent)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Capabilities(u32); - -impl Capabilities { - 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; - } +/// 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; + } + } + }; } -#[repr(transparent)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Degradations(u32); +bitset_u32!(Capabilities); +bitset_u32!(Degradations); impl Degradations { - 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 - } - pub const fn with(self, flag: Self) -> Self { Self(self.0 | flag.0) } - - fn insert(&mut self, flag: Self) { - self.0 |= flag.0; - } } pub const RECEIVER_OK: Capabilities = Capabilities::from_bits(1 << 0); diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index 1154547d9f..aed30399f1 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -376,7 +376,7 @@ fn strip_loader_injection_env() { let mut dst = env; while !(*src).is_null() { let entry = *src; - let injected = PREFIXES.iter().any(|p| sys::cstr_starts_with(entry, p)); + let injected = PREFIXES.iter().any(|p| sys::cstr_has_prefix(entry, p)); if !injected { *dst = entry; dst = dst.add(1); diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index 9ea61c2d37..4edc09837f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -551,11 +551,7 @@ pub unsafe fn cstr_bytes_bounded<'a>(p: *const c_char) -> &'a [u8] { CStr::from_bytes_with_nul_unchecked(bytes).to_bytes() } -pub unsafe fn cstr_starts_with(s: *const c_char, prefix: &[u8]) -> bool { - cstr_has_prefix(s, prefix) -} - -unsafe fn cstr_has_prefix(s: *const c_char, prefix: &[u8]) -> bool { +pub unsafe fn cstr_has_prefix(s: *const c_char, prefix: &[u8]) -> bool { let mut i = 0usize; while i < prefix.len() { let c = *s.add(i); @@ -589,25 +585,12 @@ pub fn set_errno(errno: i32) { } } -#[cfg(any(target_os = "linux", target_os = "android"))] -unsafe fn errno_location() -> *mut i32 { - libc::__errno_location() -} - #[cfg(any(target_os = "macos", target_os = "ios"))] unsafe fn errno_location() -> *mut i32 { libc::__error() } -#[cfg(all( - unix, - not(any( - target_os = "linux", - target_os = "android", - target_os = "macos", - target_os = "ios" - )) -))] +#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios"))))] unsafe fn errno_location() -> *mut i32 { libc::__errno_location() } diff --git a/libdd-crashtracker/src/protocol.rs b/libdd-crashtracker/src/protocol.rs index b44214ff85..e23373cc1a 100644 --- a/libdd-crashtracker/src/protocol.rs +++ b/libdd-crashtracker/src/protocol.rs @@ -1,80 +1,43 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -macro_rules! protocol_marker { - ($vis:vis const $name:ident: &str = $value:literal;) => { - #[allow(dead_code)] - $vis const $name: &str = $value; - }; -} +// 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"; -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_BEGIN_WHOLE_STACKTRACE"; -); -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_COUNTERS: &str = "DD_CRASHTRACK_BEGIN_COUNTERS"; -); -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_FILE: &str = "DD_CRASHTRACK_BEGIN_FILE"; -); +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"; -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME: &str = - "DD_CRASHTRACK_BEGIN_RUNTIME_STACK_FRAME"; -); -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING: &str = - "DD_CRASHTRACK_BEGIN_RUNTIME_STACK_STRING"; -); +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"; -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_SPAN_IDS: &str = "DD_CRASHTRACK_BEGIN_SPAN_IDS"; -); +pub const DD_CRASHTRACK_BEGIN_SPAN_IDS: &str = "DD_CRASHTRACK_BEGIN_SPAN_IDS"; pub const DD_CRASHTRACK_BEGIN_STACKTRACE: &str = "DD_CRASHTRACK_BEGIN_STACKTRACE"; -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_TRACE_IDS: &str = "DD_CRASHTRACK_BEGIN_TRACE_IDS"; -); -protocol_marker!( - pub const DD_CRASHTRACK_BEGIN_UCONTEXT: &str = "DD_CRASHTRACK_BEGIN_UCONTEXT"; -); +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"; -protocol_marker!( - pub const DD_CRASHTRACK_END_WHOLE_STACKTRACE: &str = "DD_CRASHTRACK_END_WHOLE_STACKTRACE"; -); -protocol_marker!( - pub const DD_CRASHTRACK_END_COUNTERS: &str = "DD_CRASHTRACK_END_COUNTERS"; -); -protocol_marker!( - pub const DD_CRASHTRACK_END_FILE: &str = "DD_CRASHTRACK_END_FILE"; -); +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"; -protocol_marker!( - pub const DD_CRASHTRACK_END_RUNTIME_STACK_FRAME: &str = "DD_CRASHTRACK_END_RUNTIME_STACK_FRAME"; -); -protocol_marker!( - pub const DD_CRASHTRACK_END_RUNTIME_STACK_STRING: &str = - "DD_CRASHTRACK_END_RUNTIME_STACK_STRING"; -); +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"; -protocol_marker!( - pub const DD_CRASHTRACK_END_SPAN_IDS: &str = "DD_CRASHTRACK_END_SPAN_IDS"; -); +pub const DD_CRASHTRACK_END_SPAN_IDS: &str = "DD_CRASHTRACK_END_SPAN_IDS"; pub const DD_CRASHTRACK_END_STACKTRACE: &str = "DD_CRASHTRACK_END_STACKTRACE"; -protocol_marker!( - pub const DD_CRASHTRACK_END_TRACE_IDS: &str = "DD_CRASHTRACK_END_TRACE_IDS"; -); -protocol_marker!( - pub const DD_CRASHTRACK_END_UCONTEXT: &str = "DD_CRASHTRACK_END_UCONTEXT"; -); +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 { From 81b9879306cf83aa970f37946608625fe86d3dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Tue, 7 Jul 2026 03:18:05 +0200 Subject: [PATCH 24/32] refactor(crashtracker): simplify signal-safe collector cleanups Single-source the receiver-path capacity as PATH_CAPACITY, replace the hand-rolled eq_ic with eq_ignore_ascii_case, flatten the collect_crash fork flow, dedup the sanitize_clone stdio fallback, drop a redundant getpid syscall on the crash path, and inline put_marker_line. --- .../src/collector_signal_safe/config.rs | 25 ++----- .../src/collector_signal_safe/emitter.rs | 6 +- .../src/collector_signal_safe/handler.rs | 69 ++++++++++--------- .../src/collector_signal_safe/state.rs | 4 +- 4 files changed, 46 insertions(+), 58 deletions(-) diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index c79ae40936..4297ad43d1 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -35,6 +35,9 @@ pub const COMPAT_LIBRARY_NAME: &str = "dd-trace-c"; pub const COMPAT_LIBRARY_FAMILY: &str = "native"; pub const COMPAT_DEFAULT_SERVICE: &str = "dd-trace-c"; +/// Capacity for signal-safe filesystem path buffers (PATH_MAX + trailing NUL). +pub const PATH_CAPACITY: usize = 513; + pub const RECEIVER_TIMEOUT_SECS: u32 = DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS; pub const RECEIVER_TIMEOUT_SECS_MAX: u32 = 60; pub const COLLECTOR_REAP_MS: i32 = 500; @@ -327,7 +330,7 @@ fn set_str_or(dst: &mut HeaplessString, src: &[u8], default: } } -fn set_receiver_path(dst: &mut heapless::Vec, path: &[u8]) -> bool { +fn set_receiver_path(dst: &mut heapless::Vec, path: &[u8]) -> bool { dst.clear(); let selected = if path.is_empty() { DEFAULT_RECEIVER_PATH.as_bytes() @@ -354,7 +357,7 @@ fn validate(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { } else { config.receiver_path }; - if receiver_path.len() >= 513 { + if receiver_path.len() >= PATH_CAPACITY { return Err(PrepareError::InvalidConfig); } @@ -365,30 +368,16 @@ fn validate(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { Ok(()) } -fn eq_ic(a: &[u8], lower: &[u8]) -> bool { - if a.len() != lower.len() { - return false; - } - let mut i = 0usize; - while i < a.len() { - if a[i].to_ascii_lowercase() != lower[i] { - return false; - } - i += 1; - } - true -} - fn is_false(v: Option<&[u8]>) -> bool { match v { - Some(s) => s == b"0" || eq_ic(s, b"false") || eq_ic(s, b"f"), + Some(s) => s == b"0" || s.eq_ignore_ascii_case(b"false") || s.eq_ignore_ascii_case(b"f"), None => false, } } fn is_true(v: Option<&[u8]>) -> bool { match v { - Some(s) => s == b"1" || eq_ic(s, b"true") || eq_ic(s, b"t"), + Some(s) => s == b"1" || s.eq_ignore_ascii_case(b"true") || s.eq_ignore_ascii_case(b"t"), None => false, } } diff --git a/libdd-crashtracker/src/collector_signal_safe/emitter.rs b/libdd-crashtracker/src/collector_signal_safe/emitter.rs index ae90053ef6..1259fbb277 100644 --- a/libdd-crashtracker/src/collector_signal_safe/emitter.rs +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -316,10 +316,6 @@ fn emit_truncated_tail( emit_message(sink, &context.signal) && emit_done(sink) } -fn put_marker_line(sink: &mut impl Sink, marker: &str) -> bool { - protocol::marker_line::<_, ()>(sink, marker).is_ok() -} - fn emit_done(sink: &mut impl Sink) -> bool { - put_marker_line(sink, protocol::DD_CRASHTRACK_DONE) + protocol::marker_line::<_, ()>(sink, protocol::DD_CRASHTRACK_DONE).is_ok() } diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index aed30399f1..781527c920 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -289,17 +289,17 @@ fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> i32 { let _ = reset_signals_to_default(&config::CRASH_SIGNALS); - if capabilities::has(capabilities::DEV_NULL) { - let devnull = sys::open_readwrite(c"/dev/null".as_ptr().cast()); - 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 close_stdio_without_devnull { - close_stdio(); + 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 close_stdio_without_devnull { close_stdio(); @@ -497,8 +497,14 @@ fn exited_with(status: i32, code: i32) -> bool { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == code } -fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontext: *mut c_void) { - let pid = sys::getpid(); +fn collect_crash( + sig: i32, + si_code: i32, + has_info: bool, + si_addr: usize, + ucontext: *mut c_void, + pid: i32, +) { let tid = sys::gettid(); let report_fd = state::REPORT_FD.load(Ordering::Relaxed); let event = CrashEvent { @@ -549,14 +555,7 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex if receiver == 0 { receiver_child(read_fd, write_fd); } - - let collector: isize; - if receiver > 0 { - collector = unsafe { sys::fork_raw() }; - if collector == 0 { - collector_child(read_fd, write_fd, event); - } - } else { + if receiver < 0 { crash_debug(b"receiver fork failed", sig); sys::close(read_fd); sys::close(write_fd); @@ -564,6 +563,11 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex return; } + let collector = unsafe { sys::fork_raw() }; + if collector == 0 { + collector_child(read_fd, write_fd, event); + } + sys::close(read_fd); sys::close(write_fd); @@ -573,20 +577,19 @@ fn collect_crash(sig: i32, si_code: i32, has_info: bool, si_addr: usize, ucontex state::COLLECTOR_REAP_MS.load(Ordering::Relaxed) as i64, true, ); - } else if receiver > 0 { + } else { crash_debug(b"collector fork failed", sig); direct_report(capabilities::DEGRADED_FORK_FAILED); } - if receiver > 0 { - let receiver_status = reap_or_kill( - receiver as i32, - state::RECEIVER_TIMEOUT_MS.load(Ordering::Relaxed) as i64, - true, - ); - if receiver_status.is_some_and(|status| exited_with(status, EXIT_CODE_FAILURE)) { - crash_debug(b"receiver exec failed", sig); - direct_report(capabilities::DEGRADED_RECEIVER_UNAVAILABLE); - } + + let receiver_status = reap_or_kill( + receiver as i32, + state::RECEIVER_TIMEOUT_MS.load(Ordering::Relaxed) as i64, + true, + ); + if receiver_status.is_some_and(|status| exited_with(status, EXIT_CODE_FAILURE)) { + crash_debug(b"receiver exec failed", sig); + direct_report(capabilities::DEGRADED_RECEIVER_UNAVAILABLE); } } @@ -655,7 +658,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m }; let genuine_fault = is_genuine_fault(has_info, si_code, si_pid, self_pid); if genuine_fault && !COLLECTING.swap(true, Ordering::Relaxed) { - collect_crash(sig, si_code, has_info, si_addr, ucontext); + collect_crash(sig, si_code, has_info, si_addr, ucontext, self_pid); } sys::set_errno(saved_errno); diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index 8703d830fd..bf928c0e63 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -10,7 +10,7 @@ use core::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, AtomicUsize, Ordering use heapless::{String as HeaplessString, Vec as HeaplessVec}; use thiserror::Error; -use super::config::CONFIG_JSON_BUF_SIZE; +use super::config::{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; @@ -27,7 +27,7 @@ pub struct Meta { pub app_version: HeaplessString<256>, pub platform: HeaplessString<256>, pub runtime_id: HeaplessString<64>, - pub process_path: HeaplessVec, + pub process_path: HeaplessVec, pub library_name: HeaplessString<128>, pub library_version: HeaplessString<128>, pub family: HeaplessString<128>, From 35528f460289c1cf021b89f462a16519d503145d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Tue, 7 Jul 2026 03:55:26 +0200 Subject: [PATCH 25/32] refactor(crashtracker): make signal-safe collector struct-only Remove all environment reading from the signal-safe collector: the consumer populates SignalSafeInitConfig directly. Drops the from-env init path (init_from_env, prepare_from_env_result, disabled_by_env, env_get and helpers) and neutralizes the identity defaults (no hardcoded dd-trace-c name or /opt install path, no option_env! build hooks); COMPAT_* renamed DEFAULT_* with neutral placeholders. Remove the signal-safe FFI bridges (crashtracker-ffi module + feature, profiling-ffi feature, cbindgen exclude, C example, builder wiring, CI step); the Rust collector module stays. Also dedup gettid on the crash path, let capabilities::publish be the sole report_fd validator, and revert the unrelated libdd-profiling-ffi EncodedProfile rework. --- .../workflows/crashtracker-signal-safe.yml | 2 - builder/src/profiling.rs | 4 +- examples/ffi/CMakeLists.txt | 3 - examples/ffi/signal_safe_crashtracking.c | 64 ------ libdd-crashtracker-ffi/Cargo.toml | 2 - libdd-crashtracker-ffi/cbindgen.toml | 5 +- .../src/collector_signal_safe.rs | 196 ------------------ libdd-crashtracker-ffi/src/lib.rs | 4 - .../src/collector_signal_safe/config.rs | 175 +++------------- .../src/collector_signal_safe/handler.rs | 19 +- .../src/collector_signal_safe/mod.rs | 23 +- .../src/collector_signal_safe/sys.rs | 62 +----- .../tests/collector_signal_safe_e2e.rs | 21 +- libdd-profiling-ffi/Cargo.toml | 2 - libdd-profiling-ffi/src/exporter.rs | 23 +- libdd-profiling-ffi/src/profiles/datatypes.rs | 64 +----- libdd-profiling-ffi/src/profiles/mod.rs | 2 - tools/src/bin/ffi_test.rs | 28 +-- 18 files changed, 84 insertions(+), 615 deletions(-) delete mode 100644 examples/ffi/signal_safe_crashtracking.c delete mode 100644 libdd-crashtracker-ffi/src/collector_signal_safe.rs diff --git a/.github/workflows/crashtracker-signal-safe.yml b/.github/workflows/crashtracker-signal-safe.yml index c7ac6bf00c..03d01de087 100644 --- a/.github/workflows/crashtracker-signal-safe.yml +++ b/.github/workflows/crashtracker-signal-safe.yml @@ -36,8 +36,6 @@ jobs: 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 signal-safe FFI - run: cargo check -p libdd-crashtracker-ffi --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 diff --git a/builder/src/profiling.rs b/builder/src/profiling.rs index 4f858fb22c..8c8f213aee 100644 --- a/builder/src/profiling.rs +++ b/builder/src/profiling.rs @@ -132,9 +132,7 @@ impl Module for Profiling { fn build(&self) -> Result<()> { let features = self.features.to_string() + "," + "cbindgen"; #[cfg(feature = "crashtracker")] - let features = features.add( - ",crashtracker-collector,crashtracker-collector-signal-safe,crashtracker-receiver,demangler", - ); + let features = features.add(",crashtracker-collector,crashtracker-receiver,demangler"); // Using rustc instead of build in order to overcome issues with LTO optimization. let mut cargo_args = vec![ diff --git a/examples/ffi/CMakeLists.txt b/examples/ffi/CMakeLists.txt index aa9f31c880..c6e1988001 100644 --- a/examples/ffi/CMakeLists.txt +++ b/examples/ffi/CMakeLists.txt @@ -76,9 +76,6 @@ if(NOT WIN32) add_executable(crashtracking_unhandled_exception crashtracking_unhandled_exception.c) target_link_libraries(crashtracking_unhandled_exception PRIVATE Datadog::Profiling) - - add_executable(signal_safe_crashtracking signal_safe_crashtracking.c) - target_link_libraries(signal_safe_crashtracking PRIVATE Datadog::Profiling) endif() add_executable(trace_exporter trace_exporter.c) diff --git a/examples/ffi/signal_safe_crashtracking.c b/examples/ffi/signal_safe_crashtracking.c deleted file mode 100644 index bbbfa31fea..0000000000 --- a/examples/ffi/signal_safe_crashtracking.c +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -#include -#include -#include -#include -#include -#include -#include - -#define MAX_FILE_PATH 512 - -int main(void) { - const char *output_dir = getenv("DDOG_CRASHT_TEST_OUTPUT_DIR"); - if (!output_dir || output_dir[0] == '\0') { - output_dir = "/tmp/crashreports"; - } - - char report_path[MAX_FILE_PATH]; - snprintf(report_path, sizeof(report_path), "%s/signal_safe_crashreport.txt", output_dir); - - int report_fd = open(report_path, O_CREAT | O_TRUNC | O_WRONLY, 0600); - if (report_fd < 0) { - perror("open signal-safe report"); - return EXIT_FAILURE; - } - - struct ddog_crasht_SignalSafeConfig config = { - .receiver_path = "/definitely/missing-signal-safe-receiver", - .service = "signal-safe-ffi-test", - .env = "test", - .app_version = "1", - .runtime_id = "00000000-0000-0000-0000-000000000001", - .platform = "host", - .library_name = "signal-safe-ffi-test", - .library_version = "1.0.0", - .family = "native", - .default_service = "signal-safe-ffi-test", - .force_on_top = false, - .only_bootstrap = false, - .debug_logging = false, - .create_alt_stack = false, - .use_alt_stack = false, - .block_signals = true, - .disarm_on_entry = false, - .report_fd = report_fd, - .collector_reap_ms = 500, - .receiver_timeout_secs = 5, - .max_frames = 32, - .close_fds_on_receiver = true, - .probe_seccomp = false, - }; - - if (ddog_crasht_signal_safe_init(&config) != DDOG_CRASHT_SIGNAL_SAFE_INIT_RESULT_ENABLED) { - fprintf(stderr, "signal-safe crashtracker init failed\n"); - close(report_fd); - return EXIT_FAILURE; - } - - ddog_crasht_signal_safe_bootstrap_complete(); - raise(SIGABRT); - return EXIT_FAILURE; -} diff --git a/libdd-crashtracker-ffi/Cargo.toml b/libdd-crashtracker-ffi/Cargo.toml index 5a5b0f76c8..9864fb24a9 100644 --- a/libdd-crashtracker-ffi/Cargo.toml +++ b/libdd-crashtracker-ffi/Cargo.toml @@ -26,8 +26,6 @@ cbindgen = ["build_common/cbindgen"] regex-lite = ["libdd-common-ffi/regex-lite"] # Enables the in-process collection of crash-info collector = ["std", "libdd-crashtracker/collector"] -# Enables the signal-safe in-process collection of crash-info -collector_signal-safe = ["libdd-crashtracker/collector_signal-safe"] 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 diff --git a/libdd-crashtracker-ffi/cbindgen.toml b/libdd-crashtracker-ffi/cbindgen.toml index acc187154b..773064854a 100644 --- a/libdd-crashtracker-ffi/cbindgen.toml +++ b/libdd-crashtracker-ffi/cbindgen.toml @@ -71,7 +71,4 @@ must_use = "DDOG_CHECK_RETURN" [parse] parse_deps = true include = ["libdd-common", "libdd-common-ffi", "libdd-crashtracker", "ux"] -exclude = [ - "libdd_crashtracker::collector_signal_safe", - "libdd_crashtracker::crash_info::cxx", -] +exclude = ["libdd_crashtracker::crash_info::cxx"] diff --git a/libdd-crashtracker-ffi/src/collector_signal_safe.rs b/libdd-crashtracker-ffi/src/collector_signal_safe.rs deleted file mode 100644 index 27f49b4a64..0000000000 --- a/libdd-crashtracker-ffi/src/collector_signal_safe.rs +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -use std::ffi::c_char; -use std::panic::{catch_unwind, AssertUnwindSafe}; - -use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, capability_bits, cstr_bytes_bounded, degradation_bits, - init_from_env_result, init_result, owned_signal_count, owns_signal, shutdown, InitResult, - SignalSafeInitConfig, -}; - -#[repr(C)] -pub struct SignalSafeConfig { - pub receiver_path: *const c_char, - pub service: *const c_char, - pub env: *const c_char, - pub app_version: *const c_char, - pub runtime_id: *const c_char, - pub platform: *const c_char, - pub library_name: *const c_char, - pub library_version: *const c_char, - pub family: *const c_char, - pub default_service: *const c_char, - pub force_on_top: bool, - pub only_bootstrap: bool, - pub debug_logging: bool, - /// Installs the built-in alternate signal stack on the init thread only. - /// - /// Signal alternate stacks are per-thread kernel state. Stack-overflow crashes on other - /// threads require those threads to install their own alternate stacks. - pub create_alt_stack: bool, - /// Registers 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, - /// Runs app handlers invoked from the signal-safe handler with managed crash signals blocked. - pub block_signals: bool, - pub disarm_on_entry: bool, - pub report_fd: i32, - pub collector_reap_ms: i32, - pub receiver_timeout_secs: u32, - pub max_frames: usize, - pub close_fds_on_receiver: bool, - pub probe_seccomp: bool, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub enum SignalSafeInitResult { - Enabled = 0, - DisabledByConfig = 1, - Failed = 2, - AlreadyInitialized = 3, - OwnerConflict = 4, - InvalidConfig = 5, -} - -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_init_from_env() -> SignalSafeInitResult { - ffi_result(|| init_result_to_ffi(init_from_env_result())) -} - -/// Initialize the signal-safe crashtracker with explicitly provided metadata. -/// -/// # Safety -/// `config` must be either null or point to a valid `SignalSafeConfig`. Any non-null C string -/// pointer inside `config` must point to a valid NUL-terminated string for the duration of this -/// call. The strings are copied before the function returns. -#[no_mangle] -pub unsafe extern "C" fn ddog_crasht_signal_safe_init( - config: *const SignalSafeConfig, -) -> SignalSafeInitResult { - ffi_result(|| { - let Some(config) = config.as_ref() else { - return SignalSafeInitResult::Failed; - }; - - init_result_to_ffi(init_result(&SignalSafeInitConfig { - receiver_path: cstr_bytes(config.receiver_path), - service: cstr_bytes(config.service), - env: cstr_bytes(config.env), - app_version: cstr_bytes(config.app_version), - runtime_id: cstr_bytes(config.runtime_id), - platform: cstr_bytes(config.platform), - library_name: cstr_bytes(config.library_name), - library_version: cstr_bytes(config.library_version), - family: cstr_bytes(config.family), - default_service: cstr_bytes(config.default_service), - force_on_top: config.force_on_top, - only_bootstrap: config.only_bootstrap, - debug_logging: config.debug_logging, - create_alt_stack: config.create_alt_stack, - use_alt_stack: config.use_alt_stack, - block_signals: config.block_signals, - disarm_on_entry: config.disarm_on_entry, - report_fd: config.report_fd, - collector_reap_ms: config.collector_reap_ms, - receiver_timeout_secs: config.receiver_timeout_secs, - max_frames: config.max_frames, - close_fds_on_receiver: config.close_fds_on_receiver, - probe_seccomp: config.probe_seccomp, - })) - }) -} - -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_bootstrap_complete() { - ffi_void(bootstrap_complete); -} - -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_shutdown() { - ffi_void(shutdown); -} - -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_capabilities() -> u32 { - ffi_u32(capability_bits) -} - -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_degradations() -> u32 { - ffi_u32(degradation_bits) -} - -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_owned_signal_count() -> u32 { - ffi_u32(owned_signal_count) -} - -#[no_mangle] -pub extern "C" fn ddog_crasht_signal_safe_owns_signal(signum: i32) -> bool { - catch_unwind(AssertUnwindSafe(|| owns_signal(signum))).unwrap_or(false) -} - -fn init_result_to_ffi(result: InitResult) -> SignalSafeInitResult { - match result { - InitResult::Enabled => SignalSafeInitResult::Enabled, - InitResult::DisabledByConfig => SignalSafeInitResult::DisabledByConfig, - InitResult::Failed => SignalSafeInitResult::Failed, - InitResult::AlreadyInitialized => SignalSafeInitResult::AlreadyInitialized, - InitResult::OwnerConflict => SignalSafeInitResult::OwnerConflict, - InitResult::InvalidConfig => SignalSafeInitResult::InvalidConfig, - } -} - -fn ffi_result(f: impl FnOnce() -> SignalSafeInitResult) -> SignalSafeInitResult { - catch_unwind(AssertUnwindSafe(f)).unwrap_or(SignalSafeInitResult::Failed) -} - -fn ffi_void(f: impl FnOnce()) { - let _ = catch_unwind(AssertUnwindSafe(f)); -} - -fn ffi_u32(f: impl FnOnce() -> u32) -> u32 { - catch_unwind(AssertUnwindSafe(f)).unwrap_or(0) -} - -unsafe fn cstr_bytes<'a>(ptr: *const c_char) -> &'a [u8] { - unsafe { cstr_bytes_bounded(ptr) } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ffi_init_result_values_match_library_values() { - assert_eq!( - SignalSafeInitResult::Enabled as i32, - InitResult::Enabled as i32 - ); - assert_eq!( - SignalSafeInitResult::DisabledByConfig as i32, - InitResult::DisabledByConfig as i32 - ); - assert_eq!( - SignalSafeInitResult::Failed as i32, - InitResult::Failed as i32 - ); - assert_eq!( - SignalSafeInitResult::AlreadyInitialized as i32, - InitResult::AlreadyInitialized as i32 - ); - assert_eq!( - SignalSafeInitResult::OwnerConflict as i32, - InitResult::OwnerConflict as i32 - ); - assert_eq!( - SignalSafeInitResult::InvalidConfig as i32, - InitResult::InvalidConfig as i32 - ); - } -} diff --git a/libdd-crashtracker-ffi/src/lib.rs b/libdd-crashtracker-ffi/src/lib.rs index c0e268094a..e9f012b48f 100644 --- a/libdd-crashtracker-ffi/src/lib.rs +++ b/libdd-crashtracker-ffi/src/lib.rs @@ -9,8 +9,6 @@ #[cfg(all(unix, feature = "collector"))] mod collector; -#[cfg(all(unix, feature = "collector_signal-safe"))] -mod collector_signal_safe; #[cfg(all(windows, feature = "collector_windows"))] mod collector_windows; #[cfg(feature = "std")] @@ -23,8 +21,6 @@ mod receiver; mod runtime_callback; #[cfg(all(unix, feature = "collector"))] pub use collector::*; -#[cfg(all(unix, feature = "collector_signal-safe"))] -pub use collector_signal_safe::*; #[cfg(all(windows, feature = "collector_windows"))] pub use collector_windows::api::ddog_crasht_init_windows; #[cfg(feature = "std")] diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 4297ad43d1..853841d694 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -8,32 +8,20 @@ use serde::Serialize; use thiserror::Error; use super::state::meta_mut; -use super::{capabilities, state, sys}; +use super::{capabilities, state}; use crate::shared::{ defaults::DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS, signals::SIGNAL_SAFE_CRASH_SIGNALS, stacktrace_collection::StacktraceCollection, }; -// Compatibility preset for the existing C-tracer consumer. New integrators should pass -// explicit metadata through SignalSafeInitConfig instead of relying on these defaults. -pub const COMPAT_LIBRARY_VERSION: &str = match option_env!("DD_TRACE_C_VERSION") { - Some(v) => v, - None => "dev", -}; - -// Prefer the neutral build-time receiver path name. The DD_TRACE_C_* name remains as a -// lower-priority compatibility alias for existing C-tracer package builds. -const DEFAULT_RECEIVER_PATH: &str = match option_env!("DD_CRASHTRACKING_RECEIVER_PATH") { - Some(p) => p, - None => match option_env!("DD_TRACE_C_CRASHTRACKER_PROCESS_PATH") { - Some(p) => p, - None => "/opt/datadog-packages/datadog-apm-library-c/stable/process-crash-receiver", - }, -}; - -pub const COMPAT_LIBRARY_NAME: &str = "dd-trace-c"; -pub const COMPAT_LIBRARY_FAMILY: &str = "native"; -pub const COMPAT_DEFAULT_SERVICE: &str = "dd-trace-c"; +// Default metadata used only when the caller leaves a `SignalSafeInitConfig` 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"; /// Capacity for signal-safe filesystem path buffers (PATH_MAX + trailing NUL). pub const PATH_CAPACITY: usize = 513; @@ -51,7 +39,8 @@ pub const CONFIG_JSON_BUF_SIZE: usize = 2048; #[derive(Clone, Copy, Debug)] pub struct SignalSafeInitConfig<'a> { - /// Receiver executable path. Empty uses the compatibility default. + /// Receiver executable path. When empty, no receiver is spawned and collection degrades to + /// the `report_fd` fallback. pub receiver_path: &'a [u8], pub service: &'a [u8], pub env: &'a [u8], @@ -105,10 +94,10 @@ impl<'a> Default for SignalSafeInitConfig<'a> { app_version: &[], runtime_id: &[], platform: &[], - library_name: COMPAT_LIBRARY_NAME.as_bytes(), - library_version: COMPAT_LIBRARY_VERSION.as_bytes(), - family: COMPAT_LIBRARY_FAMILY.as_bytes(), - default_service: COMPAT_DEFAULT_SERVICE.as_bytes(), + 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(), force_on_top: false, only_bootstrap: false, debug_logging: false, @@ -195,22 +184,22 @@ pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareEr metadata_truncated |= !set_str_or( &mut m.library_name, config.library_name, - COMPAT_LIBRARY_NAME.as_bytes(), + DEFAULT_LIBRARY_NAME.as_bytes(), ); metadata_truncated |= !set_str_or( &mut m.library_version, config.library_version, - COMPAT_LIBRARY_VERSION.as_bytes(), + DEFAULT_LIBRARY_VERSION.as_bytes(), ); metadata_truncated |= !set_str_or( &mut m.family, config.family, - COMPAT_LIBRARY_FAMILY.as_bytes(), + DEFAULT_LIBRARY_FAMILY.as_bytes(), ); metadata_truncated |= !set_str_or( &mut m.default_service, config.default_service, - COMPAT_DEFAULT_SERVICE.as_bytes(), + DEFAULT_SERVICE.as_bytes(), ); if !set_receiver_path(&mut m.process_path, config.receiver_path) { @@ -247,41 +236,6 @@ pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareEr Ok(()) } -pub fn prepare_from_env_result() -> Result<(), PrepareError> { - if disabled_by_env() { - return Err(PrepareError::InvalidConfig); - } - - // Prefer the neutral runtime receiver path name. DD_TRACE_C_CRASHTRACKER_PROCESS is - // retained as a lower-priority compatibility alias for existing deployments. - let receiver_path = env_get(b"DD_CRASHTRACKING_RECEIVER_PATH\0") - .or_else(|| env_get(b"DD_TRACE_C_CRASHTRACKER_PROCESS\0")) - .filter(|v| !v.is_empty()) - .unwrap_or(DEFAULT_RECEIVER_PATH.as_bytes()); - let platform = env_get(b"DD_INJECT_SENDER_TYPE\0") - .filter(|v| !v.is_empty()) - .unwrap_or(b"host"); - let debug_logging = parse_log_level(env_get(b"DD_TRACE_LOG_LEVEL\0")) >= DD_LOG_DEBUG; - - prepare_result(&SignalSafeInitConfig { - receiver_path, - service: env_get(b"DD_SERVICE\0").unwrap_or(&[]), - env: env_get(b"DD_ENV\0").unwrap_or(&[]), - app_version: env_get(b"DD_VERSION\0").unwrap_or(&[]), - runtime_id: env_get(b"DD_RUNTIME_ID\0").unwrap_or(&[]), - platform, - force_on_top: is_true(env_get(b"DD_CRASHTRACKING_ALWAYS_ON_TOP\0")), - only_bootstrap: is_true(env_get(b"DD_CRASHTRACKING_ONLY_BOOTSTRAP\0")), - debug_logging, - probe_seccomp: is_true(env_get(b"DD_CRASHTRACKING_PROBE_SECCOMP\0")), - ..SignalSafeInitConfig::default() - }) -} - -pub fn disabled_by_env() -> bool { - is_false(env_get(b"DD_CRASHTRACKING_ENABLED\0")) -} - fn normalized_receiver_timeout_secs(value: u32) -> u32 { if value == 0 { RECEIVER_TIMEOUT_SECS @@ -332,19 +286,10 @@ fn set_str_or(dst: &mut HeaplessString, src: &[u8], default: fn set_receiver_path(dst: &mut heapless::Vec, path: &[u8]) -> bool { dst.clear(); - let selected = if path.is_empty() { - DEFAULT_RECEIVER_PATH.as_bytes() - } else { - path - }; - if selected.len() >= dst.capacity() { + if path.len() >= dst.capacity() { return false; } - dst.extend_from_slice(selected).is_ok() && dst.push(0).is_ok() -} - -fn env_get(name_nul: &[u8]) -> Option<&'static [u8]> { - sys::env_get(name_nul) + dst.extend_from_slice(path).is_ok() && dst.push(0).is_ok() } fn validate(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { @@ -352,61 +297,16 @@ fn validate(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { return Err(PrepareError::InvalidConfig); } - let receiver_path = if config.receiver_path.is_empty() { - DEFAULT_RECEIVER_PATH.as_bytes() - } else { - config.receiver_path - }; - if receiver_path.len() >= PATH_CAPACITY { - return Err(PrepareError::InvalidConfig); - } - - if config.report_fd >= 0 && !sys::fd_valid(config.report_fd) { + 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(()) } -fn is_false(v: Option<&[u8]>) -> bool { - match v { - Some(s) => s == b"0" || s.eq_ignore_ascii_case(b"false") || s.eq_ignore_ascii_case(b"f"), - None => false, - } -} - -fn is_true(v: Option<&[u8]>) -> bool { - match v { - Some(s) => s == b"1" || s.eq_ignore_ascii_case(b"true") || s.eq_ignore_ascii_case(b"t"), - None => false, - } -} - -const DD_LOG_INFO: i32 = 3; -const DD_LOG_DEBUG: i32 = 4; - -fn parse_log_level(v: Option<&[u8]>) -> i32 { - match v { - None => DD_LOG_INFO, - Some(s) => { - const LEVELS: [(&[u8], i32); 6] = [ - (b"off", 0), - (b"error", 1), - (b"warn", 2), - (b"info", 3), - (b"debug", 4), - (b"trace", 5), - ]; - for (name, level) in LEVELS { - if s == name { - return level; - } - } - DD_LOG_INFO - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -465,31 +365,6 @@ mod tests { ); } - #[test] - fn env_get_walks_environ_without_getenv() { - let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK - .lock() - .expect("test lock poisoned"); - - std::env::set_var("DD_SIGNAL_SAFE_ENV_GET_TEST", "walked"); - assert_eq!( - env_get(b"DD_SIGNAL_SAFE_ENV_GET_TEST\0"), - Some(&b"walked"[..]) - ); - std::env::remove_var("DD_SIGNAL_SAFE_ENV_GET_TEST"); - } - - #[test] - fn bool_and_log_parsing_matches_compatibility_inputs() { - assert!(is_false(Some(b"FALSE"))); - assert!(is_false(Some(b"0"))); - assert!(!is_false(Some(b"true"))); - assert!(is_true(Some(b"TrUe"))); - assert!(is_true(Some(b"1"))); - assert_eq!(parse_log_level(Some(b"debug")), DD_LOG_DEBUG); - assert_eq!(parse_log_level(Some(b"DEBUG")), DD_LOG_INFO); - } - #[test] fn prepare_caches_fixed_metadata() { let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index 781527c920..9b6c2dda33 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -122,7 +122,6 @@ static COLLECTING: AtomicBool = AtomicBool::new(false); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum InitResult { Enabled = 0, - DisabledByConfig = 1, Failed = 2, AlreadyInitialized = 3, OwnerConflict = 4, @@ -133,13 +132,6 @@ pub fn init_result(config: &SignalSafeInitConfig<'_>) -> InitResult { init_with_prepare(|| config::prepare_result(config)) } -pub fn init_from_env_result() -> InitResult { - if config::disabled_by_env() { - return InitResult::DisabledByConfig; - } - init_with_prepare(config::prepare_from_env_result) -} - fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> InitResult { let begin = state::begin_init(); if let Err(err) = begin { @@ -504,8 +496,8 @@ fn collect_crash( si_addr: usize, ucontext: *mut c_void, pid: i32, + tid: i32, ) { - let tid = sys::gettid(); let report_fd = state::REPORT_FD.load(Ordering::Relaxed); let event = CrashEvent { sig, @@ -616,6 +608,11 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m 0 }; + // Identity of the crashing thread, resolved once and reused by the app-handler chain guard + // and the crash collection below. + let self_pid = sys::getpid(); + let tid = sys::gettid(); + let force_on_top = state::FORCE_ON_TOP.load(Ordering::Relaxed); if let Some(i) = idx { let target = effective_target(i); @@ -623,7 +620,6 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m if should_run_app_first(force_on_top, app_is_real) { let stack_marker = 0u8; let stack_pos = (&stack_marker as *const u8) as usize; - let tid = sys::gettid(); if enter_app_chain(tid, stack_pos) { sys::set_errno(saved_errno); // If the application handler recovers with siglongjmp, no code after this call @@ -650,7 +646,6 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m } } - let self_pid = sys::getpid(); let si_pid = if has_info { unsafe { siginfo_pid(info) } } else { @@ -658,7 +653,7 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m }; let genuine_fault = is_genuine_fault(has_info, si_code, si_pid, self_pid); if genuine_fault && !COLLECTING.swap(true, Ordering::Relaxed) { - collect_crash(sig, si_code, has_info, si_addr, ucontext, self_pid); + collect_crash(sig, si_code, has_info, si_addr, ucontext, self_pid, tid); } sys::set_errno(saved_errno); diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index 9a4fe9e2e9..b7793182ba 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -3,9 +3,8 @@ //! Signal-safe Unix crash collection. //! -//! `init` takes explicit caller-provided configuration and does not read environment variables. -//! `init_from_env` is the preload/bootstrap compatibility entry point and is the only path that -//! reads `DD_CRASHTRACKING_*`, `DD_SERVICE`, `DD_ENV`, `DD_VERSION`, and `DD_RUNTIME_ID`. +//! `init` takes explicit caller-provided configuration. The collector reads no environment +//! variables of its own; the consumer populates [`SignalSafeInitConfig`] with the values it wants. //! //! Support matrix: //! @@ -45,12 +44,10 @@ 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_from_env_result, init_result, shutdown, InitResult}; +pub use handler::{bootstrap_complete, init_result, shutdown, InitResult}; pub(crate) use report::{CrashContext, Report, SignalInfo, SECTION_BUF_CAPACITY}; #[cfg(test)] pub(crate) use signal_names::*; -#[doc(hidden)] -pub use sys::cstr_bytes_bounded; pub fn capability_bits() -> u32 { capabilities::get().bits() @@ -100,8 +97,8 @@ mod tests { config_json: "{\"resolve_frames\":\"Disabled\"}", library_name: &oversized_library_name, library_version: "golden-1.0", - family: config::COMPAT_LIBRARY_FAMILY, - default_service: config::COMPAT_DEFAULT_SERVICE, + family: config::DEFAULT_LIBRARY_FAMILY, + default_service: config::DEFAULT_SERVICE, service: "", env: "prod", app_version: "v1", @@ -134,10 +131,10 @@ mod tests { }; let report = Report { config_json: "{\"resolve_frames\":\"Disabled\"}", - library_name: config::COMPAT_LIBRARY_NAME, + library_name: config::DEFAULT_LIBRARY_NAME, library_version: "golden-1.0", - family: config::COMPAT_LIBRARY_FAMILY, - default_service: config::COMPAT_DEFAULT_SERVICE, + family: config::DEFAULT_LIBRARY_FAMILY, + default_service: config::DEFAULT_SERVICE, service: "", env: "prod", app_version: "v1", @@ -189,7 +186,7 @@ mod tests { 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::COMPAT_LIBRARY_NAME); + 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!( @@ -197,7 +194,7 @@ mod tests { .as_str() .unwrap() .strip_prefix("service:"), - Some(config::COMPAT_DEFAULT_SERVICE) + Some(config::DEFAULT_SERVICE) ); assert_eq!(metadata["tags"][5], "env:prod"); assert_eq!(metadata["tags"][6], "version:v1"); diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index 4edc09837f..827c9c2300 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -1,9 +1,7 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use core::ffi::{c_char, CStr}; - -const CSTR_MAX_LEN: usize = 4096; +use core::ffi::c_char; unsafe extern "C" { static mut environ: *mut *mut c_char; @@ -501,56 +499,10 @@ fn wait_child_until(pid: i32, timeout_ms: i64, poll_ms: i32) -> ChildReap { } } -pub fn env_get(name_nul: &[u8]) -> Option<&'static [u8]> { - if name_nul.is_empty() || name_nul[name_nul.len() - 1] != 0 { - return None; - } - - let name = &name_nul[..name_nul.len() - 1]; - let env = unsafe { environ }; - if env.is_null() { - return None; - } - - unsafe { - let mut cur = env; - while !(*cur).is_null() { - let entry = *cur; - if let Some(value) = env_entry_value(entry, name) { - return Some(cstr_bytes_bounded(value)); - } - cur = cur.add(1); - } - } - None -} - pub fn environ_ptr() -> *mut *mut c_char { unsafe { environ } } -/// Reads bytes from a NUL-terminated C string, capped at the signal-safe maximum length. -/// -/// # Safety -/// `p` must be null or point to readable memory containing a NUL byte within `CSTR_MAX_LEN` -/// bytes. The returned slice borrows from `p` and must not outlive that memory. -pub unsafe fn cstr_bytes_bounded<'a>(p: *const c_char) -> &'a [u8] { - if p.is_null() { - return &[]; - } - - let mut len = 0usize; - while len < CSTR_MAX_LEN && *p.add(len) != 0 { - len += 1; - } - if len == CSTR_MAX_LEN { - return core::slice::from_raw_parts(p.cast(), len); - } - - let bytes = core::slice::from_raw_parts(p.cast(), len + 1); - CStr::from_bytes_with_nul_unchecked(bytes).to_bytes() -} - pub unsafe fn cstr_has_prefix(s: *const c_char, prefix: &[u8]) -> bool { let mut i = 0usize; while i < prefix.len() { @@ -563,18 +515,6 @@ pub unsafe fn cstr_has_prefix(s: *const c_char, prefix: &[u8]) -> bool { true } -unsafe fn env_entry_value(entry: *const c_char, name: &[u8]) -> Option<*const c_char> { - if !cstr_has_prefix(entry, name) { - return None; - } - - if *entry.add(name.len()) as u8 == b'=' { - Some(entry.add(name.len() + 1)) - } else { - None - } -} - pub fn errno() -> i32 { unsafe { *errno_location() } } diff --git a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index a8284989db..c66b1f12f4 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -9,8 +9,6 @@ use std::os::fd::AsRawFd; use std::os::unix::fs::PermissionsExt; use std::process::Command; -#[cfg(any(target_os = "linux", target_os = "android"))] -use libdd_crashtracker::collector_signal_safe::init_from_env_result; use libdd_crashtracker::collector_signal_safe::{ bootstrap_complete, init_result, owned_signal_count, owns_signal, InitResult, SignalSafeInitConfig, @@ -22,8 +20,19 @@ 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_from_env_result(), InitResult::Enabled); + assert_eq!( + init_result(&SignalSafeInitConfig { + 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", + ..SignalSafeInitConfig::default() + }), + InitResult::Enabled + ); bootstrap_complete(); std::process::abort(); @@ -116,11 +125,7 @@ fn signal_safe_crash_writes_report_through_receiver() { .arg("--nocapture") .env("DD_SIGNAL_SAFE_E2E_RECEIVER_CHILD", "1") .env("DD_SIGNAL_SAFE_E2E_REPORT", &report) - .env("DD_TRACE_C_CRASHTRACKER_PROCESS", &receiver) - .env("DD_SERVICE", "signal-safe-e2e") - .env("DD_ENV", "test") - .env("DD_VERSION", "1") - .env("DD_RUNTIME_ID", "00000000-0000-0000-0000-000000000001") + .env("DD_SIGNAL_SAFE_E2E_RECEIVER", &receiver) .status() .expect("spawn child"); diff --git a/libdd-profiling-ffi/Cargo.toml b/libdd-profiling-ffi/Cargo.toml index 9606368f24..e1b40f7063 100644 --- a/libdd-profiling-ffi/Cargo.toml +++ b/libdd-profiling-ffi/Cargo.toml @@ -26,8 +26,6 @@ data-pipeline-ffi = ["dep:libdd-data-pipeline-ffi"] crashtracker-ffi = ["dep:libdd-crashtracker-ffi"] # Enables the in-process collection of crash-info crashtracker-collector = ["crashtracker-ffi", "libdd-crashtracker-ffi/collector"] -# Enables the signal-safe in-process collection of crash-info -crashtracker-collector-signal-safe = ["crashtracker-ffi", "libdd-crashtracker-ffi/collector_signal-safe"] # Enables the use of this library to receiver crash-info from a suitable collector crashtracker-receiver = ["crashtracker-ffi", "libdd-crashtracker-ffi/receiver"] demangler = ["crashtracker-ffi", "libdd-crashtracker-ffi/demangler"] diff --git a/libdd-profiling-ffi/src/exporter.rs b/libdd-profiling-ffi/src/exporter.rs index 27637cad10..80d72ebc1a 100644 --- a/libdd-profiling-ffi/src/exporter.rs +++ b/libdd-profiling-ffi/src/exporter.rs @@ -12,13 +12,10 @@ use libdd_common_ffi::{ }; use libdd_profiling::exporter; use libdd_profiling::exporter::{ExporterManager, ProfileExporter}; -#[cfg(test)] -use libdd_profiling::internal::EncodedProfile as InternalEncodedProfile; +use libdd_profiling::internal::EncodedProfile; use std::borrow::Cow; use std::str::FromStr; -use crate::profiles::EncodedProfile; - type TokioCancellationToken = tokio_util::sync::CancellationToken; #[allow(dead_code)] @@ -283,7 +280,7 @@ pub unsafe extern "C" fn ddog_prof_Exporter_init_runtime( #[named] pub unsafe extern "C" fn ddog_prof_Exporter_send_blocking( mut exporter: *mut Handle, - profile: *mut EncodedProfile, + mut profile: *mut Handle, files_to_compress_and_export: Slice, optional_additional_tags: Option<&libdd_common_ffi::Vec>, optional_process_tags: Option<&CharSlice>, @@ -293,9 +290,7 @@ pub unsafe extern "C" fn ddog_prof_Exporter_send_blocking( ) -> Result { wrap_with_ffi_result!({ let exporter = exporter.to_inner_mut()?; - let profile = *unsafe { profile.as_mut() } - .context("Null pointer")? - .take()?; + let profile = *profile.take()?; let files_to_compress_and_export = try_into_vec_files(files_to_compress_and_export)?; let tags = try_clone_optional_tags(optional_additional_tags)?; let process_tags_str = optional_process_tags @@ -438,7 +433,7 @@ pub unsafe extern "C" fn ddog_prof_ExporterManager_new( #[named] pub unsafe extern "C" fn ddog_prof_ExporterManager_queue( mut manager: *mut Handle, - profile: *mut EncodedProfile, + mut profile: *mut Handle, files_to_compress_and_export: Slice, optional_additional_tags: Option<&libdd_common_ffi::Vec>, optional_process_tags: Option<&CharSlice>, @@ -447,9 +442,7 @@ pub unsafe extern "C" fn ddog_prof_ExporterManager_queue( ) -> VoidResult { wrap_with_void_ffi_result!({ let manager = manager.to_inner_mut()?; - let profile = *unsafe { profile.as_mut() } - .context("Null pointer")? - .take()?; + let profile = *profile.take()?; let files_to_compress_and_export = try_into_vec_files(files_to_compress_and_export)?; let tags = try_clone_optional_tags(optional_additional_tags)?; let process_tags_str = optional_process_tags @@ -613,7 +606,7 @@ mod tests { } .unwrap(); - let profile = &mut InternalEncodedProfile::test_instance().unwrap().into(); + let profile = &mut EncodedProfile::test_instance().unwrap().into(); // This should fail with a connection error since there's no server, // but it validates that the function works end-to-end @@ -682,7 +675,7 @@ mod tests { let mut manager = unsafe { ddog_prof_ExporterManager_new(&mut exporter) }.unwrap(); // Queue a profile - let profile = &mut InternalEncodedProfile::test_instance().unwrap().into(); + let profile = &mut EncodedProfile::test_instance().unwrap().into(); // Should succeed unsafe { ddog_prof_ExporterManager_queue( @@ -747,7 +740,7 @@ mod tests { } .unwrap(); - let profile = &mut InternalEncodedProfile::test_instance().unwrap().into(); + let profile = &mut EncodedProfile::test_instance().unwrap().into(); let raw_internal_metadata = CharSlice::from(r#"{"test": "value"}"#); let raw_info = CharSlice::from(r#"{"runtime": {"engine": "test"}}"#); diff --git a/libdd-profiling-ffi/src/profiles/datatypes.rs b/libdd-profiling-ffi/src/profiles/datatypes.rs index 2d3880a9c0..22d00ef9c0 100644 --- a/libdd-profiling-ffi/src/profiles/datatypes.rs +++ b/libdd-profiling-ffi/src/profiles/datatypes.rs @@ -6,7 +6,7 @@ use crate::{ensure_non_null_out_parameter, ArcHandle, ProfileError, ProfileStatu use anyhow::Context; use function_name::named; use libdd_common_ffi::slice::{AsBytes, ByteSlice, CharSlice, Slice}; -use libdd_common_ffi::{wrap_with_ffi_result, Error, Timespec}; +use libdd_common_ffi::{wrap_with_ffi_result, Error, Handle, Timespec, ToInner}; use libdd_profiling::api::{self, ManagedStringId}; use libdd_profiling::profiles::datatypes::{ProfilesDictionary, StringId2}; use libdd_profiling::{api2, internal}; @@ -49,47 +49,6 @@ impl Drop for Profile { } } -/// Represents a serialized profile. Do not access its member for any reason, only use -/// the C API functions on this struct. -#[repr(C)] -pub struct EncodedProfile { - // This may be null, but if not it will point to a valid EncodedProfile. - inner: *mut internal::EncodedProfile, -} - -impl EncodedProfile { - pub(crate) fn take(&mut self) -> anyhow::Result> { - let raw = std::mem::replace(&mut self.inner, std::ptr::null_mut()); - anyhow::ensure!( - !raw.is_null(), - "inner pointer was null, indicates use after free" - ); - Ok(unsafe { Box::from_raw(raw) }) - } - - fn to_inner_mut(&mut self) -> anyhow::Result<&mut internal::EncodedProfile> { - unsafe { - self.inner - .as_mut() - .context("inner pointer was null, indicates use after free") - } - } -} - -impl From for EncodedProfile { - fn from(value: internal::EncodedProfile) -> Self { - Self { - inner: Box::into_raw(Box::new(value)), - } - } -} - -impl Drop for EncodedProfile { - fn drop(&mut self) { - drop(self.take()) - } -} - /// A generic result type for when a profiling operation may fail, but there's /// nothing to return in the case of success. #[allow(dead_code)] @@ -124,7 +83,7 @@ pub enum ProfileNewResult { #[allow(dead_code)] #[repr(C)] pub enum SerializeResult { - Ok(EncodedProfile), + Ok(Handle), Err(Error), } @@ -870,11 +829,13 @@ unsafe fn add_upscaling_rule( /// valid reference also means that it hasn't already been dropped or exported (do not /// call this twice on the same object). #[no_mangle] -pub unsafe extern "C" fn ddog_prof_EncodedProfile_drop(profile: *mut EncodedProfile) { +pub unsafe extern "C" fn ddog_prof_EncodedProfile_drop( + profile: *mut Handle, +) { // Technically, this function has been designed so if it's double-dropped // then it's okay, but it's not something that should be relied on. - if let Some(profile) = unsafe { profile.as_mut() } { - drop(profile.take()) + if !profile.is_null() { + drop((*profile).take()) } } @@ -887,17 +848,10 @@ pub unsafe extern "C" fn ddog_prof_EncodedProfile_drop(profile: *mut EncodedProf #[must_use] #[named] pub unsafe extern "C" fn ddog_prof_EncodedProfile_bytes<'a>( - encoded_profile: *mut EncodedProfile, + mut encoded_profile: *mut Handle, ) -> libdd_common_ffi::Result> { wrap_with_ffi_result!({ - let slice = unsafe { - encoded_profile - .as_mut() - .context("Null pointer")? - .to_inner_mut()? - .buffer - .as_slice() - }; + let slice = encoded_profile.to_inner_mut()?.buffer.as_slice(); // Rountdtrip through raw pointers to avoid Rust complaining about lifetimes. let byte_slice = ByteSlice::from_raw_parts(slice.as_ptr(), slice.len()); anyhow::Ok(byte_slice) diff --git a/libdd-profiling-ffi/src/profiles/mod.rs b/libdd-profiling-ffi/src/profiles/mod.rs index 69a3598cd7..077500d41a 100644 --- a/libdd-profiling-ffi/src/profiles/mod.rs +++ b/libdd-profiling-ffi/src/profiles/mod.rs @@ -6,8 +6,6 @@ mod interning_api; mod profiles_dictionary; mod utf8; -pub(crate) use datatypes::EncodedProfile; - #[macro_export] macro_rules! ensure_non_null_out_parameter { ($expr:expr) => { diff --git a/tools/src/bin/ffi_test.rs b/tools/src/bin/ffi_test.rs index 31f1946ee1..4ac1bb9cad 100644 --- a/tools/src/bin/ffi_test.rs +++ b/tools/src/bin/ffi_test.rs @@ -139,24 +139,14 @@ struct ExpectedCrash { fn expected_crashes() -> &'static HashMap<&'static str, ExpectedCrash> { static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { - HashMap::from([ - ( - "crashtracking", - ExpectedCrash { - #[cfg(unix)] - signal: 11, // SIGSEGV - output_file: "crashreport.json", - }, - ), - ( - "signal_safe_crashtracking", - ExpectedCrash { - #[cfg(unix)] - signal: 6, // SIGABRT - output_file: "signal_safe_crashreport.txt", - }, - ), - ]) + HashMap::from([( + "crashtracking", + ExpectedCrash { + #[cfg(unix)] + signal: 11, // SIGSEGV + output_file: "crashreport.json", + }, + )]) }) } @@ -228,7 +218,7 @@ fn base_env_vars(_project_root: &Path) -> Vec<(String, String)> { /// receiver binary) can find them without hard-coding paths. fn per_test_env(name: &str, project_root: &Path, work_dir: &Path) -> Vec<(String, String)> { match name { - "crashtracking" | "signal_safe_crashtracking" => { + "crashtracking" => { let (receiver, lib_dir) = find_receiver_paths(project_root); let (search_var, search_val) = library_search_path_env(&lib_dir); vec![ From 3fc78e068c8a7d1b9e863f726c7ddbcedad3f003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Tue, 7 Jul 2026 04:09:59 +0200 Subject: [PATCH 26/32] refactor(crashtracker): simplify signal-safe collector hot paths Collapse emit_report_sections guards into a short-circuit chain, dedup the init_with_prepare cleanup paths, and use is_some_and in state.rs. On the crash path, snapshot the capability word once instead of re-loading it, and resolve effective_target a single time in the signal handler. --- .../src/collector_signal_safe/emitter.rs | 59 ++++++++----------- .../src/collector_signal_safe/handler.rs | 49 ++++++++------- .../src/collector_signal_safe/state.rs | 8 +-- 3 files changed, 55 insertions(+), 61 deletions(-) diff --git a/libdd-crashtracker/src/collector_signal_safe/emitter.rs b/libdd-crashtracker/src/collector_signal_safe/emitter.rs index 1259fbb277..c31142a332 100644 --- a/libdd-crashtracker/src/collector_signal_safe/emitter.rs +++ b/libdd-crashtracker/src/collector_signal_safe/emitter.rs @@ -82,40 +82,31 @@ fn emit_report_sections( report: &Report<'_>, context: &CrashContext<'_>, ) -> bool { - if !emit_config(sink, report.config_json) || !emit_metadata(sink, report) { - return false; - } - if !emit_additional_tags( - sink, - report.stackwalk_method, - report.capabilities, - report.degradations, - ) { - return false; - } - if !emit_kind(sink) { - return false; - } - if !emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_SIGINFO, - &context.signal, - protocol::DD_CRASHTRACK_END_SIGINFO, - ) { - return false; - } - if !emit_json_section( - sink, - protocol::DD_CRASHTRACK_BEGIN_PROCINFO, - &ProcInfo { - pid: context.pid, - tid: context.tid, - }, - protocol::DD_CRASHTRACK_END_PROCINFO, - ) { - return false; - } - emit_stacktrace(sink, context.frames) + 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( diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index 9b6c2dda33..70ecf43df4 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -141,15 +141,18 @@ fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> Init state::reset_init(); return InitResult::OwnerConflict; } - if let Err(err) = prepare() { - signal_owner::release(SignalOwner::SignalSafeCollector); - state::reset_init(); - return err.into(); - } - if !install_alt_stack_if_requested() { + // Once ownership is acquired, every failure must release it and reset init state. + let acquired = (|| { + prepare().map_err(InitResult::from)?; + if !install_alt_stack_if_requested() { + return Err(InitResult::Failed); + } + Ok(()) + })(); + if let Err(err) = acquired { signal_owner::release(SignalOwner::SignalSafeCollector); state::reset_init(); - return InitResult::Failed; + return err; } install_all_handlers(); state::INSTALLED.store(true, Ordering::Release); @@ -422,7 +425,8 @@ fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> let max_frames = state::MAX_FRAMES .load(Ordering::Relaxed) .min(config::BACKTRACE_LEVELS_MAX); - let can_walk = capabilities::has(capabilities::PROC_VM_READV); + let caps = capabilities::get(); + let can_walk = caps.contains(capabilities::PROC_VM_READV); let n = backtrace::backtrace_from_ucontext( &mut frames[..max_frames], event.ucontext, @@ -455,7 +459,7 @@ fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> runtime_id, platform: meta.platform.as_str(), stackwalk_method, - capabilities: capabilities::get(), + capabilities: caps, degradations: capabilities::degradations(), }; let context = event.context(&frames[..n]); @@ -499,6 +503,7 @@ fn collect_crash( tid: i32, ) { let report_fd = state::REPORT_FD.load(Ordering::Relaxed); + let caps = capabilities::get(); let event = CrashEvent { sig, si_code, @@ -511,23 +516,23 @@ fn collect_crash( let direct_report = |reason: capabilities::Degradations| { capabilities::note_degraded(reason); - if capabilities::has(capabilities::REPORT_FD_OK) { + if caps.contains(capabilities::REPORT_FD_OK) { capabilities::note_degraded(capabilities::DEGRADED_REPORT_TO_FD); let _ = emit_crash_report(report_fd, event, false); } }; - if !capabilities::has(capabilities::FORK_OK) { + if !caps.contains(capabilities::FORK_OK) { crash_debug(b"fork unavailable", sig); direct_report(capabilities::DEGRADED_NO_FORK); return; } - if !capabilities::has(capabilities::RECEIVER_OK) { + if !caps.contains(capabilities::RECEIVER_OK) { crash_debug(b"receiver unavailable", sig); direct_report(capabilities::DEGRADED_RECEIVER_UNAVAILABLE); return; } - if !capabilities::has(capabilities::PIPE_OK) { + if !caps.contains(capabilities::PIPE_OK) { crash_debug(b"pipe unavailable", sig); direct_report(capabilities::DEGRADED_NO_PIPE); return; @@ -613,9 +618,18 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m let self_pid = sys::getpid(); let tid = sys::gettid(); + // The installed target is immutable while the handler runs, so resolve it once and reuse it for + // both the app-first chain and the final chaining decision below. + let target = match idx { + Some(i) => effective_target(i), + None => Target { + fn_ptr: core::ptr::null_mut(), + flags: 0, + }, + }; + let force_on_top = state::FORCE_ON_TOP.load(Ordering::Relaxed); if let Some(i) = idx { - let target = effective_target(i); let app_is_real = app_handler_is_real(target.fn_ptr); if should_run_app_first(force_on_top, app_is_real) { let stack_marker = 0u8; @@ -658,13 +672,6 @@ extern "C" fn crash_handler(sig: c_int, info: *mut libc::siginfo_t, ucontext: *m sys::set_errno(saved_errno); - let target = match idx { - Some(i) => effective_target(i), - None => Target { - fn_ptr: core::ptr::null_mut(), - flags: 0, - }, - }; let action = chain_action(disposition_of(target.fn_ptr), has_info, si_code); match action { ChainAction::RestoreDefaultAndRefault | ChainAction::RestoreDefaultAndReraise => { diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index bf928c0e63..cd4c28578d 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -200,9 +200,7 @@ pub fn clear_signal_state() { } pub fn owns_signal(sig: i32) -> bool { - sig_index(sig) - .map(|i| signal_slot(i).owns_signal()) - .unwrap_or(false) + sig_index(sig).is_some_and(|i| signal_slot(i).owns_signal()) } pub fn owned_signal_count() -> u32 { @@ -213,7 +211,5 @@ pub fn owned_signal_count() -> u32 { } pub fn app_handler_present(sig: i32) -> bool { - sig_index(sig) - .map(|i| signal_slot(i).app_handler_present()) - .unwrap_or(false) + sig_index(sig).is_some_and(|i| signal_slot(i).app_handler_present()) } From 22dee7ae885116eaf3678472a47118139e7e44dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Tue, 7 Jul 2026 04:30:12 +0200 Subject: [PATCH 27/32] refactor(crashtracker): use rustix runtime for fork/exit, drop close_range Bump rustix to =1.1.4 and enable its runtime feature. Replace the hand-rolled clone/exit_group inline asm with rustix::runtime::kernel_fork and exit_group, which issue the same bare syscalls through the linux_raw backend without running libc atfork handlers. Drop SYS_close_range (Linux 5.9+, unavailable on kernels as old as CentOS 7's 3.10) in favor of a close() loop bounded by the RLIMIT_NOFILE soft limit, so descriptor hygiene before exec works on old kernels instead of silently no-opping. dup3 and process_vm_readv stay as raw syscalls (rustix's dup3 requires owned fds; process_vm_readv is absent). --- Cargo.lock | 18 ++-- libdd-crashtracker/Cargo.toml | 2 +- .../src/collector_signal_safe/sys.rs | 99 +++++-------------- 3 files changed, 37 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index baf0b0208e..0b9318e4a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2833,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" @@ -2972,7 +2972,7 @@ dependencies = [ "page_size", "portable-atomic", "rand 0.8.5", - "rustix 1.1.3", + "rustix 1.1.4", "schemars", "serde", "serde-json-core", @@ -3590,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" @@ -4918,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", ] @@ -5777,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/libdd-crashtracker/Cargo.toml b/libdd-crashtracker/Cargo.toml index 387d3abd7e..8a34b5dec4 100644 --- a/libdd-crashtracker/Cargo.toml +++ b/libdd-crashtracker/Cargo.toml @@ -90,7 +90,7 @@ 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.3", default-features = false, features = ["event", "fs", "mm", "pipe", "process", "stdio", "thread", "time"], 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 } diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index 827c9c2300..da28ef26ec 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -180,20 +180,9 @@ mod raw { mprotect_none, open_readwrite, pipe, poll_sleep_ms, waitpid_nohang_status, write, }; - #[cfg(target_arch = "x86_64")] - #[inline] - unsafe fn syscall1(nr: i64, a0: usize) -> isize { - let ret: isize; - asm!( - "syscall", - inlateout("rax") nr as isize => ret, - in("rdi") a0, - lateout("rcx") _, - lateout("r11") _, - options(nostack, preserves_flags), - ); - ret - } + /// 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] @@ -240,19 +229,6 @@ mod raw { ret } - #[cfg(target_arch = "aarch64")] - #[inline] - unsafe fn syscall1(nr: i64, a0: usize) -> isize { - let ret: isize; - asm!( - "svc 0", - in("x8") nr, - inlateout("x0") a0 => ret, - options(nostack), - ); - ret - } - #[cfg(target_arch = "aarch64")] #[inline] unsafe fn syscall3(nr: i64, a0: usize, a1: usize, a2: usize) -> isize { @@ -302,15 +278,22 @@ mod raw { } pub fn close_range_from(first_fd: i32) -> bool { - first_fd >= 0 - && unsafe { - syscall3( - libc::SYS_close_range, - first_fd as usize, - u32::MAX as usize, - 0, - ) == 0 - } + 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 { @@ -318,46 +301,18 @@ mod raw { } pub unsafe fn fork_raw() -> isize { - #[cfg(target_arch = "x86_64")] - { - let ret: isize; - asm!( - "syscall", - inlateout("rax") libc::SYS_clone as isize => ret, - in("rdi") libc::SIGCHLD as usize, - in("rsi") 0usize, - in("rdx") 0usize, - in("r10") 0usize, - in("r8") 0usize, - lateout("rcx") _, - lateout("r11") _, - options(nostack), - ); - ret - } - #[cfg(target_arch = "aarch64")] - { - let ret: isize; - asm!( - "svc 0", - in("x8") libc::SYS_clone, - inlateout("x0") libc::SIGCHLD as usize => ret, - in("x1") 0usize, - in("x2") 0usize, - in("x3") 0usize, - in("x4") 0usize, - options(nostack), - ); - ret + // `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) -> ! { - loop { - unsafe { - syscall1(libc::SYS_exit_group, code as usize); - } - } + rustix::runtime::exit_group(code) } pub fn gettid() -> i32 { From 7f37773b654de51fd4f428bf6d32c09d949018e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Tue, 7 Jul 2026 05:05:05 +0200 Subject: [PATCH 28/32] refactor(crashtracker): harden signal-safe collector fork children Disable the inherited alternate signal stack (SS_DISABLE) in forked children so a re-armed handler can never run on a stack region the child no longer maintains, and ignore SIGPIPE in the collector child so a dead receiver surfaces as EPIPE (handled by the sink) instead of terminating the collector mid-report. Also read bytes via a pointer cast in cstr_has_prefix so the comparison does not depend on c_char's platform-varying signedness, fixing an unnecessary-cast clippy error on aarch64-linux. --- .../src/collector_signal_safe/handler.rs | 31 +++++++++++++++++++ .../src/collector_signal_safe/sys.rs | 7 +++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index 70ecf43df4..2508df781c 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -283,6 +283,7 @@ fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> i32 { } 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()) @@ -360,6 +361,35 @@ fn install_sigaltstack(stack: &libc::stack_t) -> bool { unsafe { libc::sigaltstack(stack, null_mut()) == 0 } } +/// 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`](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() { @@ -415,6 +445,7 @@ fn collector_child(read_fd: i32, write_fd: i32, event: CrashEvent) -> ! { if write_fd < 0 { sys::exit_process(EXIT_CODE_FAILURE); } + ignore_sigpipe(); let _ = emit_crash_report(write_fd, event, true); sys::exit_process(0); diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index da28ef26ec..944868ef8f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -459,10 +459,13 @@ pub fn environ_ptr() -> *mut *mut c_char { } 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 = *s.add(i); - if c == 0 || c as u8 != prefix[i] { + let c = *bytes.add(i); + if c == 0 || c != prefix[i] { return false; } i += 1; From 7dd1c9abbf49cf44c0b776fae0f02f513b0e58f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Tue, 7 Jul 2026 05:22:20 +0200 Subject: [PATCH 29/32] perf(crashtracker): buffer signal-safe report writes Stage report output in FdSink and flush in a few write(2) syscalls instead of the ~40 tiny ones report emission previously issued (every marker, body, and trailing newline was its own write). Buffering lives inside the sink, so the protocol framing, emitter, and wire bytes are unchanged; callers flush before closing the fd, and Drop flushes as a safety net. Also fold in signal-safe collector cleanups: - drop the dead INSTALLED atomic (tracked HANDLERS_ENABLED exactly, read only in one test) - collapse the three copy-paste capability guards into a table loop - default runtime_id to the zero UUID in prepare_result instead of hardcoding it on the crash path --- .../src/collector_signal_safe/config.rs | 7 +- .../src/collector_signal_safe/handler.rs | 58 ++++----- .../src/collector_signal_safe/state.rs | 1 - .../src/collector_signal_safe/sys.rs | 110 ++++++++++++++++-- 4 files changed, 140 insertions(+), 36 deletions(-) diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 853841d694..7c729301bf 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -22,6 +22,7 @@ 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; @@ -176,7 +177,11 @@ pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareEr 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(&mut m.runtime_id, config.runtime_id); + 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"); diff --git a/libdd-crashtracker/src/collector_signal_safe/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs index 2508df781c..4ba7899aae 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler.rs @@ -155,7 +155,6 @@ fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> Init return err; } install_all_handlers(); - state::INSTALLED.store(true, Ordering::Release); state::finish_init(); state::HANDLERS_ENABLED.store(true, Ordering::Release); InitResult::Enabled @@ -171,7 +170,6 @@ pub fn shutdown() { state::HANDLERS_ENABLED.store(false, Ordering::Release); uninstall_all_handlers(); COLLECTING.store(false, Ordering::Relaxed); - state::INSTALLED.store(false, Ordering::Release); signal_owner::release(SignalOwner::SignalSafeCollector); state::reset_init(); } @@ -473,11 +471,6 @@ fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> }; let meta = state::meta(); - let runtime_id = if meta.runtime_id.is_empty() { - "00000000-0000-0000-0000-000000000000" - } else { - meta.runtime_id.as_str() - }; let report = Report { config_json: meta.config_json.as_str(), library_name: meta.library_name.as_str(), @@ -487,7 +480,7 @@ fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> service: meta.service.as_str(), env: meta.env.as_str(), app_version: meta.app_version.as_str(), - runtime_id, + runtime_id: meta.runtime_id.as_str(), platform: meta.platform.as_str(), stackwalk_method, capabilities: caps, @@ -497,10 +490,13 @@ fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> let mut sink = FdSink::new(write_fd); let emitted = super::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(); if close_when_done { sys::close(write_fd); } - emitted + emitted && flushed } fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) -> Option { @@ -553,20 +549,28 @@ fn collect_crash( } }; - if !caps.contains(capabilities::FORK_OK) { - crash_debug(b"fork unavailable", sig); - direct_report(capabilities::DEGRADED_NO_FORK); - return; - } - if !caps.contains(capabilities::RECEIVER_OK) { - crash_debug(b"receiver unavailable", sig); - direct_report(capabilities::DEGRADED_RECEIVER_UNAVAILABLE); - return; - } - if !caps.contains(capabilities::PIPE_OK) { - crash_debug(b"pipe unavailable", sig); - direct_report(capabilities::DEGRADED_NO_PIPE); - return; + for (cap, msg, reason) in [ + ( + capabilities::FORK_OK, + &b"fork unavailable"[..], + capabilities::DEGRADED_NO_FORK, + ), + ( + capabilities::RECEIVER_OK, + &b"receiver unavailable"[..], + capabilities::DEGRADED_RECEIVER_UNAVAILABLE, + ), + ( + capabilities::PIPE_OK, + &b"pipe unavailable"[..], + capabilities::DEGRADED_NO_PIPE, + ), + ] { + if !caps.contains(cap) { + crash_debug(msg, sig); + direct_report(reason); + return; + } } let mut fds = [0i32; 2]; @@ -939,13 +943,13 @@ mod tests { ..SignalSafeInitConfig::default() }; assert_eq!(init_result(&config), InitResult::Enabled); - assert!(state::INSTALLED.load(Ordering::Acquire)); + assert!(state::HANDLERS_ENABLED.load(Ordering::Acquire)); assert_eq!(init_result(&config), InitResult::AlreadyInitialized); shutdown(); - assert!(!state::INSTALLED.load(Ordering::Acquire)); + assert!(!state::HANDLERS_ENABLED.load(Ordering::Acquire)); assert_eq!(init_result(&config), InitResult::Enabled); - assert!(state::INSTALLED.load(Ordering::Acquire)); + assert!(state::HANDLERS_ENABLED.load(Ordering::Acquire)); shutdown(); - assert!(!state::INSTALLED.load(Ordering::Acquire)); + assert!(!state::HANDLERS_ENABLED.load(Ordering::Acquire)); } } diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index cd4c28578d..f1b382c1e1 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -182,7 +182,6 @@ pub static HANDLERS_ENABLED: AtomicBool = AtomicBool::new(false); pub static FORCE_ON_TOP: AtomicBool = AtomicBool::new(false); pub static ONLY_BOOTSTRAP: AtomicBool = AtomicBool::new(false); pub static DEBUG_LOG: AtomicBool = AtomicBool::new(false); -pub static INSTALLED: AtomicBool = AtomicBool::new(false); pub static CREATE_ALT_STACK: AtomicBool = AtomicBool::new(false); pub static USE_ALT_STACK: AtomicBool = AtomicBool::new(false); pub static BLOCK_SIGNALS: AtomicBool = AtomicBool::new(true); diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index 944868ef8f..cb728acc0f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -7,13 +7,47 @@ 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 } + 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(); + } } } @@ -21,19 +55,42 @@ impl crate::protocol::ByteSink for FdSink { type Error = (); fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error> { - let mut off = 0usize; - while off < bytes.len() { - let n = write(self.fd, &bytes[off..]); - if n > 0 { - off += n as usize; - continue; + // 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(()) } } +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; @@ -504,6 +561,7 @@ mod tests { assert!(pipe(&mut fds)); let mut sink = FdSink::new(fds[1]); assert!(sink.put(b"abc")); + assert!(sink.flush()); close(fds[1]); let mut out = [0u8; 3]; @@ -512,4 +570,42 @@ mod tests { assert_eq!(n, 3); assert_eq!(&out, b"abc"); } + + #[test] + fn fd_sink_coalesces_and_handles_overflow() { + let mut fds = [0i32; 2]; + assert!(pipe(&mut fds)); + let mut sink = FdSink::new(fds[1]); + + // 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(fds[1]); + + let expected = written + big.len(); + let mut out = [0u8; 4 * FD_SINK_BUF_CAPACITY]; + let mut got = 0usize; + loop { + let n = unsafe { libc::read(fds[0], out[got..].as_mut_ptr().cast(), out.len() - got) }; + if n <= 0 { + break; + } + got += n as usize; + } + close(fds[0]); + + assert_eq!(got, expected); + assert!(out[..written].iter().all(|&b| b == b'a')); + assert!(out[written..expected].iter().all(|&b| b == b'b')); + } } From 9b46d0c0c24c71d5e6ebce9726163cee58da67e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Wed, 8 Jul 2026 05:15:13 +0200 Subject: [PATCH 30/32] refactor(crashtracker): organize signal-safe collector --- AGENTS.md | 2 +- libdd-crashtracker/README.md | 2 +- .../src/collector_signal_safe/capabilities.rs | 9 +- .../src/collector_signal_safe/config.rs | 114 ++- .../src/collector_signal_safe/handler.rs | 955 ------------------ .../collector_signal_safe/handler/child.rs | 151 +++ .../collector_signal_safe/handler/collect.rs | 239 +++++ .../collector_signal_safe/handler/crash.rs | 247 +++++ .../handler/lifecycle.rs | 194 ++++ .../src/collector_signal_safe/handler/mod.rs | 42 + .../handler/sigaction.rs | 207 ++++ .../src/collector_signal_safe/mod.rs | 26 +- .../src/collector_signal_safe/state.rs | 106 +- .../src/collector_signal_safe/sys.rs | 58 +- libdd-crashtracker/src/lib.rs | 12 +- .../tests/collector_signal_safe_e2e.rs | 15 +- scripts/semver-level.sh | 2 +- 17 files changed, 1297 insertions(+), 1084 deletions(-) delete mode 100644 libdd-crashtracker/src/collector_signal_safe/handler.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/handler/child.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/handler/collect.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/handler/crash.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/handler/lifecycle.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/handler/mod.rs create mode 100644 libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs 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/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/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index 71f50bce03..50c6ad2980 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -119,11 +119,10 @@ pub fn publish(receiver_path: &[u8], report_fd: i32, probe_seccomp: bool) { degraded.insert(DEGRADED_NO_DEV_NULL); } - let mut fds = [0i32; 2]; - if sys::pipe(&mut fds) { + if let Some(pipe) = sys::pipe() { caps.insert(PIPE_OK); - sys::close(fds[0]); - sys::close(fds[1]); + sys::close(pipe.read); + sys::close(pipe.write); } else { degraded.insert(DEGRADED_NO_PIPE); } @@ -171,7 +170,7 @@ fn probe_process_vm_readv_in_child() -> bool { return true; } - match sys::reap_child(child as i32, 100, 10, true, 10) { + match sys::reap_child(child as i32, 100, 10, 10) { sys::ChildReap::Reaped(status) => libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, sys::ChildReap::NoChild | sys::ChildReap::WaitFailed(_) | sys::ChildReap::TimedOut => true, } diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 7c729301bf..8f92109cbb 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -7,14 +7,13 @@ use heapless::String as HeaplessString; use serde::Serialize; use thiserror::Error; -use super::state::meta_mut; 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 a `SignalSafeInitConfig` field empty. The +// 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. @@ -39,7 +38,7 @@ pub const CRASH_SIGNALS: [i32; 5] = SIGNAL_SAFE_CRASH_SIGNALS; pub const CONFIG_JSON_BUF_SIZE: usize = 2048; #[derive(Clone, Copy, Debug)] -pub struct SignalSafeInitConfig<'a> { +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], @@ -86,7 +85,7 @@ pub enum PrepareError { Failed, } -impl<'a> Default for SignalSafeInitConfig<'a> { +impl<'a> Default for InitConfig<'a> { fn default() -> Self { Self { receiver_path: &[], @@ -137,7 +136,7 @@ struct WireTimeout { pub fn build_config_json( out: &mut HeaplessString, - config: &SignalSafeInitConfig<'_>, + config: &InitConfig<'_>, ) -> bool { out.clear(); let wire = WireConfig { @@ -165,10 +164,9 @@ pub fn build_config_json( out.push_str(json).is_ok() && out.push('\n').is_ok() } -pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { +pub fn apply(config: &InitConfig<'_>, m: &mut state::Meta) -> Result<(), PrepareError> { validate(config)?; - let m = meta_mut(); if !build_config_json(&mut m.config_json, config) { return Err(PrepareError::Failed); } @@ -211,25 +209,43 @@ pub fn prepare_result(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareEr return Err(PrepareError::InvalidConfig); } - state::FORCE_ON_TOP.store(config.force_on_top, Relaxed); - state::ONLY_BOOTSTRAP.store(config.only_bootstrap, Relaxed); - state::DEBUG_LOG.store(config.debug_logging, Relaxed); - state::CREATE_ALT_STACK.store(config.create_alt_stack, Relaxed); - state::USE_ALT_STACK.store(config.use_alt_stack, Relaxed); - state::BLOCK_SIGNALS.store(config.block_signals, Relaxed); - state::DISARM_ON_ENTRY.store(config.disarm_on_entry, Relaxed); - state::CLOSE_FDS_ON_RECEIVER.store(config.close_fds_on_receiver, Relaxed); - state::REPORT_FD.store(config.report_fd, Relaxed); - state::COLLECTOR_REAP_MS.store( + state::SETTINGS + .force_on_top + .store(config.force_on_top, Relaxed); + 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::RECEIVER_TIMEOUT_MS.store( + state::SETTINGS.receiver_reap_ms.store( normalized_receiver_timeout_secs(config.receiver_timeout_secs) as i32 * 1000 + RECEIVER_TIMEOUT_GRACE_MS, Relaxed, ); - state::MAX_FRAMES.store(normalized_max_frames(config.max_frames), Relaxed); + state::SETTINGS + .max_frames + .store(normalized_max_frames(config.max_frames), Relaxed); capabilities::publish( m.process_path.as_slice(), config.report_fd, @@ -297,7 +313,7 @@ fn set_receiver_path(dst: &mut heapless::Vec, path: &[u8]) -> dst.extend_from_slice(path).is_ok() && dst.push(0).is_ok() } -fn validate(config: &SignalSafeInitConfig<'_>) -> Result<(), PrepareError> { +fn validate(config: &InitConfig<'_>) -> Result<(), PrepareError> { if config.create_alt_stack && !config.use_alt_stack { return Err(PrepareError::InvalidConfig); } @@ -322,10 +338,7 @@ mod tests { #[test] fn config_json_contains_receiver_contract() { let mut out = HeaplessString::::new(); - assert!(build_config_json( - &mut out, - &SignalSafeInitConfig::default() - )); + assert!(build_config_json(&mut out, &InitConfig::default())); let signals = CRASH_SIGNALS .iter() .map(i32::to_string) @@ -361,10 +374,10 @@ mod tests { #[test] fn validate_rejects_pointless_alt_stack_configuration() { assert_eq!( - validate(&SignalSafeInitConfig { + validate(&InitConfig { create_alt_stack: true, use_alt_stack: false, - ..SignalSafeInitConfig::default() + ..InitConfig::default() }), Err(PrepareError::InvalidConfig) ); @@ -376,30 +389,33 @@ mod tests { .lock() .expect("test lock poisoned"); - assert!(prepare_result(&SignalSafeInitConfig { - receiver_path: b"/tmp/receiver", - service: b"svc", - env: b"prod", - app_version: b"1.2.3", - runtime_id: b"rid", - platform: b"host", - force_on_top: true, - only_bootstrap: true, - debug_logging: true, - ..SignalSafeInitConfig::default() - }) + 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", + force_on_top: true, + only_bootstrap: true, + debug_logging: true, + ..InitConfig::default() + }, + &mut meta + ) .is_ok()); - let meta = state::meta(); 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::FORCE_ON_TOP.load(Relaxed)); - assert!(state::ONLY_BOOTSTRAP.load(Relaxed)); - assert!(state::DEBUG_LOG.load(Relaxed)); + assert!(state::SETTINGS.force_on_top.load(Relaxed)); + assert!(state::SETTINGS.only_bootstrap.load(Relaxed)); + assert!(state::SETTINGS.debug_log.load(Relaxed)); } #[test] @@ -409,11 +425,15 @@ mod tests { .expect("test lock poisoned"); let oversized_service = "s".repeat(300); - assert!(prepare_result(&SignalSafeInitConfig { - receiver_path: b"/definitely/missing-signal-safe-receiver", - service: oversized_service.as_bytes(), - ..SignalSafeInitConfig::default() - }) + 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/handler.rs b/libdd-crashtracker/src/collector_signal_safe/handler.rs deleted file mode 100644 index 4ba7899aae..0000000000 --- a/libdd-crashtracker/src/collector_signal_safe/handler.rs +++ /dev/null @@ -1,955 +0,0 @@ -// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -use core::cell::UnsafeCell; -use core::ffi::{c_char, c_int, c_void}; -use core::ptr::null_mut; -use core::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; - -use super::config::{self, PrepareError, SignalSafeInitConfig}; -use super::fmt::{write_i32, I32_BUF_CAPACITY}; -use super::policy::{ - app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, - should_run_app_first, ChainAction, -}; -use super::state::{self, sig_index, BeginInitError}; -use super::sys::{self, FdSink}; -use super::{backtrace, capabilities}; -use super::{CrashContext, Report, SignalInfo}; -use crate::signal_owner::{self, SignalOwner}; - -// Used only by forked children; 125 matches the existing shell-like "cannot exec" convention. -const EXIT_CODE_FAILURE: i32 = 125; -const REAP_KILL_TIMEOUT_MS: i64 = 500; -const REAP_WAIT_INTERVAL_MS: i32 = 100; -const ALT_STACK_SIZE: usize = 64 * 1024; -const ALT_STACK_GUARD_SIZE: usize = 4096; - -const _: () = assert!(super::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], -})); - -#[derive(Clone, Copy)] -struct Target { - fn_ptr: *mut c_void, - flags: i32, -} - -#[derive(Clone, Copy)] -struct CrashEvent { - sig: i32, - si_code: i32, - has_info: bool, - si_addr: usize, - pid: i32, - tid: i32, - ucontext: *mut c_void, -} - -impl CrashEvent { - 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, - } - } -} - -static APP_CHAIN_TID: AtomicI32 = AtomicI32::new(0); -static APP_CHAIN_STACK: AtomicUsize = AtomicUsize::new(0); - -struct RepeatFaultSlot { - pc: AtomicUsize, - addr: AtomicUsize, - count: AtomicUsize, -} - -impl RepeatFaultSlot { - const fn new() -> Self { - Self { - pc: AtomicUsize::new(0), - addr: AtomicUsize::new(0), - count: AtomicUsize::new(0), - } - } - - fn tripped(&self, pc: usize, addr: usize) -> bool { - if pc == 0 { - return false; - } - - let last_pc = self.pc.load(Ordering::Relaxed); - let last_addr = self.addr.load(Ordering::Relaxed); - if last_pc == pc && last_addr == addr { - self.count.fetch_add(1, Ordering::Relaxed) + 1 >= 2 - } else { - self.addr.store(addr, Ordering::Relaxed); - self.count.store(1, Ordering::Relaxed); - self.pc.store(pc, Ordering::Relaxed); - false - } - } - - #[cfg(test)] - fn reset(&self) { - self.pc.store(0, Ordering::Relaxed); - self.addr.store(0, Ordering::Relaxed); - self.count.store(0, Ordering::Relaxed); - } -} - -static REPEAT_FAULT: [RepeatFaultSlot; state::NSIG] = - [const { RepeatFaultSlot::new() }; state::NSIG]; -/// Prevents recursive crash collection. Reset only during explicit shutdown/re-init lifecycle. -static COLLECTING: AtomicBool = AtomicBool::new(false); - -#[repr(i32)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InitResult { - Enabled = 0, - Failed = 2, - AlreadyInitialized = 3, - OwnerConflict = 4, - InvalidConfig = 5, -} - -pub fn init_result(config: &SignalSafeInitConfig<'_>) -> InitResult { - init_with_prepare(|| config::prepare_result(config)) -} - -fn init_with_prepare(prepare: impl FnOnce() -> Result<(), PrepareError>) -> InitResult { - let begin = state::begin_init(); - if let Err(err) = begin { - return err.into(); - } - if !signal_owner::acquire(SignalOwner::SignalSafeCollector) { - state::reset_init(); - return InitResult::OwnerConflict; - } - // Once ownership is acquired, every failure must release it and reset init state. - let acquired = (|| { - prepare().map_err(InitResult::from)?; - if !install_alt_stack_if_requested() { - return Err(InitResult::Failed); - } - Ok(()) - })(); - if let Err(err) = acquired { - signal_owner::release(SignalOwner::SignalSafeCollector); - state::reset_init(); - return err; - } - install_all_handlers(); - state::finish_init(); - state::HANDLERS_ENABLED.store(true, Ordering::Release); - InitResult::Enabled -} - -pub fn bootstrap_complete() { - if state::ONLY_BOOTSTRAP.load(Ordering::Relaxed) { - shutdown(); - } -} - -pub fn shutdown() { - state::HANDLERS_ENABLED.store(false, Ordering::Release); - uninstall_all_handlers(); - COLLECTING.store(false, Ordering::Relaxed); - signal_owner::release(SignalOwner::SignalSafeCollector); - state::reset_init(); -} - -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 effective_target(idx: usize) -> Target { - let (fn_ptr, flags) = state::signal_slot(idx).original_handler(); - Target { fn_ptr, flags } -} - -unsafe fn invoke_handler( - t: &Target, - sig: c_int, - info: *mut libc::siginfo_t, - ucontext: *mut c_void, -) { - if t.flags & libc::SA_SIGINFO != 0 { - let f: extern "C" fn(c_int, *mut libc::siginfo_t, *mut c_void) = - core::mem::transmute(t.fn_ptr); - f(sig, info, ucontext); - } else { - let f: extern "C" fn(c_int) = core::mem::transmute(t.fn_ptr); - f(sig); - } -} - -/// Tracks app-first handler invocation without relying on cleanup after the call. -/// -/// A recovering app handler may leave this frame via siglongjmp, so a simple boolean would stay -/// set forever. Supported Unix targets use downward-growing stacks: a nested crash inside the app -/// handler has a stack address below the recorded frame, while a later signal after longjmp has -/// unwound above it. Different-thread entries skip app-first while the earlier handler is active. -fn enter_app_chain(tid: i32, stack_pos: usize) -> bool { - let owner = APP_CHAIN_TID.load(Ordering::Relaxed); - if owner == 0 { - APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); - APP_CHAIN_TID.store(tid, Ordering::Relaxed); - return true; - } - - if owner != tid { - return false; - } - - let recorded = APP_CHAIN_STACK.load(Ordering::Relaxed); - if stack_pos > recorded { - APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); - APP_CHAIN_TID.store(tid, Ordering::Relaxed); - true - } else { - false - } -} - -fn leave_app_chain(tid: i32, stack_pos: usize) { - if APP_CHAIN_TID.load(Ordering::Relaxed) == tid - && APP_CHAIN_STACK.load(Ordering::Relaxed) == stack_pos - { - APP_CHAIN_STACK.store(0, Ordering::Relaxed); - APP_CHAIN_TID.store(0, Ordering::Relaxed); - } -} - -fn app_return_repeated_fault(idx: usize, pc: usize, addr: usize) -> bool { - REPEAT_FAULT[idx].tripped(pc, addr) -} - -fn crash_debug(msg: &[u8], sig: i32) { - if !state::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"); -} - -fn sanitize_clone(mut keep_fd: i32, close_stdio_without_devnull: bool) -> 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 close_stdio_without_devnull { - close_stdio(); - } - keep_fd -} - -fn close_stdio() { - sys::close(libc::STDIN_FILENO); - sys::close(libc::STDOUT_FILENO); - sys::close(libc::STDERR_FILENO); -} - -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 -} - -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_alt_stack_if_requested() -> bool { - if !state::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 } -} - -/// 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`](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(); - } -} - -fn receiver_child(read_fd: i32, write_fd: i32) -> ! { - sys::close(write_fd); - let read_fd = sanitize_clone(read_fd, true); - 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::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); -} - -fn collector_child(read_fd: i32, write_fd: i32, event: CrashEvent) -> ! { - sys::close(read_fd); - let write_fd = sanitize_clone(write_fd, false); - if write_fd < 0 { - sys::exit_process(EXIT_CODE_FAILURE); - } - ignore_sigpipe(); - - let _ = emit_crash_report(write_fd, event, true); - sys::exit_process(0); -} - -fn emit_crash_report(write_fd: i32, event: CrashEvent, close_when_done: bool) -> bool { - let mut frames = [0usize; config::BACKTRACE_LEVELS_MAX]; - let max_frames = state::MAX_FRAMES - .load(Ordering::Relaxed) - .min(config::BACKTRACE_LEVELS_MAX); - let caps = capabilities::get(); - let can_walk = caps.contains(capabilities::PROC_VM_READV); - let n = backtrace::backtrace_from_ucontext( - &mut frames[..max_frames], - event.ucontext, - event.pid, - 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 = super::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(); - if close_when_done { - sys::close(write_fd); - } - emitted && flushed -} - -fn reap_or_kill(pid: i32, timeout_ms: i64, kill_process: bool) -> Option { - match sys::reap_child( - pid, - timeout_ms, - REAP_WAIT_INTERVAL_MS, - kill_process, - 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 collect_crash( - sig: i32, - si_code: i32, - has_info: bool, - si_addr: usize, - ucontext: *mut c_void, - pid: i32, - tid: i32, -) { - let report_fd = state::REPORT_FD.load(Ordering::Relaxed); - let caps = capabilities::get(); - let event = CrashEvent { - sig, - si_code, - has_info, - si_addr, - pid, - tid, - ucontext, - }; - - let direct_report = |reason: capabilities::Degradations| { - capabilities::note_degraded(reason); - if caps.contains(capabilities::REPORT_FD_OK) { - capabilities::note_degraded(capabilities::DEGRADED_REPORT_TO_FD); - let _ = emit_crash_report(report_fd, event, false); - } - }; - - for (cap, msg, reason) in [ - ( - capabilities::FORK_OK, - &b"fork unavailable"[..], - capabilities::DEGRADED_NO_FORK, - ), - ( - capabilities::RECEIVER_OK, - &b"receiver unavailable"[..], - capabilities::DEGRADED_RECEIVER_UNAVAILABLE, - ), - ( - capabilities::PIPE_OK, - &b"pipe unavailable"[..], - capabilities::DEGRADED_NO_PIPE, - ), - ] { - if !caps.contains(cap) { - crash_debug(msg, sig); - direct_report(reason); - return; - } - } - - let mut fds = [0i32; 2]; - if !sys::pipe(&mut fds) { - crash_debug(b"pipe failed", sig); - direct_report(capabilities::DEGRADED_PIPE_FAILED); - return; - } - - let read_fd = fds[0]; - let write_fd = fds[1]; - - let receiver = unsafe { sys::fork_raw() }; - if receiver == 0 { - receiver_child(read_fd, write_fd); - } - if receiver < 0 { - crash_debug(b"receiver fork failed", sig); - sys::close(read_fd); - sys::close(write_fd); - direct_report(capabilities::DEGRADED_FORK_FAILED); - return; - } - - let collector = unsafe { sys::fork_raw() }; - if collector == 0 { - collector_child(read_fd, write_fd, event); - } - - sys::close(read_fd); - sys::close(write_fd); - - if collector > 0 { - let _ = reap_or_kill( - collector as i32, - state::COLLECTOR_REAP_MS.load(Ordering::Relaxed) as i64, - true, - ); - } else { - crash_debug(b"collector fork failed", sig); - direct_report(capabilities::DEGRADED_FORK_FAILED); - } - - let receiver_status = reap_or_kill( - receiver as i32, - state::RECEIVER_TIMEOUT_MS.load(Ordering::Relaxed) as i64, - true, - ); - if receiver_status.is_some_and(|status| exited_with(status, EXIT_CODE_FAILURE)) { - crash_debug(b"receiver exec failed", sig); - direct_report(capabilities::DEGRADED_RECEIVER_UNAVAILABLE); - } -} - -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); - let disarmed_on_entry = - state::DISARM_ON_ENTRY.load(Ordering::Relaxed) && reset_signals_to_default(&[sig]); - - let idx = sig_index(sig); - 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 - }; - - // Identity of the crashing thread, resolved once and reused by the app-handler chain guard - // and the crash collection below. - let self_pid = sys::getpid(); - let tid = sys::gettid(); - - // The installed target is immutable while the handler runs, so resolve it once and reuse it for - // both the app-first chain and the final chaining decision below. - let target = match idx { - Some(i) => effective_target(i), - None => Target { - fn_ptr: core::ptr::null_mut(), - flags: 0, - }, - }; - - let force_on_top = state::FORCE_ON_TOP.load(Ordering::Relaxed); - if let Some(i) = idx { - let app_is_real = app_handler_is_real(target.fn_ptr); - if should_run_app_first(force_on_top, app_is_real) { - let stack_marker = 0u8; - let stack_pos = (&stack_marker as *const u8) as usize; - if enter_app_chain(tid, stack_pos) { - sys::set_errno(saved_errno); - // If the application handler recovers with siglongjmp, no code after this call - // runs. Keep this path free of Drop-dependent state. - unsafe { invoke_handler(&target, sig, info, ucontext) }; - - let handler_after = live_handler_for_recovery(sig).unwrap_or(target.fn_ptr); - leave_app_chain(tid, stack_pos); - if app_recovered(handler_after) { - let pc = backtrace::instruction_pointer(ucontext); - if app_return_repeated_fault(i, pc, si_addr) { - crash_debug(b"app handler returned without recovery", sig); - } else { - if disarmed_on_entry { - restore_our_handler(sig); - } - sys::set_errno(saved_errno); - return; - } - } - } else { - crash_debug(b"app handler recursion detected", sig); - } - } - } - - let si_pid = if has_info { - unsafe { siginfo_pid(info) } - } else { - 0 - }; - let genuine_fault = is_genuine_fault(has_info, si_code, si_pid, self_pid); - if genuine_fault && !COLLECTING.swap(true, Ordering::Relaxed) { - collect_crash(sig, si_code, has_info, si_addr, ucontext, self_pid, tid); - } - - sys::set_errno(saved_errno); - - let action = chain_action(disposition_of(target.fn_ptr), has_info, 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); - } - } - } - ChainAction::Resume => { - if disarmed_on_entry { - restore_our_handler(sig); - } - } - ChainAction::InvokeApp => unsafe { - if disarmed_on_entry && !genuine_fault { - restore_our_handler(sig); - } - invoke_handler(&target, sig, info, ucontext); - }, - } -} - -fn live_handler_for_recovery(sig: c_int) -> Option<*mut c_void> { - query_sigaction(sig).map(|cur| cur.sa_sigaction as *mut c_void) -} - -#[cfg(any( - target_os = "linux", - target_os = "android", - target_os = "macos", - target_os = "ios" -))] -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" -)))] -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" -))] -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" -)))] -unsafe fn siginfo_addr(_info: *mut libc::siginfo_t) -> usize { - 0 -} - -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::USE_ALT_STACK.load(Ordering::Relaxed) { - sa.sa_flags |= libc::SA_ONSTACK; - } - unsafe { - libc::sigemptyset(&mut sa.sa_mask); - if state::BLOCK_SIGNALS.load(Ordering::Relaxed) { - for &blocked in &config::CRASH_SIGNALS { - let _ = libc::sigaddset(&mut sa.sa_mask, blocked); - } - } - } - sa -} - -fn restore_our_handler(sig: c_int) { - let sa = build_crash_sigaction(); - unsafe { - let _ = libc::sigaction(sig, &sa, 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); - } - } -} - -fn install_all_handlers() { - state::clear_signal_state(); - for &sig in &config::CRASH_SIGNALS { - install_crash_handler(sig); - } -} - -fn uninstall_all_handlers() { - for &sig in &config::CRASH_SIGNALS { - uninstall_crash_handler(sig); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn app_chain_guard_distinguishes_recursion_from_unwind() { - let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK - .lock() - .expect("test lock poisoned"); - - APP_CHAIN_TID.store(0, Ordering::Relaxed); - APP_CHAIN_STACK.store(0, Ordering::Relaxed); - - assert!(enter_app_chain(123, 1_000)); - assert!(!enter_app_chain(123, 900)); - assert!(!enter_app_chain(456, 1_100)); - assert!(enter_app_chain(123, 1_100)); - leave_app_chain(123, 1_100); - assert_eq!(APP_CHAIN_TID.load(Ordering::Relaxed), 0); - } - - #[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)); - } - - #[test] - fn repeated_app_return_trips_on_second_same_fault() { - let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK - .lock() - .expect("test lock poisoned"); - let idx = sig_index(libc::SIGSEGV).expect("SIGSEGV index"); - - REPEAT_FAULT[idx].reset(); - - assert!(!app_return_repeated_fault(idx, 0x1234, 0)); - assert!(app_return_repeated_fault(idx, 0x1234, 0)); - assert!(!app_return_repeated_fault(idx, 0x5678, 0)); - } - - #[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 = SignalSafeInitConfig { - receiver_path: b"/bin/cat", - ..SignalSafeInitConfig::default() - }; - assert_eq!(init_result(&config), InitResult::Enabled); - assert!(state::HANDLERS_ENABLED.load(Ordering::Acquire)); - assert_eq!(init_result(&config), InitResult::AlreadyInitialized); - shutdown(); - assert!(!state::HANDLERS_ENABLED.load(Ordering::Acquire)); - assert_eq!(init_result(&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/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..61e1479f3e --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs @@ -0,0 +1,239 @@ +// 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; +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 instruction_pointer(self) -> usize { + backtrace::instruction_pointer(self.ucontext) + } +} + +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. A receiver exit status of `EXIT_CODE_FAILURE` means exec + // failed, so the handler can still fall back to `report_fd`. + 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(|status| exited_with(status, EXIT_CODE_FAILURE)) { + crash_debug(b"receiver exec failed", event.sig); + 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); + let n = backtrace::backtrace_from_ucontext( + &mut frames[..max_frames], + event.ucontext, + event.pid, + 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 +} 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..9e965c6e41 --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/crash.rs @@ -0,0 +1,247 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Crash-handler entry point, app-handler chaining guard, and final chain dispatch. + +use core::ffi::{c_int, c_void}; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; + +use super::collect::{collect_crash, CrashEvent}; +use super::sigaction::{ + effective_target, invoke_handler, query_sigaction, reset_signals_to_default, + restore_our_handler, unblock_signal, Target, +}; +use super::{crash_debug, EXIT_CODE_FAILURE}; +use crate::collector_signal_safe::policy::{ + app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, + should_run_app_first, ChainAction, +}; +use crate::collector_signal_safe::state::{self, sig_index}; +use crate::collector_signal_safe::sys; + +static APP_CHAIN_TID: AtomicI32 = AtomicI32::new(0); +static APP_CHAIN_STACK: AtomicUsize = AtomicUsize::new(0); + +struct RepeatFaultSlot { + pc: AtomicUsize, + addr: AtomicUsize, + count: AtomicUsize, +} + +impl RepeatFaultSlot { + const fn new() -> Self { + Self { + pc: AtomicUsize::new(0), + addr: AtomicUsize::new(0), + count: AtomicUsize::new(0), + } + } + + fn tripped(&self, pc: usize, addr: usize) -> bool { + if pc == 0 { + return false; + } + + let last_pc = self.pc.load(Ordering::Relaxed); + let last_addr = self.addr.load(Ordering::Relaxed); + if last_pc == pc && last_addr == addr { + self.count.fetch_add(1, Ordering::Relaxed) + 1 >= 2 + } else { + self.addr.store(addr, Ordering::Relaxed); + self.count.store(1, Ordering::Relaxed); + self.pc.store(pc, Ordering::Relaxed); + false + } + } + + #[cfg(test)] + fn reset(&self) { + self.pc.store(0, Ordering::Relaxed); + self.addr.store(0, Ordering::Relaxed); + self.count.store(0, Ordering::Relaxed); + } +} + +static REPEAT_FAULT: [RepeatFaultSlot; state::NSIG] = + [const { RepeatFaultSlot::new() }; state::NSIG]; +/// 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); +} + +/// Tracks app-first handler invocation without relying on cleanup after the call. +/// +/// A recovering app handler may leave this frame via siglongjmp, so a simple boolean would stay +/// set forever. Supported Unix targets use downward-growing stacks: a nested crash inside the app +/// handler has a stack address below the recorded frame, while a later signal after longjmp has +/// unwound above it. Different-thread entries skip app-first while the earlier handler is active. +fn enter_app_chain(tid: i32, stack_pos: usize) -> bool { + let owner = APP_CHAIN_TID.load(Ordering::Relaxed); + if owner == 0 { + APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); + APP_CHAIN_TID.store(tid, Ordering::Relaxed); + return true; + } + + if owner != tid { + return false; + } + + let recorded = APP_CHAIN_STACK.load(Ordering::Relaxed); + if stack_pos > recorded { + APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); + APP_CHAIN_TID.store(tid, Ordering::Relaxed); + true + } else { + false + } +} + +fn leave_app_chain(tid: i32, stack_pos: usize) { + if APP_CHAIN_TID.load(Ordering::Relaxed) == tid + && APP_CHAIN_STACK.load(Ordering::Relaxed) == stack_pos + { + APP_CHAIN_STACK.store(0, Ordering::Relaxed); + APP_CHAIN_TID.store(0, Ordering::Relaxed); + } +} + +fn app_return_repeated_fault(idx: usize, pc: usize, addr: usize) -> bool { + REPEAT_FAULT[idx].tripped(pc, addr) +} + +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); + let disarmed_on_entry = + state::SETTINGS.disarm_on_entry.load(Ordering::Relaxed) && reset_signals_to_default(&[sig]); + + let idx = sig_index(sig); + let event = CrashEvent::from_signal(sig, info, ucontext); + + // The installed target is immutable while the handler runs, so resolve it once and reuse it for + // both the app-first chain and the final chaining decision below. + let target = match idx { + Some(i) => effective_target(i), + None => Target { + fn_ptr: core::ptr::null_mut(), + flags: 0, + }, + }; + + let force_on_top = state::SETTINGS.force_on_top.load(Ordering::Relaxed); + if let Some(i) = idx { + let app_is_real = app_handler_is_real(target.fn_ptr); + if should_run_app_first(force_on_top, app_is_real) { + let stack_marker = 0u8; + let stack_pos = (&stack_marker as *const u8) as usize; + if enter_app_chain(event.tid, stack_pos) { + sys::set_errno(saved_errno); + // If the application handler recovers with siglongjmp, no code after this call + // runs. Keep this path free of Drop-dependent state. + unsafe { invoke_handler(&target, sig, info, ucontext) }; + + let handler_after = live_handler_for_recovery(sig).unwrap_or(target.fn_ptr); + leave_app_chain(event.tid, stack_pos); + if app_recovered(handler_after) { + let pc = event.instruction_pointer(); + if app_return_repeated_fault(i, pc, event.si_addr) { + crash_debug(b"app handler returned without recovery", sig); + } else { + if disarmed_on_entry { + restore_our_handler(sig); + } + sys::set_errno(saved_errno); + return; + } + } + } else { + crash_debug(b"app handler recursion detected", sig); + } + } + } + + 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); + } + } + } + ChainAction::Resume => { + if disarmed_on_entry { + restore_our_handler(sig); + } + } + ChainAction::InvokeApp => unsafe { + if disarmed_on_entry && !genuine_fault { + restore_our_handler(sig); + } + invoke_handler(&target, sig, info, ucontext); + }, + } +} + +fn live_handler_for_recovery(sig: c_int) -> Option<*mut c_void> { + query_sigaction(sig).map(|cur| cur.sa_sigaction as *mut c_void) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_chain_guard_distinguishes_recursion_from_unwind() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + + APP_CHAIN_TID.store(0, Ordering::Relaxed); + APP_CHAIN_STACK.store(0, Ordering::Relaxed); + + assert!(enter_app_chain(123, 1_000)); + assert!(!enter_app_chain(123, 900)); + assert!(!enter_app_chain(456, 1_100)); + assert!(enter_app_chain(123, 1_100)); + leave_app_chain(123, 1_100); + assert_eq!(APP_CHAIN_TID.load(Ordering::Relaxed), 0); + } + + #[test] + fn repeated_app_return_trips_on_second_same_fault() { + let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK + .lock() + .expect("test lock poisoned"); + let idx = sig_index(libc::SIGSEGV).expect("SIGSEGV index"); + + REPEAT_FAULT[idx].reset(); + + assert!(!app_return_repeated_fault(idx, 0x1234, 0)); + assert!(app_return_repeated_fault(idx, 0x1234, 0)); + assert!(!app_return_repeated_fault(idx, 0x5678, 0)); + } +} 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..9a28258b3c --- /dev/null +++ b/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs @@ -0,0 +1,207 @@ +// 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) unsafe fn invoke_handler( + t: &Target, + sig: c_int, + info: *mut libc::siginfo_t, + ucontext: *mut c_void, +) { + if t.flags & libc::SA_SIGINFO != 0 { + let f: extern "C" fn(c_int, *mut libc::siginfo_t, *mut c_void) = + core::mem::transmute(t.fn_ptr); + f(sig, info, ucontext); + } else { + let f: extern "C" fn(c_int) = core::mem::transmute(t.fn_ptr); + f(sig); + } +} + +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 restore_our_handler(sig: c_int) { + let sa = build_crash_sigaction(); + unsafe { + let _ = libc::sigaction(sig, &sa, null_mut()); + } +} + +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 index b7793182ba..b67fc9873f 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -4,7 +4,15 @@ //! Signal-safe Unix crash collection. //! //! `init` takes explicit caller-provided configuration. The collector reads no environment -//! variables of its own; the consumer populates [`SignalSafeInitConfig`] with the values it wants. +//! 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: //! @@ -13,7 +21,7 @@ //! | 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 | unsupported | unsupported | compile error | +//! | 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 @@ -38,29 +46,39 @@ use crate::shared::signal_names; #[cfg(test)] pub(crate) static TEST_GLOBAL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -pub use config::SignalSafeInitConfig; +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_result, shutdown, InitResult}; +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) } diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index f1b382c1e1..d964f45f99 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -1,6 +1,13 @@ // 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; @@ -10,7 +17,7 @@ use core::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, AtomicUsize, Ordering use heapless::{String as HeaplessString, Vec as HeaplessVec}; use thiserror::Error; -use super::config::{CONFIG_JSON_BUF_SIZE, PATH_CAPACITY}; +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; @@ -35,7 +42,7 @@ pub struct Meta { } impl Meta { - const fn new() -> Self { + pub(super) const fn new() -> Self { Self { config_json: HeaplessString::new(), service: HeaplessString::new(), @@ -72,24 +79,43 @@ pub enum BeginInitError { Busy, } -pub fn begin_init() -> Result<(), BeginInitError> { +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(()), + Ok(_) => Ok(InitSession { finished: false }), Err(INIT_READY) => Err(BeginInitError::AlreadyInitialized), Err(_) => Err(BeginInitError::Busy), } } -pub fn finish_init() { - INIT_STATE.store(INIT_READY, Ordering::Release); -} - -pub fn reset_init() { +pub fn reset_after_shutdown() { INIT_STATE.store(INIT_UNINIT, Ordering::Release); } @@ -97,10 +123,6 @@ pub fn meta() -> &'static Meta { unsafe { &*META.0.get() } } -pub fn meta_mut() -> &'static mut Meta { - unsafe { &mut *META.0.get() } -} - pub(super) struct SignalSlot { orig_fn: AtomicPtr, orig_flags: AtomicI32, @@ -153,7 +175,7 @@ impl SignalSlot { } pub(super) fn owns_signal(&self) -> bool { - self.own_signal.load(Ordering::Acquire) + self.own_signal.load(Ordering::Relaxed) } pub(super) fn set_app_handler_present(&self) { @@ -161,7 +183,7 @@ impl SignalSlot { } pub(super) fn app_handler_present(&self) -> bool { - self.app_handler_present.load(Ordering::Acquire) + self.app_handler_present.load(Ordering::Relaxed) } fn clear(&self) { @@ -179,18 +201,48 @@ pub(super) fn signal_slot(idx: usize) -> &'static SignalSlot { } pub static HANDLERS_ENABLED: AtomicBool = AtomicBool::new(false); -pub static FORCE_ON_TOP: AtomicBool = AtomicBool::new(false); -pub static ONLY_BOOTSTRAP: AtomicBool = AtomicBool::new(false); -pub static DEBUG_LOG: AtomicBool = AtomicBool::new(false); -pub static CREATE_ALT_STACK: AtomicBool = AtomicBool::new(false); -pub static USE_ALT_STACK: AtomicBool = AtomicBool::new(false); -pub static BLOCK_SIGNALS: AtomicBool = AtomicBool::new(true); -pub static DISARM_ON_ENTRY: AtomicBool = AtomicBool::new(false); -pub static CLOSE_FDS_ON_RECEIVER: AtomicBool = AtomicBool::new(true); -pub static REPORT_FD: AtomicI32 = AtomicI32::new(-1); -pub static COLLECTOR_REAP_MS: AtomicI32 = AtomicI32::new(500); -pub static RECEIVER_TIMEOUT_MS: AtomicI32 = AtomicI32::new(6_000); -pub static MAX_FRAMES: AtomicUsize = AtomicUsize::new(32); + +/// 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 force_on_top: AtomicBool, + 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 { + force_on_top: AtomicBool::new(false), + 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), + receiver_reap_ms: AtomicI32::new( + config::RECEIVER_TIMEOUT_SECS as i32 * 1000 + config::RECEIVER_TIMEOUT_GRACE_MS, + ), + max_frames: AtomicUsize::new(config::BACKTRACE_LEVELS_DEFAULT), + } + } +} + +pub static SETTINGS: Settings = Settings::new(); pub fn clear_signal_state() { for slot in &SIGNAL_SLOTS { diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index cb728acc0f..df65acce00 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -78,6 +78,12 @@ impl crate::protocol::ByteSink for FdSink { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +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() { @@ -96,6 +102,8 @@ mod raw_common { 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() @@ -133,14 +141,13 @@ mod raw_common { fd >= 0 && rustix::io::fcntl_getfd(unsafe { borrowed_fd(fd) }).is_ok() } - pub fn pipe(fds: &mut [i32; 2]) -> bool { + pub fn pipe() -> Option { match rustix::pipe::pipe() { - Ok((read_fd, write_fd)) => { - fds[0] = read_fd.into_raw_fd(); - fds[1] = write_fd.into_raw_fd(); - true - } - Err(_) => false, + Ok((read_fd, write_fd)) => Some(Pipe { + read: read_fd.into_raw_fd(), + write: write_fd.into_raw_fd(), + }), + Err(_) => None, } } @@ -454,9 +461,9 @@ mod raw { pub use raw::{ access_executable, close, close_range_from, dup2, exit_process, fcntl_dupfd, fd_valid, - fork_raw, fork_supported, getpid, gettid, kill, monotonic_nanos, mprotect_none, open_readwrite, - pipe, poll_sleep_ms, read_own_mem, waitpid_nohang_status, write, + 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 { @@ -466,15 +473,9 @@ pub enum ChildReap { TimedOut, } -pub fn reap_child( - pid: i32, - timeout_ms: i64, - poll_ms: i32, - kill_on_timeout: bool, - kill_timeout_ms: i64, -) -> ChildReap { +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 = kill_on_timeout; + let mut should_kill = true; loop { match wait_child_until(pid, remaining_timeout_ms, poll_ms) { ChildReap::TimedOut if should_kill => { @@ -557,25 +558,23 @@ mod tests { #[test] fn fd_sink_writes_to_pipe() { - let mut fds = [0i32; 2]; - assert!(pipe(&mut fds)); - let mut sink = FdSink::new(fds[1]); + let pipe = pipe().expect("pipe"); + let mut sink = FdSink::new(pipe.write); assert!(sink.put(b"abc")); assert!(sink.flush()); - close(fds[1]); + close(pipe.write); let mut out = [0u8; 3]; - let n = unsafe { libc::read(fds[0], out.as_mut_ptr().cast(), out.len()) }; - close(fds[0]); + 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 mut fds = [0i32; 2]; - assert!(pipe(&mut fds)); - let mut sink = FdSink::new(fds[1]); + 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 @@ -590,19 +589,20 @@ mod tests { let big = [b'b'; FD_SINK_BUF_CAPACITY + 1]; assert!(sink.put(&big)); assert!(sink.flush()); - close(fds[1]); + 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(fds[0], out[got..].as_mut_ptr().cast(), out.len() - got) }; + 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(fds[0]); + close(pipe.read); assert_eq!(got, expected); assert!(out[..written].iter().all(|&b| b == b'a')); diff --git a/libdd-crashtracker/src/lib.rs b/libdd-crashtracker/src/lib.rs index 8541fad51a..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,9 +46,6 @@ //! 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))] @@ -51,9 +54,6 @@ #![cfg_attr(not(test), deny(clippy::unimplemented))] #![cfg_attr(not(feature = "std"), no_std)] -#[cfg(all(not(unix), feature = "collector_signal-safe"))] -compile_error!("The collector_signal-safe feature is only supported on Unix targets."); - extern crate alloc; #[cfg(all(test, not(feature = "std")))] diff --git a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index c66b1f12f4..e9f3a6cba8 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -10,8 +10,7 @@ use std::os::unix::fs::PermissionsExt; use std::process::Command; use libdd_crashtracker::collector_signal_safe::{ - bootstrap_complete, init_result, owned_signal_count, owns_signal, InitResult, - SignalSafeInitConfig, + bootstrap_complete, init, owned_signal_count, owns_signal, InitConfig, InitResult, }; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -23,13 +22,13 @@ fn signal_safe_receiver_child_process() { let receiver = std::env::var_os("DD_SIGNAL_SAFE_E2E_RECEIVER").expect("receiver"); assert_eq!( - init_result(&SignalSafeInitConfig { + 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", - ..SignalSafeInitConfig::default() + ..InitConfig::default() }), InitResult::Enabled ); @@ -46,14 +45,14 @@ fn signal_safe_report_fd_child_process() { let report = fs::File::create(report).expect("create report"); assert_eq!( - init_result(&SignalSafeInitConfig { + 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(), - ..SignalSafeInitConfig::default() + ..InitConfig::default() }), InitResult::Enabled ); @@ -241,7 +240,7 @@ fn init_report_fd( ) -> fs::File { let report = fs::File::create(report_path).expect("create report"); assert_eq!( - init_result(&SignalSafeInitConfig { + init(&InitConfig { receiver_path, service: b"signal-safe-e2e", env: b"test", @@ -249,7 +248,7 @@ fn init_report_fd( runtime_id: b"00000000-0000-0000-0000-000000000001", report_fd: report.as_raw_fd(), only_bootstrap, - ..SignalSafeInitConfig::default() + ..InitConfig::default() }), InitResult::Enabled ); 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 From befc336ac51a18ed035bd39aae41480e20093724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Wed, 8 Jul 2026 05:49:58 +0200 Subject: [PATCH 31/32] fix(crashtracker): stabilize signal-safe collector Fix tolerant raw fd closing, receiver abnormal-exit fallback, and Linux clippy regressions. Trim unreachable app-handler chaining code and pin the signal-safe workflow action to the allowlisted SHA. --- .../workflows/crashtracker-signal-safe.yml | 4 +- .../src/collector_signal_safe/backtrace.rs | 19 +- .../src/collector_signal_safe/capabilities.rs | 11 +- .../src/collector_signal_safe/config.rs | 52 +++-- .../collector_signal_safe/handler/collect.rs | 20 +- .../collector_signal_safe/handler/crash.rs | 188 +----------------- .../handler/sigaction.rs | 23 --- .../src/collector_signal_safe/mod.rs | 4 +- .../src/collector_signal_safe/policy.rs | 52 ++--- .../src/collector_signal_safe/state.rs | 8 +- .../src/collector_signal_safe/sys.rs | 52 ++++- .../tests/collector_signal_safe_e2e.rs | 42 ++++ 12 files changed, 174 insertions(+), 301 deletions(-) diff --git a/.github/workflows/crashtracker-signal-safe.yml b/.github/workflows/crashtracker-signal-safe.yml index 03d01de087..3699cb9eb9 100644 --- a/.github/workflows/crashtracker-signal-safe.yml +++ b/.github/workflows/crashtracker-signal-safe.yml @@ -31,7 +31,7 @@ jobs: rustup component add clippy --toolchain stable rustup target add aarch64-unknown-linux-gnu - name: Install cargo nextest - uses: taiki-e/install-action@d12e869b89167df346dd0ff65da342d1fb1202fb # v2.57.4 + uses: taiki-e/install-action@2c41309d51ede152b6f2ee6bf3b71e6dc9a8b7df # 2.49.27 with: tool: nextest@0.9.96 - name: Check signal-safe collector @@ -57,7 +57,7 @@ jobs: rustup set profile minimal rustup toolchain install stable - name: Install cargo nextest - uses: taiki-e/install-action@d12e869b89167df346dd0ff65da342d1fb1202fb # v2.57.4 + uses: taiki-e/install-action@2c41309d51ede152b6f2ee6bf3b71e6dc9a8b7df # 2.49.27 with: tool: nextest@0.9.96 - name: Test signal-safe collector fallback paths diff --git a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs index 46494f0ff8..eba1dea3e4 100644 --- a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs +++ b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs @@ -68,21 +68,6 @@ fn arch_seed(uc: &libc::ucontext_t, out: &mut [usize]) -> (usize, usize) { (n, registers.fp) } -pub fn instruction_pointer(ucontext: *const c_void) -> usize { - if ucontext.is_null() { - return 0; - } - - let uc = unsafe { &*(ucontext as *const libc::ucontext_t) }; - let mut out = [0usize; 1]; - let (n, _) = arch_seed(uc, &mut out); - if n == 0 { - 0 - } else { - out[0] - } -} - pub fn backtrace_from_ucontext( out: &mut [usize], ucontext: *const c_void, @@ -118,6 +103,8 @@ mod tests { 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); @@ -139,6 +126,8 @@ mod tests { 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); diff --git a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs index 50c6ad2980..d78bd742a3 100644 --- a/libdd-crashtracker/src/collector_signal_safe/capabilities.rs +++ b/libdd-crashtracker/src/collector_signal_safe/capabilities.rs @@ -89,6 +89,10 @@ pub const DEGRADATION_REASONS: &[(Degradations, &str)] = &[ 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(); @@ -170,7 +174,12 @@ fn probe_process_vm_readv_in_child() -> bool { return true; } - match sys::reap_child(child as i32, 100, 10, 10) { + 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, } diff --git a/libdd-crashtracker/src/collector_signal_safe/config.rs b/libdd-crashtracker/src/collector_signal_safe/config.rs index 8f92109cbb..6ad84997f3 100644 --- a/libdd-crashtracker/src/collector_signal_safe/config.rs +++ b/libdd-crashtracker/src/collector_signal_safe/config.rs @@ -26,10 +26,12 @@ 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: u32 = DD_CRASHTRACK_DEFAULT_TIMEOUT_SECS; +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: i32 = 500; -pub const RECEIVER_TIMEOUT_GRACE_MS: i32 = 1000; +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; @@ -42,17 +44,27 @@ 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], - pub force_on_top: bool, + /// 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. /// @@ -64,16 +76,21 @@ pub struct InitConfig<'a> { /// 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 handler mask. - /// - /// Application handlers invoked by the signal-safe handler run with this mask in effect. + /// 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, } @@ -98,7 +115,6 @@ impl<'a> Default for InitConfig<'a> { library_version: DEFAULT_LIBRARY_VERSION.as_bytes(), family: DEFAULT_LIBRARY_FAMILY.as_bytes(), default_service: DEFAULT_SERVICE.as_bytes(), - force_on_top: false, only_bootstrap: false, debug_logging: false, create_alt_stack: false, @@ -106,8 +122,8 @@ impl<'a> Default for InitConfig<'a> { block_signals: true, disarm_on_entry: false, report_fd: -1, - collector_reap_ms: COLLECTOR_REAP_MS, - receiver_timeout_secs: RECEIVER_TIMEOUT_SECS, + 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, @@ -209,9 +225,6 @@ pub fn apply(config: &InitConfig<'_>, m: &mut state::Meta) -> Result<(), Prepare return Err(PrepareError::InvalidConfig); } - state::SETTINGS - .force_on_top - .store(config.force_on_top, Relaxed); state::SETTINGS .only_bootstrap .store(config.only_bootstrap, Relaxed); @@ -240,7 +253,7 @@ pub fn apply(config: &InitConfig<'_>, m: &mut state::Meta) -> Result<(), Prepare ); state::SETTINGS.receiver_reap_ms.store( normalized_receiver_timeout_secs(config.receiver_timeout_secs) as i32 * 1000 - + RECEIVER_TIMEOUT_GRACE_MS, + + RECEIVER_REAP_GRACE_MS, Relaxed, ); state::SETTINGS @@ -259,7 +272,7 @@ pub fn apply(config: &InitConfig<'_>, m: &mut state::Meta) -> Result<(), Prepare fn normalized_receiver_timeout_secs(value: u32) -> u32 { if value == 0 { - RECEIVER_TIMEOUT_SECS + RECEIVER_TIMEOUT_SECS_DEFAULT } else if value > RECEIVER_TIMEOUT_SECS_MAX { RECEIVER_TIMEOUT_SECS_MAX } else { @@ -269,7 +282,7 @@ fn normalized_receiver_timeout_secs(value: u32) -> u32 { fn normalized_collector_reap_ms(value: i32) -> i32 { if value <= 0 { - COLLECTOR_REAP_MS + COLLECTOR_REAP_MS_DEFAULT } else { value } @@ -363,7 +376,10 @@ mod tests { #[test] fn timeout_seconds_are_clamped() { - assert_eq!(normalized_receiver_timeout_secs(0), RECEIVER_TIMEOUT_SECS); + 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), @@ -398,7 +414,6 @@ mod tests { app_version: b"1.2.3", runtime_id: b"rid", platform: b"host", - force_on_top: true, only_bootstrap: true, debug_logging: true, ..InitConfig::default() @@ -413,7 +428,6 @@ mod tests { 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.force_on_top.load(Relaxed)); assert!(state::SETTINGS.only_bootstrap.load(Relaxed)); assert!(state::SETTINGS.debug_log.load(Relaxed)); } diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs b/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs index 61e1479f3e..ad67e9552a 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs @@ -16,6 +16,8 @@ use crate::collector_signal_safe::{ }; 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)] @@ -73,18 +75,14 @@ impl CrashEvent { frames, } } - - pub(super) fn instruction_pointer(self) -> usize { - backtrace::instruction_pointer(self.ucontext) - } } 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. A receiver exit status of `EXIT_CODE_FAILURE` means exec - // failed, so the handler can still fall back to `report_fd`. + // 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(); @@ -151,8 +149,10 @@ pub(super) fn collect_crash(event: CrashEvent) { receiver as i32, state::SETTINGS.receiver_reap_ms.load(Ordering::Relaxed) as i64, ); - if receiver_status.is_some_and(|status| exited_with(status, EXIT_CODE_FAILURE)) { - crash_debug(b"receiver exec failed", event.sig); + 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, @@ -237,3 +237,7 @@ fn reap_or_kill(pid: i32, timeout_ms: i64) -> Option { 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 index 9e965c6e41..7121d78066 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler/crash.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler/crash.rs @@ -1,69 +1,20 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -//! Crash-handler entry point, app-handler chaining guard, and final chain dispatch. +//! Crash-handler entry point and final default-signal dispatch. use core::ffi::{c_int, c_void}; -use core::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, Ordering}; use super::collect::{collect_crash, CrashEvent}; -use super::sigaction::{ - effective_target, invoke_handler, query_sigaction, reset_signals_to_default, - restore_our_handler, unblock_signal, Target, -}; +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::{ - app_handler_is_real, app_recovered, chain_action, disposition_of, is_genuine_fault, - should_run_app_first, ChainAction, + chain_action, disposition_of, is_genuine_fault, ChainAction, }; use crate::collector_signal_safe::state::{self, sig_index}; use crate::collector_signal_safe::sys; -static APP_CHAIN_TID: AtomicI32 = AtomicI32::new(0); -static APP_CHAIN_STACK: AtomicUsize = AtomicUsize::new(0); - -struct RepeatFaultSlot { - pc: AtomicUsize, - addr: AtomicUsize, - count: AtomicUsize, -} - -impl RepeatFaultSlot { - const fn new() -> Self { - Self { - pc: AtomicUsize::new(0), - addr: AtomicUsize::new(0), - count: AtomicUsize::new(0), - } - } - - fn tripped(&self, pc: usize, addr: usize) -> bool { - if pc == 0 { - return false; - } - - let last_pc = self.pc.load(Ordering::Relaxed); - let last_addr = self.addr.load(Ordering::Relaxed); - if last_pc == pc && last_addr == addr { - self.count.fetch_add(1, Ordering::Relaxed) + 1 >= 2 - } else { - self.addr.store(addr, Ordering::Relaxed); - self.count.store(1, Ordering::Relaxed); - self.pc.store(pc, Ordering::Relaxed); - false - } - } - - #[cfg(test)] - fn reset(&self) { - self.pc.store(0, Ordering::Relaxed); - self.addr.store(0, Ordering::Relaxed); - self.count.store(0, Ordering::Relaxed); - } -} - -static REPEAT_FAULT: [RepeatFaultSlot; state::NSIG] = - [const { RepeatFaultSlot::new() }; state::NSIG]; /// Prevents recursive crash collection. Reset only during explicit shutdown/re-init lifecycle. static COLLECTING: AtomicBool = AtomicBool::new(false); @@ -71,47 +22,6 @@ pub(super) fn reset_collecting() { COLLECTING.store(false, Ordering::Relaxed); } -/// Tracks app-first handler invocation without relying on cleanup after the call. -/// -/// A recovering app handler may leave this frame via siglongjmp, so a simple boolean would stay -/// set forever. Supported Unix targets use downward-growing stacks: a nested crash inside the app -/// handler has a stack address below the recorded frame, while a later signal after longjmp has -/// unwound above it. Different-thread entries skip app-first while the earlier handler is active. -fn enter_app_chain(tid: i32, stack_pos: usize) -> bool { - let owner = APP_CHAIN_TID.load(Ordering::Relaxed); - if owner == 0 { - APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); - APP_CHAIN_TID.store(tid, Ordering::Relaxed); - return true; - } - - if owner != tid { - return false; - } - - let recorded = APP_CHAIN_STACK.load(Ordering::Relaxed); - if stack_pos > recorded { - APP_CHAIN_STACK.store(stack_pos, Ordering::Relaxed); - APP_CHAIN_TID.store(tid, Ordering::Relaxed); - true - } else { - false - } -} - -fn leave_app_chain(tid: i32, stack_pos: usize) { - if APP_CHAIN_TID.load(Ordering::Relaxed) == tid - && APP_CHAIN_STACK.load(Ordering::Relaxed) == stack_pos - { - APP_CHAIN_STACK.store(0, Ordering::Relaxed); - APP_CHAIN_TID.store(0, Ordering::Relaxed); - } -} - -fn app_return_repeated_fault(idx: usize, pc: usize, addr: usize) -> bool { - REPEAT_FAULT[idx].tripped(pc, addr) -} - pub(super) extern "C" fn crash_handler( sig: c_int, info: *mut libc::siginfo_t, @@ -123,14 +33,13 @@ pub(super) extern "C" fn crash_handler( let saved_errno = sys::errno(); crash_debug(b"handler entered", sig); - let disarmed_on_entry = - state::SETTINGS.disarm_on_entry.load(Ordering::Relaxed) && reset_signals_to_default(&[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); - // The installed target is immutable while the handler runs, so resolve it once and reuse it for - // both the app-first chain and the final chaining decision below. let target = match idx { Some(i) => effective_target(i), None => Target { @@ -139,38 +48,6 @@ pub(super) extern "C" fn crash_handler( }, }; - let force_on_top = state::SETTINGS.force_on_top.load(Ordering::Relaxed); - if let Some(i) = idx { - let app_is_real = app_handler_is_real(target.fn_ptr); - if should_run_app_first(force_on_top, app_is_real) { - let stack_marker = 0u8; - let stack_pos = (&stack_marker as *const u8) as usize; - if enter_app_chain(event.tid, stack_pos) { - sys::set_errno(saved_errno); - // If the application handler recovers with siglongjmp, no code after this call - // runs. Keep this path free of Drop-dependent state. - unsafe { invoke_handler(&target, sig, info, ucontext) }; - - let handler_after = live_handler_for_recovery(sig).unwrap_or(target.fn_ptr); - leave_app_chain(event.tid, stack_pos); - if app_recovered(handler_after) { - let pc = event.instruction_pointer(); - if app_return_repeated_fault(i, pc, event.si_addr) { - crash_debug(b"app handler returned without recovery", sig); - } else { - if disarmed_on_entry { - restore_our_handler(sig); - } - sys::set_errno(saved_errno); - return; - } - } - } else { - crash_debug(b"app handler recursion detected", sig); - } - } - } - 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); @@ -192,56 +69,5 @@ pub(super) extern "C" fn crash_handler( } } } - ChainAction::Resume => { - if disarmed_on_entry { - restore_our_handler(sig); - } - } - ChainAction::InvokeApp => unsafe { - if disarmed_on_entry && !genuine_fault { - restore_our_handler(sig); - } - invoke_handler(&target, sig, info, ucontext); - }, - } -} - -fn live_handler_for_recovery(sig: c_int) -> Option<*mut c_void> { - query_sigaction(sig).map(|cur| cur.sa_sigaction as *mut c_void) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn app_chain_guard_distinguishes_recursion_from_unwind() { - let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK - .lock() - .expect("test lock poisoned"); - - APP_CHAIN_TID.store(0, Ordering::Relaxed); - APP_CHAIN_STACK.store(0, Ordering::Relaxed); - - assert!(enter_app_chain(123, 1_000)); - assert!(!enter_app_chain(123, 900)); - assert!(!enter_app_chain(456, 1_100)); - assert!(enter_app_chain(123, 1_100)); - leave_app_chain(123, 1_100); - assert_eq!(APP_CHAIN_TID.load(Ordering::Relaxed), 0); - } - - #[test] - fn repeated_app_return_trips_on_second_same_fault() { - let _guard = crate::collector_signal_safe::TEST_GLOBAL_LOCK - .lock() - .expect("test lock poisoned"); - let idx = sig_index(libc::SIGSEGV).expect("SIGSEGV index"); - - REPEAT_FAULT[idx].reset(); - - assert!(!app_return_repeated_fault(idx, 0x1234, 0)); - assert!(app_return_repeated_fault(idx, 0x1234, 0)); - assert!(!app_return_repeated_fault(idx, 0x5678, 0)); } } diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs b/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs index 9a28258b3c..48f6ef799e 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler/sigaction.rs @@ -25,22 +25,6 @@ pub(super) fn effective_target(idx: usize) -> Target { Target { fn_ptr, flags } } -pub(super) unsafe fn invoke_handler( - t: &Target, - sig: c_int, - info: *mut libc::siginfo_t, - ucontext: *mut c_void, -) { - if t.flags & libc::SA_SIGINFO != 0 { - let f: extern "C" fn(c_int, *mut libc::siginfo_t, *mut c_void) = - core::mem::transmute(t.fn_ptr); - f(sig, info, ucontext); - } else { - let f: extern "C" fn(c_int) = core::mem::transmute(t.fn_ptr); - f(sig); - } -} - 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 { @@ -75,13 +59,6 @@ fn build_crash_sigaction() -> libc::sigaction { sa } -pub(super) fn restore_our_handler(sig: c_int) { - let sa = build_crash_sigaction(); - unsafe { - let _ = libc::sigaction(sig, &sa, null_mut()); - } -} - 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; diff --git a/libdd-crashtracker/src/collector_signal_safe/mod.rs b/libdd-crashtracker/src/collector_signal_safe/mod.rs index b67fc9873f..a095f5d4e7 100644 --- a/libdd-crashtracker/src/collector_signal_safe/mod.rs +++ b/libdd-crashtracker/src/collector_signal_safe/mod.rs @@ -26,9 +26,7 @@ //! `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, app handlers invoked from this handler run with the -//! crash-signal mask in effect; a nested crash on another managed signal is deferred until the -//! app handler returns. +//! When `block_signals` is enabled, managed crash signals are masked while this handler runs. mod backtrace; pub(crate) mod capabilities; diff --git a/libdd-crashtracker/src/collector_signal_safe/policy.rs b/libdd-crashtracker/src/collector_signal_safe/policy.rs index b60c0e17d0..51bf8d6ff8 100644 --- a/libdd-crashtracker/src/collector_signal_safe/policy.rs +++ b/libdd-crashtracker/src/collector_signal_safe/policy.rs @@ -14,10 +14,8 @@ pub enum Disposition { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ChainAction { - InvokeApp, RestoreDefaultAndRefault, RestoreDefaultAndReraise, - Resume, } pub(super) fn disposition_of(handler: *mut c_void) -> Disposition { @@ -32,14 +30,6 @@ pub(super) fn app_handler_is_real(handler: *mut c_void) -> bool { matches!(disposition_of(handler), Disposition::Handler) } -pub(super) fn should_run_app_first(force_on_top: bool, app_is_real: bool) -> bool { - !force_on_top && app_is_real -} - -pub(super) fn app_recovered(handler_after: *mut c_void) -> bool { - disposition_of(handler_after) != Disposition::Default -} - pub(super) fn is_genuine_fault( has_siginfo: bool, si_code: i32, @@ -60,13 +50,14 @@ pub(super) fn chain_action( has_siginfo: bool, si_code: i32, ) -> ChainAction { - match disposition { - Disposition::Ignore => ChainAction::Resume, - Disposition::Handler => ChainAction::InvokeApp, - Disposition::Default if should_refault(has_siginfo, si_code) => { - ChainAction::RestoreDefaultAndRefault + // 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 } - Disposition::Default => ChainAction::RestoreDefaultAndReraise, } } @@ -101,29 +92,6 @@ mod tests { assert!(app_handler_is_real(handler)); } - #[test] - fn handler_policy_tracks_application_recovery() { - 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!(should_run_app_first(false, true)); - assert!(!should_run_app_first(true, true)); - assert!(!should_run_app_first(false, false)); - - assert!(app_recovered(handler)); - assert!(app_recovered(ign)); - assert!(!app_recovered(dfl)); - } - - #[test] - fn disposition_based_chain_action_resumes_ignored_signals() { - assert_eq!( - chain_action(Disposition::Ignore, true, SEGV_MAPERR), - ChainAction::Resume - ); - } - #[test] fn genuine_fault_filter_ignores_external_async_signal() { assert!(!is_genuine_fault(true, SI_USER, 7, 9)); @@ -146,7 +114,11 @@ mod tests { ); assert_eq!( chain_action(Disposition::Handler, true, SEGV_MAPERR), - ChainAction::InvokeApp + ChainAction::RestoreDefaultAndRefault + ); + assert_eq!( + chain_action(Disposition::Ignore, true, SI_USER), + ChainAction::RestoreDefaultAndReraise ); } } diff --git a/libdd-crashtracker/src/collector_signal_safe/state.rs b/libdd-crashtracker/src/collector_signal_safe/state.rs index d964f45f99..cd0347bc7d 100644 --- a/libdd-crashtracker/src/collector_signal_safe/state.rs +++ b/libdd-crashtracker/src/collector_signal_safe/state.rs @@ -207,7 +207,6 @@ pub static HANDLERS_ENABLED: AtomicBool = AtomicBool::new(false); /// 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 force_on_top: AtomicBool, pub only_bootstrap: AtomicBool, pub debug_log: AtomicBool, pub create_alt_stack: AtomicBool, @@ -224,7 +223,6 @@ pub struct Settings { impl Settings { const fn new() -> Self { Self { - force_on_top: AtomicBool::new(false), only_bootstrap: AtomicBool::new(false), debug_log: AtomicBool::new(false), create_alt_stack: AtomicBool::new(false), @@ -233,10 +231,8 @@ impl Settings { 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), - receiver_reap_ms: AtomicI32::new( - config::RECEIVER_TIMEOUT_SECS as i32 * 1000 + config::RECEIVER_TIMEOUT_GRACE_MS, - ), + 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), } } diff --git a/libdd-crashtracker/src/collector_signal_safe/sys.rs b/libdd-crashtracker/src/collector_signal_safe/sys.rs index df65acce00..5d9e1b72a1 100644 --- a/libdd-crashtracker/src/collector_signal_safe/sys.rs +++ b/libdd-crashtracker/src/collector_signal_safe/sys.rs @@ -78,7 +78,7 @@ impl crate::protocol::ByteSink for FdSink { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy)] pub struct Pipe { pub read: i32, pub write: i32, @@ -121,6 +121,10 @@ mod raw_common { } } + #[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); @@ -240,8 +244,8 @@ mod raw { use core::ffi::c_void; 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, + 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) @@ -341,6 +345,10 @@ mod raw { 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; @@ -555,6 +563,11 @@ unsafe fn errno_location() -> *mut i32 { 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() { @@ -608,4 +621,37 @@ mod tests { 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/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index e9f3a6cba8..d7a9847d99 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -86,6 +86,19 @@ fn signal_safe_receiver_deleted_child_process() { 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 { @@ -209,6 +222,35 @@ fn signal_safe_receiver_deleted_after_init_falls_back_to_report_fd() { 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"); From bda1c1b887a023325bbabda32cba3a8d6332aecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Wed, 8 Jul 2026 06:13:33 +0200 Subject: [PATCH 32/32] fix(crashtracker): walk frames via self-read in forked collector child --- .../src/collector_signal_safe/backtrace.rs | 31 +++++++++++++++++++ .../collector_signal_safe/handler/collect.rs | 4 ++- .../tests/collector_signal_safe_e2e.rs | 20 ++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs index eba1dea3e4..4a31a1d90a 100644 --- a/libdd-crashtracker/src/collector_signal_safe/backtrace.rs +++ b/libdd-crashtracker/src/collector_signal_safe/backtrace.rs @@ -112,6 +112,37 @@ mod tests { 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]; diff --git a/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs b/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs index ad67e9552a..30515015ed 100644 --- a/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs +++ b/libdd-crashtracker/src/collector_signal_safe/handler/collect.rs @@ -184,10 +184,12 @@ pub(super) fn emit_report_to_fd(write_fd: i32, event: CrashEvent) -> bool { .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, - event.pid, + sys::getpid(), can_walk, ); let stackwalk_method = if n == 0 { diff --git a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs index d7a9847d99..8a499c141b 100644 --- a/libdd-crashtracker/tests/collector_signal_safe_e2e.rs +++ b/libdd-crashtracker/tests/collector_signal_safe_e2e.rs @@ -146,6 +146,17 @@ fn signal_safe_crash_writes_report_through_receiver() { 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] @@ -318,3 +329,12 @@ fn assert_common_report_shape(report: &str) { 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) +}