From e83109662947d9a08c3e1edfff179280bfc37495 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 22 Oct 2026 17:03:25 +0200 Subject: [PATCH 1/6] fix(profiler): recover carrier frames without preserved frame pointers --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 43 +++++--- ddprof-lib/src/main/cpp/hotspot/vmStructs.cpp | 10 +- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 38 ++++++- .../src/main/cpp/hotspot/vmStructs.inline.h | 7 ++ .../test/cpp/continuationCarrierFrame_ut.cpp | 100 ++++++++++++++++++ .../FrozenVirtualThreadStackTest.java | 71 +++++++++++-- 6 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 26e3d55aca..3ea6a0510d 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -390,6 +390,8 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex // Last ContinuationEntry crossed; advanced via parent() for nested continuations. VMContinuationEntry* cont_entry = nullptr; + int continuation_stack_args = -1; + VMNMethod* continuation_entry_nm = VMStructs::enterSpecialNMethod(); // Saved anchor data — preserved across anchor consumption so inline // recovery can redirect even after the anchor pointer has been set to NULL. @@ -459,18 +461,21 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex } entry_fp = cont_entry->entryFP(); } else { - // ContinuationEntry absent from vmStructs (JDK 21-26). - // Derive the enterSpecial frame FP from the current fp: - // all frames thawed (pc == cont_entry_return_pc): fp IS the - // enterSpecial frame FP. - // frozen frames remain (pc == cont_returnBarrier): the saved - // caller FP at *fp leads to the enterSpecial frame on the - // carrier stack. - // Nested continuation tracking is unavailable without type_size(). - entry_fp = all_frames_thawed ? fp : (uintptr_t)SafeAccess::load((void**)fp); + VMNMethod* entry_nm = continuation_entry_nm; + bool known_boundary = VMStructs::isContReturnBarrier(walk_pc.raw()) + || VMStructs::isContEntryReturnPc(walk_pc.raw()) + || (entry_nm != nullptr && CodeHeap::findNMethod(walk_pc.raw()) == entry_nm); + if (entry_nm != nullptr && known_boundary) { + // Fast thaw does not repair FP links. Recover the entry from SP, + // stack-argument padding and enterSpecial's generated frame size. + entry_fp = entry_nm->continuationEntryFP(sp, all_frames_thawed ? 0 : continuation_stack_args); + } else { + entry_fp = all_frames_thawed ? fp : 0; + } } - if (!StackWalkValidation::isValidFP(entry_fp)) { + if (!StackWalkValidation::isValidFP(entry_fp) || entry_fp < sp || + entry_fp >= bottom || bottom - entry_fp <= (FRAME_PC_SLOT + 1) * sizeof(void*)) { fillFrame(frames[depth++], BCI_ERROR, "break_cont_entry_fp"); return false; } @@ -481,7 +486,14 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex 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) || + // Compiled carrier code can use FP as a general-purpose register. + // Its sender is recovered from nmethod frame metadata instead. + bool valid_carrier_fp = StackWalkValidation::isValidFP(carrier_fp); + if (!valid_carrier_fp) { + VMNMethod* carrier_nm = CodeHeap::findNMethod(carrier_pc); + valid_carrier_fp = carrier_nm != nullptr && carrier_nm->isNMethod(); + } + if (!valid_carrier_fp || StackWalkValidation::inDeadZone(carrier_pc) || !StackWalkValidation::isValidSP(carrier_sp, sp, bottom)) { fillFrame(frames[depth++], BCI_ERROR, "break_cont_carrier_sp"); @@ -621,6 +633,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex sp = ((uintptr_t*)fp)[InterpreterFrame::sender_sp_offset]; walk_pc.setReturnAddress(stripPointer(((void**)fp)[FRAME_PC_SLOT])); fp = *(uintptr_t*)INJECT_FAULT_ADDRESS_UNLIKELY(fp); + continuation_stack_args = 0; continue; } } @@ -652,7 +665,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex // continuation entry stub on JDK 27+. It has no JavaCallWrapper, so // isEntryFrame() will not fire for it. Detect it by identity // and navigate to the carrier thread via ContinuationEntry. - if (!CONT_UNWIND_DISABLED && nm == VMStructs::enterSpecialNMethod()) { + if (!CONT_UNWIND_DISABLED && nm == continuation_entry_nm) { Counters::increment(WALKVM_ENTER_SPECIAL_HIT); if (walkThroughContinuation(true)) continue; break; @@ -712,6 +725,12 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex break; } sp = (uintptr_t)INJECT_FAULT_ADDRESS_UNLIKELY(sp); + if (features.carrier_frames && VMContinuationEntry::type_size() == 0) { + VMConstMethod* const_method = method->constMethod_or_null(); + continuation_stack_args = const_method != nullptr ? const_method->stackArgWords() : -1; + // nmethod sets its stack-argument count to zero for OSR entries. + if (continuation_stack_args > 0 && nm->isOsr()) continuation_stack_args = 0; + } fp = ((uintptr_t*)sp)[-FRAME_PC_SLOT - 1]; // Saved return address of the caller frame. walk_pc.setReturnAddress(((const void**)sp)[-FRAME_PC_SLOT]); diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.cpp b/ddprof-lib/src/main/cpp/hotspot/vmStructs.cpp index 0a04e46343..0082acd74d 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.cpp @@ -420,7 +420,10 @@ void VMStructs::resolveOffsets() { } if (_cont_entry_return_pc == nullptr && VM::hotspot_version() >= 21) { const void** sym = (const void**)_libjvm->findSymbol("_ZN17ContinuationEntry10_return_pcE"); - if (sym != nullptr) _cont_entry_return_pc = *sym; + if (sym != nullptr) { + _cont_entry_return_pc_addr = sym; + _cont_entry_return_pc = *sym; + } } // Since JDK 23, _metadata_offset is relative to _data_offset. See metadata() @@ -472,11 +475,6 @@ void VMStructs::resolveOffsets() { _interpreter_nm = CodeHeap::findNMethod(_interpreter_start); } if (_enter_special_nm == NULL && _cont_entry_return_pc != NULL) { - // On JDK 27+, enterSpecial is a proper nmethod; findNMethod succeeds. - // On JDK 21-26, it is a RuntimeBlob; findNMethod returns NULL and - // _enter_special_nm stays NULL. The cont_entry_return_pc boundary is - // then detected via isContEntryReturnPc() in the walk loop rather than - // nmethod identity. _enter_special_nm = CodeHeap::findNMethod(_cont_entry_return_pc); } } diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index e3ed7e53ea..5230580327 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -202,6 +202,7 @@ typedef void* address; field_with_version(_nmethod_entry_address, offset, 8, 22, MATCH_SYMBOLS("_verified_entry_point")) \ field(_nmethod_state_offset, offset, MATCH_SYMBOLS("_state")) \ field(_nmethod_level_offset, offset, MATCH_SYMBOLS("_comp_level")) \ + field_with_version(_nmethod_entry_bci_offset, offset, 21, MAX_VERSION, MATCH_SYMBOLS("_entry_bci")) \ field_with_version(_nmethod_metadata_offset, offset, MIN_VERSION, 24, MATCH_SYMBOLS("_metadata_offset")) \ field_with_version(_nmethod_immutable_offset, offset, 23, MAX_VERSION, MATCH_SYMBOLS("_immutable_data")) \ field(_scopes_pcs_offset, offset, MATCH_SYMBOLS("_scopes_pcs_offset")) \ @@ -219,6 +220,7 @@ typedef void* address; field(_constmethod_sig_index_offset, offset, MATCH_SYMBOLS("_signature_index")) \ field(_constmethod_flags_offset, offset, MATCH_SYMBOLS("_flags", "_flags._flags")) \ field(_constmethod_code_size_offset, offset, MATCH_SYMBOLS("_code_size")) \ + field_with_version(_constmethod_stack_arg_slots_offset, offset, 21, MAX_VERSION, MATCH_SYMBOLS("_num_stack_arg_slots")) \ type_end() \ type_begin(VMConstantPool, MATCH_SYMBOLS("ConstantPool")) \ field(_pool_holder_offset, offset, MATCH_SYMBOLS("_pool_holder")) \ @@ -525,14 +527,18 @@ class VMStructs { // True when the bottom VT frame's return PC is cont_entry_return_pc, meaning all // VT frames are thawed (CPU-bound VT that never yielded). // Available on JDK 21+ via vmStructs or symbol fallback. - static bool isContEntryReturnPc(const void* pc) { - return _cont_entry_return_pc != nullptr && pc == _cont_entry_return_pc; + static const void* contEntryReturnPc() { + return _cont_entry_return_pc_addr != nullptr + ? __atomic_load_n((const void**)_cont_entry_return_pc_addr, __ATOMIC_ACQUIRE) : _cont_entry_return_pc; } - static VMNMethod* enterSpecialNMethod() { - return _enter_special_nm; + static bool isContEntryReturnPc(const void* pc) { + const void* return_pc = contEntryReturnPc(); + return return_pc != nullptr && pc == return_pc; } + static VMNMethod* enterSpecialNMethod(); + // Datadog-specific extensions static bool isSafeToWalk(uintptr_t pc); static void JNICALL NativeMethodBind(jvmtiEnv *jvmti, JNIEnv *jni, @@ -981,6 +987,12 @@ DECLARE(VMConstantPool) DECLARE_END DECLARE(VMConstMethod) + // ConstMethod stores 4-byte VMReg slots; compiled frames round to whole machine words. + int stackArgWords() const { + if (_constmethod_stack_arg_slots_offset < 0) return -1; + unsigned short slots = *(const unsigned short*)at(_constmethod_stack_arg_slots_offset); + return (slots + 1) / 2; + } inline VMConstantPool* constants_or_null() const; inline VMSymbol* name() const; inline VMSymbol* signature() const; @@ -1033,6 +1045,24 @@ static inline bool startsWith(const char* s, const char (&pattern)[N]) { } DECLARE(VMNMethod) + bool isOsr() const { + assert(_nmethod_entry_bci_offset >= 0); + return *(const int*)at(_nmethod_entry_bci_offset) != -1; + } + + // ContinuationEntry::bottom_sender_sp() subtracts stack arguments and aligns down + // to 16 bytes. enterSpecial's frame size includes its saved FP and return PC. + uintptr_t continuationEntryFP(uintptr_t sp, int stack_args) { + int frame_size = frameSize(); + if (frame_size < 4 || frame_size > 256 || (frame_size & 1) != 0 || + stack_args < 0 || stack_args > 128 || (sp & 15) != 0) { + return 0; + } + uintptr_t words = ((stack_args + 1) & ~1) + frame_size - 2; + if (sp > UINTPTR_MAX - words * sizeof(uintptr_t)) return 0; + return sp + words * sizeof(uintptr_t); + } + int size() { assert(_blob_size_offset >= 0); return *(int*) at(_blob_size_offset); diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h index e24ba1169c..cfd0fc2eab 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h @@ -21,6 +21,13 @@ inline bool crashProtectionActive() { return false; } +inline VMNMethod* VMStructs::enterSpecialNMethod() { + if (_enter_special_nm != nullptr) return _enter_special_nm; + // The native wrapper can be generated after profiler initialization. + const void* pc = contEntryReturnPc(); + return pc != nullptr ? CodeHeap::findNMethod(pc) : nullptr; +} + template inline T* cast_to(const void* ptr) { assert(VM::isHotspot()); // This should only be used in HotSpot-specific code diff --git a/ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp b/ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp new file mode 100644 index 0000000000..b85d9672fb --- /dev/null +++ b/ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp @@ -0,0 +1,100 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include "hotspot/vmStructs.inline.h" + +class VMStructsTestAccessor { + int _frame_size = VMStructs::_frame_size_offset; + int _stack_args = VMStructs::_constmethod_stack_arg_slots_offset; + const void* _return_pc = VMStructs::_cont_entry_return_pc; + address _return_pc_addr = VMStructs::_cont_entry_return_pc_addr; + +public: + VMStructsTestAccessor() { + VMStructs::_frame_size_offset = 0; + VMStructs::_constmethod_stack_arg_slots_offset = 0; + VMStructs::_cont_entry_return_pc = nullptr; + VMStructs::_cont_entry_return_pc_addr = nullptr; + } + + ~VMStructsTestAccessor() { + VMStructs::_frame_size_offset = _frame_size; + VMStructs::_constmethod_stack_arg_slots_offset = _stack_args; + VMStructs::_cont_entry_return_pc = _return_pc; + VMStructs::_cont_entry_return_pc_addr = _return_pc_addr; + } + + static void setReturnPcAddress(const void** value) { VMStructs::_cont_entry_return_pc_addr = value; } + static void clearStackArgOffset() { VMStructs::_constmethod_stack_arg_slots_offset = -1; } +}; + +class ContinuationCarrierFrameTest : public ::testing::Test { +protected: + VMStructsTestAccessor _layout; + int _frame_size = 10; + alignas(16) uintptr_t _stack[64] = {}; + + VMNMethod* entryMethod() { return VMNMethod::cast_raw(&_frame_size); } +}; + +TEST_F(ContinuationCarrierFrameTest, RecoversSavedCarrierSlotsWithoutAFramePointerChain) { + _stack[8] = (uintptr_t)&_stack[32]; + _stack[9] = (uintptr_t)&_stack[40]; + // The thawed frame's saved FP need not point to the entry frame. + _stack[0] = 0; + uintptr_t entry_fp = entryMethod()->continuationEntryFP((uintptr_t)_stack, 0); + ASSERT_EQ((uintptr_t)&_stack[8], entry_fp); + EXPECT_EQ((uintptr_t)&_stack[32], *(uintptr_t*)entry_fp); + EXPECT_EQ((uintptr_t)&_stack[40], ((uintptr_t*)entry_fp)[1]); +} + +TEST_F(ContinuationCarrierFrameTest, AccountsForStackArgumentsAndAlignment) { + _stack[12] = (uintptr_t)&_stack[32]; + _stack[13] = (uintptr_t)&_stack[40]; + // Three argument words require four words below the aligned entry SP. + uintptr_t entry_fp = entryMethod()->continuationEntryFP((uintptr_t)_stack, 3); + ASSERT_EQ((uintptr_t)&_stack[12], entry_fp); + EXPECT_EQ((uintptr_t)&_stack[32], *(uintptr_t*)entry_fp); + EXPECT_EQ((uintptr_t)&_stack[40], ((uintptr_t*)entry_fp)[1]); +} + +TEST_F(ContinuationCarrierFrameTest, ConvertsExportedArgumentSlotsToWords) { + for (unsigned short slots : {0, 1, 2, 3, 17}) { + VMConstMethod* method = VMConstMethod::cast_raw(&slots); + EXPECT_EQ((slots + 1) / 2, method->stackArgWords()); + } + VMStructsTestAccessor::clearStackArgOffset(); + EXPECT_EQ(-1, VMConstMethod::cast_raw(_stack)->stackArgWords()); +} + +TEST_F(ContinuationCarrierFrameTest, RejectsUnknownOrInvalidArgumentCounts) { + for (int args : {-1, 129, INT_MAX}) { + EXPECT_EQ(0u, entryMethod()->continuationEntryFP((uintptr_t)_stack, args)); + } +} + +TEST_F(ContinuationCarrierFrameTest, RejectsInvalidEntryFrameSizes) { + for (int size : {-1, 0, 1, 2, 3, 11, 257, INT_MAX}) { + _frame_size = size; + EXPECT_EQ(0u, entryMethod()->continuationEntryFP((uintptr_t)_stack, 0)); + } +} + +TEST_F(ContinuationCarrierFrameTest, RejectsMisalignmentAndAddressOverflow) { + EXPECT_EQ(0u, entryMethod()->continuationEntryFP((uintptr_t)_stack + sizeof(uintptr_t), 0)); + EXPECT_EQ(0u, entryMethod()->continuationEntryFP(UINTPTR_MAX & ~(uintptr_t)15, 0)); +} + +TEST_F(ContinuationCarrierFrameTest, ObservesEntryWrapperGeneratedAfterInitialization) { + const void* return_pc = nullptr; + const void* expected_pc = &_stack[9]; + VMStructsTestAccessor::setReturnPcAddress(&return_pc); + EXPECT_FALSE(VMStructs::isContEntryReturnPc(expected_pc)); + return_pc = expected_pc; + EXPECT_TRUE(VMStructs::isContEntryReturnPc(expected_pc)); + EXPECT_FALSE(VMStructs::isContEntryReturnPc(&_stack[10])); +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/FrozenVirtualThreadStackTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/FrozenVirtualThreadStackTest.java index cd4a68b0f8..52af406e97 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/FrozenVirtualThreadStackTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/FrozenVirtualThreadStackTest.java @@ -63,6 +63,40 @@ public void marksSkippedFrozenCallersTruncated() throws Exception { assertEquals(0, counts[1], "Samples omitted a JVM-confirmed caller but reported a complete stack"); } + /** A return barrier must retain carrier ancestry even while older Java callers remain frozen. */ + @TestTemplate + @ValueSource(strings = {"vmx"}) + public void keepsCarrierFramesAfterPartialThaw() throws Exception { + runWorkload(true, true); + assertCarrierFramesAfterPartialThaw("recursiveWorkload"); + } + + /** Stack-passed arguments extend and align the continuation entry frame during thaw. */ + @TestTemplate + @ValueSource(strings = {"vmx"}) + public void keepsCarrierFramesAfterPartialThawWithStackArguments() throws Exception { + runWorkload(true, true, true); + assertCarrierFramesAfterPartialThaw("recursiveWorkloadWithArguments"); + } + + /** Checks carrier recovery without requiring the separately implemented heap-caller recovery. */ + private void assertCarrierFramesAfterPartialThaw(String workloadMethod) { + assertTrue(profiler.getDebugCounters().getOrDefault("walkvm_cont_barrier_hit", 0L) > 0, + "The workload must encounter a continuation return barrier"); + + int[] counts = reduceEvents("datadog.MethodSample", () -> new int[2], (count, sample) -> { + JfrStackTrace trace = sample.getStackTrace(); + if (!containsMethod(trace, workloadMethod) || containsMethod(trace, "outerWorkload")) return; + for (JfrFrame frame : trace.frames()) { + if ("java.util.concurrent.ForkJoinWorkerThread".equals(frame.className()) + && "run".equals(frame.methodName())) count[0]++; + if (frame.methodName() != null && frame.methodName().startsWith("break_cont_")) count[1]++; + } + }); + assertEquals(0, counts[1], "Carrier recovery rejected continuation frame pointers"); + assertTrue(counts[0] > 0, "No partially thawed workload sample retained carrier ancestry"); + } + /** The same parked workload on a platform thread keeps its application callers on the stack. */ @TestTemplate @ValueSource(strings = {"vm", "vmx"}) @@ -71,9 +105,9 @@ public void keepsPlatformCallersComplete() throws Exception { assertCompleteCallers(); } - /** Ending at a fully thawed continuation is a complete logical stack in the ordinary VM mode. */ + /** Fully thawed virtual threads retain complete callers with and without carrier walking. */ @TestTemplate - @ValueSource(strings = {"vm"}) + @ValueSource(strings = {"vm", "vmx"}) public void keepsFullyThawedContinuationComplete() throws Exception { runWorkload(true, false); assertCompleteCallers(); @@ -81,9 +115,14 @@ public void keepsFullyThawedContinuationComplete() throws Exception { /** Runs the same recursion on either kind of thread and verifies that its callers really exist. */ private void runWorkload(boolean virtual, boolean park) throws Exception { + runWorkload(virtual, park, false); + } + + /** Selects recursive frames with either register-only or stack-passed arguments. */ + private void runWorkload(boolean virtual, boolean park, boolean stackArguments) throws Exception { waitForProfilerReady(2_000); FutureTask task = new FutureTask<>(() -> { - outerWorkload(park); + outerWorkload(park, stackArguments); return null; }); Thread worker; @@ -104,7 +143,7 @@ private void runWorkload(boolean virtual, boolean park) throws Exception { boolean outer = false; for (StackTraceElement frame : logicalStack) { if (!getClass().getName().equals(frame.getClassName())) continue; - if (frame.getMethodName().equals("recursiveWorkload")) recursion++; + if (frame.getMethodName().equals(stackArguments ? "recursiveWorkloadWithArguments" : "recursiveWorkload")) recursion++; if (frame.getMethodName().equals("outerWorkload")) outer = true; } assertEquals(RECURSION_DEPTH + 1, recursion, "The JVM must preserve all recursive callers"); @@ -133,9 +172,15 @@ private boolean containsMethod(JfrStackTrace trace, String method) { return false; } - private void outerWorkload(boolean park) { + private void outerWorkload(boolean park, boolean stackArguments) { long deadline = System.nanoTime() + 2_000_000_000L; - while (System.nanoTime() < deadline) recursiveWorkload(RECURSION_DEPTH, park); + while (System.nanoTime() < deadline) { + if (stackArguments) { + recursiveWorkloadWithArguments(RECURSION_DEPTH, park, 1, 2, 3, 4, 5, 6, 7, 8, 9); + } else { + recursiveWorkload(RECURSION_DEPTH, park); + } + } } /** Work after the recursive call keeps callers alive; deep recursion exceeds partial-thaw sizes. */ @@ -150,4 +195,18 @@ private void recursiveWorkload(int depth, boolean park) { } sink += depth; } + + /** Keeps stack-passed arguments live across the recursive call and the park/remount cycle. */ + private void recursiveWorkloadWithArguments(int depth, boolean park, + long a, long b, long c, long d, long e, long f, long g, long h, long i) { + if (depth > 0) { + recursiveWorkloadWithArguments(depth - 1, park, a, b, c, d, e, f, g, h, i); + } else { + if (park) LockSupport.parkNanos(500_000L); + if (logicalStack == null) logicalStack = Thread.currentThread().getStackTrace(); + long deadline = System.nanoTime() + 2_000_000L; + while (System.nanoTime() < deadline) sink ^= System.nanoTime(); + } + sink += depth + a + b + c + d + e + f + g + h + i; + } } From 6f8269aa4199cb50e027c36376030be72ba1d77d Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 22 Oct 2026 17:53:18 +0200 Subject: [PATCH 2/6] fix(profiler): preserve continuation arguments across recovery paths --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 18 +- ddprof-stresstest/build.gradle.kts | 9 +- ddprof-stresstest/src/chaos/README.md | 33 ++++ .../com/datadoghq/profiler/chaos/Main.java | 2 + .../chaos/VirtualThreadRemountAntagonist.java | 178 ++++++++++++++++++ ddprof-test/build.gradle.kts | 1 + .../VirtualThreadCarrierChaosTest.java | 147 +++++++++++++++ 7 files changed, 380 insertions(+), 8 deletions(-) create mode 100644 ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/VirtualThreadRemountAntagonist.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualThreadCarrierChaosTest.java diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 3ea6a0510d..2520b632fa 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -615,6 +615,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex } if (nm->isInterpreter()) { + continuation_stack_args = 0; if (vm_thread != NULL && vm_thread->inDeopt()) { fillFrame(frames[depth++], BCI_ERROR, "break_deopt"); break; @@ -633,7 +634,6 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex sp = ((uintptr_t*)fp)[InterpreterFrame::sender_sp_offset]; walk_pc.setReturnAddress(stripPointer(((void**)fp)[FRAME_PC_SLOT])); fp = *(uintptr_t*)INJECT_FAULT_ADDRESS_UNLIKELY(fp); - continuation_stack_args = 0; continue; } } @@ -684,6 +684,14 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex jmethodID method_id = method->id(); HotspotSupport::fillJavaFrame(frames[depth++], type, 0, method_id, method); + // Prologue/epilogue unwinds can reach a return barrier without the frame-size step. + if (features.carrier_frames && VMContinuationEntry::type_size() == 0) { + VMConstMethod* const_method = method->constMethod_or_null(); + continuation_stack_args = const_method != nullptr ? const_method->stackArgWords() : -1; + // nmethod sets its stack-argument count to zero for OSR entries. + if (continuation_stack_args > 0 && nm->isOsr()) continuation_stack_args = 0; + } + if (nm->isFrameCompleteAt(walk_pc.raw())) { const void* epilogue_pc = walk_pc.raw(); if (depth == 1 && frame.unwindEpilogue(nm, (uintptr_t&)epilogue_pc, sp, fp)) { @@ -725,12 +733,6 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex break; } sp = (uintptr_t)INJECT_FAULT_ADDRESS_UNLIKELY(sp); - if (features.carrier_frames && VMContinuationEntry::type_size() == 0) { - VMConstMethod* const_method = method->constMethod_or_null(); - continuation_stack_args = const_method != nullptr ? const_method->stackArgWords() : -1; - // nmethod sets its stack-argument count to zero for OSR entries. - if (continuation_stack_args > 0 && nm->isOsr()) continuation_stack_args = 0; - } fp = ((uintptr_t*)sp)[-FRAME_PC_SLOT - 1]; // Saved return address of the caller frame. walk_pc.setReturnAddress(((const void**)sp)[-FRAME_PC_SLOT]); @@ -927,6 +929,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex const char* bcp = ((const char**)recovery_fp)[bcp_offset]; int bci = bytecode_start == NULL || bcp < bytecode_start ? 0 : bcp - bytecode_start; HotspotSupport::fillJavaFrame(frames[depth++], FRAME_INTERPRETED, bci, method_id, method); + continuation_stack_args = 0; sp = ((uintptr_t*)recovery_fp)[InterpreterFrame::sender_sp_offset]; walk_pc.setReturnAddress(stripPointer(((void**)recovery_fp)[FRAME_PC_SLOT])); fp = *(uintptr_t*)recovery_fp; @@ -1026,6 +1029,7 @@ __attribute__((no_sanitize("address"))) int HotspotSupport::walkVM(void* ucontex const char* bcp = ((const char**)anchor_fp)[bcp_offset]; int bci = bytecode_start == NULL || bcp < bytecode_start ? 0 : bcp - bytecode_start; HotspotSupport::fillJavaFrame(frames[depth++], FRAME_INTERPRETED, bci, method_id, method); + continuation_stack_args = 0; sp = ((uintptr_t*)anchor_fp)[InterpreterFrame::sender_sp_offset]; walk_pc.setReturnAddress(stripPointer(((void**)anchor_fp)[FRAME_PC_SLOT])); fp = *(uintptr_t*)anchor_fp; diff --git a/ddprof-stresstest/build.gradle.kts b/ddprof-stresstest/build.gradle.kts index 67896f2b7d..c774484333 100644 --- a/ddprof-stresstest/build.gradle.kts +++ b/ddprof-stresstest/build.gradle.kts @@ -87,7 +87,7 @@ dependencies { "chaosCompileOnly"(project(mapOf("path" to ":ddprof-lib", "configuration" to "debug"))) } -tasks.register("chaosJar") { +val chaosJar = tasks.register("chaosJar") { group = "build" description = "Fat jar of the chaos reliability harness" archiveFileName.set("chaos.jar") @@ -101,6 +101,13 @@ tasks.register("chaosJar") { } } +// Reuse the same chaos workloads in the profiler's JFR correctness tests. +configurations.create("chaosElements") { + isCanBeConsumed = true + isCanBeResolved = false + outgoing.artifact(chaosJar) +} + // --- reference-chains repro app --------------------------------------------- // Plain, dependency-free demo app for manually reproducing the reference-chains // feature: launched directly with `-agentpath:libjavaProfiler.so=start,...` diff --git a/ddprof-stresstest/src/chaos/README.md b/ddprof-stresstest/src/chaos/README.md index 74e5f19d4b..d941f2ba4f 100644 --- a/ddprof-stresstest/src/chaos/README.md +++ b/ddprof-stresstest/src/chaos/README.md @@ -12,11 +12,44 @@ the runner script. | ----------------- | ------------------------------------------------------------------------ | | `thread-churn` | signal-vs-teardown races, slot reuse, calltrace storage put/get | | `vthread-churn` | virtual thread mount/unmount, carrier-thread context, `ProfiledThread` | +| `vthread-remount` | bounded workers parking under deep callers, post-remount CPU work, stack arguments and JIT transitions | | `classloader-churn` | class unload racing stack walk, `CodeCache`/`Symbols` invalidation | | `alloc-storm` | Java alloc engine + GOT-patched libc malloc/free | | `trace-context` | `setTraceContext`/`clearTraceContext` racing signals, span ID propagation | | `reapply-context-value` | concurrent per-slot `setContextValue`/`clearContextValue` churn interleaved with span activation, racing the all-native record's write/read window | +## Virtual-thread carrier recovery + +`vthread-remount` keeps four virtual threads alive and alternates register-only +and stack-passed arguments under 128–224 recursive callers. Each leaf parks, +then burns CPU after remounting. Worker failures and shutdown timeouts propagate +to the harness. It can be selected by name through `run-chaos-harness.sh`'s +fourth argument, including alongside other antagonists. + +`VirtualThreadCarrierChaosTest` profiles this same workload with native CPU and +wall sampling in both `cstack=vmx` and `cstack=vm`. It requires return-barrier +hits, progress in both argument layouts, and enough eligible samples from each +engine. It checks carrier presence, uniqueness and ordering, and requires +missing outer callers to remain marked truncated. Samples that stop before a +continuation boundary are counted separately; continuation-pointer errors fail +the check. Profile events are streamed rather than retained for the whole run. + +The ordinary `testDebug` suite runs six seconds per mode on supported HotSpot +JDK 21+ runtimes. For a longer run, select the build and test JDKs with +`JAVA_HOME` and `JAVA_TEST_HOME`, then run: + +```sh +DDPROF_VTHREAD_STRESS_SECONDS=120 JAVA_TOOL_OPTIONS=-XX:-PreserveFramePointer \ + ./.claude/commands/build-and-summarize :ddprof-test:testDebug \ + -Ptests=VirtualThreadCarrierChaosTest -Pskip-gtest -PkeepJFRs +``` + +Repeat with `JAVA_TOOL_OPTIONS=-XX:+PreserveFramePointer` as a control. +The duration accepts 2–3600 seconds. Test XML and retained JFR recordings include +both the CPU and wall-clock checks; inspect the logged eligible-sample and +pre-boundary-stop counts when comparing runs. Passing process survival in the +agent chaos harness alone does not establish stack correctness. + ## Deferred - **`dlopen`/`dlclose` churn** — load and unload throwaway native libraries to diff --git a/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java index a8e51e8666..155b6f6d93 100644 --- a/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java +++ b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java @@ -74,6 +74,8 @@ private static Antagonist create(String name) { return new AllocStormAntagonist(); case "vthread-churn": return new VirtualThreadChurnAntagonist(); + case "vthread-remount": + return new VirtualThreadRemountAntagonist(); case "classloader-churn": return new ClassLoaderChurnAntagonist(); case "trace-context": diff --git a/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/VirtualThreadRemountAntagonist.java b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/VirtualThreadRemountAntagonist.java new file mode 100644 index 0000000000..2c2b272739 --- /dev/null +++ b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/VirtualThreadRemountAntagonist.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.datadoghq.profiler.chaos; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +/** + * Repeatedly parks a bounded set of virtual threads under deep Java callers, then burns CPU after + * remounting. Alternating argument layouts and recursion depths exercise partial thaw while the + * recursive methods progress through interpreted, compiled and OSR execution. + */ +public final class VirtualThreadRemountAntagonist implements Antagonist { + private static final Method START_VIRTUAL_THREAD = resolveStartVirtualThread(); + private final Worker[] workers; + private final AtomicReference failure = new AtomicReference<>(); + private volatile boolean running; + private boolean started; + + /** Creates four workers, with no thread or allocation churn in their recursive hot loops. */ + public VirtualThreadRemountAntagonist() { + this(4); + } + + /** Creates between one and sixteen workers; each alternates both argument layouts. */ + public VirtualThreadRemountAntagonist(int workerCount) { + if (workerCount < 1 || workerCount > 16) throw new IllegalArgumentException("workerCount must be 1..16"); + workers = new Worker[workerCount]; + for (int i = 0; i < workers.length; i++) workers[i] = new Worker(i); + } + + /** Name accepted by the chaos harness. */ + @Override + public String name() { + return "vthread-remount"; + } + + /** Starts the workers once; gracefully skips runtimes without virtual threads. */ + @Override + public void start() { + if (started) throw new IllegalStateException("Already started"); + started = true; + if (START_VIRTUAL_THREAD == null) { + System.out.println("[chaos] vthread-remount: skipping (virtual threads unavailable)"); + return; + } + running = true; + try { + for (Worker worker : workers) { + worker.thread = (Thread) START_VIRTUAL_THREAD.invoke(null, worker); + } + } catch (ReflectiveOperationException e) { + failure.compareAndSet(null, e); + stopGracefully(Duration.ofSeconds(10)); + } + } + + /** Unparks and joins every worker within one shared deadline; propagates worker failures. */ + @Override + public void stopGracefully(Duration timeout) { + running = false; + for (Worker worker : workers) { + if (worker.thread != null) worker.thread.interrupt(); + } + long deadline = System.nanoTime() + timeout.toNanos(); + boolean interrupted = false; + for (Worker worker : workers) { + if (worker.thread == null) continue; + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) break; + try { + worker.thread.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining))); + } catch (InterruptedException e) { + interrupted = true; + break; + } + } + if (interrupted) Thread.currentThread().interrupt(); + for (Worker worker : workers) { + if (worker.thread != null && worker.thread.isAlive()) { + throw new IllegalStateException("vthread-remount worker did not stop"); + } + } + if (failure.get() != null) throw new IllegalStateException("vthread-remount worker failed", failure.get()); + } + + /** Number of completed register-argument park/remount cycles across all workers. */ + public long registerCycles() { + long count = 0; + for (Worker worker : workers) count += worker.registerCycles; + return count; + } + + /** Number of completed stack-argument park/remount cycles across all workers. */ + public long stackCycles() { + long count = 0; + for (Worker worker : workers) count += worker.stackCycles; + return count; + } + + private final class Worker implements Runnable { + private final int id; + private Thread thread; + private volatile long registerCycles; + private volatile long stackCycles; + private volatile long checksum; + + private Worker(int id) { + this.id = id; + } + + /** Alternates layouts until stopped, retaining any failure for the owner. */ + @Override + public void run() { + try { + long cycle = id; + while (running) { + int depth = 128 + 32 * (int) (cycle & 3); + registerWorkload(this, depth, cycle); + if (!running) break; + stackWorkload(this, depth, cycle); + cycle++; + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + running = false; + } + } + } + + /** This outer caller always exists logically, even if it remains frozen at sampling time. */ + private void registerWorkload(Worker worker, int depth, long seed) { + worker.checksum = registerFrames(depth, seed); + worker.registerCycles++; + } + + /** Keeps recursive callers live while work in the leaf is sampled after a park. */ + private long registerFrames(int depth, long seed) { + if (depth > 0) return registerFrames(depth - 1, seed) + depth; + LockSupport.parkNanos(500_000L); + long deadline = System.nanoTime() + 2_000_000L; + do { + for (int i = 0; i < 256; i++) seed = seed * 6364136223846793005L + 1442695040888963407L; + } while (running && System.nanoTime() < deadline); + return seed; + } + + /** Establishes an outer caller for the stack-passed argument variant. */ + private void stackWorkload(Worker worker, int depth, long seed) { + worker.checksum = stackFrames(depth, seed, 2, 3, 4, 5, 6, 7, 8, 9); + worker.stackCycles++; + } + + /** Keeps nine long arguments live across recursive calls, partial thaw and compilation. */ + private long stackFrames(int depth, long a, long b, long c, long d, long e, long f, long g, long h, long i) { + if (depth > 0) return stackFrames(depth - 1, a, b, c, d, e, f, g, h, i) + depth + b + c + d + e + f + g + h + i; + LockSupport.parkNanos(500_000L); + long deadline = System.nanoTime() + 2_000_000L; + do { + for (int n = 0; n < 256; n++) a = a * 6364136223846793005L + 1442695040888963407L; + } while (running && System.nanoTime() < deadline); + return a + b + c + d + e + f + g + h + i; + } + + /** Resolves the Java 21 factory while keeping the harness compatible with Java 8. */ + private static Method resolveStartVirtualThread() { + try { + return Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException e) { + return null; + } + } +} diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index e0fa1e7a23..d19b88a452 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { "testCommon"(libs.bundles.profiler.runtime) "testCommon"(libs.jafar.parser) "testCommon"(libs.asm) + "testCommon"(project(mapOf("path" to ":ddprof-stresstest", "configuration" to "chaosElements"))) // Main/application dependencies "mainCommon"(libs.slf4j.simple) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualThreadCarrierChaosTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualThreadCarrierChaosTest.java new file mode 100644 index 0000000000..ea09e9ace0 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualThreadCarrierChaosTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.CStackAwareAbstractProfilerTest; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrFrame; +import com.datadoghq.profiler.JfrStackTrace; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.chaos.VirtualThreadRemountAntagonist; +import com.datadoghq.profiler.junit.CStack; +import java.time.Duration; +import java.util.Arrays; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Checks CPU and wall-clock stack correctness while a shared chaos workload repeatedly remounts. */ +public class VirtualThreadCarrierChaosTest extends CStackAwareAbstractProfilerTest { + private static final String WORKLOAD_CLASS = VirtualThreadRemountAntagonist.class.getName(); + private final boolean carrierFrames; + + /** Selects carrier walking or the ordinary VM-mode control. */ + public VirtualThreadCarrierChaosTest(@CStack String cstack) { + super(cstack); + carrierFrames = "vmx".equals(cstack); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isJavaVersionAtLeast(21) && !Platform.isJ9() && !Platform.isZing() + && !(isInCI() && Platform.isMusl() && !Platform.isAarch64()); + } + + @Override + protected String getProfilerCommand() { + return "cpu=2ms,wall=2ms,walltpt=64,filter=,jstackdepth=512"; + } + + /** Requires exercised return barriers and checks both layouts in each sampling engine. */ + @TestTemplate + @ValueSource(strings = {"vm", "vmx"}) + public void retainsCarrierAncestryDuringRepeatedRemounts() throws Exception { + String seconds = System.getenv("DDPROF_VTHREAD_STRESS_SECONDS"); + long duration = seconds == null ? 6 : Long.parseLong(seconds); + assertTrue(duration >= 2 && duration <= 3600, "Stress duration must be 2..3600 seconds"); + waitForProfilerReady(2_000); + VirtualThreadRemountAntagonist workload = new VirtualThreadRemountAntagonist(); + try { + workload.start(); + Thread.sleep(duration * 1000); + } finally { + try { + workload.stopGracefully(Duration.ofSeconds(10)); + } finally { + stopProfiler(); + } + } + assertTrue(workload.registerCycles() >= 10, "Register-argument workers made insufficient progress"); + assertTrue(workload.stackCycles() >= 10, "Stack-argument workers made insufficient progress"); + assertTrue(profiler.getDebugCounters().getOrDefault("walkvm_cont_barrier_hit", 0L) >= 20, + "The stress run must sample continuation return barriers"); + verifyCStackSettings(); + assertStacks("datadog.ExecutionSample"); + assertStacks("datadog.MethodSample"); + } + + /** Reduces streamed events so extending the run does not retain every sampled stack in memory. */ + private void assertStacks(String eventType) { + StackCounts counts = reduceEvents(eventType, StackCounts::new, StackCounts::accept); + System.out.println(eventType + " carrier=" + carrierFrames + " " + counts); + assertEquals(0, counts.continuationErrors, "Continuation recovery errors: " + counts); + assertEquals(0, counts.invalidAncestry, "Missing, duplicated or misplaced carrier frames: " + counts); + assertEquals(0, counts.falseComplete, "Missing outer callers reported complete: " + counts); + for (int layout = 0; layout < 2; layout++) { + assertTrue(counts.eligible[layout] >= 10, + eventType + " must capture both argument layouts: " + counts); + } + } + + /** Counts boundary walks separately from samples interrupted before reaching a boundary. */ + private final class StackCounts { + private final long[] eligible = new long[2]; + private final long[] otherStops = new long[2]; + private long continuationErrors; + private long invalidAncestry; + private long falseComplete; + + private void accept(JfrEvent event) { + JfrStackTrace trace = event.getStackTrace(); + boolean register = false; + boolean stack = false; + boolean outer = false; + boolean continuationError = false; + int carriers = 0; + int carrierIndex = -1; + int lastApplicationIndex = -1; + int roots = 0; + int index = 0; + for (JfrFrame frame : trace.frames()) { + String method = frame.methodName(); + if (WORKLOAD_CLASS.equals(frame.className())) { + register |= "registerFrames".equals(method); + stack |= "stackFrames".equals(method); + outer |= "registerWorkload".equals(method) || "stackWorkload".equals(method); + lastApplicationIndex = index; + } + if ("java.util.concurrent.ForkJoinWorkerThread".equals(frame.className()) + && "run".equals(method)) { + carriers++; + carrierIndex = index; + } + if ("JVM Continuation".equals(method)) roots++; + if (method != null && method.startsWith("break_cont_")) continuationError = true; + index++; + } + if (!register && !stack) return; + if (continuationError) { + continuationErrors++; + return; + } + int layout = stack ? 1 : 0; + if (carriers == 0 && roots == 0) { + otherStops[layout]++; + if (!trace.isTruncated()) falseComplete++; + return; + } + eligible[layout]++; + if (register && stack || (carrierFrames + ? carriers != 1 || roots != 0 || carrierIndex <= lastApplicationIndex + : carriers != 0 || roots != 1)) invalidAncestry++; + if (!outer && !trace.isTruncated()) falseComplete++; + } + + /** Reports coverage and failures without retaining sampled stacks. */ + @Override + public String toString() { + return "eligible=" + Arrays.toString(eligible) + " otherStops=" + Arrays.toString(otherStops) + + " continuationErrors=" + continuationErrors + " invalidAncestry=" + invalidAncestry + + " falseComplete=" + falseComplete; + } + } +} From 4e5d24a43f97cb31afc1382b5e0544be17a2afb2 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 22 Oct 2026 22:19:08 +0200 Subject: [PATCH 3/6] fix(profiler): accept high-arity continuation stack arguments --- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 4 +- .../test/cpp/continuationCarrierFrame_ut.cpp | 19 +- .../HighArityVirtualThreadStackTest.java | 187 ++++++++++++++++++ 3 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index 5230580327..e9f3064b16 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -1054,8 +1054,10 @@ DECLARE(VMNMethod) // to 16 bytes. enterSpecial's frame size includes its saved FP and return PC. uintptr_t continuationEntryFP(uintptr_t sp, int stack_args) { int frame_size = frameSize(); + // A reference occupies one of the JVM's 255 parameter slots but a full + // machine word on the stack; allow that limit plus alignment padding. if (frame_size < 4 || frame_size > 256 || (frame_size & 1) != 0 || - stack_args < 0 || stack_args > 128 || (sp & 15) != 0) { + stack_args < 0 || stack_args > 256 || (sp & 15) != 0) { return 0; } uintptr_t words = ((stack_args + 1) & ~1) + frame_size - 2; diff --git a/ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp b/ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp index b85d9672fb..79d3e7d614 100644 --- a/ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp +++ b/ddprof-lib/src/test/cpp/continuationCarrierFrame_ut.cpp @@ -36,7 +36,7 @@ class ContinuationCarrierFrameTest : public ::testing::Test { protected: VMStructsTestAccessor _layout; int _frame_size = 10; - alignas(16) uintptr_t _stack[64] = {}; + alignas(16) uintptr_t _stack[512] = {}; VMNMethod* entryMethod() { return VMNMethod::cast_raw(&_frame_size); } }; @@ -63,7 +63,7 @@ TEST_F(ContinuationCarrierFrameTest, AccountsForStackArgumentsAndAlignment) { } TEST_F(ContinuationCarrierFrameTest, ConvertsExportedArgumentSlotsToWords) { - for (unsigned short slots : {0, 1, 2, 3, 17}) { + for (unsigned short slots : {0, 1, 2, 3, 17, 258, 498, 512}) { VMConstMethod* method = VMConstMethod::cast_raw(&slots); EXPECT_EQ((slots + 1) / 2, method->stackArgWords()); } @@ -71,8 +71,20 @@ TEST_F(ContinuationCarrierFrameTest, ConvertsExportedArgumentSlotsToWords) { EXPECT_EQ(-1, VMConstMethod::cast_raw(_stack)->stackArgWords()); } +TEST_F(ContinuationCarrierFrameTest, RecoversCarrierSlotsAbove128ArgumentWords) { + struct Layout { int arguments; int entry_slot; }; + for (Layout layout : {Layout{129, 138}, Layout{249, 258}, Layout{256, 264}}) { + _stack[layout.entry_slot] = (uintptr_t)&_stack[400]; + _stack[layout.entry_slot + 1] = (uintptr_t)&_stack[410]; + uintptr_t entry_fp = entryMethod()->continuationEntryFP((uintptr_t)_stack, layout.arguments); + ASSERT_EQ((uintptr_t)&_stack[layout.entry_slot], entry_fp); + EXPECT_EQ((uintptr_t)&_stack[400], *(uintptr_t*)entry_fp); + EXPECT_EQ((uintptr_t)&_stack[410], ((uintptr_t*)entry_fp)[1]); + } +} + TEST_F(ContinuationCarrierFrameTest, RejectsUnknownOrInvalidArgumentCounts) { - for (int args : {-1, 129, INT_MAX}) { + for (int args : {-1, 257, INT_MAX}) { EXPECT_EQ(0u, entryMethod()->continuationEntryFP((uintptr_t)_stack, args)); } } @@ -87,6 +99,7 @@ TEST_F(ContinuationCarrierFrameTest, RejectsInvalidEntryFrameSizes) { TEST_F(ContinuationCarrierFrameTest, RejectsMisalignmentAndAddressOverflow) { EXPECT_EQ(0u, entryMethod()->continuationEntryFP((uintptr_t)_stack + sizeof(uintptr_t), 0)); EXPECT_EQ(0u, entryMethod()->continuationEntryFP(UINTPTR_MAX & ~(uintptr_t)15, 0)); + EXPECT_EQ(0u, entryMethod()->continuationEntryFP(UINTPTR_MAX & ~(uintptr_t)15, 256)); } TEST_F(ContinuationCarrierFrameTest, ObservesEntryWrapperGeneratedAfterInitialization) { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java new file mode 100644 index 0000000000..35a6d12795 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java @@ -0,0 +1,187 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.CStackAwareAbstractProfilerTest; +import com.datadoghq.profiler.JfrFrame; +import com.datadoghq.profiler.JfrStackTrace; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.junit.CStack; +import java.util.concurrent.FutureTask; +import java.util.concurrent.locks.LockSupport; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.params.provider.ValueSource; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Checks carrier recovery for compiled methods near the JVM's parameter-slot limit. */ +public class HighArityVirtualThreadStackTest extends CStackAwareAbstractProfilerTest { + private static final String GENERATED = "com/datadoghq/profiler/generated/HighArityContinuation"; + private static final int REFERENCES = 254; + private static final int DEPTH = 64; + private static volatile boolean parking; + private static volatile StackTraceElement[] logicalStack; + /** Keeps the generated recursive return values live. */ + public static volatile int sink; + + /** Selects the VM walker with carrier recovery enabled. */ + public HighArityVirtualThreadStackTest(@CStack String cstack) { + super(cstack); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isJavaVersionAtLeast(21) && !Platform.isJ9() && !Platform.isZing() + && !(isInCI() && Platform.isMusl() && !Platform.isAarch64()); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,walltpt=64,filter=,jstackdepth=512"; + } + + /** Requires compiled, partially thawed samples to recover the carrier without pointer errors. */ + @TestTemplate + @ValueSource(strings = {"vmx"}) + public void retainsCarrierWithMoreThan128StackArgumentWords() throws Exception { + waitForProfilerReady(2_000); + Runnable workload = new Loader().workload(); + parking = false; + logicalStack = null; + // The generated recursive method has no back edge, so it cannot be an OSR entry. + for (int i = 0; i < 2_000; i++) workload.run(); + parking = true; + FutureTask task = new FutureTask<>(() -> { + long deadline = System.nanoTime() + 3_000_000_000L; + while (System.nanoTime() < deadline) workload.run(); + return null; + }); + Thread worker = (Thread) Thread.class.getMethod("startVirtualThread", Runnable.class).invoke(null, task); + try { + worker.join(10_000); + assertFalse(worker.isAlive(), "The high-arity workload did not finish"); + task.get(); + } finally { + parking = false; + worker.interrupt(); + stopProfiler(); + } + assertTrue(logicalStack != null, "The high-arity workload did not capture its logical stack"); + int recursion = 0; + for (StackTraceElement frame : logicalStack) { + if (GENERATED.replace('/', '.').equals(frame.getClassName()) && "recursive".equals(frame.getMethodName())) recursion++; + } + assertEquals(DEPTH + 1, recursion, "The JVM must retain the recursive callers"); + assertTrue(profiler.getDebugCounters().getOrDefault("walkvm_cont_barrier_hit", 0L) > 0, + "The workload must exercise a continuation return barrier"); + int[] counts = reduceEvents("datadog.MethodSample", () -> new int[5], (count, sample) -> { + JfrStackTrace trace = sample.getStackTrace(); + boolean compiled = false; + boolean outer = false; + int carriers = 0; + int errors = 0; + boolean stoppedBeforeBoundary = false; + for (JfrFrame frame : trace.frames()) { + if (GENERATED.replace('/', '.').equals(frame.className())) { + compiled |= "recursive".equals(frame.methodName()) + && ("JIT compiled".equals(frame.typeDescription()) || "C1 compiled".equals(frame.typeDescription())); + outer |= "run".equals(frame.methodName()); + } + if ("java.util.concurrent.ForkJoinWorkerThread".equals(frame.className()) && "run".equals(frame.methodName())) carriers++; + if (frame.methodName() != null && frame.methodName().startsWith("break_cont_")) errors++; + else if (frame.methodName() != null && frame.methodName().startsWith("break_")) stoppedBeforeBoundary = true; + } + if (compiled && !outer) { + if (stoppedBeforeBoundary && errors == 0 && carriers == 0) { + count[4]++; + return; + } + count[0]++; + count[1] += errors; + if (carriers != 1) count[2]++; + if (!trace.isTruncated()) count[3]++; + } + }); + System.out.println("High-arity compiled partial samples=" + counts[0] + " continuationErrors=" + counts[1] + + " stopsBeforeBoundary=" + counts[4]); + assertTrue(counts[0] > 0, "No compiled high-arity sample left callers frozen"); + assertEquals(0, counts[1], "High-arity carrier recovery rejected valid argument metadata"); + assertEquals(0, counts[2], "High-arity samples must retain exactly one carrier"); + assertEquals(0, counts[3], "Skipped frozen callers must remain marked truncated"); + } + + /** Called from generated code after remounting; warm-up calls return without parking. */ + public static int parkAndSpin() { + if (!parking) return 1; + LockSupport.parkNanos(500_000L); + if (logicalStack == null) logicalStack = Thread.currentThread().getStackTrace(); + long deadline = System.nanoTime() + 2_000_000L; + int value = 1; + while (System.nanoTime() < deadline) value = value * 1664525 + 1013904223; + return value; + } + + /** Generates a legal 255-slot static signature: one int and 254 references. */ + private static final class Loader extends ClassLoader { + private Loader() { + super(HighArityVirtualThreadStackTest.class.getClassLoader()); + } + + /** Defines high-arity recursion without a source signature containing hundreds of parameters. */ + private Runnable workload() throws Exception { + StringBuilder descriptor = new StringBuilder("(I"); + for (int i = 0; i < REFERENCES; i++) descriptor.append("Ljava/lang/Object;"); + descriptor.append(")I"); + String signature = descriptor.toString(); + String owner = HighArityVirtualThreadStackTest.class.getName().replace('.', '/'); + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, GENERATED, null, "java/lang/Object", new String[]{"java/lang/Runnable"}); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + mv.visitCode(); + mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "run", "()V", null, null); + mv.visitCode(); + mv.visitIntInsn(Opcodes.SIPUSH, DEPTH); + for (int i = 0; i < REFERENCES; i++) mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, GENERATED, "recursive", signature, false); + mv.visitFieldInsn(Opcodes.PUTSTATIC, owner, "sink", "I"); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "recursive", signature, null, null); + mv.visitCode(); + Label leaf = new Label(); + mv.visitVarInsn(Opcodes.ILOAD, 0); + mv.visitJumpInsn(Opcodes.IFEQ, leaf); + mv.visitVarInsn(Opcodes.ILOAD, 0); + mv.visitInsn(Opcodes.ICONST_1); + mv.visitInsn(Opcodes.ISUB); + for (int i = 1; i <= REFERENCES; i++) mv.visitVarInsn(Opcodes.ALOAD, i); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, GENERATED, "recursive", signature, false); + mv.visitVarInsn(Opcodes.ALOAD, REFERENCES); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "identityHashCode", "(Ljava/lang/Object;)I", false); + mv.visitInsn(Opcodes.IXOR); + mv.visitInsn(Opcodes.IRETURN); + mv.visitLabel(leaf); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, "parkAndSpin", "()I", false); + mv.visitInsn(Opcodes.IRETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + cw.visitEnd(); + byte[] bytes = cw.toByteArray(); + return (Runnable) defineClass(GENERATED.replace('/', '.'), bytes, 0, bytes.length).getDeclaredConstructor().newInstance(); + } + } +} From 3b420795ae858a307abc5e1a6addbdde80617d6e Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 22 Oct 2026 23:46:09 +0200 Subject: [PATCH 4/6] test(profiler): sample high-arity frames directly after thaw --- .../HighArityVirtualThreadStackTest.java | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java index 35a6d12795..916a6c40c4 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/HighArityVirtualThreadStackTest.java @@ -10,7 +10,6 @@ import com.datadoghq.profiler.Platform; import com.datadoghq.profiler.junit.CStack; import java.util.concurrent.FutureTask; -import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.params.provider.ValueSource; import org.objectweb.asm.ClassWriter; @@ -26,8 +25,9 @@ public class HighArityVirtualThreadStackTest extends CStackAwareAbstractProfilerTest { private static final String GENERATED = "com/datadoghq/profiler/generated/HighArityContinuation"; private static final int REFERENCES = 254; - private static final int DEPTH = 64; - private static volatile boolean parking; + private static final int DEPTH = 16; + /** Enables parking in generated bytecode after compilation warm-up. */ + public static volatile boolean parking; private static volatile StackTraceElement[] logicalStack; /** Keeps the generated recursive return values live. */ public static volatile int sink; @@ -118,15 +118,9 @@ public void retainsCarrierWithMoreThan128StackArgumentWords() throws Exception { assertEquals(0, counts[3], "Skipped frozen callers must remain marked truncated"); } - /** Called from generated code after remounting; warm-up calls return without parking. */ - public static int parkAndSpin() { - if (!parking) return 1; - LockSupport.parkNanos(500_000L); + /** Captures the JVM's logical callers once, independently of the profiler's physical walk. */ + public static void captureLogicalStack() { if (logicalStack == null) logicalStack = Thread.currentThread().getStackTrace(); - long deadline = System.nanoTime() + 2_000_000L; - int value = 1; - while (System.nanoTime() < deadline) value = value * 1664525 + 1013904223; - return value; } /** Generates a legal 255-slot static signature: one int and 254 references. */ @@ -175,7 +169,23 @@ private Runnable workload() throws Exception { mv.visitInsn(Opcodes.IXOR); mv.visitInsn(Opcodes.IRETURN); mv.visitLabel(leaf); - mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, "parkAndSpin", "()I", false); + Label warmup = new Label(); + mv.visitFieldInsn(Opcodes.GETSTATIC, owner, "parking", "Z"); + mv.visitJumpInsn(Opcodes.IFEQ, warmup); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, "captureLogicalStack", "()V", false); + mv.visitLdcInsn(500_000L); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/util/concurrent/locks/LockSupport", "parkNanos", "(J)V", false); + // Unroll the sampling window in the high-arity frame itself. A bytecode loop + // could compile as an OSR entry, whose nmethod has no stack arguments. + mv.visitInsn(Opcodes.ICONST_1); + for (int i = 0; i < 512; i++) { + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitInsn(Opcodes.L2I); + mv.visitInsn(Opcodes.IXOR); + } + mv.visitInsn(Opcodes.IRETURN); + mv.visitLabel(warmup); + mv.visitInsn(Opcodes.ICONST_1); mv.visitInsn(Opcodes.IRETURN); mv.visitMaxs(0, 0); mv.visitEnd(); From 52c5f8a48a81bd6ad9919df820c129752eb86935 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 23 Oct 2026 00:12:27 +0200 Subject: [PATCH 5/6] fix(profiler): encode debug unwind event sizes as varints --- ddprof-lib/src/main/cpp/flightRecorder.cpp | 24 ++++-- ddprof-lib/src/main/cpp/flightRecorder.h | 4 + .../src/test/cpp/unwindFailureEncoding_ut.cpp | 84 +++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 64c158df8e..cabf0ec909 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -2309,19 +2309,29 @@ void Recording::writeCounters(Buffer *buf) { } } +#ifdef DEBUG +void Recording::writeUnwindFailureEvent(Buffer *buf, u64 ticks, const char *kind, + const char *name, u64 count) { + // A 255-byte name plus the other fields needs a multi-byte event size. + int start = buf->skip(MAX_VAR32_LENGTH); + buf->putVar64(T_UNWIND_FAILURE); + buf->putVar64(ticks); + buf->putUtf8(kind); + buf->putUtf8(name); + buf->putVar64(count); + buf->putVar32(start, buf->offset() - start); +} +#endif + void Recording::writeUnwindFailures(Buffer *buf) { #ifdef DEBUG static UnwindFailures failures; UnwindStats::collectAndReset(failures); failures.forEach([&](UnwindFailureKind kind, const char *name, u64 count) { - int start = buf->skip(1); - buf->putVar64(T_UNWIND_FAILURE); - buf->putVar64(_start_ticks); - buf->putUtf8((kind & UNWIND_FAILURE_STUB) ? "stub" : "other"); - buf->putUtf8(name); - buf->putVar64(count); - writeEventSizePrefix(buf, start); + writeUnwindFailureEvent(buf, _start_ticks, + (kind & UNWIND_FAILURE_STUB) ? "stub" : "other", + name, count); flushIfNeeded(buf); }); #endif // DEBUG diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index 5c827a2ced..252d5b46fb 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -279,6 +279,10 @@ class Recording { // the encoded count can never diverge from what actually gets serialized. static size_t countSerializableChildren( const std::vector &children, int depth); +#ifdef DEBUG + static void writeUnwindFailureEvent(Buffer *buf, u64 ticks, const char *kind, + const char *name, u64 count); +#endif RecordingBuffer _buf[CONCURRENCY_LEVEL]; // we have several tables to avoid lock contention diff --git a/ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp b/ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp new file mode 100644 index 0000000000..b612dba8d7 --- /dev/null +++ b/ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp @@ -0,0 +1,84 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include "flightRecorder.h" +#include "unwindStats.h" + +#ifdef DEBUG +class RecordingTestAccessor { +public: + static void writeFailure(Buffer* buffer, const char* kind, const char* name, u64 count) { + Recording::writeUnwindFailureEvent(buffer, 123456789, kind, name, count); + } +}; + +// JFR varints use seven bits per byte, with all eight bits used in byte nine. +static u64 readVar(const Buffer& buffer, int& offset) { + u64 result = 0; + for (int i = 0; i < 9; i++) { + if (offset >= buffer.offset()) throw std::runtime_error("Truncated varint"); + unsigned char byte = buffer.data()[offset++]; + result |= (u64)(i == 8 ? byte : byte & 0x7f) << (i * 7); + if (i == 8 || (byte & 0x80) == 0) return result; + } + return result; +} + +static std::string readString(const Buffer& buffer, int& offset) { + if (offset >= buffer.offset() || buffer.data()[offset++] != 3) { + throw std::runtime_error("Expected UTF-8 string"); + } + u64 length = readVar(buffer, offset); + if (length > (u64)(buffer.offset() - offset)) throw std::runtime_error("Truncated string"); + std::string value(buffer.data() + offset, (size_t)length); + offset += (int)length; + return value; +} + +class UnwindFailureEncodingTest : public ::testing::TestWithParam {}; + +TEST_P(UnwindFailureEncodingTest, PreservesEventBoundariesAndDiagnosticFields) { + size_t length = GetParam(); + // Keep an assertion in the old writer from aborting the whole test binary. + ASSERT_EXIT({ + Buffer buffer; + buffer.skip(13); + std::string name(length, 'x'); + UnwindFailures failures; + failures.record(UNWIND_FAILURE_STUB, name.c_str()); + failures.forEach([&](UnwindFailureKind, const char* stored_name, u64 count) { + RecordingTestAccessor::writeFailure(&buffer, "stub", stored_name, count); + }); + int next = buffer.offset(); + RecordingTestAccessor::writeFailure(&buffer, "other", "control", ~0ULL); + + int offset = 13; + u64 event_size = readVar(buffer, offset); + EXPECT_EQ((u64)(next - 13), event_size); + EXPECT_EQ((u64)T_UNWIND_FAILURE, readVar(buffer, offset)); + EXPECT_EQ(123456789u, readVar(buffer, offset)); + EXPECT_EQ("stub", readString(buffer, offset)); + EXPECT_EQ(name.substr(0, MAX_NAME_LENGTH - 1), readString(buffer, offset)); + EXPECT_EQ(1u, readVar(buffer, offset)); + EXPECT_EQ(next, offset); + + EXPECT_EQ((u64)(buffer.offset() - next), readVar(buffer, offset)); + EXPECT_EQ((u64)T_UNWIND_FAILURE, readVar(buffer, offset)); + EXPECT_EQ(123456789u, readVar(buffer, offset)); + EXPECT_EQ("other", readString(buffer, offset)); + EXPECT_EQ("control", readString(buffer, offset)); + EXPECT_EQ(~0ULL, readVar(buffer, offset)); + EXPECT_EQ(buffer.offset(), offset); + _exit(::testing::Test::HasFailure() ? 1 : 0); + }, ::testing::ExitedWithCode(0), ""); +} + +INSTANTIATE_TEST_SUITE_P(NameLengths, UnwindFailureEncodingTest, + ::testing::Values(32, 110, 127, 230, 255, 512)); +#endif From b4e815425e765fb13be33b4e639bed8bc807854c Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 23 Oct 2026 00:34:44 +0200 Subject: [PATCH 6/6] test(profiler): register debug-only unwind test in release builds --- ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp b/ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp index b612dba8d7..f18e01986a 100644 --- a/ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp +++ b/ddprof-lib/src/test/cpp/unwindFailureEncoding_ut.cpp @@ -81,4 +81,8 @@ TEST_P(UnwindFailureEncodingTest, PreservesEventBoundariesAndDiagnosticFields) { INSTANTIATE_TEST_SUITE_P(NameLengths, UnwindFailureEncodingTest, ::testing::Values(32, 110, 127, 230, 255, 512)); +#else +TEST(UnwindFailureEncodingTest, RequiresDebugInstrumentation) { + GTEST_SKIP() << "Unwind-failure diagnostics require DEBUG instrumentation"; +} #endif