Repository navigation
Compiler-instrumentation backend - #7
Conversation
-finstrument-functions hooks in a separately compiled, uninstrumented static library: fixed-depth POD frame stacks per thread (no allocation in hooks), dladdr plus demangle name interning resolved once per unique function, and a thread-local suppression flag that silences hooks while collector internals hold locks, which closes the self-deadlock an instrumented ODR copy of an inline collector function would otherwise cause. The collector singleton and interning mutex are deliberately immortal so teardown-time hooks never touch destroyed objects. Instrumented fixture tests cover naming, counts, and the flush-from- instrumented-code regression; an instrumented fib demo writes a trace that loads in the Perfetto UI. Closes #3 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if (cyclescope::detail::tls_suppress) { | ||
| return; | ||
| } | ||
| cyclescope::detail::suppress_scope suppress; |
There was a problem hiding this comment.
Potential infinite recursion in debug builds
suppress_scope::suppress_scope() is an inline function defined in trace.hpp. When a TU compiled with -finstrument-functions (e.g., demo.cpp, which is the sole non-library TU in the demo and includes trace.hpp) is linked before cyclescope_instrument.a, the linker selects the instrumented weak symbol for the constructor. At -O0, instrument.cpp emits a real call instruction here rather than inlining the two-line body.
Consequence: entering that instrumented constructor fires __cyg_profile_func_enter(suppress_scope_ctor_addr, …). At that point tls_suppress is still false (the constructor hasn't run yet), so the check at line 101 passes and the hook tries to construct another suppress_scope → infinite recursion → stack overflow.
The same risk applies at line 119 in the exit hook.
Because the hooks are no_instrument_function, no recursion guard exists for the hook's own entry, so the suppression mechanism must be in place before any potentially-instrumented function is called.
Fix – avoid the function call entirely:
| cyclescope::detail::suppress_scope suppress; | |
| cyclescope::detail::tls_suppress = true; | |
| frame_stack& stack = tls_stack; |
Then at the end of the hook body (before the closing }) restore with cyclescope::detail::tls_suppress = false;. The hooks are non-throwing C functions, so no RAII is needed for the restore.
Alternatively, add [[gnu::always_inline]] / __attribute__((always_inline)) to both suppress_scope::suppress_scope() and ~suppress_scope() in trace.hpp so the constructor is never emitted as an out-of-line call.
| // destroyed; the event is dropped rather than touching dead TLS. | ||
| return; | ||
| } | ||
| detail::suppress_scope suppress; |
There was a problem hiding this comment.
Same suppress_scope recursion risk applies to all three inline collector methods
record(), write_json() (line 112), and clear() (line 174) all create a suppress_scope to prevent re-entrancy. When any of these inline functions is instantiated in an instrumented TU (e.g. instrument_fixture.cpp or demo.cpp) and the linker selects that instrumented copy program-wide, the entry hook for the function fires before suppress_scope is constructed. If the compiler also emits a call to the un-inlined suppress_scope::suppress_scope() (likely at -O0), the constructor's entry hook fires with tls_suppress == false, and we have the same recursion chain described on the hooks.
record() is the most critical case: it is called on the hot path from every exit hook, so if the linker picks an instrumented copy the recursion is reached on every traced call, not just during flush.
The cleanest fix across the board is to mark both suppress_scope methods always_inline:
struct suppress_scope {
bool previous;
[[gnu::always_inline]] suppress_scope() noexcept
: previous(tls_suppress) { tls_suppress = true; }
suppress_scope(const suppress_scope&) = delete;
suppress_scope& operator=(const suppress_scope&) = delete;
[[gnu::always_inline]] ~suppress_scope() { tls_suppress = previous; }
};This guarantees no call instruction is emitted for the guard regardless of TU, optimization level, or linker symbol selection.
| COMPILE_OPTIONS "-finstrument-functions") | ||
| target_link_libraries(instrument_test PRIVATE cyclescope::cyclescope | ||
| cyclescope::instrument doctest::doctest Threads::Threads) | ||
| target_compile_options(instrument_test PRIVATE -Wall -Wextra) |
There was a problem hiding this comment.
cyclescope_instrument library not compiled with sanitizer flags
When CYCLESCOPE_SANITIZE=thread (or address), the compile/link options are applied only to instrument_test but not to the cyclescope_instrument static library. Object code from src/instrument.cpp (the hooks, interned_name, frame_stack accesses) enters the final binary without sanitizer instrumentation, so TSan/ASan cannot observe data races or memory errors in that code.
The top-level CMakeLists.txt builds cyclescope_instrument unconditionally before the test option is read, so the simplest fix is to propagate the flag there or use an INTERFACE propagation:
if(CYCLESCOPE_SANITIZE)
target_compile_options(cyclescope_instrument PUBLIC
-fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer)
target_link_options(cyclescope_instrument PUBLIC
-fsanitize=${CYCLESCOPE_SANITIZE})
endif()(Use PUBLIC rather than PRIVATE so the flag is consistently applied to any target linking the library.)
Review: Compiler-instrumentation backendReviewed: The overall architecture — fixed-depth per-thread frame stacks, immortal collector/intern-map singletons, and the reentrancy-suppression flag — is well thought out. The two defects called out in the PR description (self-deadlock and teardown crash) are correctly diagnosed and fixed. Two substantive issues remain. Issue 1 — Infinite recursion in debug builds (high severity) Files:
At The same applies to the inline Fix: add Issue 2 — File: Sanitizer compile/link options are applied only to the Fix: propagate Everything else looks correct: lock ordering (registry_mutex_ always taken before buffer mutex), TLS teardown safety (trivially-destructible |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both findings applied. suppress_scope's constructor and destructor are forced inline (with the ODR rationale in a comment), and the fix is validated by an actual Debug (-O0) build: the instrumented demo runs to a clean exit where the described recursion would have overflowed. The instrument library now compiles and links with the sanitizer flags when CYCLESCOPE_SANITIZE is set; the cache variable is hoisted above the library block so the guard actually sees it. All three configs (Release, TSan, Debug) are 3/3 locally. 🤖 Generated with Claude Code |
| target_compile_options(cyclescope_instrument PRIVATE | ||
| -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) | ||
| target_link_options(cyclescope_instrument PUBLIC | ||
| -fsanitize=${CYCLESCOPE_SANITIZE}) |
There was a problem hiding this comment.
Sanitizer link flags propagate to uninstrumented consumers via PUBLIC scope.
target_link_options(... PUBLIC -fsanitize=...) on a static library propagates the flag to every consuming target. cyclescope_demo (and any downstream library user who sets CYCLESCOPE_SANITIZE) will link the sanitizer runtime without their code being compiled with -fsanitize. For TSAN this is genuinely problematic: partially-instrumented binaries can produce false positives because TSAN's shadow memory doesn't account for accesses in uninstrumented frames. For instrument_test the flag is harmlessly duplicated (once from this PUBLIC propagation, once from the test's own target_link_options).
Consider using PRIVATE for the link options and having each consuming executable opt in, as instrument_test already does:
| -fsanitize=${CYCLESCOPE_SANITIZE}) | |
| target_link_options(cyclescope_instrument PRIVATE | |
| -fsanitize=${CYCLESCOPE_SANITIZE}) |
If the intent is to auto-propagate to consumers, document the expectation explicitly and add the compile options to INTERFACE or PUBLIC too so the whole toolchain is consistent.
| #define CYCLESCOPE_ALWAYS_INLINE __forceinline | ||
| #else | ||
| #define CYCLESCOPE_ALWAYS_INLINE __attribute__((always_inline)) inline | ||
| #endif |
There was a problem hiding this comment.
CYCLESCOPE_ALWAYS_INLINE macro leaks into consumer namespace.
The macro is defined in a public header but never #undef'd. Any translation unit that includes <cyclescope/trace.hpp> inherits it and could conflict with a consumer-defined macro of the same name. Add #undef CYCLESCOPE_ALWAYS_INLINE immediately after the suppress_scope struct that uses it:
| #endif | |
| #define CYCLESCOPE_ALWAYS_INLINE __attribute__((always_inline)) inline | |
| #endif |
(with a #undef CYCLESCOPE_ALWAYS_INLINE inserted after the closing brace of suppress_scope)
| suppress_scope(const suppress_scope&) = delete; | ||
| suppress_scope& operator=(const suppress_scope&) = delete; | ||
| CYCLESCOPE_ALWAYS_INLINE ~suppress_scope() { tls_suppress = previous; } | ||
| }; |
There was a problem hiding this comment.
Belt-and-suspenders: suppress_scope ctor/dtor could also carry no_instrument_function.
The comment explains the correctness argument: always_inline forces the ctor/dtor to be inlined at every call site, so -finstrument-functions never inserts a function-entry/exit hook for them. This is sound in practice.
However, if an instrumented TU happens to emit an out-of-line copy (e.g. via a pointer-to-member, a virtual dispatch, or a compiler that declines to inline in certain debug/LTO paths), the entry hook would fire before tls_suppress = true is set, allowing a hook to re-enter and recurse unboundedly — exactly the failure mode the comment warns about.
Adding __attribute__((no_instrument_function)) to both the constructor and destructor provides an independent, compiler-enforced guarantee at zero runtime cost:
struct suppress_scope {
bool previous;
CYCLESCOPE_ALWAYS_INLINE __attribute__((no_instrument_function))
suppress_scope() noexcept : previous(tls_suppress) {
tls_suppress = true;
}
suppress_scope(const suppress_scope&) = delete;
suppress_scope& operator=(const suppress_scope&) = delete;
CYCLESCOPE_ALWAYS_INLINE __attribute__((no_instrument_function))
~suppress_scope() { tls_suppress = previous; }
};This is a defence-in-depth change, not a fix for a currently-triggered bug.
Review SummaryReviewed: CMakeLists.txt, src/instrument.cpp, include/cyclescope/trace.hpp, tests/instrument_test.cpp, tests/instrument_fixture.cpp, examples/demo.cpp. The suppression/reentrancy mechanism is well-designed and sound: tls_suppress + suppress_scope + no_instrument_function on the hooks + the immortal singleton correctly handle all the tricky lifecycle cases (hook re-entry, collector-mutex deadlock, teardown ordering). The frame stack overflow tracking and longjmp/exception mismatch guard (entered.fn == fn) are also correct. Three issues found:
|
…ument defense Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three cycle 2 findings applied: the static library no longer propagates the sanitizer link flag, and the demo opts into full sanitizer compile+link under CYCLESCOPE_SANITIZE so no binary mixes instrumentation (verified: TSan-built demo runs clean); the helper macros are undef'd after use; and suppress_scope carries no_instrument_function alongside always_inline. Findings plateaued; merging on green. 🤖 Generated with Claude Code |
Summary
Function-level tracing via -finstrument-functions: hooks in
cyclescope::instrument(a separately compiled, uninstrumented static library) record every call in the trace core; names resolve once per unique function via dladdr + demangling and are interned for the process lifetime.Closes #3
Changes
src/instrument.cpp: __cyg_profile hooks with fixed-depth (512) POD per-thread frame stacks, overflow counting, unmatched-frame protection (longjmp/exceptions), reentrancy + collector suppressioninclude/cyclescope/trace.hpp: thread-local suppress flag set inside record/write_json/clear and honored by the hooks; collector singleton made immortalexamples/demo.cpp: instrumented fib demo writingdemo.trace.jsonfor ui.perfetto.dev; ENABLE_EXPORTS so dladdr resolves executable symbols on Linuxcyclescope::instrumenttarget (non-MSVC), examples optionTwo defects found and fixed during bring-up (real runs, stack-sampled)
write_json; a hook fired while the thread held a buffer mutex andrecord()relocked it. Fixed by the suppression flag; regression-tested.Test plan
Assumptions
🤖 Generated with Claude Code