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}