Skip to main content

crcbl_core/
trace.rs

1//! CPU spans and counters: where a frame's time went, and on which thread.
2//!
3//! Topic 40's span API — "a scoped CPU span with a static
4//! name, opened and closed by RAII, nesting freely; the frame is the outermost",
5//! and counters as "spans' siblings: a named `u64` sampled per frame". The GPU
6//! half already exists as `crcbl_render::timing`'s per-pass timestamps; this is
7//! the CPU half it was always meant to sit beside, and it lives here for the
8//! reason [`log`](crate::log) and [`time`](crate::time) do: every crate that has
9//! to open a span already depends on this one.
10//!
11//! ```
12//! use crcbl_core::trace;
13//!
14//! trace::set_enabled(true);
15//! {
16//!     let _frame = trace::span("frame");
17//!     {
18//!         let _tick = trace::span("tick");
19//!         trace::counter("draws", 128);
20//!     }
21//! }
22//!
23//! // Two spans are four records — a begin and an end each — plus the counter.
24//! let snapshot = trace::drain();
25//! assert_eq!(snapshot.record_count(), 5);
26//! ```
27//!
28//! # Always compiled, gated at runtime
29//!
30//! The plan's decision, and its reasoning: "a profiler you must rebuild to use
31//! is one nobody turns on mid-investigation, and a build that changes what it
32//! measures is the classic way to measure the wrong thing." So [`span`] and
33//! [`counter`] are compiled into every build and cost **one relaxed atomic
34//! load** — [`is_enabled`] — when the gate is off. Nothing else runs: no
35//! timestamp is read, no lock is taken, and the [`Span`] returned remembers that
36//! it opened nothing, so its drop is a branch on a bool. Verified by reading the
37//! release assembly: a plain byte load of the gate, a test, and a tail call
38//! straight through to whatever the span wrapped.
39//!
40//! The gate starts **disabled**, so a process pays that and nothing else until
41//! something calls [`set_enabled`] — or until [`init_from_env`] reads
42//! `CRCBL_TRACE` at start-up, which is how a run turns the profiler on without a
43//! rebuild.
44//!
45//! There is no compile-time removal, and that is deliberate for now. The plan
46//! reserves one for shipping builds; nothing ships from this repo yet, so it has
47//! no caller. When it earns its place it arrives as `--cfg crcbl_trace_off` with
48//! a `build.rs` declaring `cargo::rustc-check-cfg`, **not** as a Cargo feature: a
49//! feature participates in unification, so `--all-features` — which is how this
50//! workspace's only test jobs run — would turn recording off everywhere and
51//! leave CI asserting nothing but "nothing recorded".
52//!
53//! # A flat buffer of begin/end records, not a tree
54//!
55//! Every record goes into one flat per-thread [`Vec`] in the order it happened;
56//! a span is a [`SpanBegin`](RecordKind::SpanBegin) and a matching
57//! [`SpanEnd`](RecordKind::SpanEnd), never a node with children.
58//!
59//! That is what the plan's three consumers want. Chrome Trace Event JSON — the
60//! decided export format — *is* a begin/end stream per thread. A debug-panel row
61//! showing p50/p95 for a name is a scan for that name. Per-thread tracks are the
62//! partition the buffers already are. A tree built at record time would buy none
63//! of them anything and would cost a parent pointer maintained on the hot path.
64//!
65//! **What it costs the consumer**: two records per span rather than one, and
66//! nesting that has to be recovered rather than read. So each record carries the
67//! [`depth`](Record::depth) it sat at — the consumer gets nesting without a
68//! stack walk, and the recorded depth agrees with a stack walk by construction,
69//! because it *is* the height of the recorded stack at that moment.
70//!
71//! # What bounds the buffer, and what happens at the bound
72//!
73//! A per-thread buffer of [`RECORDS_PER_THREAD`] records, allocated once and
74//! never grown. Past it, records are **refused and counted** — never dropped
75//! silently, and never the oldest: the oldest record in a frame is the frame's
76//! own begin, and a ring that evicted it would corrupt the nesting of everything
77//! after it while reporting nothing. [`ThreadTrace::dropped`] carries the count
78//! and [`Snapshot::report`] prints it, the way `FrameTimings` reports its own
79//! truncation.
80//!
81//! Refusal keeps the buffer *structurally* intact, not merely short. The rule is
82//! `records + ends still owed <= RECORDS_PER_THREAD`, so a span whose begin was
83//! admitted always has room for its end, and a span whose begin was refused
84//! records no end either. A reader never sees a begin with no end because the
85//! buffer filled up.
86//!
87//! [`drain`] is the frame boundary: it takes every track's records and resets
88//! the counts. It does **not** disturb spans that are open across it, which is
89//! the one seam a reader has to know about — a span open at a drain has its
90//! begin in one snapshot and its end in the next. Both halves carry their depth,
91//! so both are still placeable, but a consumer pairing begins to ends must
92//! tolerate an unmatched end at the start of a snapshot and an unmatched begin
93//! at the end of one.
94//!
95//! # Threads
96//!
97//! Each thread records into a buffer of its own, reached through a thread-local
98//! and owned by a process-wide registry, and [`drain`] collects them. Buffers
99//! are per-thread and not one shared log because a profiler that serialises the
100//! job system's workers on a lock changes the thing it is measuring, which is
101//! the first risk the plan names.
102//!
103//! A thread is identified by a [`ThreadTrack`], a small counter this module
104//! hands out. Not [`std::thread::ThreadId`]: it is opaque, it is not
105//! [`Display`](core::fmt::Display), and there is no stable way to get a number
106//! out of it — while a Chrome Trace `tid` and a debug-panel row both need one.
107//! The thread's name is captured alongside it, once, so a track can be labelled
108//! `worker-2` rather than `thread 3`. On `wasm32` there is one thread and it
109//! gets one track, with no special case anywhere in this module.
110//!
111//! A track outlives the thread that owned it, so a worker's spans survive its
112//! exit; the next [`drain`] hands them over and reclaims the track.
113//! [`MAX_TRACKS`] is the hard bound on how many exist at once, and a thread
114//! refused one is counted in [`Snapshot::refused_tracks`].
115//!
116//! # The clock
117//!
118//! One process-wide [`MonotonicTime`], created on first use, read by every
119//! thread. It is [`crate::time`]'s own source rather than a bare
120//! [`Instant`](std::time::Instant), because the whole point of a shared epoch is
121//! that two threads' records are comparable, and a per-thread `Instant` origin
122//! would make them not be.
123//!
124//! It is a real clock read from inside the engine, which
125//! `docs/notes/process.md` otherwise forbids. That is sound here for one
126//! reason and only that one: **nothing the trace records is ever an input to
127//! simulation.** Nothing reads a span back into the world. The tests below
128//! assert structure — names, kinds, depths, counts — and the one that asserts on
129//! time asserts a bound a [`sleep`](std::thread::sleep) guarantees.
130//!
131//! The epoch is the trace's own, taken when the first span opens. It is not the
132//! frame clock's epoch, so a trace timestamp is not directly comparable with an
133//! [`EventTime`](crate::time::EventTime).
134//!
135//! **On `wasm32-unknown-unknown`, [`Instant::now`](std::time::Instant::now)
136//! compiles and panics** — `std`'s unsupported-platform stub — so enabling the
137//! trace in a browser build panics on the first span. Left loud rather than
138//! papered over with a zero clock: a trace whose every span lasts zero
139//! nanoseconds is worse than no trace, and the gate starts off, so a browser
140//! build that never enables it never reads the clock at all. A browser clock
141//! means `performance.now()` through a dependency this crate does not have, and
142//! that is the browser slice's decision to take.
143//!
144//! # Reentrancy
145//!
146//! A span opened from inside the trace machinery would deadlock on the buffer
147//! lock it already holds. Nothing can: the recording path takes one lock, and
148//! under it it does integer arithmetic and [`Vec::push`] on a vector that cannot
149//! reallocate — the capacity is reserved up front and the refusal rule above
150//! keeps the length inside it. It calls no caller code, formats nothing, and
151//! logs nothing, so there is no path from inside it back to [`span`].
152//!
153//! The one caller it cannot rule out is a global allocator that opens spans of
154//! its own, which would re-enter through the *first* span on a thread — the one
155//! that allocates the buffer. That is caught rather than argued away: the
156//! thread-local is a [`RefCell`] and a re-entrant call finds it already
157//! borrowed, records nothing, and returns.
158
159use core::fmt;
160use core::fmt::Write as _;
161use core::marker::PhantomData;
162use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
163use std::cell::RefCell;
164use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
165
166use crate::time::{MonotonicTime, TimeSource as _};
167
168/// Records one thread's buffer holds before it starts refusing.
169///
170/// **Records, not spans** — a span is two of them. The buffer is allocated at
171/// this size the first time a thread records, and never grows: a profiler that
172/// reallocates on the hot path is measuring its own allocator. That is the
173/// trade-off in the number, memory against how much of a frame survives a burst
174/// of instrumentation, and it is sized for a frame's worth of engine-level spans
175/// rather than per-entity ones.
176pub const RECORDS_PER_THREAD: usize = 4096;
177
178/// Threads that can hold a track at once.
179///
180/// A thread past the bound records nothing and is counted in
181/// [`Snapshot::refused_tracks`]. Tracks are reclaimed by [`drain`] once the
182/// thread that owned one has exited, so this bounds *concurrent* tracers rather
183/// than the number of threads a process may ever create.
184pub const MAX_TRACKS: usize = 64;
185
186/// Deepest span nesting a buffer can reach.
187///
188/// Not a separate budget: every open span has already recorded a begin and still
189/// owes an end, so [`RECORDS_PER_THREAD`] cannot hold more than this many at
190/// once. Stated as a constant because the open-span stack is reserved to it and
191/// therefore never reallocates either.
192const MAX_DEPTH: usize = RECORDS_PER_THREAD / 2;
193
194/// The runtime gate. Relaxed everywhere: it orders nothing, and a thread that
195/// sees a stale value for a few nanoseconds after [`set_enabled`] records one
196/// span more or less than it might have, which is not a defect in a profiler.
197static ENABLED: AtomicBool = AtomicBool::new(false);
198
199/// Next [`ThreadTrack`] to hand out. Monotonic, so a reclaimed track's number is
200/// never reused and a snapshot's numbering cannot alias across drains.
201static NEXT_TRACK: AtomicU32 = AtomicU32::new(0);
202
203/// Threads refused a track since the last [`drain`].
204static REFUSED_TRACKS: AtomicU64 = AtomicU64::new(0);
205
206/// Every live track. Taken by [`drain`] and by registration, never by the
207/// recording path, which holds only its own buffer's lock.
208static TRACKS: Mutex<Vec<Arc<TrackEntry>>> = Mutex::new(Vec::new());
209
210/// The trace's own epoch. See the module docs on the clock.
211static EPOCH: OnceLock<MonotonicTime> = OnceLock::new();
212
213/// Turns recording on or off, returning what it was.
214///
215/// Takes effect on other threads as soon as they next load the gate. Spans
216/// already open are unaffected: one opened while enabled records its end however
217/// this is set by the time it drops, so the buffer's pairing never breaks
218/// mid-span.
219pub fn set_enabled(enabled: bool) -> bool {
220    ENABLED.swap(enabled, Ordering::Relaxed)
221}
222
223/// Whether [`span`] and [`counter`] record anything.
224///
225/// The disabled cost of the whole module: this one relaxed load.
226#[inline]
227#[must_use]
228pub fn is_enabled() -> bool {
229    ENABLED.load(Ordering::Relaxed)
230}
231
232/// Environment variable that decides whether a run records anything.
233///
234/// `CRCBL_TRACE=1` to record, `CRCBL_TRACE=0` not to. See [`init_from_env`].
235pub const ENV_VAR: &str = "CRCBL_TRACE";
236
237/// The gate's state when [`ENV_VAR`] says nothing readable — the same default
238/// [`set_enabled`] has never been called from.
239pub const DEFAULT_ENABLED: bool = false;
240
241/// Sets the gate from [`ENV_VAR`], and reports what it left it at.
242///
243/// Call once at start-up, beside [`init_logging`](crate::log::init_logging) —
244/// it is the same kind of thing, and the reason it exists at all is that
245/// [`set_enabled`] is a code change and the plan's "always compiled, runtime
246/// gated" decision exists to avoid needing one.
247///
248/// `1`, `true`, `on` and `yes` turn it on; `0`, `false`, `off` and `no` turn it
249/// off. Case and surrounding space do not matter.
250///
251/// # An unreadable value is [`DEFAULT_ENABLED`], not an error
252///
253/// Exactly what [`Filter::parse`](crate::log::Filter::parse) does with a
254/// directive it cannot read, and for the reason stated there: a typo in an
255/// environment variable must not stop the engine from starting. `CRCBL_TRACE=yse`
256/// therefore leaves the trace off and says nothing, the same as not setting it.
257///
258/// # It never turns the trace on in a browser build
259///
260/// `wasm32-unknown-unknown` has no environment to read *and* a clock that panics
261/// on the first span — see the module docs. The early return below is what makes
262/// the first of those irrelevant: this cannot be the thing that enables a trace
263/// the browser cannot run.
264pub fn init_from_env() -> bool {
265    // Compiled on every target, taken on one: `cfg!` rather than `#[cfg]` so the
266    // code below is type-checked by every build rather than by the ones that
267    // happen to run it.
268    if cfg!(target_arch = "wasm32") {
269        return false;
270    }
271    let enabled = std::env::var(ENV_VAR)
272        .ok()
273        .and_then(|value| parse_enabled(&value))
274        .unwrap_or(DEFAULT_ENABLED);
275    set_enabled(enabled);
276    if enabled {
277        // A run that is paying for instrumentation should say so, the way one
278        // that is capped by a frame limit says what capped it. Silence about the
279        // *off* case is deliberate: that is every other run.
280        log::info!("trace: recording CPU spans ({ENV_VAR} is set)");
281    }
282    enabled
283}
284
285/// The pure half of [`init_from_env`], so the spellings are testable without an
286/// environment.
287///
288/// `None` for anything unreadable, including the empty string, which
289/// [`init_from_env`] resolves to [`DEFAULT_ENABLED`].
290pub(crate) fn parse_enabled(value: &str) -> Option<bool> {
291    match value.trim().to_ascii_lowercase().as_str() {
292        "1" | "true" | "on" | "yes" => Some(true),
293        "0" | "false" | "off" | "no" => Some(false),
294        _ => None,
295    }
296}
297
298/// Nanoseconds since the trace epoch: the clock every record's `nanos` is
299/// on, for a caller pairing it with another clock.
300#[must_use]
301pub fn clock_nanos() -> u64 {
302    now_nanos()
303}
304
305/// Nanoseconds since the trace epoch.
306fn now_nanos() -> u64 {
307    let epoch = EPOCH.get_or_init(MonotonicTime::new);
308    // A `u64` of nanoseconds is centuries; the saturation is a formality that
309    // costs nothing and avoids a panic in a profiler.
310    u64::try_from(epoch.elapsed().as_nanos()).unwrap_or(u64::MAX)
311}
312
313/// Steps over a poisoned lock rather than propagating it.
314///
315/// A panic unwinding through an open [`Span`] drops the guard, which records an
316/// end — so a poisoned buffer is the *normal* aftermath of a test that panicked
317/// on purpose, and it must not disable the profiler for the rest of the process.
318/// The same call [`capture`](crate::log::capture) makes, for the same reason.
319fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
320    mutex.lock().unwrap_or_else(PoisonError::into_inner)
321}
322
323// ---------------------------------------------------------------------------
324// What a trace is made of
325// ---------------------------------------------------------------------------
326
327/// Which thread a run of records came from.
328///
329/// A number this module hands out, not [`std::thread::ThreadId`]; see the module
330/// docs on threads for why.
331#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
332pub struct ThreadTrack(u32);
333
334impl ThreadTrack {
335    /// The raw track number — a Chrome Trace `tid`, and the sort key for a
336    /// per-thread row.
337    #[inline]
338    #[must_use]
339    pub const fn get(self) -> u32 {
340        self.0
341    }
342
343    /// A track with this number.
344    ///
345    /// [`get`](Self::get)'s inverse, and the piece a consumer needs to build a
346    /// [`Snapshot`] rather than record one — every other field of one is already
347    /// public. [`drain`] is the only thing that hands out the tracks a *recorded*
348    /// snapshot carries; this cannot mint one of those, because a number is not a
349    /// buffer.
350    #[inline]
351    #[must_use]
352    pub const fn new(number: u32) -> Self {
353        Self(number)
354    }
355}
356
357impl fmt::Display for ThreadTrack {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        write!(f, "thread {}", self.0)
360    }
361}
362
363/// What one [`Record`] is.
364#[derive(Clone, Copy, PartialEq, Eq, Debug)]
365pub enum RecordKind {
366    /// A span opened.
367    SpanBegin,
368    /// A span closed — the innermost one still open on this track, so begins and
369    /// ends nest.
370    SpanEnd,
371    /// A counter sampled, carrying its value.
372    Counter(u64),
373}
374
375/// One thing that happened, on one thread.
376#[derive(Clone, Copy, PartialEq, Eq, Debug)]
377pub struct Record {
378    /// The span's or counter's name. `&'static str` rather than a `String`
379    /// because a profiler that allocates a name per span is measuring its own
380    /// allocator; an end carries the name its begin was given.
381    pub name: &'static str,
382    /// What happened.
383    pub kind: RecordKind,
384    /// How many spans were open around it — zero for the outermost, and the
385    /// same on a span's begin and its end.
386    ///
387    /// Counted over spans this buffer actually *holds*, so it always agrees with
388    /// a stack walk of [`ThreadTrace::records`] even after records were refused.
389    pub depth: u16,
390    /// Nanoseconds since the trace epoch. Comparable with every other record in
391    /// the same [`Snapshot`], whatever thread it came from; not comparable with
392    /// an [`EventTime`](crate::time::EventTime).
393    pub nanos: u64,
394}
395
396/// One thread's share of a [`Snapshot`].
397#[derive(Clone, PartialEq, Eq, Debug)]
398pub struct ThreadTrace {
399    /// Which track these came from.
400    pub track: ThreadTrack,
401    /// The thread's name when it first recorded, if it had one.
402    pub name: Option<String>,
403    /// Everything the thread recorded since the last [`drain`], oldest first.
404    pub records: Vec<Record>,
405    /// Records this track refused for want of room. Non-zero means the trace has
406    /// a hole in it and the reader is being told so; see the module docs.
407    pub dropped: u64,
408}
409
410/// Everything every thread recorded since the last [`drain`].
411///
412/// Tracks that recorded nothing and dropped nothing are absent: a per-frame
413/// trace has no room for a row saying a thread was idle.
414#[derive(Clone, Default, PartialEq, Eq, Debug)]
415pub struct Snapshot {
416    /// One entry per thread that recorded, in track order.
417    pub threads: Vec<ThreadTrace>,
418    /// Threads refused a track because [`MAX_TRACKS`] were already taken. Their
419    /// spans are not in here and are not counted in any
420    /// [`ThreadTrace::dropped`], because they never reached a buffer.
421    pub refused_tracks: u64,
422}
423
424impl Snapshot {
425    /// Records across every track.
426    #[must_use]
427    pub fn record_count(&self) -> usize {
428        self.threads.iter().map(|thread| thread.records.len()).sum()
429    }
430
431    /// Whether anything at all was recorded, dropped or refused.
432    #[must_use]
433    pub fn is_empty(&self) -> bool {
434        self.threads.is_empty() && self.refused_tracks == 0
435    }
436
437    /// Records refused for want of room, across every track.
438    ///
439    /// Non-zero is the whole trace's "there is a hole here" signal.
440    #[must_use]
441    pub fn dropped(&self) -> u64 {
442        self.threads.iter().map(|thread| thread.dropped).sum()
443    }
444
445    /// The snapshot, as text — the counterpart to `FrameTimings::report`, and
446    /// what makes a dropped record *reported* rather than merely counted.
447    #[must_use]
448    pub fn report(&self) -> String {
449        if self.is_empty() {
450            return "trace: nothing recorded\n".to_string();
451        }
452        let mut out = String::new();
453        let _ = writeln!(
454            out,
455            "trace: {} record(s) on {} thread(s), {} dropped",
456            self.record_count(),
457            self.threads.len(),
458            self.dropped(),
459        );
460        for thread in &self.threads {
461            let _ = writeln!(
462                out,
463                "  {:<12} {:<16} {:>6} record(s), {:>6} dropped",
464                thread.track.to_string(),
465                thread.name.as_deref().unwrap_or("-"),
466                thread.records.len(),
467                thread.dropped,
468            );
469        }
470        if self.refused_tracks != 0 {
471            let _ = writeln!(
472                out,
473                "  {} thread(s) refused a track: MAX_TRACKS were already taken",
474                self.refused_tracks,
475            );
476        }
477        out
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Recording
483// ---------------------------------------------------------------------------
484
485/// An open span. Closes when dropped.
486///
487/// Neither `Send` nor `Sync`, and that is enforced rather than asked for: the
488/// end is pushed onto the buffer of whichever thread drops the guard, so a guard
489/// that crossed a thread boundary would pair its end with another thread's begin
490/// and corrupt both tracks' nesting.
491///
492/// An end always closes the innermost span still open on the track, which for
493/// guards held in locals — the shape this is for — is the guard being dropped.
494/// Guards kept in a collection and dropped out of order still produce a
495/// well-formed trace, but the durations are attributed by nesting rather than by
496/// which guard went first.
497///
498/// The `Send` bound is what refuses the first of those, and it refuses it at
499/// compile time:
500///
501/// ```compile_fail,E0277
502/// let span = crcbl_core::trace::span("frame");
503/// std::thread::spawn(move || drop(span));
504/// ```
505#[derive(Debug)]
506#[must_use = "a span runs for as long as its guard lives; `trace::span(name)` \
507              on its own opens and closes it in the same instant"]
508pub struct Span {
509    /// Whether a begin was recorded, and so whether an end is owed. Set once, at
510    /// creation — **not** re-read from the gate at drop, so a span opened while
511    /// the trace was on always closes, and one opened while it was off never
512    /// leaves an end with no begin.
513    open: bool,
514    _not_send: PhantomData<*const ()>,
515}
516
517impl Drop for Span {
518    #[inline]
519    fn drop(&mut self) {
520        if self.open {
521            record_end();
522        }
523    }
524}
525
526/// Opens a span named `name`, closed when the returned guard drops.
527///
528/// Nests freely; the frame is meant to be the outermost. Costs one relaxed
529/// atomic load when the trace is disabled.
530///
531/// ```
532/// use crcbl_core::trace;
533///
534/// trace::set_enabled(true);
535/// let _frame = trace::span("frame");
536/// ```
537#[inline]
538pub fn span(name: &'static str) -> Span {
539    if !is_enabled() {
540        return Span {
541            open: false,
542            _not_send: PhantomData,
543        };
544    }
545    Span {
546        open: record_begin(name),
547        _not_send: PhantomData,
548    }
549}
550
551/// Samples a counter — a named `u64`, on the track and at the depth it was
552/// sampled from.
553///
554/// Spans' sibling, not a log line: it lands in the same buffer, under the same
555/// gate and the same bound.
556#[inline]
557pub fn counter(name: &'static str, value: u64) {
558    if !is_enabled() {
559        return;
560    }
561    let nanos = now_nanos();
562    with_buffer(|buffer| buffer.sample(name, value, nanos));
563}
564
565/// Takes everything recorded since the last call and resets the counts.
566///
567/// The frame boundary. Spans open across it are split rather than disturbed —
568/// see the module docs. Also reclaims the tracks of threads that have exited.
569pub fn drain() -> Snapshot {
570    let mut tracks = lock(&TRACKS);
571    let mut threads = Vec::new();
572    tracks.retain(|entry| {
573        let mut buffer = lock(&entry.buffer);
574        if !buffer.records.is_empty() || buffer.dropped != 0 {
575            threads.push(ThreadTrace {
576                track: entry.track,
577                name: entry.name.clone(),
578                // Copy and clear rather than take: `Vec::clear` keeps the
579                // capacity the recording path relies on never having to grow.
580                records: buffer.records.clone(),
581                dropped: buffer.dropped,
582            });
583            buffer.records.clear();
584            buffer.dropped = 0;
585        }
586        drop(buffer);
587        // The registry holds one reference and the owning thread's thread-local
588        // holds the other; one alone means that thread has exited, and its
589        // records have just been handed over.
590        Arc::strong_count(entry) > 1
591    });
592    drop(tracks);
593    Snapshot {
594        threads,
595        refused_tracks: REFUSED_TRACKS.swap(0, Ordering::Relaxed),
596    }
597}
598
599/// One thread's buffer, and the bookkeeping that keeps it bounded.
600#[derive(Debug)]
601struct TrackBuffer {
602    records: Vec<Record>,
603    /// The names of the spans open on this thread whose begin this buffer holds
604    /// — one entry per end still owed, so its length is both the depth the next
605    /// record sits at and the number of slots that must stay free.
606    open: Vec<&'static str>,
607    dropped: u64,
608}
609
610impl TrackBuffer {
611    fn new() -> Self {
612        Self {
613            records: Vec::with_capacity(RECORDS_PER_THREAD),
614            open: Vec::with_capacity(MAX_DEPTH),
615            dropped: 0,
616        }
617    }
618
619    /// Whether `extra` more records fit with every owed end still accounted for.
620    ///
621    /// The invariant every path below preserves:
622    /// `records.len() + open.len() <= RECORDS_PER_THREAD`.
623    fn has_room_for(&self, extra: usize) -> bool {
624        self.records.len() + self.open.len() + extra <= RECORDS_PER_THREAD
625    }
626
627    /// Appends a record. Never reallocates, which the caller must have
628    /// established with [`TrackBuffer::has_room_for`].
629    fn push(&mut self, name: &'static str, kind: RecordKind, nanos: u64) {
630        debug_assert!(
631            self.records.len() < self.records.capacity(),
632            "the trace buffer is about to reallocate on the recording path"
633        );
634        // `open` is bounded by `MAX_DEPTH`, far below `u16::MAX`; the saturation
635        // is unreachable and costs a compare.
636        let depth = u16::try_from(self.open.len()).unwrap_or(u16::MAX);
637        self.records.push(Record {
638            name,
639            kind,
640            depth,
641            nanos,
642        });
643    }
644
645    /// Opens a span, reporting whether it was admitted.
646    ///
647    /// Two slots, not one: a begin is only admitted if its end fits too, so a
648    /// full buffer never strands a begin.
649    fn begin(&mut self, name: &'static str, nanos: u64) -> bool {
650        if !self.has_room_for(2) {
651            self.dropped += 1;
652            return false;
653        }
654        self.push(name, RecordKind::SpanBegin, nanos);
655        self.open.push(name);
656        true
657    }
658
659    /// Closes the innermost open span.
660    fn end(&mut self, nanos: u64) {
661        let Some(name) = self.open.pop() else {
662            // Only reachable if a `Span` whose begin was admitted is dropped
663            // after `drain` cleared this buffer's open stack — which `drain`
664            // does not do. Recording nothing is the safe answer either way.
665            return;
666        };
667        // Guaranteed by the invariant: the slot this end occupies was reserved
668        // when its begin was admitted.
669        self.push(name, RecordKind::SpanEnd, nanos);
670    }
671
672    /// Samples a counter.
673    fn sample(&mut self, name: &'static str, value: u64, nanos: u64) {
674        if !self.has_room_for(1) {
675            self.dropped += 1;
676            return;
677        }
678        self.push(name, RecordKind::Counter(value), nanos);
679    }
680}
681
682/// A track and the buffer behind it. Outlives the thread that owned it, so a
683/// worker's spans survive its exit until the next [`drain`].
684#[derive(Debug)]
685struct TrackEntry {
686    track: ThreadTrack,
687    name: Option<String>,
688    buffer: Mutex<TrackBuffer>,
689}
690
691/// Whether this thread has a track, asked once.
692#[derive(Debug)]
693enum Registration {
694    /// Nothing has been recorded on this thread yet.
695    Untried,
696    /// [`MAX_TRACKS`] were taken when this thread first recorded. Sticky, so a
697    /// refused thread does not take the registry lock on every span.
698    Refused,
699    /// This thread's track.
700    Track(Arc<TrackEntry>),
701}
702
703thread_local! {
704    /// This thread's registration.
705    ///
706    /// A `RefCell` behind a `const` initialiser rather than a lazily initialised
707    /// thread-local holding the `Arc` directly, and both halves of that are
708    /// load-bearing. The `const` init means there is no initialiser that a
709    /// re-entrant call could run a second time, and the `RefCell` means such a
710    /// call finds the slot borrowed and gives up instead of recursing — see the
711    /// module docs on reentrancy. It is the shape
712    /// [`log`](crate::log)'s capture buffer uses, for the same reason.
713    static LOCAL: RefCell<Registration> = const { RefCell::new(Registration::Untried) };
714}
715
716/// Claims a track for the calling thread, or `None` if [`MAX_TRACKS`] are taken.
717fn register() -> Option<Arc<TrackEntry>> {
718    let mut tracks = lock(&TRACKS);
719    if tracks.len() >= MAX_TRACKS {
720        REFUSED_TRACKS.fetch_add(1, Ordering::Relaxed);
721        return None;
722    }
723    // Built under the lock so a refused thread neither allocates a buffer it
724    // will never use nor burns a track number.
725    let entry = Arc::new(TrackEntry {
726        track: ThreadTrack(NEXT_TRACK.fetch_add(1, Ordering::Relaxed)),
727        name: std::thread::current().name().map(str::to_owned),
728        buffer: Mutex::new(TrackBuffer::new()),
729    });
730    // Pushed while the lock is held and while this function still owns a
731    // reference, so `drain` can never observe the entry at a strong count of one
732    // and reclaim a track that is about to be handed to a live thread.
733    tracks.push(Arc::clone(&entry));
734    drop(tracks);
735    Some(entry)
736}
737
738/// Runs `f` against this thread's buffer, or nothing at all if this thread has
739/// no track, is already inside the recording path, or is being torn down.
740fn with_buffer<R>(f: impl FnOnce(&mut TrackBuffer) -> R) -> Option<R> {
741    LOCAL
742        .try_with(|slot| {
743            // Already borrowed means this call re-entered the recording path.
744            let mut slot = slot.try_borrow_mut().ok()?;
745            if matches!(*slot, Registration::Untried) {
746                *slot = match register() {
747                    Some(entry) => Registration::Track(entry),
748                    None => Registration::Refused,
749                };
750            }
751            let Registration::Track(entry) = &*slot else {
752                return None;
753            };
754            Some(f(&mut lock(&entry.buffer)))
755        })
756        // `Err` is a thread past its thread-local destructors — a span opened
757        // from another `Drop` at thread exit. It records nothing.
758        .ok()
759        .flatten()
760}
761
762/// Records a span's begin, reporting whether it was admitted.
763fn record_begin(name: &'static str) -> bool {
764    let nanos = now_nanos();
765    with_buffer(|buffer| buffer.begin(name, nanos)).unwrap_or(false)
766}
767
768/// Records the end of the innermost open span on this thread.
769fn record_end() {
770    let nanos = now_nanos();
771    with_buffer(|buffer| buffer.end(nanos));
772}
773
774#[cfg(test)]
775mod tests {
776    use std::time::Duration;
777
778    use proptest::prelude::*;
779
780    use super::*;
781
782    /// Serialises the tests that touch the process-wide trace.
783    ///
784    /// `cargo test` runs a crate's tests as threads in one process — nextest
785    /// gives each its own — so without this two of them would share the gate and
786    /// the registry, and drain each other's records.
787    static TRACE_TESTS: Mutex<()> = Mutex::new(());
788
789    /// One test's exclusive hold on the trace, handed over enabled and clean.
790    #[derive(Debug)]
791    struct TraceTest {
792        _guard: MutexGuard<'static, ()>,
793    }
794
795    impl Drop for TraceTest {
796        fn drop(&mut self) {
797            set_enabled(false);
798            drop(drain());
799        }
800    }
801
802    /// Takes the trace, clears whatever an earlier test left behind, and enables
803    /// it. Dropping the returned value disables and clears it again.
804    fn trace_test() -> TraceTest {
805        // Stepped over rather than propagated: a test that panics on purpose
806        // must not take every later one with it.
807        let guard = TRACE_TESTS.lock().unwrap_or_else(PoisonError::into_inner);
808        set_enabled(false);
809        drop(drain());
810        set_enabled(true);
811        TraceTest { _guard: guard }
812    }
813
814    /// A record reduced to what an assertion is written against.
815    type Shape = (&'static str, RecordKind, u16);
816
817    fn shape(record: &Record) -> Shape {
818        (record.name, record.kind, record.depth)
819    }
820
821    /// This thread's records, drained.
822    ///
823    /// Asserts the single-track assumption instead of trusting it: a stray track
824    /// would mean an earlier test leaked a thread and this test is reading
825    /// someone else's buffer.
826    fn drain_this_thread() -> Vec<Record> {
827        let snapshot = drain();
828        assert!(
829            snapshot.threads.len() <= 1,
830            "another thread is recording: {}",
831            snapshot.report()
832        );
833        assert_eq!(snapshot.refused_tracks, 0);
834        snapshot
835            .threads
836            .into_iter()
837            .next()
838            .map_or_else(Vec::new, |thread| {
839                assert_eq!(thread.dropped, 0, "records were refused unexpectedly");
840                thread.records
841            })
842    }
843
844    fn shapes_of(records: &[Record]) -> Vec<Shape> {
845        records.iter().map(shape).collect()
846    }
847
848    #[test]
849    fn nesting_is_recorded_in_order_and_at_the_right_depth() {
850        let _test = trace_test();
851        {
852            let _frame = span("frame");
853            {
854                let _tick = span("tick");
855                let _physics = span("physics");
856            }
857            let _render = span("render");
858        }
859
860        assert_eq!(
861            shapes_of(&drain_this_thread()),
862            vec![
863                ("frame", RecordKind::SpanBegin, 0),
864                ("tick", RecordKind::SpanBegin, 1),
865                ("physics", RecordKind::SpanBegin, 2),
866                // Guards drop in reverse declaration order, innermost first.
867                ("physics", RecordKind::SpanEnd, 2),
868                ("tick", RecordKind::SpanEnd, 1),
869                ("render", RecordKind::SpanBegin, 1),
870                ("render", RecordKind::SpanEnd, 1),
871                ("frame", RecordKind::SpanEnd, 0),
872            ]
873        );
874    }
875
876    /// The disabled arm records **nothing** — not fewer records, none.
877    #[test]
878    fn the_disabled_gate_records_nothing() {
879        let _test = trace_test();
880        set_enabled(false);
881        assert!(!is_enabled());
882
883        {
884            let _frame = span("frame");
885            let _tick = span("tick");
886            counter("draws", 4096);
887        }
888
889        let snapshot = drain();
890        assert!(
891            snapshot.is_empty(),
892            "the disabled trace recorded something: {}",
893            snapshot.report()
894        );
895        assert_eq!(snapshot.record_count(), 0);
896        assert_eq!(snapshot.dropped(), 0);
897        assert_eq!(snapshot.report(), "trace: nothing recorded\n");
898    }
899
900    /// The gate reports its own state, and reports what it displaced.
901    #[test]
902    fn the_gate_reports_what_it_was_set_to() {
903        let _test = trace_test();
904        assert!(
905            set_enabled(true),
906            "`trace_test` hands the gate over enabled"
907        );
908        assert!(is_enabled());
909        assert!(set_enabled(false), "the previous value was `true`");
910        assert!(!is_enabled());
911        assert!(!set_enabled(true), "the previous value was `false`");
912        assert!(is_enabled());
913    }
914
915    /// Every spelling the variable accepts, and the ones it refuses.
916    ///
917    /// The refusals are the half that matters: `CRCBL_TRACE=yse` must land on
918    /// [`DEFAULT_ENABLED`] rather than on "anything not falsey is true", which is
919    /// what [`crate::log::Filter::parse`] does with a directive it cannot read.
920    #[test]
921    fn the_variables_spellings_are_read_the_way_crcbl_log_reads_a_directive() {
922        for on in ["1", "true", "on", "yes", "TRUE", "  On  ", "YES"] {
923            assert_eq!(parse_enabled(on), Some(true), "{on:?}");
924        }
925        for off in ["0", "false", "off", "no", "OFF", " False "] {
926            assert_eq!(parse_enabled(off), Some(false), "{off:?}");
927        }
928        for nonsense in ["", "   ", "yse", "2", "enabled", "trace", "-1"] {
929            assert_eq!(parse_enabled(nonsense), None, "{nonsense:?}");
930        }
931    }
932
933    /// **`CRCBL_TRACE` actually turns the gate on**, rather than a parser that
934    /// would pass while `init_from_env` read a variable of a different name.
935    ///
936    /// # Why a child process
937    ///
938    /// An environment variable is process-global and
939    /// [`std::env::set_var`] is `unsafe` precisely because another thread
940    /// reading the environment at the same moment is undefined — and `cargo
941    /// test` runs this crate's tests as threads in one process. So the run with
942    /// the variable set is a *process* of its own: this test re-executes the test
943    /// binary against its own name with `CRCBL_TRACE=1` in the child's
944    /// environment. Nothing in this process is mutated, so no other test can see
945    /// an ordering.
946    ///
947    /// Both halves are asserted. The parent asserts the unset case in the
948    /// ambient environment; the child, which finds the variable set, asserts the
949    /// set one. A developer who happens to have `CRCBL_TRACE` exported runs the
950    /// child arm here and the test still means something.
951    ///
952    /// # Not under miri
953    ///
954    /// Spawning a process is one of the things the interpreter does not
955    /// implement, so under miri this aborts the whole test binary rather than
956    /// failing one test — which is how it took `cron.yml`'s weekly job down
957    /// from 2026-08-17 until anybody looked. It is skipped there with a reason
958    /// rather than compiled out, so a miri run says out loud that it did not
959    /// run this. Nothing is lost: what the test checks is a name and a gate,
960    /// not memory behaviour, and the ordinary suite runs it every time.
961    #[test]
962    #[cfg_attr(miri, ignore = "miri cannot spawn the child process this needs")]
963    fn the_environment_variable_is_what_turns_the_gate_on() {
964        // The trace is process-wide, so this takes the same lock every other
965        // test here does — and then puts the gate back to where a fresh process
966        // starts, which is what this test is about.
967        let _test = trace_test();
968        set_enabled(false);
969
970        if std::env::var(ENV_VAR).is_ok() {
971            assert!(init_from_env(), "the child was launched with {ENV_VAR}=1");
972            assert!(is_enabled(), "and the gate is what it set");
973            return;
974        }
975
976        assert!(!init_from_env(), "{ENV_VAR} is not set in this environment");
977        assert!(!is_enabled(), "so the gate is left at DEFAULT_ENABLED");
978
979        let output = crcbl_spawn::command(
980            std::env::current_exe().expect("a test binary knows its own path"),
981        )
982        .args([
983            "--exact",
984            "trace::tests::the_environment_variable_is_what_turns_the_gate_on",
985        ])
986        .env(ENV_VAR, "1")
987        .output()
988        .expect("re-running this test binary");
989        let report = String::from_utf8_lossy(&output.stdout);
990        assert!(output.status.success(), "the child failed:\n{report}");
991        // **`--exact` with a name that matches nothing exits zero**, so without
992        // this the whole test passes vacuously the moment it is renamed.
993        assert!(
994            report.contains("1 passed"),
995            "the child ran no test — has this one been renamed?\n{report}"
996        );
997    }
998
999    /// The gate flipping under a live guard must not leave a begin without an
1000    /// end, or an end without a begin.
1001    #[test]
1002    fn a_guard_survives_the_gate_flipping_under_it() {
1003        let _test = trace_test();
1004
1005        // Opened while enabled, dropped while disabled: the end still lands, so
1006        // the pair is intact.
1007        {
1008            let _outer = span("opened-enabled");
1009            set_enabled(false);
1010        }
1011        assert_eq!(
1012            shapes_of(&drain_this_thread()),
1013            vec![
1014                ("opened-enabled", RecordKind::SpanBegin, 0),
1015                ("opened-enabled", RecordKind::SpanEnd, 0),
1016            ]
1017        );
1018
1019        // Opened while disabled, dropped while enabled: nothing at all, rather
1020        // than an orphan end.
1021        {
1022            let _outer = span("opened-disabled");
1023            set_enabled(true);
1024        }
1025        assert_eq!(shapes_of(&drain_this_thread()), Vec::new());
1026
1027        // And the buffer is still usable afterwards, at depth zero.
1028        {
1029            let _after = span("after");
1030        }
1031        assert_eq!(
1032            shapes_of(&drain_this_thread()),
1033            vec![
1034                ("after", RecordKind::SpanBegin, 0),
1035                ("after", RecordKind::SpanEnd, 0),
1036            ]
1037        );
1038    }
1039
1040    #[test]
1041    fn counters_carry_their_value_and_the_depth_they_were_sampled_at() {
1042        let _test = trace_test();
1043        counter("jobs", 7);
1044        {
1045            let _frame = span("frame");
1046            counter("draws", 128);
1047            counter("instances", u64::MAX);
1048        }
1049
1050        assert_eq!(
1051            shapes_of(&drain_this_thread()),
1052            vec![
1053                ("jobs", RecordKind::Counter(7), 0),
1054                ("frame", RecordKind::SpanBegin, 0),
1055                ("draws", RecordKind::Counter(128), 1),
1056                ("instances", RecordKind::Counter(u64::MAX), 1),
1057                ("frame", RecordKind::SpanEnd, 0),
1058            ]
1059        );
1060    }
1061
1062    /// A full buffer refuses new records, counts them, and still closes the
1063    /// spans that were already open — and never grows to do it.
1064    #[test]
1065    fn a_full_buffer_refuses_and_counts_instead_of_growing() {
1066        let _test = trace_test();
1067        // The frame's own begin means the counters run out of room before they
1068        // run out of count; the surplus only guarantees they overshoot.
1069        let attempted = u64::try_from(RECORDS_PER_THREAD).unwrap() + 16;
1070        {
1071            let _frame = span("frame");
1072            for value in 0..attempted {
1073                counter("filler", value);
1074            }
1075            // The buffer is full, so this span is refused outright rather than
1076            // recording a begin it could not close.
1077            let _refused = span("refused");
1078        }
1079        let capacity_before = buffer_capacity();
1080
1081        let snapshot = drain();
1082        let thread = &snapshot.threads[0];
1083        assert_eq!(
1084            thread.records.len(),
1085            RECORDS_PER_THREAD,
1086            "the buffer should be exactly full"
1087        );
1088        let admitted = thread
1089            .records
1090            .iter()
1091            .filter(|record| matches!(record.kind, RecordKind::Counter(_)))
1092            .count();
1093        assert!(admitted > 0 && u64::try_from(admitted).unwrap() < attempted);
1094        // Every counter that did not fit, plus the one span that was refused.
1095        assert_eq!(
1096            thread.dropped,
1097            attempted - u64::try_from(admitted).unwrap() + 1
1098        );
1099        assert_eq!(snapshot.dropped(), thread.dropped);
1100        // The outermost span still closed: its end slot was reserved when its
1101        // begin was admitted.
1102        assert_eq!(
1103            shape(&thread.records[0]),
1104            ("frame", RecordKind::SpanBegin, 0)
1105        );
1106        let last = thread.records.last().expect("the buffer is not empty");
1107        assert_eq!(shape(last), ("frame", RecordKind::SpanEnd, 0));
1108        // Nothing named `refused` reached the buffer at all.
1109        assert!(thread.records.iter().all(|record| record.name != "refused"));
1110        assert!(
1111            snapshot.report().contains("dropped"),
1112            "the report hides the hole: {}",
1113            snapshot.report()
1114        );
1115        // The whole point of refusing: the buffer never grew to take the
1116        // overflow, either while recording or while draining.
1117        assert_eq!(capacity_before, RECORDS_PER_THREAD);
1118        assert_eq!(
1119            buffer_capacity(),
1120            RECORDS_PER_THREAD,
1121            "draining reallocated"
1122        );
1123    }
1124
1125    /// This thread's record-buffer capacity, read straight out of its track.
1126    fn buffer_capacity() -> usize {
1127        LOCAL.with(|slot| {
1128            let slot = slot.borrow();
1129            let Registration::Track(entry) = &*slot else {
1130                panic!("this thread has no track");
1131            };
1132            lock(&entry.buffer).records.capacity()
1133        })
1134    }
1135
1136    /// A worker's spans land on their own track, under the worker's own name,
1137    /// and survive the worker exiting.
1138    #[test]
1139    fn a_worker_thread_gets_a_track_of_its_own() {
1140        let _test = trace_test();
1141        {
1142            let _frame = span("frame");
1143            std::thread::Builder::new()
1144                .name("worker-0".to_owned())
1145                .spawn(|| {
1146                    let _job = span("job");
1147                    counter("steals", 3);
1148                })
1149                .expect("spawn a worker")
1150                .join()
1151                .expect("the worker did not panic");
1152        }
1153
1154        let snapshot = drain();
1155        assert_eq!(snapshot.threads.len(), 2, "{}", snapshot.report());
1156        let worker = snapshot
1157            .threads
1158            .iter()
1159            .find(|thread| thread.name.as_deref() == Some("worker-0"))
1160            .unwrap_or_else(|| panic!("no worker track: {}", snapshot.report()));
1161        assert_eq!(
1162            shapes_of(&worker.records),
1163            vec![
1164                ("job", RecordKind::SpanBegin, 0),
1165                ("steals", RecordKind::Counter(3), 1),
1166                ("job", RecordKind::SpanEnd, 0),
1167            ]
1168        );
1169
1170        let main = snapshot
1171            .threads
1172            .iter()
1173            .find(|thread| thread.track != worker.track)
1174            .expect("the main thread's track");
1175        assert_ne!(main.track, worker.track);
1176        assert_eq!(
1177            shapes_of(&main.records),
1178            vec![
1179                ("frame", RecordKind::SpanBegin, 0),
1180                ("frame", RecordKind::SpanEnd, 0),
1181            ]
1182        );
1183
1184        // The exited worker's track was reclaimed by the drain above, so the
1185        // next one starts clean.
1186        assert_eq!(drain_this_thread(), Vec::new());
1187    }
1188
1189    /// Past [`MAX_TRACKS`] a thread records nothing and is counted saying so.
1190    #[test]
1191    fn threads_past_the_track_bound_are_refused_and_counted() {
1192        let _test = trace_test();
1193        // More than the bound, whatever tracks this process already holds.
1194        let surplus = 4;
1195        for index in 0..MAX_TRACKS + surplus {
1196            std::thread::Builder::new()
1197                .name(format!("crowd-{index}"))
1198                .spawn(|| {
1199                    let _job = span("job");
1200                })
1201                .expect("spawn")
1202                .join()
1203                .expect("the thread did not panic");
1204        }
1205
1206        let snapshot = drain();
1207        assert!(
1208            snapshot.threads.len() <= MAX_TRACKS,
1209            "{} tracks past a bound of {MAX_TRACKS}",
1210            snapshot.threads.len()
1211        );
1212        assert!(
1213            snapshot.refused_tracks >= u64::try_from(surplus).unwrap(),
1214            "{} refusals: {}",
1215            snapshot.refused_tracks,
1216            snapshot.report()
1217        );
1218        assert!(snapshot.report().contains("refused a track"));
1219
1220        // Every one of those threads has exited, so the drain above reclaimed
1221        // their tracks and the registry is back to this thread's alone.
1222        drop(span("after"));
1223        assert_eq!(drain().threads.len(), 1);
1224        assert_eq!(drain().refused_tracks, 0);
1225    }
1226
1227    /// The one assertion here about *time*, and what keeps it from being the
1228    /// classic vacuous "a duration is greater than zero": the inner span sleeps,
1229    /// so its recorded length has a floor the clock cannot be under, and the
1230    /// outer span contains the inner one, so the two bounds also order the four
1231    /// timestamps against each other.
1232    #[test]
1233    fn a_spans_timestamps_bracket_what_ran_inside_it() {
1234        let _test = trace_test();
1235        let nap = Duration::from_millis(5);
1236        {
1237            let _outer = span("outer");
1238            let _inner = span("inner");
1239            std::thread::sleep(nap);
1240        }
1241
1242        let records = drain_this_thread();
1243        let [outer_begin, inner_begin, inner_end, outer_end] = records.as_slice() else {
1244            panic!("expected two nested spans, got {records:?}");
1245        };
1246        let floor = u64::try_from(nap.as_nanos()).unwrap();
1247        assert!(
1248            inner_end.nanos - inner_begin.nanos >= floor,
1249            "the inner span slept {nap:?} and reports {} ns",
1250            inner_end.nanos - inner_begin.nanos
1251        );
1252        assert!(
1253            outer_end.nanos - outer_begin.nanos >= inner_end.nanos - inner_begin.nanos,
1254            "the outer span is shorter than the inner one it contains"
1255        );
1256        assert!(outer_begin.nanos <= inner_begin.nanos);
1257        assert!(inner_end.nanos <= outer_end.nanos);
1258    }
1259
1260    /// A span open across a drain is split, not lost: the begin is in one
1261    /// snapshot and the end in the next, both carrying their depth.
1262    #[test]
1263    fn a_drain_splits_a_span_that_is_open_across_it() {
1264        let _test = trace_test();
1265        let frame = span("frame");
1266        assert_eq!(
1267            shapes_of(&drain_this_thread()),
1268            vec![("frame", RecordKind::SpanBegin, 0)]
1269        );
1270        drop(frame);
1271        assert_eq!(
1272            shapes_of(&drain_this_thread()),
1273            vec![("frame", RecordKind::SpanEnd, 0)]
1274        );
1275    }
1276
1277    #[test]
1278    fn tracks_are_numbered_and_displayed() {
1279        assert_eq!(ThreadTrack(7).get(), 7);
1280        assert_eq!(ThreadTrack(7).to_string(), "thread 7");
1281        assert!(ThreadTrack(1) < ThreadTrack(2));
1282        assert!(Snapshot::default().is_empty());
1283        assert_eq!(Snapshot::default().record_count(), 0);
1284    }
1285
1286    /// One step of the script the property below drives.
1287    #[derive(Clone, Copy, Debug)]
1288    enum Step {
1289        Open(&'static str),
1290        Close,
1291        Sample(u64),
1292    }
1293
1294    /// A pool of static names, because that is what a span takes and a property
1295    /// test cannot leak a fresh `&'static str` per case.
1296    const NAMES: [&str; 4] = ["a", "b", "c", "d"];
1297
1298    fn any_step() -> impl Strategy<Value = Step> {
1299        prop_oneof![
1300            5 => (0..NAMES.len()).prop_map(|index| Step::Open(NAMES[index])),
1301            4 => Just(Step::Close),
1302            2 => any::<u64>().prop_map(Step::Sample),
1303        ]
1304    }
1305
1306    proptest! {
1307        /// The buffer's structural invariant, under an arbitrary script of
1308        /// opens, closes and samples: a stack walk of the records reproduces
1309        /// exactly the depth each record was recorded with, every begin is
1310        /// matched by an end carrying the same name and depth, and the buffer
1311        /// never exceeds its bound. This is the property a consumer relies on to
1312        /// rebuild nesting from a flat buffer.
1313        #[test]
1314        fn prop_recorded_depth_matches_a_stack_walk(steps in proptest::collection::vec(any_step(), 1..200)) {
1315            let _test = trace_test();
1316            let mut open: Vec<Span> = Vec::new();
1317            for step in &steps {
1318                match *step {
1319                    Step::Open(name) => open.push(span(name)),
1320                    Step::Close => drop(open.pop()),
1321                    Step::Sample(value) => counter("sample", value),
1322                }
1323            }
1324            // Innermost first, the order locals drop in.
1325            while open.pop().is_some() {}
1326
1327            let records = drain_this_thread();
1328            prop_assert!(records.len() <= RECORDS_PER_THREAD);
1329            let mut stack: Vec<&'static str> = Vec::new();
1330            for record in &records {
1331                match record.kind {
1332                    RecordKind::SpanBegin => {
1333                        prop_assert_eq!(usize::from(record.depth), stack.len());
1334                        stack.push(record.name);
1335                    }
1336                    RecordKind::SpanEnd => {
1337                        let opened = stack.pop();
1338                        prop_assert_eq!(opened, Some(record.name));
1339                        prop_assert_eq!(usize::from(record.depth), stack.len());
1340                    }
1341                    RecordKind::Counter(_) => {
1342                        prop_assert_eq!(usize::from(record.depth), stack.len());
1343                    }
1344                }
1345            }
1346            // Every guard was dropped above, so nothing is left open.
1347            prop_assert!(stack.is_empty(), "unclosed spans: {:?}", stack);
1348            // Keeps the walk above from passing vacuously on a script that
1349            // recorded nothing: a `Close` with nothing open is a no-op, but an
1350            // `Open` or a `Sample` always lands.
1351            let recording = steps.iter().any(|step| !matches!(step, Step::Close));
1352            prop_assert_eq!(records.is_empty(), !recording);
1353        }
1354    }
1355}