Repository navigation
Trace core: events, buffers, Perfetto emitter, scope macro - #6
Conversation
Per-thread bounded buffers with drop counting behind a registry that outlives exiting threads; recording locks only the caller's own buffer. write_json emits chrome trace-event complete events (ts/dur in microseconds, escaped names) loadable by the Perfetto UI. RAII trace_scope and the CYCLESCOPE_SCOPE / CYCLESCOPE_SCOPE_FUNC macros cover the annotation surface. Closes #2 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| std::uint64_t duration_ns) { | ||
| thread_buffer& mine = local_buffer(); | ||
| std::lock_guard guard(mine.mutex); | ||
| if (mine.events.size() >= capacity_.load(std::memory_order_relaxed)) { |
There was a problem hiding this comment.
OOM in destructor → std::terminate
push_back can throw std::bad_alloc when the vector reallocates (the size-vs-capacity_ check guards the logical limit but not the allocator). That exception propagates through record() and then through trace_scope::~trace_scope(), which is implicitly noexcept in C++11+, causing std::terminate.
Minimal fix — catch and count:
| if (mine.events.size() >= capacity_.load(std::memory_order_relaxed)) { | |
| try { | |
| mine.events.push_back({name, start_ns, duration_ns}); | |
| } catch (const std::bad_alloc&) { | |
| ++mine.dropped; | |
| } |
Alternatively, reserve(capacity) in the thread_buffer constructor so push_back never allocates; that trades upfront memory for no-throw guarantee.
| // Registered once per thread; the registry keeps the buffer alive after | ||
| // the thread exits so its events still reach the trace. | ||
| thread_buffer& local_buffer() { | ||
| thread_local std::shared_ptr<thread_buffer> mine = [this] { |
There was a problem hiding this comment.
buffers_ grows without bound on thread churn
Every OS thread that calls record() pushes a shared_ptr<thread_buffer> here and that entry is never removed. For programs using thread pools or many short-lived threads this is an unbounded memory leak; it also makes each write_json() and clear() walk increasingly large numbers of empty, dead-thread buffers.
A low-overhead mitigation: mark the buffer "dead" from the thread_local destructor (a std::atomic<bool> alive{true} in thread_buffer, flipped to false when the shared_ptr in thread_local is destroyed via a custom deleter). Then write_json() can skip dead-and-empty buffers, and a future compact() can prune them under registry_mutex_.
| first ? "" : ",", name.c_str(), | ||
| static_cast<double>(event.start_ns) / 1e3, | ||
| static_cast<double>(event.duration_ns) / 1e3, | ||
| buffer->tid) >= 0; |
There was a problem hiding this comment.
Malformed JSON left on disk when any I/O error occurs
ok = ok && fputs(...) short-circuits: once ok is false this line is skipped, so the file on disk ends with a truncated event list — no closing ]}. The function correctly returns false, but Perfetto's JSON parser will reject the file silently, and callers that don't propagate the error (e.g. fire-and-forget flush on shutdown) may be left confused.
Consider writing the footer unconditionally so the output is always well-formed JSON (even if truncated mid-list earlier):
| buffer->tid) >= 0; | |
| std::fputs("\n]}\n", out); | |
| ok = ok && !std::ferror(out); | |
| ok = (std::fclose(out) == 0) && ok; |
At minimum, document that the file should be unlinked if write_json returns false.
Review summary — 3 issues foundReviewed 1.
|
…tional footer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three findings applied. record() is noexcept and counts an allocation failure as a dropped event, so a trace_scope destructor can never terminate the program. Buffers carry a liveness flag flipped from the thread_local handle's destructor; clear() prunes dead buffers, tids come from a monotonic counter so pruning never recycles them, and the churn contract is documented (clear after each flush). The JSON footer is written unconditionally so the on-disk file stays syntactically complete even after a mid-write failure. New test covers churn pruning and tid stability. 🤖 Generated with Claude Code |
| // - Event names must outlive the collector (string literals, __func__, or | ||
| // otherwise static storage); the buffer stores the pointer, not a copy. | ||
| // - Recording is safe from any thread. Each thread locks only its own | ||
| // buffer's mutex, which is uncontended except while a flush snapshots it. |
There was a problem hiding this comment.
This contract claim is inaccurate. write_json holds buffer->mutex for the entire inner event loop, including every fprintf call (lines 86–100). That is not a brief snapshot — it is a full file-write under lock. If the underlying I/O stalls (NFS, disk pressure, full buffer), any thread trying to record() into that buffer blocks for the same duration.
The fix is to copy events under the lock and then write without holding it:
std::vector<trace_event> snapshot;
uint64_t local_dropped;
{
std::lock_guard buffer_guard(buffer->mutex);
snapshot = buffer->events;
local_dropped = buffer->dropped;
}
dropped += local_dropped;
for (const auto& event : snapshot) { ... }The comment should then read "uncontended except while a flush copies it."
|
|
||
| std::lock_guard registry_guard(registry_mutex_); | ||
| for (const auto& buffer : buffers_) { | ||
| std::lock_guard buffer_guard(buffer->mutex); |
There was a problem hiding this comment.
buffer_guard is held across all fprintf calls in the inner loop (lines 88–100). For a buffer with many events this keeps buffer->mutex locked for the full duration of the disk write, blocking any concurrent record() call on the same thread's buffer. See the comment on line 22 — a snapshot-then-write pattern fixes both the documentation mismatch and the liveness issue.
| std::fprintf( | ||
| out, | ||
| "%s\n{\"name\":\"%s\",\"cat\":\"scope\",\"ph\":\"X\"," | ||
| "\"ts\":%.3f,\"dur\":%.3f,\"pid\":1,\"tid\":%u}", |
There was a problem hiding this comment.
%u expects unsigned int, but buffer->tid is std::uint32_t, which may be unsigned long on some platforms (ILP32L LP64). This is undefined behavior per the C standard. Use PRIu32 from <cinttypes>:
| "\"ts\":%.3f,\"dur\":%.3f,\"pid\":1,\"tid\":%u}", | |
| "\"ts\":%.3f,\"dur\":%.3f,\"pid\":1,\"tid\":%" PRIu32 "}", |
(Add #include <cinttypes> to the include block.)
| std::uint64_t dropped = 0; | ||
| bool first = true; | ||
|
|
||
| std::lock_guard registry_guard(registry_mutex_); |
There was a problem hiding this comment.
registry_mutex_ is held for the entire write_json call — including all formatting and I/O. Any thread whose local_buffer() hasn't been called yet (first event on that thread) will block on registry_mutex_ for the full flush duration, because thread-local buffer registration also acquires this lock (line 161). Consider snapshotting buffers_ under the registry lock and then releasing it before writing.
| } | ||
| } | ||
| // The footer is written even after an earlier failure, so the file on | ||
| // disk is syntactically complete JSON whenever the filesystem allows. |
There was a problem hiding this comment.
The "syntactically complete JSON" guarantee only holds when the header write on line 80 succeeded. If fputs("{\"traceEvents\":[", out) fails, ok becomes false, no events are written (short-circuit), but the footer "\n]}\n" is still unconditionally written — leaving a file containing only \n]}\n, which is not valid JSON.
Additionally, if a fprintf inside the loop partially writes before failing (which fprintf can do), the file may contain a truncated event object, also invalid JSON.
Consider only writing the footer when the header succeeded, or noting in the comment that the guarantee applies only to I/O that fails cleanly (complete-write or no-write semantics).
Review summary — 4 issues found1.
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All four cycle 2 findings applied. write_json snapshots the registry and every buffer up front (documented as a transient copy) and performs all file I/O with no lock held, so recording threads and thread registration never wait on the disk. tid formatting uses PRIu32. The footer is written only once the header succeeded, and the completeness guarantee in the comment is scoped accordingly. This is cycle 3 of the review; merging on green per the cycle policy. 🤖 Generated with Claude Code |
|
|
||
| std::mutex registry_mutex_; | ||
| std::vector<std::shared_ptr<thread_buffer>> buffers_; | ||
| std::uint32_t next_tid_ = 1; // Guarded by registry_mutex_. |
There was a problem hiding this comment.
Bug: next_tid_ wraps at 2^32, contradicting the "never recycles" invariant
next_tid_ is uint32_t, so after exactly 2^32 thread registrations it wraps back to 0 (or 1 on the first overflow if it starts at 1) and silently reuses tid values. The comment on lines 179–180 says "Tids come from a monotonic counter, so pruning never recycles them" — that claim is only correct before the overflow.
For programs with heavy thread churn this means two live buffers in buffers_ can end up with the same tid, producing a trace where Perfetto merges unrelated threads on the same lane, silently corrupting the timeline.
Fix: use uint64_t (essentially infinite) or detect overflow and either assert or wrap to a reserved "overflow" tid value.
| // the thread exits so its events still reach the trace. Tids come from a | ||
| // monotonic counter, so pruning never recycles them. | ||
| thread_buffer& local_buffer() { | ||
| thread_local buffer_handle handle = [this] { |
There was a problem hiding this comment.
Potential UB: "phoenix singleton" — accessing a destroyed thread-local during thread exit
handle is a function-local thread_local, so its constructor runs the first time this thread calls local_buffer() and its destructor runs at thread exit in LIFO order relative to other thread-locals.
The hazard: any thread-local object that was constructed before the first call to record() in this thread will be destroyed after handle. If that object's destructor calls record() (directly or via a library function like a logger), the program re-enters local_buffer() and dereferences handle.buffer — a destroyed object — which is undefined behaviour.
This is the classic "phoenix singleton" problem. A minimal mitigation is a thread_local bool dead-flag:
thread_local bool tls_dead = false;
~buffer_handle() {
tls_dead = true; // mark before locking, so record() can check
if (buffer != nullptr) { ... }
}
void record(...) noexcept {
if (tls_dead) return; // thread is unwinding, buffer gone
...
}The bool is always initialized before handle (it's a trivially-initialized thread_local), so it's always valid to read.
| if (dropped_total != nullptr) { | ||
| *dropped_total = dropped; | ||
| } |
There was a problem hiding this comment.
Logic error: output parameter dropped_total is written before fopen, so it is mutated even when the function returns false
*dropped_total is set here (line 100–101), but fopen isn't attempted until line 103. If fopen fails the function returns false at line 105 — yet *dropped_total already holds the snapshot value.
A caller that passes a non-null dropped_total and only consults it on success is fine, but the API contract documented in the comment ("receives the number of events lost to capacity limits") doesn't say it's conditional on success. Moving the assignment to just before return ok (or only when ok is true) would make the behaviour unambiguous:
ok = (std::fclose(out) == 0) && ok;
if (dropped_total != nullptr && ok) {
*dropped_total = dropped;
}
return ok;Alternatively, document explicitly that dropped_total may be updated even on failure.
Review summaryThree substantive issues found — all in Bugs / UB
API correctness
Everything else looks solid: the two-phase snapshot design is correct, mutex nesting is deadlock-free, the escape function is JSON-spec compliant, |
…tal documented Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three cycle 3 findings applied: tids are uint64 (never wrap), a trivially destructible thread_local flag guards record() against traced destructors running after TLS teardown (late events drop safely), and dropped_total is documented as always written. Cycle limit reached with findings at hardening level; merging on green. 🤖 Generated with Claude Code |
Summary
The heart of the library: threads record complete events into their own buffers and the collector writes chrome trace-event JSON that Perfetto and chrome://tracing load.
Closes #2
Changes
include/cyclescope/trace.hpp:collectorsingleton with per-thread buffers (each thread locks only its own mutex on the hot path; a flush snapshots buffers briefly), bounded capacity with drop counting reported bywrite_json, registry keeps buffers of exited threads aliveph:"X"complete events, ts/dur in microseconds, JSON-escaped names, small stable tidstrace_scopeRAII plusCYCLESCOPE_SCOPE(name)andCYCLESCOPE_SCOPE_FUNC(); names require static lifetime (documented)Test plan
Assumptions
🤖 Generated with Claude Code