diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt index 681f76a1bc..c05b9c9ab2 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt @@ -37,12 +37,21 @@ object ConfigurationPresets { project.logger.lifecycle("Setting up standard build configurations for $currentPlatform-$currentArch") project.logger.lifecycle("Using compiler: $compiler") + // Opt-in compile-time fault injection. When -PenableFaultInjection is + // passed we append -D__FAULT_INJECTION__ to the standard release/debug + // library builds, so the documented buildRelease / buildDebug workflows + // produce a fault-injected libjavaProfiler.so. It is intentionally NOT + // applied to asan/tsan/fuzzer: those configs install their own SIGSEGV + // interception, which conflicts with the deliberately-faulting loads. + val faultInjection = project.hasProperty("enableFaultInjection") extension.buildConfigurations.apply { register("release") { configureRelease(this, currentPlatform, currentArch, version) + if (faultInjection) compilerArgs.add("-D__FAULT_INJECTION__") } register("debug") { configureDebug(this, currentPlatform, currentArch, version) + if (faultInjection) compilerArgs.add("-D__FAULT_INJECTION__") } register("asan") { configureAsan(this, currentPlatform, currentArch, version, rootDir, compiler) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt index 81d5742268..2c8151f62c 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt @@ -257,6 +257,16 @@ class GtestTaskBuilder( // Mark unit-test builds so test-only production APIs are compiled in. args.add("-DUNIT_TEST") + // NOTE: -D__FAULT_INJECTION__ is intentionally NOT added here. It is + // applied per-config in ConfigurationPresets (release/debug only) and we + // start from config.compilerArgs.get() above, so the release/debug gtest + // builds inherit it automatically under -PenableFaultInjection while the + // asan/tsan/fuzzer configs correctly never receive it. Re-deriving it + // from the project property here would be redundant for release/debug + // (they already carry it) and would wrongly leak the deliberately- + // faulting loads into the sanitizer configs, which install their own + // SIGSEGV interception. + return args } diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 34b2e908dd..ffee1c0c4b 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -125,7 +125,49 @@ * paths (delegated and direct) go into SAMPLES_DROPPED_REC_LOCK. */ \ X(JVMTI_STACKS_DROPPED_LOCK, "jvmti_stacks_dropped_lock") \ X(SAMPLES_DROPPED_REC_LOCK, "samples_dropped_rec_lock") \ - X(SAMPLES_DROPPED_THREAD_LOCAL, "samples_dropped_thread_local") + X(SAMPLES_DROPPED_THREAD_LOCAL, "samples_dropped_thread_local") \ + X(SAFECOPY_FAILED, "safecopy_failed") \ + X(SAFEFETCH_FAILED, "safefetch_failed") \ + X(WALKVM_LONGJMP_RECOVERED, "walkvm_longjmp_recovered") \ + DD_COUNTER_TABLE_FAULT_INJECTION(X) \ + DD_COUNTER_TABLE_FI_DEBUG(X) \ + DD_COUNTER_TABLE_DEBUG(X) + +// Fault-injection-only counter: number of faults actually injected. Only +// compiled in when __FAULT_INJECTION__ is defined, so it occupies no enum slot +// and adds no storage in normal builds. +#ifdef __FAULT_INJECTION__ +#define DD_COUNTER_TABLE_FAULT_INJECTION(X) \ + X(FAULTS_INJECTED, "faults_injected") +#else +#define DD_COUNTER_TABLE_FAULT_INJECTION(X) +#endif + +// Fault-injection + debug only: faults injected while the current thread was +// NOT inside a walkVM longjmp-protected region. Such a site relies solely on +// safefetch (or would genuinely crash if the poisoned pointer is raw-dereferenced +// outside any recovery), so a non-zero value flags injection sites that are not +// covered by longjmp protection. Compiled in only when both __FAULT_INJECTION__ +// and DEBUG are defined. +#if defined(__FAULT_INJECTION__) && defined(DEBUG) +#define DD_COUNTER_TABLE_FI_DEBUG(X) \ + X(FAULTS_INJECTED_UNPROTECTED, "faults_injected_unprotected") +#else +#define DD_COUNTER_TABLE_FI_DEBUG(X) +#endif + +// Debug-only counters: SafeAccess reads/copies issued while the thread is +// already inside a walkVM longjmp-protected region (redundant safefetch +// overhead). Not compiled into release builds at all, so they occupy no enum +// slot and add no storage there. +#ifdef DEBUG +#define DD_COUNTER_TABLE_DEBUG(X) \ + X(SAFEFETCH_WHILE_PROTECTED, "safefetch_while_protected") \ + X(SAFECOPY_WHILE_PROTECTED, "safecopy_while_protected") +#else +#define DD_COUNTER_TABLE_DEBUG(X) +#endif + #define X_ENUM(a, b) a, typedef enum CounterId : int { DD_COUNTER_TABLE(X_ENUM) DD_NUM_COUNTERS diff --git a/ddprof-lib/src/main/cpp/faultInjection.cpp b/ddprof-lib/src/main/cpp/faultInjection.cpp new file mode 100644 index 0000000000..1572037063 --- /dev/null +++ b/ddprof-lib/src/main/cpp/faultInjection.cpp @@ -0,0 +1,117 @@ +/* + * Copyright 2026, Datadog, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "faultInjection.h" + +// The whole translation unit is empty unless fault injection is enabled, so a +// normal build links a no-op object file. +#ifdef __FAULT_INJECTION__ + +#include "counters.h" // Counters::increment (FAULTS_INJECTED) +#include "os.h" // OS::page_size +#include "threadLocalData.h" // ProfiledThread::currentSignalSafe / nextFiRandom +#include +#include + +namespace faultinj { + +// Knuth multiplicative constant (== common.h KNUTH_MULTIPLICATIVE_CONSTANT), +// used to hash the function name and to advance the global fallback PRNG. +static constexpr u64 KNUTH = 0x9e3779b97f4a7c15ULL; + +// Word-alignment mask for produced addresses. +static constexpr uintptr_t ALIGN_MASK = ~(uintptr_t)(sizeof(void*) - 1); + +// Guard region: mmapped once at init() with PROT_NONE so any access faults. +// Written only by init() (off the signal path); read-only afterwards. +static std::atomic g_guard_base{0}; +static std::atomic g_guard_span{0}; +static std::atomic g_guard_ok{false}; + +// Fallback PRNG for threads with no ProfiledThread context. Relaxed atomics +// keep it lock-free and async-signal-safe; a lost update on a race is harmless. +static std::atomic g_fallback_rng{KNUTH}; + +void init() { + // Avoid repeated mmaps (tests call init() in each fixture SetUp()). + if (g_guard_ok.load(std::memory_order_acquire)) { + return; + } + + // A handful of pages is plenty of distinct fault addresses; keep it small. + size_t span = 16 * OS::page_size; + void* p = mmap(nullptr, span, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (p == MAP_FAILED) { + return; + } + + g_guard_base.store((uintptr_t)p, std::memory_order_relaxed); + g_guard_span.store(span, std::memory_order_relaxed); + g_guard_ok.store(true, std::memory_order_release); +} + +u64 nextRandom() { + ProfiledThread* t = ProfiledThread::currentSignalSafe(); // never allocates + if (t != nullptr) { + return t->nextFiRandom(); + } + // Fallback: relaxed atomic xorshift64. Not perfectly serialised, but the + // stream only needs to be roughly uniform for injection decisions. + u64 x = g_fallback_rng.load(std::memory_order_relaxed); + if (x == 0) x = 1; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + g_fallback_rng.store(x, std::memory_order_relaxed); + return x; +} + +bool shouldFire(u64 threshold, const char* fn) { + // XOR with a per-function hash is a bijection on 64 bits, so it perturbs the + // stream per call site while keeping the fire probability exactly + // threshold/2^64. + u64 r = nextRandom() ^ ((u64)(uintptr_t)fn * KNUTH); + if (__builtin_expect(r < threshold, 0)) { + // Every address/int/long injection routes through here, so this is the one + // place that counts an actually-injected fault. + Counters::increment(FAULTS_INJECTED); +#ifdef DEBUG + // Flag injections fired at a site with no walkVM longjmp protection active: + // recovery there depends entirely on safefetch, and a raw deref would crash. + ProfiledThread* t = ProfiledThread::currentSignalSafe(); // never allocates + if (t == nullptr || !t->isProtected()) { + Counters::increment(FAULTS_INJECTED_UNPROTECTED); + } +#endif + return true; + } + return false; +} + +uintptr_t poisonAddress() { + u64 r = nextRandom(); + if (g_guard_ok.load(std::memory_order_acquire)) { + // Guaranteed-unmapped guard page — deterministic SIGSEGV. + uintptr_t base = g_guard_base.load(std::memory_order_relaxed); + uintptr_t span = g_guard_span.load(std::memory_order_relaxed); + return (base + (uintptr_t)(r % span)) & ALIGN_MASK; + } + // Fallback: best-effort garbage address if init() failed. + return ((uintptr_t)r | (uintptr_t)0x8000000000000000ULL) & ALIGN_MASK; +} +} // namespace faultinj + +#endif // __FAULT_INJECTION__ diff --git a/ddprof-lib/src/main/cpp/faultInjection.h b/ddprof-lib/src/main/cpp/faultInjection.h new file mode 100644 index 0000000000..f3e5ad1b64 --- /dev/null +++ b/ddprof-lib/src/main/cpp/faultInjection.h @@ -0,0 +1,104 @@ +/* + * Copyright 2026, Datadog, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Compile-time fault-injection layer for the profiler's memory-access sites. +// +// The macros below wrap a pointer or value expression at a real dereference +// site (VMStructs::at, walkVM, walkFP, walkDwarf). When __FAULT_INJECTION__ is +// defined, each wrapped expression, with the tier's probability, is replaced by +// a deliberately bad address (so the load faults and the profiler's recovery +// path — SafeAccess safefetch or walkVM's setjmp/longjmp — is exercised) or a +// random int/long value. When the flag is NOT defined, every macro is a strict +// identity: it expands to exactly the parenthesized original expression, with +// unchanged type and value category and zero runtime cost. +// +// pc = SafeAccess::load(INJECT_FAULT_ADDRESS_LIKELY((void**)fp)); +// VMMethod* m = ((VMMethod**)INJECT_FAULT_ADDRESS_UNLIKELY(fp))[off]; +// +// The three tiers name their firing frequency: RARE 0.01%, UNLIKELY 0.1%, +// LIKELY 1%. See faultInjection.cpp for the poison-address and PRNG details. + +#ifndef _FAULT_INJECTION_H +#define _FAULT_INJECTION_H + +#ifdef __FAULT_INJECTION__ + +#include "arch.h" // u64 +#include + +namespace faultinj { + +// Firing probability expressed as an xorshift64 threshold (round(p * 2^64)), so +// the hot-path check is a single integer compare (rng < threshold) with no +// floating point in the signal handler. +constexpr u64 PROB_RARE = 1844674407370955ULL; // 1e-4 (0.01%) +constexpr u64 PROB_UNLIKELY = 18446744073709552ULL; // 1e-3 (0.1%) +constexpr u64 PROB_LIKELY = 184467440737095520ULL; // 1e-2 (1%) + +// Called once at profiler startup (off the signal path) to mmap the PROT_NONE +// guard region used by poisonAddress(). Safe to call before any injection. +void init(); + +// Draws one per-thread (or global-fallback) PRNG value. Async-signal-safe. +u64 nextRandom(); + +// Returns true with probability threshold/2^64. The function name perturbs the +// draw so distinct call sites get statistically independent decisions. +bool shouldFire(u64 threshold, const char* fn); + +// A word-aligned address intended to fault on access. When init() has reserved the +// mmap'd PROT_NONE guard region, this returns an address inside it (deterministic +// SIGSEGV). If init() failed, it falls back to a best-effort garbage address. +uintptr_t poisonAddress(); + +// Returns ptr unchanged, or a poison address (cast to T) when the tier fires. +// Templated so the wrapped expression's static type (void**, const char*, +// uintptr_t, ...) is preserved exactly. +template +inline T injectAddress(T ptr, u64 threshold, const char* fn) { + if (__builtin_expect(shouldFire(threshold, fn), 0)) { + // C-style cast intentionally: converts the numeric poison address to any + // pointer type or to uintptr_t. This code only ever compiles under the flag. + return (T)poisonAddress(); + } + return ptr; +} +} // namespace faultinj + +#define INJECT_FAULT_ADDRESS_RARE(ptr) \ + ::faultinj::injectAddress((ptr), ::faultinj::PROB_RARE, __func__) +#define INJECT_FAULT_ADDRESS_UNLIKELY(ptr) \ + ::faultinj::injectAddress((ptr), ::faultinj::PROB_UNLIKELY, __func__) +#define INJECT_FAULT_ADDRESS_LIKELY(ptr) \ + ::faultinj::injectAddress((ptr), ::faultinj::PROB_LIKELY, __func__) + +#else // __FAULT_INJECTION__ not defined — strict identity, zero cost. + +#define INJECT_FAULT_ADDRESS_RARE(ptr) (ptr) +#define INJECT_FAULT_ADDRESS_UNLIKELY(ptr) (ptr) +#define INJECT_FAULT_ADDRESS_LIKELY(ptr) (ptr) + +#define INJECT_FAULT_INT_RARE(v) (v) +#define INJECT_FAULT_INT_UNLIKELY(v) (v) +#define INJECT_FAULT_INT_LIKELY(v) (v) + +#define INJECT_FAULT_LONG_RARE(v) (v) +#define INJECT_FAULT_LONG_UNLIKELY(v) (v) +#define INJECT_FAULT_LONG_LIKELY(v) (v) + +#endif // __FAULT_INJECTION__ + +#endif // _FAULT_INJECTION_H diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index d0b6c48eeb..29449114af 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -7,6 +7,7 @@ #include #include #include "asyncSampleMutex.h" +#include "faultInjection.h" #include "frames.h" #include "guards.h" #include "hotspot/hotspotSupport.h" @@ -370,8 +371,9 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex // entry_fp has been range-checked by isValidFP above; any remaining // SIGSEGV from a stale/concurrently-freed pointer is caught by the // setjmp crash protection in walkVM (checkFault -> longjmp). - uintptr_t carrier_fp = *(uintptr_t*)entry_fp; - const void* carrier_pc = ((const void**)entry_fp)[FRAME_PC_SLOT]; + uintptr_t* carrier_fp_addr = (uintptr_t*)INJECT_FAULT_ADDRESS_UNLIKELY(entry_fp); + uintptr_t carrier_fp = *carrier_fp_addr; + const void* carrier_pc = ((const void**)carrier_fp_addr)[FRAME_PC_SLOT]; uintptr_t carrier_sp = entry_fp + (FRAME_PC_SLOT + 1) * sizeof(void*); if (!StackWalkValidation::isValidFP(carrier_fp) || StackWalkValidation::inDeadZone(carrier_pc) || @@ -496,7 +498,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex bool is_plausible_interpreter_frame = StackWalkValidation::isPlausibleInterpreterFrame(fp, sp, bcp_offset); if (is_plausible_interpreter_frame) { - VMMethod* method = ((VMMethod**)fp)[InterpreterFrame::method_offset]; + VMMethod* method = ((VMMethod**)INJECT_FAULT_ADDRESS_UNLIKELY(fp))[InterpreterFrame::method_offset]; jmethodID method_id = getMethodId(method); if (method_id != JMETHODID_NOT_WALKABLE) { Counters::increment(WALKVM_JAVA_FRAME_OK); @@ -506,7 +508,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex fillFrame(frames[depth++], FRAME_INTERPRETED, bci, method_id, method); sp = ((uintptr_t*)fp)[InterpreterFrame::sender_sp_offset]; pc = stripPointer(((void**)fp)[FRAME_PC_SLOT]); - fp = *(uintptr_t*)fp; + fp = *(uintptr_t*)INJECT_FAULT_ADDRESS_UNLIKELY(fp); continue; } } @@ -518,9 +520,10 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex Counters::increment(WALKVM_JAVA_FRAME_OK); fillFrame(frames[depth++], FRAME_INTERPRETED, 0, method_id, method); if (is_plausible_interpreter_frame) { - pc = stripPointer(((void**)fp)[FRAME_PC_SLOT]); + uintptr_t* fp_addr = (uintptr_t*)INJECT_FAULT_ADDRESS_UNLIKELY(fp); + pc = stripPointer(((void**)fp_addr)[FRAME_PC_SLOT]); sp = frame.senderSP(); - fp = *(uintptr_t*)fp; + fp = *fp_addr; } else { pc = stripPointer(SafeAccess::load((void**)sp)); sp = frame.senderSP(); @@ -594,7 +597,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex fillFrame(frames[depth++], BCI_ERROR, "break_misaligned_sp"); break; } - + sp = (uintptr_t)INJECT_FAULT_ADDRESS_UNLIKELY(sp); fp = ((uintptr_t*)sp)[-FRAME_PC_SLOT - 1]; pc = ((const void**)sp)[-FRAME_PC_SLOT]; continue; @@ -737,7 +740,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex // In HotSpot, lastJavaFP is non-zero only for interpreter frames; // compiled frames record FP=0 in the anchor. if (StackWalkValidation::isPlausibleInterpreterFrame(recovery_fp, recovery_sp, bcp_offset)) { - VMMethod* method = ((VMMethod**)recovery_fp)[InterpreterFrame::method_offset]; + VMMethod* method = ((VMMethod**)INJECT_FAULT_ADDRESS_UNLIKELY(recovery_fp))[InterpreterFrame::method_offset]; jmethodID method_id = getMethodId(method); if (method_id != JMETHODID_NOT_WALKABLE) { anchor = NULL; @@ -969,6 +972,7 @@ void HotspotSupport::checkFault(ProfiledThread* thrd) { } thrd->resetCrashHandler(); + Counters::increment(WALKVM_LONGJMP_RECOVERED); longjmp(*thrd->getJmpCtx(), 1); } diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index f8c97af1b7..9ef2586851 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -14,6 +14,7 @@ #include #include "codeCache.h" #include "counters.h" +#include "faultInjection.h" #include "jvmThread.h" #include "safeAccess.h" #include "threadState.h" @@ -451,13 +452,14 @@ class VMStructs { const char* at(int offset) { const char* ptr = (const char*)this + offset; assert(crashProtectionActive() || SafeAccess::isReadable(ptr)); - return ptr; + // Poison only the returned pointer; the assert above sees the real ptr. + return INJECT_FAULT_ADDRESS_RARE(ptr); } const char* at(int offset) const { const char* ptr = (const char*)this + offset; assert(crashProtectionActive() || SafeAccess::isReadable(ptr)); - return ptr; + return INJECT_FAULT_ADDRESS_RARE(ptr); } static bool goodPtr(const void* ptr) { diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 05dc7ea624..8cc69c8714 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -16,6 +16,7 @@ #include "ctimer.h" #include "signalInflight.h" #include "dwarf.h" +#include "faultInjection.h" #include "flightRecorder.h" #include "itimer.h" #include "hotspot/vmStructs.inline.h" @@ -998,6 +999,11 @@ void Profiler::setupSignalHandlers() { // Patch sigaction GOT in libraries with broken signal handlers (already loaded) LibraryPatcher::patch_sigaction(); } +#ifdef __FAULT_INJECTION__ + // Reserve the PROT_NONE guard region used to poison memory-access sites. + // Done here (off the signal path) once handlers are installed. + faultinj::init(); +#endif } } diff --git a/ddprof-lib/src/main/cpp/safeAccess.cpp b/ddprof-lib/src/main/cpp/safeAccess.cpp index f423af9d9c..af6d47d572 100644 --- a/ddprof-lib/src/main/cpp/safeAccess.cpp +++ b/ddprof-lib/src/main/cpp/safeAccess.cpp @@ -16,10 +16,15 @@ #include "safeAccess.h" +#include "counters.h" + #include #include #include #include +#ifdef DEBUG +#include "threadLocalData.h" // ProfiledThread::currentSignalSafe / isProtected +#endif extern "C" int safefetch32_cont(int* adr, int errValue); extern "C" int64_t safefetch64_cont(int64_t* adr, int64_t errValue); @@ -264,7 +269,20 @@ static void verify_safecopy_range() { #endif #endif +#ifdef DEBUG +void SafeAccess::countIfLongjmpProtected(bool isCopy) { + ProfiledThread* t = ProfiledThread::currentSignalSafe(); // never allocates + if (t != nullptr && t->isProtected()) { + Counters::increment(isCopy ? SAFECOPY_WHILE_PROTECTED + : SAFEFETCH_WHILE_PROTECTED); + } +} +#endif + bool SafeAccess::safeCopy(void* dst, const void* src, size_t len) { +#ifdef DEBUG + countIfLongjmpProtected(true); +#endif // The copy runs entirely inside the safecopy_impl assembly stub, which // reads `src` one byte at a time. If a load faults, handle_safefetch // redirects execution to safecopy_cont, which returns 0. Because the copy @@ -276,18 +294,25 @@ bool SafeAccess::safeCopy(void* dst, const void* src, size_t len) { bool SafeAccess::handle_safefetch(int sig, void* context) { ucontext_t* uc = (ucontext_t*)context; + if (uc == nullptr) { + return false; + } + uintptr_t pc = uc->current_pc; - if ((sig == SIGSEGV || sig == SIGBUS) && uc != nullptr) { + if (sig == SIGSEGV || sig == SIGBUS) { if (pc == (uintptr_t)safefetch32_impl) { uc->current_pc = (uintptr_t)safefetch32_cont; + Counters::increment(SAFEFETCH_FAILED); return true; } else if (pc == (uintptr_t)safefetch64_impl) { uc->current_pc = (uintptr_t)safefetch64_cont; + Counters::increment(SAFEFETCH_FAILED); return true; } else if (pc >= (uintptr_t)safecopy_impl && pc < (uintptr_t)safecopy_cont) { // Unlike safefetch, the faulting load can be at any pc inside the copy // loop, so match the whole [safecopy_impl, safecopy_cont) range. uc->current_pc = (uintptr_t)safecopy_cont; + Counters::increment(SAFECOPY_FAILED); return true; } } @@ -301,11 +326,17 @@ void* SafeAccess::load(void** ptr, void* default_value) { } int32_t SafeAccess::load32(int32_t* ptr, int32_t default_value) { +#ifdef DEBUG + countIfLongjmpProtected(false); +#endif int res = safefetch32_impl((int*)ptr, (int)default_value); return static_cast(res); } void* SafeAccess::loadPtr(void** ptr, void* default_value) { +#ifdef DEBUG + countIfLongjmpProtected(false); +#endif #if defined(__x86_64__) || defined(__aarch64__) int64_t res = safefetch64_impl((int64_t*)ptr, (int64_t)reinterpret_cast(default_value)); return (void*)static_cast(res); diff --git a/ddprof-lib/src/main/cpp/safeAccess.h b/ddprof-lib/src/main/cpp/safeAccess.h index 14a009aad3..7d78f2cc61 100644 --- a/ddprof-lib/src/main/cpp/safeAccess.h +++ b/ddprof-lib/src/main/cpp/safeAccess.h @@ -52,6 +52,9 @@ class SafeAccess { */ NOINLINE static int safeFetch32(int* ptr, int errorValue) { +#ifdef DEBUG + countIfLongjmpProtected(false); +#endif return safefetch32_impl(ptr, errorValue); } @@ -62,6 +65,9 @@ class SafeAccess { */ NOINLINE static int64_t safeFetch64(int64_t* ptr, int64_t errorValue) { +#ifdef DEBUG + countIfLongjmpProtected(false); +#endif return safefetch64_impl(ptr, errorValue); } @@ -109,6 +115,17 @@ class SafeAccess { } return isReadable(end_page); } + +#ifdef DEBUG +private: + // Debug diagnostic: bump a counter when a SafeAccess read/copy is issued while + // the current thread is already inside a walkVM longjmp-protected region, where + // the safefetch/safecopy overhead is redundant (a fault there is caught by the + // longjmp anyway). Defined out-of-line in safeAccess.cpp so this widely-included + // header need not pull in threadLocalData.h / counters.h. isCopy selects the + // SAFECOPY_WHILE_PROTECTED vs SAFEFETCH_WHILE_PROTECTED counter. + static void countIfLongjmpProtected(bool isCopy); +#endif }; #endif // _SAFEACCESS_H diff --git a/ddprof-lib/src/main/cpp/stackWalker.cpp b/ddprof-lib/src/main/cpp/stackWalker.cpp index 68be876432..48255415fe 100644 --- a/ddprof-lib/src/main/cpp/stackWalker.cpp +++ b/ddprof-lib/src/main/cpp/stackWalker.cpp @@ -7,6 +7,7 @@ #include #include "stackWalker.inline.h" #include "dwarf.h" +#include "faultInjection.h" #include "profiler.h" #include "stackFrame.h" #include "symbols.h" @@ -60,13 +61,13 @@ int StackWalker::walkFP(void* ucontext, const void** callchain, int max_depth, S break; } - pc = stripPointer(SafeAccess::load((void**)fp + FRAME_PC_SLOT)); + pc = stripPointer(SafeAccess::load(INJECT_FAULT_ADDRESS_LIKELY((void**)fp + FRAME_PC_SLOT))); if (inDeadZone(pc)) { break; } sp = fp + (FRAME_PC_SLOT + 1) * sizeof(void*); - fp = (uintptr_t)SafeAccess::load((void**)fp); + fp = (uintptr_t)SafeAccess::load(INJECT_FAULT_ADDRESS_LIKELY((void**)fp)); } if (truncated && depth > max_depth) { @@ -145,7 +146,7 @@ int StackWalker::walkDwarf(void* ucontext, const void** callchain, int max_depth if (!aligned(fp_addr)) { break; } - fp = (uintptr_t)SafeAccess::load((void**)fp_addr); + fp = (uintptr_t)SafeAccess::load(INJECT_FAULT_ADDRESS_LIKELY((void**)fp_addr)); } if (EMPTY_FRAME_SIZE > 0 || f.pc_off != DW_LINK_REGISTER) { @@ -153,7 +154,7 @@ int StackWalker::walkDwarf(void* ucontext, const void** callchain, int max_depth if (!aligned(pc_addr)) { break; } - pc = stripPointer(SafeAccess::load((void**)pc_addr)); + pc = stripPointer(SafeAccess::load(INJECT_FAULT_ADDRESS_LIKELY((void**)pc_addr))); } else if (depth == 1) { pc = (const void*)frame.link(); } else { diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index e8b9a30c46..26e7af39d7 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -85,6 +85,11 @@ class ProfiledThread : public ThreadLocalData { uint8_t _signal_depth; // Nested signal-handler depth (see SignalHandlerScope) UnwindFailures _unwind_failures; bool _otel_ctx_initialized; +#ifdef __FAULT_INJECTION__ + // xorshift64 PRNG state for compile-time fault injection (faultInjection.h). + // Per-thread, so the signal-path draw needs no lock/atomic; must never be 0. + u64 _fi_rng; +#endif // alignas(8) + sizeof(OtelThreadContextRecord)==640 (multiple of 8) guarantee // _otel_tag_encodings sits at +640 with no padding, so the three fields form one // 688-byte contiguous region exposed as a combined DirectByteBuffer. @@ -103,7 +108,15 @@ class ProfiledThread : public ThreadLocalData { _park_block_token(0), _filter_slot_id(-1), _init_window(0), _signal_depth(0), _otel_ctx_initialized(false), - _otel_ctx_record{}, _otel_tag_encodings{}, _otel_local_root_span_id(0) {}; + _otel_ctx_record{}, _otel_tag_encodings{}, _otel_local_root_span_id(0) { +#ifdef __FAULT_INJECTION__ + // Seed like PoissonSampler: instance address XOR a hash of the tid, forced + // non-zero (0 is a fixed point of xorshift64). 0x9e37... is the Knuth + // multiplicative constant (see common.h KNUTH_MULTIPLICATIVE_CONSTANT). + _fi_rng = ((u64)(uintptr_t)this) ^ (0x9e3779b97f4a7c15ULL * (u64)tid); + if (_fi_rng == 0) _fi_rng = 1; +#endif + }; virtual ~ProfiledThread() { } public: @@ -224,6 +237,19 @@ class ProfiledThread : public ThreadLocalData { inline void enterSignalScope() { ++_signal_depth; } inline void exitSignalScope() { if (_signal_depth > 0) --_signal_depth; } +#ifdef __FAULT_INJECTION__ + // One xorshift64 step (Marsaglia 2003), matching PoissonSampler::nextExp. + // Plain member r/w is AS-safe: signals are delivered to the owning thread. + inline u64 nextFiRandom() { + _fi_rng ^= _fi_rng << 13; + _fi_rng ^= _fi_rng >> 7; + _fi_rng ^= _fi_rng << 17; + return _fi_rng; + } + // Test hook: force a deterministic PRNG stream for rate/recovery assertions. + inline void setFiRng(u64 seed) { _fi_rng = seed ? seed : 1; } +#endif + UnwindFailures* unwindFailures(bool reset = true) { if (reset) { _unwind_failures.clear(); diff --git a/ddprof-lib/src/test/cpp/faultInjection_ut.cpp b/ddprof-lib/src/test/cpp/faultInjection_ut.cpp new file mode 100644 index 0000000000..6059163b73 --- /dev/null +++ b/ddprof-lib/src/test/cpp/faultInjection_ut.cpp @@ -0,0 +1,183 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include +#include + +#include "faultInjection.h" +#include "safeAccess.h" +#include "os.h" +#include "threadLocalData.h" +#include "hotspot/hotspotSupport.h" +#include "../../main/cpp/gtest_crash_handler.h" + +static constexpr char FAULT_INJECTION_TEST_NAME[] = "FaultInjectionTest"; + +// --------------------------------------------------------------------------- +// (a) Disabled build: every macro must be a strict identity. Compiled only when +// the flag is absent (the default gtest / CI build). This is the guard that +// protects production from any behavioural change. +// --------------------------------------------------------------------------- +#ifndef __FAULT_INJECTION__ + +TEST(FaultInjectionTest, DisabledAddressMacrosAreIdentity) { + void* p = reinterpret_cast(0xCAFEBABE); + const char* cp = reinterpret_cast(0x1234); + uintptr_t up = 0xDEAD; + EXPECT_EQ(INJECT_FAULT_ADDRESS_RARE(p), p); + EXPECT_EQ(INJECT_FAULT_ADDRESS_UNLIKELY(cp), cp); + EXPECT_EQ(INJECT_FAULT_ADDRESS_LIKELY(up), up); + // Type is preserved: assigning back to the original type must compile. + void* p2 = INJECT_FAULT_ADDRESS_LIKELY(p); + EXPECT_EQ(p2, p); +} + +TEST(FaultInjectionTest, DisabledValueMacrosAreIdentity) { + int32_t i = 42; + int64_t l = 0x1122334455667788LL; + EXPECT_EQ(INJECT_FAULT_INT_RARE(i), i); + EXPECT_EQ(INJECT_FAULT_INT_UNLIKELY(i), i); + EXPECT_EQ(INJECT_FAULT_INT_LIKELY(i), i); + EXPECT_EQ(INJECT_FAULT_LONG_RARE(l), l); + EXPECT_EQ(INJECT_FAULT_LONG_UNLIKELY(l), l); + EXPECT_EQ(INJECT_FAULT_LONG_LIKELY(l), l); +} + +#else // __FAULT_INJECTION__ enabled (built under -PenableFaultInjection) + +// Chain: safefetch recovery first, then walkVM setjmp/longjmp recovery, then +// the crash handler as a last resort so a genuine bug still produces a report. +static void (*orig_segvHandler)(int, siginfo_t*, void*); +static void (*orig_busHandler)(int, siginfo_t*, void*); + +static void fi_signal_wrapper(int signo, siginfo_t* siginfo, void* context) { + if (SafeAccess::handle_safefetch(signo, context)) { + return; // safefetch load recovered; PC already rewritten to _cont. + } + HotspotSupport::checkFault(ProfiledThread::currentSignalSafe()); // longjmp if protected + // Not protected and not a safefetch fault — real crash. + if (signo == SIGBUS && orig_busHandler != nullptr) { + orig_busHandler(signo, siginfo, context); + } else if (signo == SIGSEGV && orig_segvHandler != nullptr) { + orig_segvHandler(signo, siginfo, context); + } else { + gtestCrashHandler(signo, siginfo, context, FAULT_INJECTION_TEST_NAME); + } +} + +class FaultInjectionTest : public ::testing::Test { +protected: + void SetUp() override { + ProfiledThread::initCurrentThread(); + faultinj::init(); + orig_segvHandler = OS::replaceSigsegvHandler(fi_signal_wrapper); + orig_busHandler = OS::replaceSigbusHandler(fi_signal_wrapper); + } + void TearDown() override { + OS::replaceSigsegvHandler(orig_segvHandler); + OS::replaceSigbusHandler(orig_busHandler); + ProfiledThread::release(); + } +}; + +// (b) The empirical firing rate is within a wide band of the nominal tier rate. +// Wide bounds ([0.3x, 3x]) + a fixed seed keep it deterministic and non-flaky. +static void expectRateInBand(u64 threshold, size_t n, double nominal) { + ProfiledThread::currentSignalSafe()->setFiRng(0x0123456789ABCDEFULL); + size_t fires = 0; + for (size_t i = 0; i < n; i++) { + if (faultinj::shouldFire(threshold, "rateProbe")) { + fires++; + } + } + double expected = nominal * (double)n; + EXPECT_GT((double)fires, 0.3 * expected) + << "fired " << fires << " of " << n << ", expected ~" << expected; + EXPECT_LT((double)fires, 3.0 * expected) + << "fired " << fires << " of " << n << ", expected ~" << expected; +} + +TEST_F(FaultInjectionTest, LikelyTierRate) { + expectRateInBand(faultinj::PROB_LIKELY, 200000, 1e-2); // ~2000 fires +} +TEST_F(FaultInjectionTest, UnlikelyTierRate) { + expectRateInBand(faultinj::PROB_UNLIKELY, 2000000, 1e-3); // ~2000 fires +} +TEST_F(FaultInjectionTest, RareTierRate) { + expectRateInBand(faultinj::PROB_RARE, 20000000, 1e-4); // ~2000 fires +} + +// poisonAddress() must always yield a fault-inducing, word-aligned address. +TEST_F(FaultInjectionTest, PoisonAddressIsAlignedAndUnmapped) { + ProfiledThread::currentSignalSafe()->setFiRng(0xF00DF00DF00DF00DULL); + for (int i = 0; i < 1000; i++) { + uintptr_t bad = faultinj::poisonAddress(); + EXPECT_EQ(bad & (sizeof(void*) - 1), 0u) << "poison address not word-aligned"; + // Reading it via SafeAccess must fault-and-recover, never return readable data. + void* got = SafeAccess::load(reinterpret_cast(bad), (void*)-1); + EXPECT_EQ(got, (void*)-1) << "poison address was unexpectedly readable"; + } +} + +// (c1) SafeAccess path: injecting a poison pointer into SafeAccess::load must be +// absorbed by the safefetch handler — it returns the default and never crashes. +TEST_F(FaultInjectionTest, SafeAccessRecoversFromInjectedFault) { + void* real = reinterpret_cast(0xABCDEF00); + void** valid = ℜ + ProfiledThread::currentSignalSafe()->setFiRng(0xBEEFCAFEBEEFCAFEULL); + size_t fired = 0; + for (int i = 0; i < 200000; i++) { + // LIKELY tier: some iterations poison the address, others read `valid`. + void* got = SafeAccess::load(INJECT_FAULT_ADDRESS_LIKELY(valid), nullptr); + ASSERT_TRUE(got == real || got == nullptr) << "unexpected value " << got; + if (got == nullptr) { + fired++; // a poison address was injected and safefetch recovered. + } + } + EXPECT_GT(fired, 0u) << "expected at least one injected fault to be recovered"; +} + +// (c2) walkVM path: a raw dereference of an injected poison pointer must be +// caught by the setjmp/longjmp crash protection, returning control to setjmp. +TEST_F(FaultInjectionTest, WalkVmSetjmpRecoversFromInjectedFault) { + ProfiledThread* t = ProfiledThread::currentSignalSafe(); + ASSERT_NE(t, nullptr); + + uintptr_t real_slot = 0; // a valid, readable pointer slot + uintptr_t base = (uintptr_t)&real_slot; + volatile bool recovered = false; + volatile size_t reads = 0; + volatile size_t faults = 0; + + jmp_buf ctx; + if (setjmp(ctx) != 0) { + recovered = true; // returned here via checkFault -> longjmp + faults++; + } + t->setJmpCtx(&ctx); + t->setFiRng(0xD00DFEEDD00DFEEDULL); + + // Force at least one fire deterministically, then let the tier drive the rest. + for (int i = 0; i < 5000 && faults == 0; i++) { + // Raw deref of the (possibly poisoned) base — mirrors walkVM's raw reads. + uintptr_t v = *(uintptr_t*)INJECT_FAULT_ADDRESS_LIKELY(base); + (void)v; + reads++; + } + t->setJmpCtx(nullptr); + + // We should have observed a recovered fault, or the loop completed cleanly. The + // essential assertion is that the process did not die and, when a fault was + // injected, setjmp regained control. + EXPECT_GT(faults, 0u) << "expected at least one injected fault to longjmp-recover"; + EXPECT_TRUE(recovered); + SUCCEED(); +} + +#endif // __FAULT_INJECTION__