Skip to main content

brep_render/
run_trace.rs

1//! The history-replay TRACE: a native instrument that counts and times every
2//! `execute_history` this crate performs, tagged with the SITE that asked for
3//! it and the thread it ran on.
4//!
5//! The engine executes a history on the RUNNER (a background thread natively,
6//! a worker in the browser). Anything the main thread re-derives by running the
7//! SAME history again is a second full execution of the document — the kernel's
8//! incremental cache is per thread, and the wire-harness / assembly / PMI tails
9//! are not cached at all, so a tail sweeps once per thread that replays.
10//! Whether that is happening cannot be read off a frame time, so this module
11//! makes it observable in two ways.
12//!
13//! A running TALLY, always on, one entry per site: how many executions that
14//! site performed, how long they took, and what came back. It is what
15//! `perf_stats`'s `runs` block reports and `perf_reset` clears, so a test can
16//! bracket one edit and assert that it cost ONE execution — the property the
17//! frame time cannot state, because a duplicate replay and a slow machine look
18//! alike in milliseconds. The cost is one mutex lock per `execute_history`,
19//! which is a millisecond-scale operation.
20//!
21//! Two things to know before reading a tally. It is PROCESS-global, not
22//! per-session — several headless sessions in one process share it — so the
23//! only meaningful read is one bracketed by [`reset`]; an unbracketed `runs` is
24//! every execution since the process started. And a CANCELLED run contributes
25//! nothing: the runner thread is terminated rather than joined, so its span is
26//! never dropped and never folded in. A tally of zero after a cancel is the
27//! expected reading, not a lost measurement.
28//!
29//! A per-execution LINE on stderr, when the environment asks for it:
30//!
31//! ```sh
32//! BREP_RUN_TRACE=1 ./brep-app --mcp 2>trace.log
33//! ```
34//!
35//! ```text
36//! [run-trace] site=runner thread=ThreadId(2) ms=412.6 features=21 reused=20 errors=0
37//! ```
38//!
39//! `site=runner` is the one execution a run is supposed to cost; every other
40//! site on a different thread in the same edit is a duplicate replay. Both
41//! halves are a no-op on wasm, where there is no second thread to trace.
42
43/// One site's running total. `features`, `reused` and `errors` are summed over
44/// the executions that reported a result — a replay that is "free" because
45/// every feature errored out reads the same as a warm cache hit without them.
46#[derive(Debug, Clone, Copy, Default, PartialEq)]
47pub struct SiteTally {
48    pub count: u64,
49    pub ms: f64,
50    pub features: u64,
51    pub reused: u64,
52    pub errors: u64,
53}
54
55#[cfg(not(target_arch = "wasm32"))]
56mod native {
57    use super::SiteTally;
58    use std::collections::BTreeMap;
59    use std::sync::atomic::{AtomicU8, Ordering};
60    use std::sync::Mutex;
61    use std::time::Instant;
62
63    static ENABLED: AtomicU8 = AtomicU8::new(0);
64
65    /// A set of per-site totals. A `BTreeMap` keyed by the site's
66    /// `&'static str` so the report comes out in a stable order and a new site
67    /// needs no registration.
68    ///
69    /// A TYPE, not just the one static, so a unit test can count into a tally
70    /// of its own: the process-global [`TALLY`] is also where every history the
71    /// crate's other tests run folds (`pipeline.rs` opens `span("runner")`), and
72    /// a test that counted there read another test's run as its own (runner 3
73    /// against an expected 2, once in a gate).
74    pub struct Tally(Mutex<BTreeMap<&'static str, SiteTally>>);
75
76    impl Tally {
77        pub const fn new() -> Self {
78            Tally(Mutex::new(BTreeMap::new()))
79        }
80
81        fn fold(&self, site: &'static str, ms: f64, features: u64, reused: u64, errors: u64) {
82            if let Ok(mut map) = self.0.lock() {
83                let entry = map.entry(site).or_default();
84                entry.count += 1;
85                entry.ms += ms;
86                entry.features += features;
87                entry.reused += reused;
88                entry.errors += errors;
89            }
90        }
91
92        /// Every site with a total, in name order.
93        pub fn sites(&self) -> Vec<(&'static str, SiteTally)> {
94            match self.0.lock() {
95                Ok(map) => map.iter().map(|(site, t)| (*site, *t)).collect(),
96                Err(poisoned) => poisoned.into_inner().iter().map(|(site, t)| (*site, *t)).collect(),
97            }
98        }
99
100        pub fn clear(&self) {
101            match self.0.lock() {
102                Ok(mut map) => map.clear(),
103                Err(poisoned) => poisoned.into_inner().clear(),
104            }
105        }
106    }
107
108    /// The tally every [`span`] folds into.
109    static TALLY: Tally = Tally::new();
110
111    /// Whether `BREP_RUN_TRACE` is set to anything but `0` (read once). Only
112    /// the stderr line is gated on it; the tally is always kept.
113    pub fn enabled() -> bool {
114        match ENABLED.load(Ordering::Relaxed) {
115            0 => {
116                let on = std::env::var("BREP_RUN_TRACE").is_ok_and(|value| value != "0");
117                ENABLED.store(if on { 2 } else { 1 }, Ordering::Relaxed);
118                on
119            }
120            2 => true,
121            _ => false,
122        }
123    }
124
125    /// Every site that has executed a history since the last [`reset`], in name
126    /// order.
127    pub fn tally() -> Vec<(&'static str, SiteTally)> {
128        TALLY.sites()
129    }
130
131    /// Drop every site's totals — the counterpart of clearing the frame-timing
132    /// window, so a caller can bracket ONE interaction.
133    pub fn reset() {
134        TALLY.clear()
135    }
136
137    /// One traced execution: folds into the tally when dropped, and prints
138    /// `site`, the running thread and the elapsed milliseconds when the trace
139    /// is switched on.
140    pub struct Span {
141        sink: &'static Tally,
142        site: &'static str,
143        start: Instant,
144        features: u64,
145        reused: u64,
146        errors: u64,
147        reported: bool,
148    }
149
150    impl Span {
151        /// Record what the execution actually did — how many features came
152        /// back, how many of them the incremental cache replayed, and how many
153        /// failed.
154        pub fn result(&mut self, result: &brep_kernel::HistoryResult) {
155            self.features = result.results.len() as u64;
156            self.reused = result.results.iter().filter(|feature| feature.reused).count() as u64;
157            self.errors = result.results.iter().filter(|feature| feature.error.is_some()).count() as u64;
158            self.reported = true;
159        }
160    }
161
162    impl Drop for Span {
163        fn drop(&mut self) {
164            let ms = self.start.elapsed().as_secs_f64() * 1e3;
165            self.sink.fold(self.site, ms, self.features, self.reused, self.errors);
166            if enabled() {
167                let note = if self.reported {
168                    format!(" features={} reused={} errors={}", self.features, self.reused, self.errors)
169                } else {
170                    String::new()
171                };
172                eprintln!(
173                    "[run-trace] site={} thread={:?} ms={ms:.1}{note}",
174                    self.site,
175                    std::thread::current().id(),
176                );
177            }
178        }
179    }
180
181    /// Open a trace span around an `execute_history` call. Always `Some`
182    /// natively — the tally is not gated on the environment variable; the
183    /// `Option` is the shape wasm needs.
184    pub fn span(site: &'static str) -> Option<Span> {
185        span_into(site, &TALLY)
186    }
187
188    /// A span that folds into `sink` instead of the process-global tally.
189    pub(crate) fn span_into(site: &'static str, sink: &'static Tally) -> Option<Span> {
190        Some(Span { sink, site, start: Instant::now(), features: 0, reused: 0, errors: 0, reported: false })
191    }
192}
193
194#[cfg(not(target_arch = "wasm32"))]
195pub use native::{reset, span, tally, Tally};
196
197/// The wasm stand-in: there is no second thread to trace and no `std::time`.
198#[cfg(target_arch = "wasm32")]
199pub struct Span;
200
201#[cfg(target_arch = "wasm32")]
202impl Span {
203    pub fn result(&mut self, _result: &brep_kernel::HistoryResult) {}
204}
205
206#[cfg(target_arch = "wasm32")]
207pub fn span(_site: &'static str) -> Option<Span> {
208    None
209}
210
211#[cfg(target_arch = "wasm32")]
212pub fn tally() -> Vec<(&'static str, SiteTally)> {
213    Vec::new()
214}
215
216#[cfg(target_arch = "wasm32")]
217pub fn reset() {}
218
219/// The tally as JSON: one object per site plus a `total`, the shape
220/// `perf_stats` embeds under `runs`.
221pub fn tally_json() -> serde_json::Value {
222    json_of(tally())
223}
224
225/// [`tally_json`] of any tally's sites.
226fn json_of(tally: Vec<(&'static str, SiteTally)>) -> serde_json::Value {
227    let mut sites = serde_json::Map::new();
228    let mut total = SiteTally::default();
229    for (site, t) in tally {
230        total.count += t.count;
231        total.ms += t.ms;
232        total.features += t.features;
233        total.reused += t.reused;
234        total.errors += t.errors;
235        sites.insert(
236            site.to_string(),
237            serde_json::json!({
238                "count": t.count,
239                "ms": (t.ms * 10.0).round() / 10.0,
240                "features": t.features,
241                "reused": t.reused,
242                "errors": t.errors,
243            }),
244        );
245    }
246    serde_json::json!({
247        "sites": serde_json::Value::Object(sites),
248        "total": {
249            "count": total.count,
250            "ms": (total.ms * 10.0).round() / 10.0,
251            "features": total.features,
252            "reused": total.reused,
253            "errors": total.errors,
254        },
255    })
256}
257