From fe116752a08fc0e3d7365c912e6bbf24768f4d13 Mon Sep 17 00:00:00 2001 From: Jason E Plumb Date: Wed, 16 Sep 2026 21:06:20 -0700 Subject: [PATCH 1/3] feat(instrument): compiler-instrumentation backend with demo -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 --- CMakeLists.txt | 15 ++++ examples/CMakeLists.txt | 6 ++ examples/demo.cpp | 35 +++++++++ include/cyclescope/trace.hpp | 24 ++++++- src/instrument.cpp | 135 +++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 23 ++++++ tests/instrument_fixture.cpp | 27 +++++++ tests/instrument_test.cpp | 63 ++++++++++++++++ 8 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 examples/CMakeLists.txt create mode 100644 examples/demo.cpp create mode 100644 src/instrument.cpp create mode 100644 tests/instrument_fixture.cpp create mode 100644 tests/instrument_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index db2a263..240db69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,11 +11,26 @@ target_include_directories(cyclescope INTERFACE ) target_compile_features(cyclescope INTERFACE cxx_std_20) +# Compiler-instrumentation backend: hooks for -finstrument-functions, +# compiled without instrumentation. Not available with MSVC. +if(NOT MSVC) + add_library(cyclescope_instrument STATIC src/instrument.cpp) + add_library(cyclescope::instrument ALIAS cyclescope_instrument) + target_link_libraries(cyclescope_instrument + PUBLIC cyclescope::cyclescope ${CMAKE_DL_LIBS}) + target_compile_features(cyclescope_instrument PUBLIC cxx_std_20) + target_compile_options(cyclescope_instrument PRIVATE -Wall -Wextra) +endif() + if(PROJECT_IS_TOP_LEVEL) include(CTest) option(CYCLESCOPE_BUILD_TESTS "Build cyclescope tests" ON) + option(CYCLESCOPE_BUILD_EXAMPLES "Build cyclescope examples" ON) set(CYCLESCOPE_SANITIZE "" CACHE STRING "Sanitizer for tests: thread, address, undefined, or empty") if(CYCLESCOPE_BUILD_TESTS AND BUILD_TESTING) add_subdirectory(tests) endif() + if(CYCLESCOPE_BUILD_EXAMPLES AND NOT MSVC) + add_subdirectory(examples) + endif() endif() diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..26d8311 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,6 @@ +add_executable(cyclescope_demo demo.cpp) +target_link_libraries(cyclescope_demo PRIVATE cyclescope::cyclescope + cyclescope::instrument) +target_compile_options(cyclescope_demo PRIVATE -finstrument-functions + -Wall -Wextra) +set_target_properties(cyclescope_demo PROPERTIES ENABLE_EXPORTS ON) diff --git a/examples/demo.cpp b/examples/demo.cpp new file mode 100644 index 0000000..fa755c6 --- /dev/null +++ b/examples/demo.cpp @@ -0,0 +1,35 @@ +// Instrumented demo: every function call in this file becomes a trace +// event. Build is compiled with -finstrument-functions and linked against +// cyclescope::instrument; running it writes demo.trace.json, which loads +// in the Perfetto UI (ui.perfetto.dev) or chrome://tracing. + +#include +#include + +namespace { + +__attribute__((noinline)) long fib(int n) { + return n < 2 ? n : fib(n - 1) + fib(n - 2); +} + +__attribute__((noinline)) long work(void) { + long total = 0; + for (int i = 0; i < 18; ++i) { + total += fib(i); + } + return total; +} + +} // namespace + +int main() { + const long total = work(); + std::uint64_t dropped = 0; + const bool ok = + cyclescope::collector::instance().write_json("demo.trace.json", + &dropped); + std::printf("fib total %ld; trace %s, %llu dropped\n", total, + ok ? "written to demo.trace.json" : "FAILED", + static_cast(dropped)); + return ok ? 0 : 1; +} diff --git a/include/cyclescope/trace.hpp b/include/cyclescope/trace.hpp index bc70c00..f690666 100644 --- a/include/cyclescope/trace.hpp +++ b/include/cyclescope/trace.hpp @@ -31,6 +31,19 @@ namespace detail { // thread's nontrivial thread_locals (including the buffer handle) are // gone. Set during TLS teardown; record() then drops late events. inline thread_local bool tls_shutdown = false; + +// True while collector internals run on this thread. Instrumentation +// hooks check it and stand down, so a hook firing inside record() or +// write_json() can never re-enter a lock this thread already holds. +inline thread_local bool tls_suppress = false; + +struct suppress_scope { + bool previous; + suppress_scope() noexcept : previous(tls_suppress) { tls_suppress = true; } + suppress_scope(const suppress_scope&) = delete; + suppress_scope& operator=(const suppress_scope&) = delete; + ~suppress_scope() { tls_suppress = previous; } +}; } // namespace detail struct trace_event { @@ -50,9 +63,13 @@ inline std::uint64_t trace_now_ns() { class collector { public: + // Deliberately immortal (never destroyed): instrumentation hooks and + // traced destructors can run during process teardown, and a destroyed + // collector would turn those into use-after-free. The buffers are + // reclaimed by the operating system at process exit. static collector& instance() { - static collector self; - return self; + static collector* self = new collector(); + return *self; } void set_capacity_per_thread(std::size_t capacity) { @@ -69,6 +86,7 @@ class collector { // destroyed; the event is dropped rather than touching dead TLS. return; } + detail::suppress_scope suppress; thread_buffer& mine = local_buffer(); std::lock_guard guard(mine.mutex); if (mine.events.size() >= capacity_.load(std::memory_order_relaxed)) { @@ -91,6 +109,7 @@ class collector { // snapshotted first (a transient copy of the event data), so recording // threads and thread registration never stall behind a slow disk. bool write_json(const char* path, std::uint64_t* dropped_total = nullptr) { + detail::suppress_scope suppress; std::vector> registered; { std::lock_guard registry_guard(registry_mutex_); @@ -152,6 +171,7 @@ class collector { // runs so their events still reach the trace; long-running programs with // thread churn should clear() after each flush. void clear() { + detail::suppress_scope suppress; std::lock_guard registry_guard(registry_mutex_); std::vector> kept; kept.reserve(buffers_.size()); diff --git a/src/instrument.cpp b/src/instrument.cpp new file mode 100644 index 0000000..2bb3f1b --- /dev/null +++ b/src/instrument.cpp @@ -0,0 +1,135 @@ +// Compiler-instrumentation backend: implements the hooks that +// -finstrument-functions inserts at every function entry and exit, and +// records each call as a complete event in the cyclescope collector. +// +// This translation unit must be compiled WITHOUT -finstrument-functions +// (the build system guarantees it); the attribute on each hook is a second +// line of defense. Usage: compile the code to trace with +// -finstrument-functions and link cyclescope::instrument. +// +// Names resolve lazily: the first return from a given function looks it up +// with dladdr, demangles it when possible, and interns the string for the +// process lifetime (the collector stores name pointers, not copies). +// Symbols outside the dynamic symbol table fall back to their address; on +// Linux, executables should enable exports (CMake ENABLE_EXPORTS) so +// dladdr can see them. + +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif +#if defined(__has_include) +#if __has_include() +#include +#define CYCLESCOPE_HAS_CXXABI 1 +#endif +#endif + +namespace { + +constexpr int kMaxDepth = 512; + +struct frame { + void* fn; + std::uint64_t start_ns; +}; + +// Trivially destructible on purpose: hooks can run during TLS teardown, +// and POD thread_local storage stays readable there. +struct frame_stack { + int depth = 0; + int beyond = 0; // Calls deeper than kMaxDepth, tracked but not recorded. +}; + +thread_local frame_stack tls_stack; +thread_local frame tls_frames[kMaxDepth]; + +const char* interned_name(void* fn) { + // Deliberately immortal, mutex included: exit hooks can fire during + // process teardown, after function-local statics with destructors are + // gone, and locking a destroyed mutex throws from a noexcept context. + static auto* mutex = new std::mutex(); + static auto* names = new std::unordered_map(); + + std::lock_guard guard(*mutex); + if (const auto it = names->find(fn); it != names->end()) { + return it->second; + } + + const char* resolved = nullptr; +#if !defined(_WIN32) + Dl_info info; + if (dladdr(fn, &info) != 0 && info.dli_sname != nullptr) { +#if defined(CYCLESCOPE_HAS_CXXABI) + int status = -1; + char* demangled = + abi::__cxa_demangle(info.dli_sname, nullptr, nullptr, &status); + resolved = (status == 0 && demangled != nullptr) + ? demangled + : ::strdup(info.dli_sname); +#else + resolved = ::strdup(info.dli_sname); +#endif + } +#endif + if (resolved == nullptr) { + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "fn@%p", fn); + resolved = ::strdup(buffer); + } + names->emplace(fn, resolved); + return resolved; +} + +} // namespace + +extern "C" { + +// Both hooks stand down while the suppress flag is set: it covers hook +// reentrancy AND collector internals. Without the latter, an instrumented +// copy of an inline collector function (the linker may pick one from an +// instrumented translation unit) fires a hook while this thread already +// holds a collector mutex, and record() self-deadlocks. +__attribute__((no_instrument_function)) void __cyg_profile_func_enter( + void* fn, void* /*call_site*/) { + if (cyclescope::detail::tls_suppress) { + return; + } + cyclescope::detail::suppress_scope suppress; + frame_stack& stack = tls_stack; + if (stack.beyond > 0 || stack.depth == kMaxDepth) { + ++stack.beyond; + } else { + tls_frames[stack.depth] = {fn, cyclescope::trace_now_ns()}; + ++stack.depth; + } +} + +__attribute__((no_instrument_function)) void __cyg_profile_func_exit( + void* fn, void* /*call_site*/) { + if (cyclescope::detail::tls_suppress) { + return; + } + cyclescope::detail::suppress_scope suppress; + frame_stack& stack = tls_stack; + if (stack.beyond > 0) { + --stack.beyond; + } else if (stack.depth > 0) { + --stack.depth; + const frame entered = tls_frames[stack.depth]; + // Longjmp or exceptions can skip exits; only record matched frames. + if (entered.fn == fn) { + cyclescope::collector::instance().record( + interned_name(fn), entered.start_ns, + cyclescope::trace_now_ns() - entered.start_ns); + } + } +} + +} // extern "C" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 09305ec..d10506a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,3 +30,26 @@ endfunction() cyclescope_add_test(log_test) cyclescope_add_test(trace_test) + +# The instrumentation backend test: only the fixture translation unit is +# compiled with -finstrument-functions, so the assertions run over a known +# call set instead of every doctest internal. ENABLE_EXPORTS lets dladdr +# resolve the fixture symbols from the executable on Linux. +if(NOT MSVC) + add_executable(instrument_test instrument_test.cpp instrument_fixture.cpp) + set_source_files_properties(instrument_fixture.cpp PROPERTIES + 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) + set_target_properties(instrument_test PROPERTIES ENABLE_EXPORTS ON) + if(CYCLESCOPE_SANITIZE) + target_compile_options(instrument_test PRIVATE + -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) + target_link_options(instrument_test PRIVATE + -fsanitize=${CYCLESCOPE_SANITIZE}) + endif() + add_test(NAME instrument_test COMMAND instrument_test) + # A suppression regression deadlocks rather than fails; bound it. + set_tests_properties(instrument_test PROPERTIES TIMEOUT 120) +endif() diff --git a/tests/instrument_fixture.cpp b/tests/instrument_fixture.cpp new file mode 100644 index 0000000..9d3ce60 --- /dev/null +++ b/tests/instrument_fixture.cpp @@ -0,0 +1,27 @@ +// Compiled WITH -finstrument-functions (see tests/CMakeLists.txt); the +// hooks in cyclescope::instrument trace every call made here. extern "C" +// keeps the symbol names stable for the assertions. + +#include + +extern "C" { + +// Regression shape: an instrumented translation unit instantiates the +// inline collector functions, the linker may select those copies +// program-wide, and a hook firing inside them must stand down instead of +// re-entering a lock this thread holds. +__attribute__((noinline)) int cyclescope_fixture_flush(const char* path) { + return cyclescope::collector::instance().write_json(path) ? 1 : 0; +} + +__attribute__((noinline)) void cyclescope_fixture_leaf(void) { + asm volatile(""); // Keeps the call from being optimized away. +} + +__attribute__((noinline)) void cyclescope_fixture_parent(void) { + for (int i = 0; i < 3; ++i) { + cyclescope_fixture_leaf(); + } +} + +} // extern "C" diff --git a/tests/instrument_test.cpp b/tests/instrument_test.cpp new file mode 100644 index 0000000..b73143e --- /dev/null +++ b/tests/instrument_test.cpp @@ -0,0 +1,63 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include +#include +#include +#include +#include + +extern "C" { +void cyclescope_fixture_parent(void); +int cyclescope_fixture_flush(const char* path); +} + +namespace { + +std::string slurp(const std::string& path) { + std::ifstream in(path); + std::ostringstream out; + out << in.rdbuf(); + return out.str(); +} + +std::size_t count_occurrences(const std::string& text, + const std::string& needle) { + std::size_t count = 0; + for (std::size_t at = text.find(needle); at != std::string::npos; + at = text.find(needle, at + needle.size())) { + ++count; + } + return count; +} + +} // namespace + +TEST_CASE("instrumented calls appear as named complete events") { + cyclescope::collector::instance().clear(); + cyclescope_fixture_parent(); + cyclescope_fixture_parent(); + + const std::string path = "cyclescope_test_instrument.trace.json"; + std::uint64_t dropped = 1; + REQUIRE(cyclescope::collector::instance().write_json(path.c_str(), + &dropped)); + const std::string json = slurp(path); + std::remove(path.c_str()); + + CHECK(dropped == 0); + CHECK(count_occurrences(json, "\"name\":\"cyclescope_fixture_parent\"") == + 2); + CHECK(count_occurrences(json, "\"name\":\"cyclescope_fixture_leaf\"") == 6); +} + +TEST_CASE("flushing from instrumented code does not deadlock") { + cyclescope::collector::instance().clear(); + cyclescope_fixture_parent(); + const std::string path = "cyclescope_test_instrument_flush.trace.json"; + CHECK(cyclescope_fixture_flush(path.c_str()) == 1); + const std::string json = slurp(path); + std::remove(path.c_str()); + CHECK(count_occurrences(json, "\"name\":\"cyclescope_fixture_parent\"") == + 1); +} From 3043a9fb89be7074782df395c56173694a59eae1 Mon Sep 17 00:00:00 2001 From: Jason E Plumb Date: Wed, 16 Sep 2026 21:19:21 -0700 Subject: [PATCH 2/3] fix(instrument): always_inline suppress guard, sanitized backend library Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 10 +++++++++- include/cyclescope/trace.hpp | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 240db69..f651b29 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,8 @@ target_include_directories(cyclescope INTERFACE ) target_compile_features(cyclescope INTERFACE cxx_std_20) +set(CYCLESCOPE_SANITIZE "" CACHE STRING "Sanitizer for tests: thread, address, undefined, or empty") + # Compiler-instrumentation backend: hooks for -finstrument-functions, # compiled without instrumentation. Not available with MSVC. if(NOT MSVC) @@ -20,13 +22,19 @@ if(NOT MSVC) PUBLIC cyclescope::cyclescope ${CMAKE_DL_LIBS}) target_compile_features(cyclescope_instrument PUBLIC cxx_std_20) target_compile_options(cyclescope_instrument PRIVATE -Wall -Wextra) + # The hooks and frame stacks must be visible to the sanitizer too. + if(CYCLESCOPE_SANITIZE AND NOT MSVC) + target_compile_options(cyclescope_instrument PRIVATE + -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) + target_link_options(cyclescope_instrument PUBLIC + -fsanitize=${CYCLESCOPE_SANITIZE}) + endif() endif() if(PROJECT_IS_TOP_LEVEL) include(CTest) option(CYCLESCOPE_BUILD_TESTS "Build cyclescope tests" ON) option(CYCLESCOPE_BUILD_EXAMPLES "Build cyclescope examples" ON) - set(CYCLESCOPE_SANITIZE "" CACHE STRING "Sanitizer for tests: thread, address, undefined, or empty") if(CYCLESCOPE_BUILD_TESTS AND BUILD_TESTING) add_subdirectory(tests) endif() diff --git a/include/cyclescope/trace.hpp b/include/cyclescope/trace.hpp index f690666..ae9ab62 100644 --- a/include/cyclescope/trace.hpp +++ b/include/cyclescope/trace.hpp @@ -37,12 +37,25 @@ inline thread_local bool tls_shutdown = false; // write_json() can never re-enter a lock this thread already holds. inline thread_local bool tls_suppress = false; +// The constructor and destructor are forced inline: an out-of-line copy +// emitted by an instrumented translation unit could be selected by the +// linker, and its own entry would fire a hook before the flag is set, +// recursing without bound in unoptimized builds. +#if defined(_MSC_VER) +#define CYCLESCOPE_ALWAYS_INLINE __forceinline +#else +#define CYCLESCOPE_ALWAYS_INLINE __attribute__((always_inline)) inline +#endif + struct suppress_scope { bool previous; - suppress_scope() noexcept : previous(tls_suppress) { tls_suppress = true; } + CYCLESCOPE_ALWAYS_INLINE suppress_scope() noexcept + : previous(tls_suppress) { + tls_suppress = true; + } suppress_scope(const suppress_scope&) = delete; suppress_scope& operator=(const suppress_scope&) = delete; - ~suppress_scope() { tls_suppress = previous; } + CYCLESCOPE_ALWAYS_INLINE ~suppress_scope() { tls_suppress = previous; } }; } // namespace detail From 987d0f8fbfedcfe7724e6e556830fdef5ae46ed5 Mon Sep 17 00:00:00 2001 From: Jason E Plumb Date: Wed, 16 Sep 2026 21:32:02 -0700 Subject: [PATCH 3/3] fix(instrument): consistent sanitizer opt-in, macro hygiene, no_instrument defense Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 4 ++-- examples/CMakeLists.txt | 6 ++++++ include/cyclescope/trace.hpp | 11 +++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f651b29..6edb31d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,11 +23,11 @@ if(NOT MSVC) target_compile_features(cyclescope_instrument PUBLIC cxx_std_20) target_compile_options(cyclescope_instrument PRIVATE -Wall -Wextra) # The hooks and frame stacks must be visible to the sanitizer too. + # Consumers opt in to the sanitizer runtime themselves (tests and + # examples do), keeping instrumentation consistent per binary. if(CYCLESCOPE_SANITIZE AND NOT MSVC) target_compile_options(cyclescope_instrument PRIVATE -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) - target_link_options(cyclescope_instrument PUBLIC - -fsanitize=${CYCLESCOPE_SANITIZE}) endif() endif() diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 26d8311..f541874 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -4,3 +4,9 @@ target_link_libraries(cyclescope_demo PRIVATE cyclescope::cyclescope target_compile_options(cyclescope_demo PRIVATE -finstrument-functions -Wall -Wextra) set_target_properties(cyclescope_demo PROPERTIES ENABLE_EXPORTS ON) +if(CYCLESCOPE_SANITIZE) + target_compile_options(cyclescope_demo PRIVATE + -fsanitize=${CYCLESCOPE_SANITIZE} -g -fno-omit-frame-pointer) + target_link_options(cyclescope_demo PRIVATE + -fsanitize=${CYCLESCOPE_SANITIZE}) +endif() diff --git a/include/cyclescope/trace.hpp b/include/cyclescope/trace.hpp index ae9ab62..023dafb 100644 --- a/include/cyclescope/trace.hpp +++ b/include/cyclescope/trace.hpp @@ -43,20 +43,27 @@ inline thread_local bool tls_suppress = false; // recursing without bound in unoptimized builds. #if defined(_MSC_VER) #define CYCLESCOPE_ALWAYS_INLINE __forceinline +#define CYCLESCOPE_NO_INSTRUMENT #else #define CYCLESCOPE_ALWAYS_INLINE __attribute__((always_inline)) inline +#define CYCLESCOPE_NO_INSTRUMENT __attribute__((no_instrument_function)) #endif struct suppress_scope { bool previous; - CYCLESCOPE_ALWAYS_INLINE suppress_scope() noexcept + CYCLESCOPE_ALWAYS_INLINE CYCLESCOPE_NO_INSTRUMENT 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 ~suppress_scope() { tls_suppress = previous; } + CYCLESCOPE_ALWAYS_INLINE CYCLESCOPE_NO_INSTRUMENT ~suppress_scope() { + tls_suppress = previous; + } }; + +#undef CYCLESCOPE_ALWAYS_INLINE +#undef CYCLESCOPE_NO_INSTRUMENT } // namespace detail struct trace_event {