LTTng flight-recorder root-cause analysis
When a concurrency bug is intermittent and the assertion fires deep in a
hot path, the three usual tools fail in three different ways:
- Static reading can't tell you the interleaving that actually happened.
- printf perturbs the timing — the µs-scale window you're hunting often
vanishes when you add I/O — and floods you with output from the wrong threads.
- A debugger stops the world; the race won't reproduce under a breakpoint,
and you can't single-step a 192-thread interleaving.
LTTng flight-recorder (snapshot) mode is the tool that fits: near-zero overhead
ring buffers per CPU, a global high-resolution clock so events from different
CPUs are comparable, and an on-demand dump of exactly the window leading up to
the failure. You instrument the culprit to fire a violation tracepoint, dump
the snapshot, and abort; then you read the last events before the abort and the
interleaving walks you to the cause. This is the tool of last resort for
concurrency bugs — reach for it once you've ruled out the cheap explanations.
The setup: snapshot + violation + abort
Flight-recorder (snapshot) session, small per-CPU buffers. Snapshot mode
keeps a rolling overwrite buffer in memory and only writes to disk when you
ask. Start small so the dump is a tight window around the failure:
lttng create mysess --snapshot
lttng enable-channel --userspace --subbuf-size=64K --num-subbuf=4 ch
lttng enable-event --userspace --channel ch 'myprovider:*'
lttng start
64 KiB/CPU is the CLAUDE.md default, and small is right for two reasons,
not one: (a) the dump is a tight window around the failure, so it decodes
fast and you read only the relevant events; (b) — the one that actually
matters for reproducing the bug — a 64 KiB ring stays resident in L2, so
the tracepoint stores don't evict the working set into L3/DRAM. A large
(multi-MiB) ring pollutes the cache and perturbs the very µs-scale race
window you're hunting; the bug can stop reproducing under heavy tracing for
the same reason it stops under printf. Keep the ring small to keep the
timing faithful. If your per-step tracepoints (below) are high-rate and the
interesting window scrolls out before the violation, FIRST cut the event
rate — disable the flooding per-iteration event and recover its data another
way (e.g. from a core, or a single enriched violation event) — and only bump
the subbuf size as a last resort (e.g. 256K × 4 = 1 MiB/CPU), as little as
you need; a bigger buffer means both more events to read and more timing
disturbance.
Emit the violation from the culprit, then snapshot, then abort. At the
exact check that detects the corruption, fire an enriched tracepoint, persist
the in-memory ring, and crash so nothing overwrites the window:
if (corruption_detected) {
FT_TP(violation, /* discriminating state — see below */);
(void) system("lttng snapshot record 1>&2");
abort();
}
Gate all of this behind a build flag (e.g. -DFT_ENABLE_TRACING) so it
compiles out of production and your normal test matrix.
Read the window:
lttng stop
babeltrace2 ~/lttng-traces/mysess-*/ > trace.txt
Instrumentation discipline
Tracepoints, never printf — timing matters. The bug lives in a
sub-microsecond window; printf's I/O perturbs it out of existence and serializes
threads. Tracepoint emission is a few hundred ns into a lock-free per-CPU buffer.
Make the violation event self-diagnosing. Don't just record "it failed" —
record the state that discriminates between hypotheses. For a bad pointer,
the high-value fields are usually:
- the object's identity and a round-trip check (e.g. resolve the object
by its own back-reference and compare): if it round-trips to itself the
object is valid; if not, it's recycled / stale memory. This one field
instantly separates "use-after-free of recycled memory" from "valid object,
wrong links."
- liveness counters (child count, refcount): zero/garbage ⇒ freed.
- the relevant back-pointers (parent, prev) so you can see which links were
and weren't wired.
These let you classify the failure from the violation event alone, before you
even read the surrounding window.
Add enter/step tracepoints to follow the algorithm. One tracepoint at the
entry of the suspect routine and one per iteration of its core loop (carrying
the loop variables) reconstruct the control flow that reached the violation —
you see the path, not just the endpoint.
Mind the LTTNG_UST_TP_ARGS limit. lttng-ust caps a tracepoint at ~10
argument pairs. Exceed it and you get a cryptic macro error like
unknown type name 'LTTNG_UST__TP_EXPROTOconst' (the arg-count machinery ran
off the end). Keep a violation event ≤ ~8 fields; drop redundant ones (e.g. a
field that's always NULL at the violation, or one a round-trip already
implies). Pointer fields use lttng_ust_field_integer_hex(uintptr_t, name, (uintptr_t) val); counters use lttng_ust_field_integer(...).
Reading the trace — the patterns that crack it
Read the full window, all CPUs, with ns timestamps and raw addresses.
Then grep by address to pull every event touching the culprit object(s)
across all threads, in time order. This reconstructs the cross-thread
interleaving that no static reading could show. Note the cpu_id on each
event to separate the writer thread from the reader thread.
Distinguish trace markers from the actual memory operation. A tracepoint
at a function's entry fires before the store inside it. Don't read the
tracepoint timestamp as the store's timestamp — find the event that
corresponds to the real rcu_assign / publish (often a different,
later marker). Mis-attributing the store's time sends you chasing ghosts.
THE key pattern — the stale-after-write "paradox" is a happens-before gap,
not a timing bug. If the trace shows a field written at time T and read
stale at T+Δ on the same object with no intervening write anywhere,
that is not a contradiction and not "the store didn't land yet" (Δ can be
microseconds). It means the reader reached that field through a pointer that
was published before the field's store, so there is no release-consume
edge carrying the store to the reader — the stale read is legal at any
wall-clock delta. Treat the paradox as a signal: find which earlier publish
anchored the reader's data-dependency (consume) chain, and you've found the
mis-ordered publish. The fix is to publish the field before the pointer
that lets readers reach it (see the rcu-mutation skill: wire back-pointers
before the forward/back-channel publish; fresh edges before the live
re-parent edge).
Walk backwards from the abort. The violation event is the last thing in
the buffer. The few events just before it — on any CPU — are the proximate
cause. Follow the addresses upward until the picture is consistent.
After you've found it
- Strip the temporary enter/step/violation tracepoints and the
system("lttng snapshot record") + abort() from the code before committing
(they were scaffolding; the build flag kept them out of the matrix, but don't
leave dead diagnostic noise in the source). Keep the durable, low-rate
tracepoints if they have ongoing value.
- A correct invariant you discovered while instrumenting may deserve to become
a permanent assertion / verify-pass — but only commit it once the code
actually satisfies it, or it turns the tree red on a pre-existing,
non-destructive gap (scope it as separate work).
Worked example (userspace-rcu fractal trie — holder != NULL)
Symptom: an ordered-traversal reader intermittently hit assert(holder != NULL)
in an up-walk (ft_skip_reanchor) under empty→rebuild churn — ~88% repro, but
no static reading found it.
Added a reanchor_violation tracepoint (the reached node, a round-trip
metadata_to_item check, its child count, and the relevant back-pointers) +
reanchor_enter/reanchor_step to follow the up-walk, all behind
-DFT_ENABLE_TRACING; snapshot + abort on the violation.
The violation event alone said: round-trip == self (so valid, not
recycled), child-count == 1 (live), back-pointer set — yet parent == NULL. So: a valid, live node, reachable, with an unwired parent.
The paradox: the writer set that node's parent at T, the reader read NULL
at T+2.3µs, same metadata object, no intervening write. → happens-before gap.
The full window (grepping the node's address across CPUs) showed the writer
publishing a recompacted cluster into the live tree by setting a live
re-parented child's back-pointer (a back-channel publish) before wiring a
fresh sibling child's parent. The reader entered via the live child, so
its consume chain anchored before the fresh-parent store → legal stale NULL.
Fix: at publish, wire the fresh edge (and the cluster top's own back-pointer)
first and the live re-parent edge last. ~88% failure → 0/96.
LTTng didn't just confirm a hypothesis — the enriched violation event and the
all-CPU window generated the explanation that static analysis had missed.
1---2name: lttng-tracing-root-cause-analysis3description: Methodology for root-causing hard concurrency / memory-ordering bugs (intermittent races, use-after-free, RCU/lock-free publish-order defects, "impossible" stale reads) with LTTng flight-recorder (snapshot) tracing — when static analysis, printf, and a debugger all fall short. Covers the snapshot+violation+abort setup, tracepoint instrumentation discipline (why tracepoints not printf), how to enrich a violation event so the trace is self-diagnosing, and the trace-reading patterns that crack these bugs (notably: a stale-after-write "paradox" is a happens-before gap, not a timing bug).4---56# LTTng flight-recorder root-cause analysis78When a concurrency bug is intermittent and the assertion fires deep in a9hot path, the three usual tools fail in three different ways:1011- **Static reading** can't tell you the *interleaving* that actually happened.12- **printf** perturbs the timing — the µs-scale window you're hunting often13 vanishes when you add I/O — and floods you with output from the wrong threads.14- **A debugger** stops the world; the race won't reproduce under a breakpoint,15 and you can't single-step a 192-thread interleaving.1617LTTng flight-recorder (snapshot) mode is the tool that fits: near-zero overhead18ring buffers per CPU, a global high-resolution clock so events from different19CPUs are comparable, and an on-demand dump of exactly the window leading up to20the failure. You instrument the *culprit* to fire a violation tracepoint, dump21the snapshot, and abort; then you read the last events before the abort and the22interleaving walks you to the cause. This is the tool of last resort for23concurrency bugs — reach for it once you've ruled out the cheap explanations.2425## The setup: snapshot + violation + abort26271. **Flight-recorder (snapshot) session, small per-CPU buffers.** Snapshot mode28 keeps a rolling overwrite buffer in memory and only writes to disk when you29 ask. Start small so the dump is a tight window around the failure:3031 ```32 lttng create mysess --snapshot33 lttng enable-channel --userspace --subbuf-size=64K --num-subbuf=4 ch34 lttng enable-event --userspace --channel ch 'myprovider:*'35 lttng start36 ```3738 64 KiB/CPU is the CLAUDE.md default, and small is right for two reasons,39 not one: (a) the dump is a tight window around the failure, so it decodes40 fast and you read only the relevant events; (b) — the one that actually41 matters for *reproducing* the bug — a 64 KiB ring stays resident in L2, so42 the tracepoint stores don't evict the working set into L3/DRAM. A large43 (multi-MiB) ring pollutes the cache and perturbs the very µs-scale race44 window you're hunting; the bug can stop reproducing under heavy tracing for45 the same reason it stops under printf. Keep the ring small to keep the46 timing faithful. If your per-step tracepoints (below) are high-rate and the47 interesting window scrolls out before the violation, FIRST cut the event48 rate — disable the flooding per-iteration event and recover its data another49 way (e.g. from a core, or a single enriched violation event) — and only bump50 the subbuf size as a last resort (e.g. 256K × 4 = 1 MiB/CPU), as little as51 you need; a bigger buffer means both more events to read and more timing52 disturbance.53542. **Emit the violation from the culprit, then snapshot, then abort.** At the55 exact check that detects the corruption, fire an enriched tracepoint, persist56 the in-memory ring, and crash so nothing overwrites the window:5758 ```c59 if (corruption_detected) {60 FT_TP(violation, /* discriminating state — see below */);61 (void) system("lttng snapshot record 1>&2");62 abort();63 }64 ```6566 Gate all of this behind a build flag (e.g. `-DFT_ENABLE_TRACING`) so it67 compiles out of production and your normal test matrix.68693. **Read the window:**7071 ```72 lttng stop73 babeltrace2 ~/lttng-traces/mysess-*/ > trace.txt74 ```7576## Instrumentation discipline7778- **Tracepoints, never printf — timing matters.** The bug lives in a79 sub-microsecond window; printf's I/O perturbs it out of existence and serializes80 threads. Tracepoint emission is a few hundred ns into a lock-free per-CPU buffer.8182- **Make the violation event self-diagnosing.** Don't just record "it failed" —83 record the state that *discriminates between hypotheses*. For a bad pointer,84 the high-value fields are usually:85 - the object's identity *and* a **round-trip check** (e.g. resolve the object86 by its own back-reference and compare): if it round-trips to itself the87 object is valid; if not, it's **recycled / stale memory**. This one field88 instantly separates "use-after-free of recycled memory" from "valid object,89 wrong links."90 - liveness counters (child count, refcount): zero/garbage ⇒ freed.91 - the relevant back-pointers (parent, prev) so you can see which links were92 and weren't wired.9394 These let you classify the failure from the violation event alone, before you95 even read the surrounding window.9697- **Add enter/step tracepoints to follow the algorithm.** One tracepoint at the98 entry of the suspect routine and one per iteration of its core loop (carrying99 the loop variables) reconstruct the control flow that reached the violation —100 you see the *path*, not just the endpoint.101102- **Mind the `LTTNG_UST_TP_ARGS` limit.** lttng-ust caps a tracepoint at ~10103 argument pairs. Exceed it and you get a cryptic macro error like104 `unknown type name 'LTTNG_UST__TP_EXPROTOconst'` (the arg-count machinery ran105 off the end). Keep a violation event ≤ ~8 fields; drop redundant ones (e.g. a106 field that's always NULL at the violation, or one a round-trip already107 implies). Pointer fields use `lttng_ust_field_integer_hex(uintptr_t, name,108 (uintptr_t) val)`; counters use `lttng_ust_field_integer(...)`.109110## Reading the trace — the patterns that crack it111112- **Read the full window, all CPUs, with ns timestamps and raw addresses.**113 Then **grep by address** to pull every event touching the culprit object(s)114 across all threads, in time order. This reconstructs the cross-thread115 interleaving that no static reading could show. Note the `cpu_id` on each116 event to separate the writer thread from the reader thread.117118- **Distinguish trace *markers* from the actual memory operation.** A tracepoint119 at a function's entry fires *before* the store inside it. Don't read the120 tracepoint timestamp as the store's timestamp — find the event that121 corresponds to the *real* `rcu_assign` / publish (often a different,122 later marker). Mis-attributing the store's time sends you chasing ghosts.123124- **THE key pattern — the stale-after-write "paradox" is a happens-before gap,125 not a timing bug.** If the trace shows a field *written* at time T and *read126 stale* at T+Δ on the **same object** with **no intervening write anywhere**,127 that is not a contradiction and not "the store didn't land yet" (Δ can be128 microseconds). It means the reader reached that field through a pointer that129 was **published before the field's store**, so there is **no release-consume130 edge** carrying the store to the reader — the stale read is legal at *any*131 wall-clock delta. Treat the paradox as a signal: find which *earlier* publish132 anchored the reader's data-dependency (consume) chain, and you've found the133 mis-ordered publish. The fix is to publish the field **before** the pointer134 that lets readers reach it (see the `rcu-mutation` skill: wire back-pointers135 before the forward/back-channel publish; fresh edges before the live136 re-parent edge).137138- **Walk backwards from the abort.** The violation event is the last thing in139 the buffer. The few events just before it — on *any* CPU — are the proximate140 cause. Follow the addresses upward until the picture is consistent.141142## After you've found it143144- Strip the temporary enter/step/violation tracepoints and the145 `system("lttng snapshot record")` + `abort()` from the code before committing146 (they were scaffolding; the build flag kept them out of the matrix, but don't147 leave dead diagnostic noise in the source). Keep the durable, low-rate148 tracepoints if they have ongoing value.149- A correct invariant you *discovered* while instrumenting may deserve to become150 a permanent assertion / verify-pass — but only commit it once the code151 actually satisfies it, or it turns the tree red on a pre-existing,152 non-destructive gap (scope it as separate work).153154## Worked example (userspace-rcu fractal trie — `holder != NULL`)155156Symptom: an ordered-traversal reader intermittently hit `assert(holder != NULL)`157in an up-walk (`ft_skip_reanchor`) under empty→rebuild churn — ~88% repro, but158no static reading found it.1591601. Added a `reanchor_violation` tracepoint (the reached node, a **round-trip**161 `metadata_to_item` check, its child count, and the relevant back-pointers) +162 `reanchor_enter`/`reanchor_step` to follow the up-walk, all behind163 `-DFT_ENABLE_TRACING`; snapshot + abort on the violation.1641652. The violation event alone said: round-trip == self (so **valid, not166 recycled**), child-count == 1 (**live**), back-pointer set — yet `parent ==167 NULL`. So: a valid, live node, reachable, with an unwired parent.1681693. The paradox: the writer set that node's `parent` at T, the reader read NULL170 at T+2.3µs, same metadata object, no intervening write. → happens-before gap.1711724. The full window (grepping the node's address across CPUs) showed the writer173 publishing a recompacted cluster into the live tree by setting a **live**174 re-parented child's back-pointer (a back-channel publish) **before** wiring a175 **fresh** sibling child's parent. The reader entered via the live child, so176 its consume chain anchored *before* the fresh-parent store → legal stale NULL.1771785. Fix: at publish, wire the fresh edge (and the cluster top's own back-pointer)179 first and the live re-parent edge last. ~88% failure → 0/96.180181LTTng didn't just confirm a hypothesis — the enriched violation event and the182all-CPU window *generated* the explanation that static analysis had missed.