1
//! Optional fine-grained timing + RSS instrumentation.
2
//!
3
//! Behind the `probe` feature flag every [`Probe::span`] returns a guard
4
//! that records the elapsed wall-clock on `Drop`, and
5
//! [`Probe::sample_rss`] records a labelled RSS checkpoint. Events are
6
//! buffered in a per-thread [`Vec`] and drained by the consumer with
7
//! [`Probe::drain`].
8
//!
9
//! With the feature off every method is a `#[inline]` no-op so
10
//! release builds without the feature pay zero cost.
11
//!
12
//! Consumer (e.g. servo-shot) groups drained events by name to produce
13
//! the per-phase averages / p99s in its trace report.
14

            
15
use core::marker::PhantomData;
16

            
17
// WASM gate: `Instant::now()` panics on browser WASM (no monotonic clock)
18
// and `libc::getrusage` isn't available, so on `target_family = "wasm"`
19
// we drop to the no-op stubs even when the `probe` feature is on.
20
// `AZ_PROFILE=cpu` then prints "(probe unavailable on this target)"
21
// rather than crashing.
22

            
23
// [WEB-LIFT 2026-06-11] `web_lift` also forces the no-op imp: the real
24
// module is Instant::now (mach-time syscall, out-of-image when lifted) +
25
// thread-local pushes + first-access dtor registration (`_tlv_atexit`).
26
// With the TLV emulation in place TLS "works", which flips these from
27
// harmlessly-failing (`try_with` Err) to actually-running — and the
28
// mach/atexit extern calls inside are unliftable. Profiling is
29
// meaningless in lifted wasm; the dylib built with `web-transpiler*`
30
// (which enables `web_lift`) is the web-server build, so desktop
31
// release builds keep real probes.
32
#[cfg(all(
33
    feature = "probe",
34
    not(target_family = "wasm"),
35
    not(feature = "web_lift")
36
))]
37
mod imp {
38
    use std::cell::RefCell;
39
    use std::sync::atomic::{AtomicU8, Ordering};
40
    use std::time::Instant;
41

            
42
    thread_local! {
43
        static EVENTS: RefCell<Vec<super::Event>> = const { RefCell::new(Vec::new()) };
44
        /// Currently-open span count. Read when a span OPENS (its own
45
        /// depth) and decremented when it closes.
46
        static DEPTH: std::cell::Cell<u16> = const { std::cell::Cell::new(0) };
47
        /// Names of the currently-open spans, outermost first. Maintained
48
        /// UNCONDITIONALLY (even with recording off): this is what a crash
49
        /// report reads as "what scope was the app in" — a diagnostic that
50
        /// must not depend on AZ_PROFILE being set. Cost: one push/pop of a
51
        /// `&'static str` per span.
52
        static SPAN_NAMES: RefCell<Vec<&'static str>> = const { RefCell::new(Vec::new()) };
53
    }
54

            
55
    /// Whether spans/samples are RECORDED. The `probe` feature being compiled
56
    /// in used to mean "always record" — but the dll builds with `probe` on
57
    /// unconditionally, and the event buffer is only drained by the
58
    /// `AZ_PROFILE=cpu` report path. Every plain run therefore pushed ~40 B
59
    /// per span into a thread-local Vec that nothing ever emptied: unbounded
60
    /// growth, invisible to the `LayoutCache` memory walk (it's a thread-local).
61
    /// A 5 s resize drag alone is ~375 relayouts × hundreds of spans.
62
    ///
63
    /// 0 = uninitialized (resolve from `AZ_PROFILE` on first probe),
64
    /// 1 = recording, 2 = off.
65
    static RECORDING: AtomicU8 = AtomicU8::new(0);
66

            
67
    #[inline]
68
    fn recording() -> bool {
69
        match RECORDING.load(Ordering::Relaxed) {
70
            1 => true,
71
            2 => false,
72
            _ => {
73
                // First probe anywhere resolves the mode once. Any profile
74
                // mode that can consume events counts; the write is
75
                // idempotent so a racing thread resolving the same env is
76
                // harmless.
77
                let on = azul_core::profile::cpu_enabled()
78
                    || azul_core::profile::memory_enabled()
79
                    || azul_core::profile::heap_enabled();
80
                RECORDING.store(if on { 1 } else { 2 }, Ordering::Relaxed);
81
                on
82
            }
83
        }
84
    }
85

            
86
    pub(super) fn set_recording(on: bool) {
87
        RECORDING.store(if on { 1 } else { 2 }, Ordering::Relaxed);
88
    }
89

            
90
    /// RAII guard that records its name + elapsed nanos on drop.
91
    /// `start == None` means recording was off when the span opened: the
92
    /// guard is inert (no clock read on open, no TLS touch on drop).
93
    #[derive(Debug)]
94
    pub struct Span {
95
        pub(crate) name: &'static str,
96
        pub(crate) start: Option<Instant>,
97
        pub(crate) depth: u16,
98
    }
99

            
100
    impl Drop for Span {
101
        fn drop(&mut self) {
102
            let _ = SPAN_NAMES.try_with(|st| {
103
                st.borrow_mut().pop();
104
            });
105
            let Some(start) = self.start else { return };
106
            let dur_ns = start.elapsed().as_nanos() as u64;
107
            // try_with (not with): the lifted-to-wasm web backend has no real
108
            // TLS, so `with` hits panic_access_error. These probe accesses are
109
            // inlined into layout_dom_recursive/layout_document, so they can't
110
            // be stubbed at the symbol level — use the non-panicking access.
111
            let depth = self.depth;
112
            let _ = DEPTH.try_with(|d| d.set(d.get().saturating_sub(1)));
113
            let _ = EVENTS.try_with(|cell| {
114
                cell.borrow_mut().push(super::Event {
115
                    name: self.name,
116
                    kind: super::EventKind::Span { dur_ns },
117
                    depth,
118
                });
119
            });
120
        }
121
    }
122

            
123
    pub(super) fn open(name: &'static str) -> Span {
124
        let _ = SPAN_NAMES.try_with(|st| st.borrow_mut().push(name));
125
        if !recording() {
126
            return Span { name, start: None, depth: 0 };
127
        }
128
        let depth = DEPTH
129
            .try_with(|d| {
130
                let cur = d.get();
131
                d.set(cur.saturating_add(1));
132
                cur
133
            })
134
            .unwrap_or(0);
135
        Span { name, start: Some(Instant::now()), depth }
136
    }
137

            
138
    pub(super) fn sample_rss(label: &'static str, bytes: u64) {
139
        if !recording() {
140
            return;
141
        }
142
        // try_with: see Span::drop — no real TLS in the lifted wasm backend.
143
        let depth = DEPTH.try_with(std::cell::Cell::get).unwrap_or(0);
144
        let _ = EVENTS.try_with(|cell| {
145
            cell.borrow_mut().push(super::Event {
146
                name: label,
147
                kind: super::EventKind::Rss { bytes },
148
                depth,
149
            });
150
        });
151
    }
152

            
153
    /// The path of currently-open spans on THIS thread, outermost first,
154
    /// joined with `" > "` — e.g. `dispatch.timer > layout > text_shape`.
155
    /// Empty when no span is open. Readable from a panic hook (same thread).
156
    pub(super) fn span_path() -> String {
157
        SPAN_NAMES
158
            .try_with(|st| st.borrow().join(" > "))
159
            .unwrap_or_default()
160
    }
161

            
162
    pub(super) fn drain() -> Vec<super::Event> {
163
        EVENTS
164
            .try_with(|cell| core::mem::take(&mut *cell.borrow_mut()))
165
            .unwrap_or_default()
166
    }
167

            
168
    /// `dladdr`-backed pointer→symbol resolution with a leak-once cache.
169
    /// Span names are `&'static str`, so each distinct callback leaks ONE
170
    /// small string for the process lifetime — bounded by the number of
171
    /// distinct callbacks an app has.
172
    pub(super) fn resolve_fn_name(fn_ptr: usize) -> &'static str {
173
        use std::collections::HashMap;
174
        use std::sync::{Mutex, OnceLock};
175
        static CACHE: OnceLock<Mutex<HashMap<usize, &'static str>>> = OnceLock::new();
176
        let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
177
        if let Ok(map) = cache.lock() {
178
            if let Some(name) = map.get(&fn_ptr) {
179
                return name;
180
            }
181
        }
182
        let resolved: &'static str = {
183
            #[cfg(unix)]
184
            {
185
                // dladdr resolves names from .dynsym only — a statically
186
                // linked, non--rdynamic binary yields NO symbol for its own
187
                // functions. Fallback ladder: (1) `addr2line` against the
188
                // module's DEBUG symbols (Linux, when the tool is installed —
189
                // this recovers the real name, e.g. `cb:demo_button_click`,
190
                // even on static binaries); (2) the MODULE-RELATIVE offset
191
                // (`cb:+0x<offset>`), stable across runs of the same binary
192
                // (ASLR shifts the base, not the offset), so distinct
193
                // callbacks stay distinguishable and comparable per version.
194
                let mut info: libc::Dl_info = unsafe { core::mem::zeroed() };
195
                let rc = unsafe { libc::dladdr(fn_ptr as *const libc::c_void, &raw mut info) };
196
                if rc != 0 && !info.dli_sname.is_null() {
197
                    let name = unsafe { core::ffi::CStr::from_ptr(info.dli_sname) };
198
                    match name.to_str() {
199
                        Ok(sym) if !sym.is_empty() => {
200
                            Box::leak(format!("cb:{sym}").into_boxed_str())
201
                        }
202
                        _ => Box::leak(format!("cb:0x{fn_ptr:x}").into_boxed_str()),
203
                    }
204
                } else if rc != 0 && !info.dli_fbase.is_null() {
205
                    let offset = fn_ptr.wrapping_sub(info.dli_fbase as usize);
206
                    let module = if info.dli_fname.is_null() {
207
                        None
208
                    } else {
209
                        unsafe { core::ffi::CStr::from_ptr(info.dli_fname) }
210
                            .to_str()
211
                            .ok()
212
                            .map(str::to_owned)
213
                    };
214
                    match addr2line_name(module.as_deref(), offset, fn_ptr) {
215
                        Some(sym) => Box::leak(format!("cb:{sym}").into_boxed_str()),
216
                        None => Box::leak(format!("cb:+0x{offset:x}").into_boxed_str()),
217
                    }
218
                } else {
219
                    // dladdr failed outright: the raw address still separates
220
                    // one callback from another within this run.
221
                    Box::leak(format!("cb:0x{fn_ptr:x}").into_boxed_str())
222
                }
223
            }
224
            #[cfg(not(unix))]
225
            {
226
                Box::leak(format!("cb:0x{fn_ptr:x}").into_boxed_str())
227
            }
228
        };
229
        if let Ok(mut map) = cache.lock() {
230
            map.insert(fn_ptr, resolved);
231
        }
232
        resolved
233
    }
234

            
235
    /// DEBUG-SYMBOL fallback: asks the system's `addr2line` for the function
236
    /// name at `offset` inside `module` (Linux; other unixes rarely ship
237
    /// it). Recovers real names on statically linked binaries whose own
238
    /// functions are absent from `.dynsym` — exactly the case `dladdr`
239
    /// cannot answer.
240
    ///
241
    /// Runs AT MOST ONCE per distinct callback pointer (the leak-once cache
242
    /// above), so the subprocess cost — up to a few hundred ms the first
243
    /// time addr2line loads a big binary's DWARF — is a one-time price per
244
    /// callback, not per span. `AZ_PROBE_ADDR2LINE=0` disables it.
245
    ///
246
    /// PIE executables and shared objects map file offsets 1:1 to link-time
247
    /// addresses, so the module-relative offset is the right query; for a
248
    /// non-PIE main binary (fixed 0x400000 base) the RAW pointer is, so a
249
    /// failed first query retries with it.
250
    /// Probed ONCE per process (first resolution), then a flag test: systems
251
    /// without addr2line never spawn a second lookup attempt, and nothing
252
    /// here can fail loudly — "not available" just means the offset form.
253
    #[cfg(unix)]
254
    fn addr2line_available() -> bool {
255
        use std::sync::OnceLock;
256
        static AVAILABLE: OnceLock<bool> = OnceLock::new();
257
        *AVAILABLE.get_or_init(|| {
258
            std::process::Command::new("addr2line")
259
                .arg("--version")
260
                .stdout(std::process::Stdio::null())
261
                .stderr(std::process::Stdio::null())
262
                .status()
263
                .is_ok_and(|s| s.success())
264
        })
265
    }
266

            
267
    #[cfg(unix)]
268
    fn addr2line_name(module: Option<&str>, offset: usize, raw_ptr: usize) -> Option<String> {
269
        if !cfg!(target_os = "linux") {
270
            return None;
271
        }
272
        if std::env::var("AZ_PROBE_ADDR2LINE").is_ok_and(|v| v == "0") {
273
            return None;
274
        }
275
        if !addr2line_available() {
276
            return None;
277
        }
278
        let module: std::borrow::Cow<'_, str> = match module {
279
            Some(m) if !m.is_empty() => m.into(),
280
            _ => std::env::current_exe().ok()?.to_string_lossy().into_owned().into(),
281
        };
282
        let ask = |addr: usize| -> Option<String> {
283
            let out = std::process::Command::new("addr2line")
284
                .arg("-f") // function names…
285
                .arg("-C") // …demangled
286
                .arg("-i") // …with the full INLINE stack: a tiny callback's
287
                //            first instruction often belongs to an inlined
288
                //            callee (black_box, a getter), and the innermost
289
                //            frame would name THAT. The callback is the
290
                //            OUTERMOST frame — the last name in the output.
291
                .arg("-e")
292
                .arg(module.as_ref())
293
                .arg(format!("0x{addr:x}"))
294
                .output()
295
                .ok()?;
296
            if !out.status.success() {
297
                return None;
298
            }
299
            let text = String::from_utf8_lossy(&out.stdout);
300
            // Lines alternate name/location, innermost first; keep the last
301
            // usable NAME line (the outermost frame).
302
            let name = text
303
                .lines()
304
                .step_by(2)
305
                .map(str::trim)
306
                .filter(|n| !n.is_empty() && *n != "??")
307
                .last()?;
308
            Some(name.to_owned())
309
        };
310
        ask(offset).or_else(|| ask(raw_ptr))
311
    }
312

            
313
    pub(super) fn drop_events() {
314
        let _ = EVENTS.try_with(|cell| cell.borrow_mut().clear());
315
    }
316

            
317
    pub(super) fn peek_len() -> usize {
318
        EVENTS.try_with(|cell| cell.borrow().len()).unwrap_or(0)
319
    }
320

            
321
    pub(super) const fn enabled() -> bool {
322
        true
323
    }
324
}
325

            
326
#[cfg(any(
327
    not(feature = "probe"),
328
    target_family = "wasm",
329
    feature = "web_lift"
330
))]
331
mod imp {
332
    #[derive(Debug)]
333
    pub struct Span;
334

            
335
    impl Drop for Span {
336
        #[inline]
337
8558316
        fn drop(&mut self) {}
338
    }
339

            
340
    #[inline]
341
8558317
    pub(super) const fn open(_name: &'static str) -> Span {
342
8558317
        Span
343
8558317
    }
344

            
345
    #[inline]
346
    pub(super) const fn span_path() -> String {
347
        String::new()
348
    }
349

            
350
    #[inline]
351
48
    pub(super) const fn resolve_fn_name(_fn_ptr: usize) -> &'static str {
352
48
        "cb:?"
353
48
    }
354

            
355
    #[inline]
356
24
    pub(super) const fn set_recording(_on: bool) {}
357

            
358
    #[inline]
359
80
    pub(super) const fn sample_rss(_label: &'static str, _bytes: u64) {}
360

            
361
    #[inline]
362
152
    pub(super) const fn drain() -> Vec<super::Event> {
363
152
        Vec::new()
364
152
    }
365

            
366
    #[inline]
367
4687
    pub(super) const fn drop_events() {}
368

            
369
    #[inline]
370
237
    pub(super) const fn peek_len() -> usize { 0 }
371

            
372
    #[inline]
373
1029
    pub(super) const fn enabled() -> bool {
374
1029
        false
375
1029
    }
376
}
377

            
378
/// Drained probe event. `Vec<Event>` is what consumers walk to render
379
/// trace summaries; the order is the order events fired in.
380
#[derive(Copy, Debug, Clone)]
381
pub struct Event {
382
    pub name: &'static str,
383
    pub kind: EventKind,
384
    /// Nesting depth at the time the span OPENED (0 = outermost).
385
    ///
386
    /// Spans are emitted post-order carrying only a duration, so a
387
    /// consumer could report a phase's CUMULATIVE time but never its own:
388
    /// an outer `layout_formatting_context` reports the whole subtree it
389
    /// contains, and the totals happily exceed wall-clock. With depth, a
390
    /// consumer walking the post-order stream can subtract each span's
391
    /// immediate children and get SELF time — which is what actually
392
    /// names a hot phase. `Rss` samples carry the current depth too.
393
    pub depth: u16,
394
}
395

            
396
#[derive(Copy, Debug, Clone)]
397
pub enum EventKind {
398
    /// A timed scope's wall-clock duration.
399
    Span { dur_ns: u64 },
400
    /// A labelled RSS checkpoint.
401
    Rss { bytes: u64 },
402
}
403

            
404
/// Re-exported guard. Held by the caller of [`Probe::span`].
405
pub use imp::Span;
406

            
407
/// Probe API. All methods are no-ops without the `probe` feature.
408
#[derive(Copy, Clone, Debug)]
409
pub struct Probe {
410
    _no_construct: PhantomData<()>,
411
}
412

            
413
impl Probe {
414
    /// Open a timed span. The returned guard records its name + nanos
415
    /// on drop into the thread-local event buffer — but ONLY while
416
    /// recording is on (any `AZ_PROFILE` mode, or [`Probe::set_recording`]).
417
    /// With recording off the guard is inert and the call is one relaxed
418
    /// atomic load: safe to leave in hot per-node paths.
419
    #[inline]
420
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
421
    #[allow(clippy::missing_const_for_fn)]
422
8558279
    #[must_use] pub fn span(name: &'static str) -> Span {
423
8558279
        imp::open(name)
424
8558279
    }
425

            
426
    /// Force event recording on/off, overriding the lazy `AZ_PROFILE`
427
    /// resolution. Tests use this (they assert on drained events without
428
    /// setting env vars); a debug server could too. Flipping mid-span only
429
    /// perturbs the saturating depth counter, never memory safety.
430
    #[inline]
431
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
432
    #[allow(clippy::missing_const_for_fn)]
433
24
    pub fn set_recording(on: bool) {
434
24
        imp::set_recording(on);
435
24
    }
436

            
437
    /// Record an RSS checkpoint with the given label + byte count. The
438
    /// caller supplies the bytes (this module does not depend on
439
    /// platform RSS readers) so consumers can use whatever measurement
440
    /// helper they own.
441
    #[inline]
442
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
443
    #[allow(clippy::missing_const_for_fn)]
444
79
    pub fn sample_rss(label: &'static str, bytes: u64) {
445
79
        imp::sample_rss(label, bytes);
446
79
    }
447

            
448
    /// Drain the per-thread event buffer.
449
    #[inline]
450
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
451
    #[allow(clippy::missing_const_for_fn)]
452
151
    #[must_use] pub fn drain() -> Vec<Event> {
453
151
        imp::drain()
454
151
    }
455

            
456
    /// The names of THIS thread's currently-open spans, outermost first,
457
    /// joined with `" > "` (empty when none). Maintained even with recording
458
    /// off — a crash report reads this as "what scope was the app in", and
459
    /// that diagnostic must not depend on `AZ_PROFILE`.
460
    #[inline]
461
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
462
    #[allow(clippy::missing_const_for_fn)]
463
    #[must_use] pub fn span_path() -> String {
464
        imp::span_path()
465
    }
466

            
467
    /// A timed span NAMED AFTER the function the pointer points at,
468
    /// resolved through the dynamic linker (`dladdr`) and cached forever:
469
    /// an `extern "C"` app callback like `my_button_click` becomes span
470
    /// `cb:my_button_click`, so the per-phase histogram answers
471
    /// "`my_button_click` takes 0.2 ms on 1.5.0, took 0.1 ms on 1.4.3".
472
    ///
473
    /// Resolution runs ONCE per distinct pointer (a leak-once cache bounded
474
    /// by the number of distinct callbacks); every later call is one map
475
    /// lookup. When the symbol is unresolvable (static non-`-rdynamic`
476
    /// binaries keep their own functions out of `.dynsym`), the fallback
477
    /// ladder is: `addr2line` against the module's debug symbols (Linux,
478
    /// when installed — recovers the real name; `AZ_PROBE_ADDR2LINE=0`
479
    /// disables), then the module-relative offset `cb:+0x<offset>` — stable
480
    /// across runs of the same binary, so distinct callbacks remain
481
    /// distinguishable and per-version comparisons still work; `cb:0x<addr>`
482
    /// is the last resort when `dladdr` fails entirely.
483
    #[inline]
484
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
485
    #[allow(clippy::missing_const_for_fn)]
486
37
    #[must_use] pub fn span_for_fn(fn_ptr: usize) -> Span {
487
37
        imp::open(imp::resolve_fn_name(fn_ptr))
488
37
    }
489

            
490
    /// Discard the per-thread event buffer without allocating a `Vec` to
491
    /// hand back. Used by long-running harnesses (e.g. `AZ_E2E_TEST`) that
492
    /// want to prevent the thread-local buffer from inflating RSS during
493
    /// thousands of layout passes without actually needing the events.
494
    #[inline]
495
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
496
    #[allow(clippy::missing_const_for_fn)]
497
4686
    pub fn drop_events() {
498
4686
        imp::drop_events();
499
4686
    }
500

            
501
    /// Current number of events in the per-thread buffer. Cheap to call.
502
    #[inline]
503
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
504
    #[allow(clippy::missing_const_for_fn)]
505
234
    #[must_use] pub fn peek_len() -> usize {
506
234
        imp::peek_len()
507
234
    }
508

            
509
    /// Whether the `probe` feature is compiled in.
510
    #[inline]
511
    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
512
    #[allow(clippy::missing_const_for_fn)]
513
1028
    #[must_use] pub fn enabled() -> bool {
514
1028
        imp::enabled()
515
1028
    }
516
}
517

            
518
/// Same monotonic clock used by `font::parsed::monotonic_now_nanos` for
519
/// LRU stamping. Re-exported here so any caller that wants raw nanos
520
/// without going through a span guard has one source of truth.
521
#[inline]
522
#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
523
10004
pub fn monotonic_now_nanos() -> u64 {
524
    use std::sync::OnceLock;
525
    use std::time::Instant;
526
    static LAUNCH: OnceLock<Instant> = OnceLock::new();
527
10004
    let start = LAUNCH.get_or_init(Instant::now);
528
10004
    start.elapsed().as_nanos() as u64
529
10004
}
530

            
531
/// Format drained probe events as a per-phase timing table to stderr.
532
///
533
/// Groups `EventKind::Span` by name and prints count / total / avg / p99 /
534
/// max in µs. `EventKind::Rss` checkpoints print in wall-clock order with
535
/// deltas so allocator purges are visible.
536
///
537
/// Sorted by total-ns descending so the slowest phase is on top — ideal
538
/// for spotting which phase spiked during a stuttering frame.
539
///
540
/// Called by `AZ_PROFILE=cpu` dumps (both initial layout and relayout),
541
/// and also by external consumers like `servo-shot --azul-trace`.
542
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
543
/// # Panics
544
///
545
/// Panics if the collected timing-sample list is empty.
546
18
pub fn print_drained_events(label: &str, events: &[Event]) {
547
    use std::collections::BTreeMap;
548

            
549
18
    if events.is_empty() {
550
3
        if Probe::enabled() {
551
            eprintln!("[CPU] {label}: no events recorded this pass");
552
3
        } else {
553
3
            // Feature absent or target-family disabled (WASM): show "???"
554
3
            // instead of a misleading "compile with feature=probe" hint.
555
3
            eprintln!(
556
3
                "[CPU] {label}: probe unavailable on this target (timings = ???)"
557
3
            );
558
3
        }
559
3
        return;
560
15
    }
561

            
562
15
    let mut spans: BTreeMap<&'static str, Vec<u64>> = BTreeMap::new();
563
15
    let mut rss_marks: Vec<(&'static str, u64)> = Vec::new();
564
1939
    for ev in events {
565
1924
        match ev.kind {
566
1916
            EventKind::Span { dur_ns } => spans.entry(ev.name).or_default().push(dur_ns),
567
8
            EventKind::Rss { bytes } => rss_marks.push((ev.name, bytes)),
568
        }
569
    }
570

            
571
15
    let mut rows: Vec<(&'static str, usize, u64, u64, u64, u64)> = spans
572
15
        .into_iter()
573
18
        .map(|(name, mut ns)| {
574
18
            ns.sort_unstable();
575
18
            let n = ns.len();
576
1916
            let total: u128 = ns.iter().map(|&x| u128::from(x)).sum();
577
18
            let avg = (total / n.max(1) as u128) as u64;
578
18
            let p99 = ns[(n.saturating_sub(1) * 99) / 100];
579
18
            let max = *ns.last().unwrap();
580
18
            (name, n, total as u64, avg, p99, max)
581
18
        })
582
15
        .collect();
583
15
    rows.sort_by(|a, b| b.2.cmp(&a.2));
584

            
585
15
    eprintln!("[CPU] === {label} ({} phases) ===", rows.len());
586
15
    eprintln!(
587
15
        "[CPU] {:<28}  {:>5}  {:>10}  {:>9}  {:>9}  {:>9}",
588
        "phase", "n", "total(µs)", "avg(µs)", "p99(µs)", "max(µs)"
589
    );
590
33
    for (name, n, total, avg, p99, max) in &rows {
591
18
        eprintln!(
592
18
            "[CPU] {:<28}  {:>5}  {:>10.1}  {:>9.2}  {:>9.2}  {:>9.2}",
593
18
            name,
594
18
            n,
595
18
            (*total as f64) / 1_000.0,
596
18
            (*avg as f64) / 1_000.0,
597
18
            (*p99 as f64) / 1_000.0,
598
18
            (*max as f64) / 1_000.0,
599
18
        );
600
18
    }
601
15
    if !rss_marks.is_empty() {
602
4
        eprintln!("[CPU]   -- RSS checkpoints (wall-clock order) --");
603
4
        let mut prev: Option<u64> = None;
604
12
        for (lbl, bytes) in &rss_marks {
605
8
            let delta = prev
606
8
                .map(|p| {
607
4
                    let diff = i128::from(*bytes) - i128::from(p);
608
4
                    if diff >= 0 {
609
2
                        format!("  (Δ +{:.2} MiB)", diff as f64 / 1_048_576.0)
610
                    } else {
611
2
                        format!("  (Δ -{:.2} MiB)", -diff as f64 / 1_048_576.0)
612
                    }
613
4
                })
614
8
                .unwrap_or_default();
615
8
            eprintln!(
616
8
                "[CPU]   {:<28}  {:.2} MiB{}",
617
                lbl,
618
8
                *bytes as f64 / 1_048_576.0,
619
                delta
620
            );
621
8
            prev = Some(*bytes);
622
        }
623
11
    }
624
18
}
625

            
626
/// Convenience wrapper: sample the process's **current** resident set
627
/// (not peak) via `task_info` on macOS / `/proc/self/statm` on Linux and
628
/// push it into the probe event buffer under the given label.
629
///
630
/// Using current RSS (not `getrusage.ru_maxrss`) is essential so that
631
/// allocator purges are visible — peak RSS only moves up. Name kept as
632
/// `sample_peak_rss` for backwards compatibility with existing
633
/// checkpoint labels; semantically it is "sample current".
634
#[inline]
635
// const only without the `probe` feature; enabled path calls non-const RSS readers
636
#[allow(clippy::missing_const_for_fn)]
637
54183
pub fn sample_peak_rss(label: &'static str) {
638
    // [WEB-LIFT 2026-06-11] also no-op under web_lift: current_rss_bytes/
639
    // peak_rss_bytes_self are mach syscalls (task_info/getrusage) —
640
    // out-of-image and unliftable. See the `imp` cfg note above.
641
    #[cfg(all(feature = "probe", not(feature = "web_lift")))]
642
    {
643
        // Self-measurement accounting: each sample reads /proc (or the mach
644
        // equivalent) — hundreds of µs each, ×10 checkpoints per pass. This
645
        // span makes the PROFILER'S OWN COST a line in its report instead of
646
        // silently inflating solver3_layout_document's self-time (~5 ms of
647
        // "unattributed" turned out to be largely this).
648
        let _p = Probe::span("probe_rss_sample_cost");
649
        let (current, _virt) = current_rss_bytes();
650
        let bytes = if current != 0 { current } else { peak_rss_bytes_self() };
651
        Probe::sample_rss(label, bytes);
652
    }
653
    #[cfg(any(not(feature = "probe"), feature = "web_lift"))]
654
54183
    let _ = label;
655
54183
}
656

            
657
#[cfg(feature = "probe")]
658
#[must_use] pub fn peak_rss_bytes_pub() -> u64 { peak_rss_bytes_self() }
659

            
660
#[cfg(feature = "probe")]
661
fn peak_rss_bytes_self() -> u64 {
662
    #[cfg(unix)]
663
    unsafe {
664
        let mut ru: libc::rusage = core::mem::zeroed();
665
        if libc::getrusage(libc::RUSAGE_SELF, &raw mut ru) != 0 {
666
            return 0;
667
        }
668
        let raw = ru.ru_maxrss as u64;
669
        if cfg!(target_os = "macos") { raw } else { raw.saturating_mul(1024) }
670
    }
671
    // Windows has no getrusage; `PeakWorkingSetSize` is the direct equivalent
672
    // of `ru_maxrss` and is already in bytes.
673
    #[cfg(all(target_os = "windows", not(miri)))]
674
    {
675
        windows_memory_counters().map_or(0, |c| c.peak_working_set)
676
    }
677
    #[cfg(not(any(unix, all(target_os = "windows", not(miri)))))]
678
    {
679
        0
680
    }
681
}
682

            
683
/// Ask the active global allocator to return freed pages to the OS.
684
///
685
/// - With `allocator_mimalloc` feature: calls `mi_collect(true)`, which
686
///   aggressively returns pages (matches `az_purge_allocator` in azul-dll).
687
/// - With `allocator_jemalloc` feature: calls `mallctl("arena.0.purge")`.
688
/// - Otherwise on macOS: falls back to `malloc_zone_pressure_relief`
689
///   which drains the system zone (no-op when a third-party allocator
690
///   is the global one — hence the explicit feature flags above).
691
/// - Other platforms with default allocator: no-op.
692
///
693
/// Call after major allocations are freed (e.g. after a layout pass).
694
#[inline]
695
// const only on the default-allocator no-op path (e.g. Linux); the mimalloc /
696
// jemalloc / macOS `malloc_zone_pressure_relief` bodies call non-const fns
697
#[allow(clippy::missing_const_for_fn)]
698
9818
pub fn hint_purge_allocator() {
699
    #[cfg(feature = "allocator_mimalloc")]
700
    {
701
        // Aggressive purge — returns arenas to the OS when possible.
702
        unsafe {
703
            libmimalloc_sys::mi_collect(true);
704
        }
705
        static PURGE_TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
706
        if *PURGE_TRACE.get_or_init(azul_core::profile::memory_enabled) {
707
            let (rss, _) = current_rss_bytes();
708
            eprintln!("[PURGE] mi_collect(true) called — current rss={:.2} MiB", rss as f64 / 1048576.0);
709
        }
710
        return;
711
    }
712
    #[cfg(feature = "allocator_jemalloc")]
713
    {
714
        // Purge all arenas. `arena.<i>.purge` with i = MALLCTL_ARENAS_ALL.
715
        unsafe {
716
            let _ = tikv_jemalloc_sys::mallctl(
717
                b"arena.4096.purge\0".as_ptr() as *const _,
718
                core::ptr::null_mut(),
719
                core::ptr::null_mut(),
720
                core::ptr::null_mut(),
721
                0,
722
            );
723
        }
724
        return;
725
    }
726
    #[cfg(all(target_os = "macos", not(miri), not(any(feature = "allocator_mimalloc", feature = "allocator_jemalloc"))))]
727
    {
728
        extern "C" {
729
            fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize;
730
        }
731
        unsafe {
732
            malloc_zone_pressure_relief(core::ptr::null_mut(), 0);
733
        }
734
    }
735
    // glibc's equivalent of malloc_zone_pressure_relief. Without this the
736
    // Linux default-allocator path was the "no-op" the doc comment describes,
737
    // so a purge-then-measure sequence could never show pages coming back.
738
    #[cfg(all(
739
        target_os = "linux",
740
        target_env = "gnu",
741
        not(miri),
742
        not(any(feature = "allocator_mimalloc", feature = "allocator_jemalloc"))
743
    ))]
744
    {
745
        // Declared here rather than via `libc::malloc_trim`: this function is
746
        // NOT gated on the `probe` feature (that is what pulls in libc), and
747
        // the macOS arm above declares `malloc_zone_pressure_relief` the same
748
        // way for the same reason.
749
        extern "C" {
750
            fn malloc_trim(pad: usize) -> core::ffi::c_int;
751
        }
752
9818
        unsafe {
753
9818
            malloc_trim(0);
754
9818
        }
755
    }
756
9818
}
757

            
758
/// Sample the process's "real" memory footprint (not peak).
759
/// Returns (`footprint_bytes`, `virtual_bytes`). On macOS this is
760
/// `phys_footprint` from `TASK_VM_INFO` — matches Activity Monitor
761
/// "Memory" and `vmmap`'s "Physical footprint" line, and excludes
762
/// shared library text pages that would otherwise inflate RSS
763
/// without costing the process anything uniquely. On Linux this
764
/// falls back to `/proc/self/statm` resident size (no direct
765
/// equivalent; the shared-lib inflation is much smaller there).
766
/// More useful than `getrusage.ru_maxrss` which only moves upward.
767
#[cfg(feature = "probe")]
768
#[must_use] pub fn current_rss_bytes() -> (u64, u64) {
769
    // Miri cannot call the mach `task_info` foreign function; memory profiling
770
    // is meaningless under Miri anyway, so report zero.
771
    #[cfg(miri)]
772
    return (0, 0);
773
    #[cfg(all(target_os = "macos", not(miri)))]
774
    {
775
        // Prefer phys_footprint (TASK_VM_INFO). Fall back to
776
        // resident_size (MACH_TASK_BASIC_INFO) if the bigger struct
777
        // isn't populated for some reason.
778
        let pf = phys_footprint_bytes();
779
        #[repr(C)]
780
        struct MachTaskBasicInfo {
781
            virtual_size: u64,
782
            resident_size: u64,
783
            resident_size_max: u64,
784
            user_time: [u32; 2],
785
            system_time: [u32; 2],
786
            policy: i32,
787
            suspend_count: i32,
788
        }
789
        const MACH_TASK_BASIC_INFO: u32 = 20;
790
        extern "C" {
791
            fn mach_task_self() -> u32;
792
            fn task_info(
793
                target: u32, flavor: u32,
794
                info: *mut core::ffi::c_void, count: *mut u32,
795
            ) -> i32;
796
        }
797
        unsafe {
798
            let mut info: MachTaskBasicInfo = core::mem::zeroed();
799
            let mut count = (core::mem::size_of::<MachTaskBasicInfo>() / 4) as u32;
800
            let kr = task_info(
801
                mach_task_self(),
802
                MACH_TASK_BASIC_INFO,
803
                &mut info as *mut _ as *mut core::ffi::c_void,
804
                &mut count,
805
            );
806
            if kr == 0 {
807
                let rss = if pf != 0 { pf } else { info.resident_size };
808
                (rss, info.virtual_size)
809
            } else {
810
                (pf, 0)
811
            }
812
        }
813
    }
814
    // The doc comment above has always promised a `/proc/self/statm` fallback
815
    // on Linux. Until 2026-07-29 this arm returned (0, 0) for every non-macOS
816
    // target, so `sample_peak_rss` silently fell back to ru_maxrss (peak-only,
817
    // never decreases) and every allocator-purge measurement on Linux read as
818
    // "no memory was returned".
819
    #[cfg(all(target_os = "linux", not(miri)))]
820
    {
821
        // statm fields are in pages: size resident shared text lib data dt.
822
        let Ok(statm) = std::fs::read_to_string("/proc/self/statm") else {
823
            return (0, 0);
824
        };
825
        let mut it = statm.split_ascii_whitespace();
826
        let size: u64 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
827
        let resident: u64 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
828
        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
829
        let page = if page > 0 { page as u64 } else { 4096 };
830
        (
831
            resident.saturating_mul(page),
832
            size.saturating_mul(page),
833
        )
834
    }
835
    // Windows: `K32GetProcessMemoryInfo` is the documented equivalent.
836
    // WorkingSetSize is the RSS analogue (what Task Manager calls "Memory
837
    // (active private working set)"'s superset), PrivateUsage is the commit
838
    // charge — the closest thing to the "virtual" slot the other arms fill.
839
    //
840
    // Until this arm existed every Windows build reported (0, 0), which made
841
    // "startup RSS after an update" — the one metric the telemetry rollout
842
    // gate is built on — silently meaningless on the majority desktop
843
    // platform (`core/src/profile.rs` documented this as a known hole).
844
    #[cfg(all(target_os = "windows", not(miri)))]
845
    {
846
        windows_memory_counters().map_or((0, 0), |c| (c.working_set, c.private_usage))
847
    }
848
    #[cfg(not(any(
849
        target_os = "macos",
850
        all(target_os = "linux", not(miri)),
851
        all(target_os = "windows", not(miri))
852
    )))]
853
    { (0, 0) }
854
}
855

            
856
/// Snapshot of `PROCESS_MEMORY_COUNTERS_EX`, in bytes.
857
#[cfg(all(feature = "probe", target_os = "windows", not(miri)))]
858
pub(crate) struct WindowsMemoryCounters {
859
    /// `WorkingSetSize` — resident bytes; the RSS analogue.
860
    pub working_set: u64,
861
    /// `PeakWorkingSetSize` — high-water mark of the above.
862
    pub peak_working_set: u64,
863
    /// `PrivateUsage` — commit charge (private bytes).
864
    pub private_usage: u64,
865
}
866

            
867
/// Reads `PROCESS_MEMORY_COUNTERS_EX` for the current process.
868
///
869
/// `K32GetProcessMemoryInfo` lives directly in `kernel32.dll` (Windows 7+),
870
/// so this needs no `psapi.dll` import library and no `windows-sys`
871
/// dependency — matching how the macOS arm above hand-declares `task_info`.
872
///
873
/// Returns `None` if the call fails.
874
#[cfg(all(feature = "probe", target_os = "windows", not(miri)))]
875
pub(crate) fn windows_memory_counters() -> Option<WindowsMemoryCounters> {
876
    // Layout per the Win32 header. On 64-bit the two leading DWORDs pack into
877
    // the first 8 bytes with no tail padding before the first SIZE_T, so the
878
    // struct maps 1:1; `cb` is validated by the callee against what we pass.
879
    #[repr(C)]
880
    struct ProcessMemoryCountersEx {
881
        cb: u32,
882
        page_fault_count: u32,
883
        peak_working_set_size: usize,
884
        working_set_size: usize,
885
        quota_peak_paged_pool_usage: usize,
886
        quota_paged_pool_usage: usize,
887
        quota_peak_non_paged_pool_usage: usize,
888
        quota_non_paged_pool_usage: usize,
889
        pagefile_usage: usize,
890
        peak_pagefile_usage: usize,
891
        private_usage: usize,
892
    }
893

            
894
    #[link(name = "kernel32")]
895
    extern "system" {
896
        fn GetCurrentProcess() -> *mut core::ffi::c_void;
897
        fn K32GetProcessMemoryInfo(
898
            process: *mut core::ffi::c_void,
899
            counters: *mut ProcessMemoryCountersEx,
900
            cb: u32,
901
        ) -> i32;
902
    }
903

            
904
    unsafe {
905
        let mut counters: ProcessMemoryCountersEx = core::mem::zeroed();
906
        let cb = u32::try_from(core::mem::size_of::<ProcessMemoryCountersEx>()).ok()?;
907
        counters.cb = cb;
908
        if K32GetProcessMemoryInfo(GetCurrentProcess(), &mut counters, cb) == 0 {
909
            return None;
910
        }
911
        Some(WindowsMemoryCounters {
912
            working_set: counters.working_set_size as u64,
913
            peak_working_set: counters.peak_working_set_size as u64,
914
            private_usage: counters.private_usage as u64,
915
        })
916
    }
917
}
918

            
919
/// Heap bytes currently held by the libc allocator (`mstats.bytes_used`).
920
///
921
/// Unlike RSS, this is what *Rust* allocations plus anything else going
922
/// through the default malloc zone is actually holding — mmap regions
923
/// for thread stacks, GL buffers, file-mapped fonts, etc. are NOT counted.
924
/// A leak that shows up here points to a genuine heap retention (an Arc
925
/// chain never dropped, a Vec never shrunk, a `Box<T>` forgotten).
926
///
927
/// - **macOS**: `mstats().bytes_used`.
928
/// - **Linux/glibc**: `mallinfo2().uordblks` — the same quantity, total
929
///   bytes currently handed out by malloc. Resolved with `dlsym` rather
930
///   than linked directly, because `mallinfo2` is glibc 2.33+ and a hard
931
///   link reference would break the build on older distros for the sake of
932
///   an opt-in diagnostic. Falls back to the `c_int`-based `mallinfo()`,
933
///   which is exact below 2 GiB of live heap.
934
/// - Everything else: 0.
935
///
936
/// CAVEAT (Linux): glibc accounts the **main arena only**. Allocations made
937
/// on other threads' arenas — and azul spawns font scout/builder threads —
938
/// are invisible here. A rising number is proof of a leak; a flat one is
939
/// not proof of its absence. Cross-check with [`current_rss_bytes`].
940
///
941
/// This returned 0 on every non-macOS target until 2026-07-29, which is the
942
/// only reason `dll/tests/leak_regression.rs` is `cfg(target_os = "macos")`:
943
/// the leak was never macOS-specific, the *instrument* was.
944
#[cfg(feature = "probe")]
945
pub fn malloc_heap_bytes() -> u64 {
946
    #[cfg(target_os = "macos")]
947
    {
948
        #[repr(C)]
949
        struct Mstats {
950
            bytes_total: usize,
951
            chunks_used: usize,
952
            bytes_used: usize,
953
            chunks_free: usize,
954
            bytes_free: usize,
955
        }
956
        extern "C" {
957
            fn mstats() -> Mstats;
958
        }
959
        unsafe { mstats().bytes_used as u64 }
960
    }
961
    #[cfg(all(target_os = "linux", target_env = "gnu", not(miri)))]
962
    {
963
        type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2;
964
        static MALLINFO2: std::sync::OnceLock<Option<Mallinfo2Fn>> =
965
            std::sync::OnceLock::new();
966
        let resolved = MALLINFO2.get_or_init(|| unsafe {
967
            // RTLD_DEFAULT is NULL on glibc; the libc crate doesn't define
968
            // the constant for linux-gnu, so spell it out.
969
            let sym = libc::dlsym(
970
                core::ptr::null_mut(),
971
                c"mallinfo2".as_ptr(),
972
            );
973
            if sym.is_null() {
974
                None
975
            } else {
976
                Some(core::mem::transmute::<
977
                    *mut core::ffi::c_void,
978
                    Mallinfo2Fn,
979
                >(sym))
980
            }
981
        });
982
        match resolved {
983
            Some(mallinfo2) => unsafe { mallinfo2().uordblks as u64 },
984
            // Pre-2.33 glibc. `uordblks` is a signed int that wraps past
985
            // 2 GiB; clamp rather than report a negative byte count.
986
            None => unsafe { libc::mallinfo().uordblks.max(0) as u64 },
987
        }
988
    }
989
    #[cfg(not(any(
990
        target_os = "macos",
991
        all(target_os = "linux", target_env = "gnu", not(miri))
992
    )))]
993
    { 0 }
994
}
995

            
996
/// Sample the Mach `phys_footprint` — the memory metric Activity
997
/// Monitor and `vmmap`'s "Physical footprint" line display. Unlike
998
/// `resident_size`, this excludes shared library text pages and
999
/// other kernel-mapped regions that inflate the traditional RSS
/// number without actually costing the process anything. For a
/// short-lived headless render this is a much more honest figure:
/// on a ~20 MiB `ru_maxrss` run, `phys_footprint` is typically ~8 MiB.
/// Returns 0 on non-macOS or if the Mach call fails.
///
/// There's no direct "peak `phys_footprint`" field; track the max
/// across calls in application code if you need it.
#[cfg(feature = "probe")]
// NOT const: the macOS branch calls mach task_info — const only held on
// targets where that branch compiles out (E0015 on aarch64-apple-darwin).
#[allow(clippy::missing_const_for_fn)]
#[must_use] pub fn phys_footprint_bytes() -> u64 {
    // Miri cannot call the mach `task_info` foreign function.
    #[cfg(miri)]
    return 0;
    #[cfg(all(target_os = "macos", not(miri)))]
    {
        // TASK_VM_INFO = 22; the struct is large (~88 u32 counts ≈ 352 B)
        // and phys_footprint lives near the end, so we have to read the
        // whole thing. Layout is from osfmk/mach/task_info.h.
        #[repr(C)]
        struct TaskVmInfo {
            virtual_size: u64,
            region_count: u32,
            page_size: u32,
            resident_size: u64,
            resident_size_peak: u64,
            device: u64,
            device_peak: u64,
            internal: u64,
            internal_peak: u64,
            external: u64,
            external_peak: u64,
            reusable: u64,
            reusable_peak: u64,
            purgeable_volatile_pmap: u64,
            purgeable_volatile_resident: u64,
            purgeable_volatile_virtual: u64,
            compressed: u64,
            compressed_peak: u64,
            compressed_lifetime: u64,
            phys_footprint: u64,
            // there are more fields after this, but we don't need them
            _rest: [u64; 12],
        }
        const TASK_VM_INFO: u32 = 22;
        extern "C" {
            fn mach_task_self() -> u32;
            fn task_info(
                target: u32, flavor: u32,
                info: *mut core::ffi::c_void, count: *mut u32,
            ) -> i32;
        }
        unsafe {
            let mut info: TaskVmInfo = core::mem::zeroed();
            let mut count = (core::mem::size_of::<TaskVmInfo>() / 4) as u32;
            let kr = task_info(
                mach_task_self(),
                TASK_VM_INFO,
                &mut info as *mut _ as *mut core::ffi::c_void,
                &mut count,
            );
            if kr == 0 { info.phys_footprint } else { 0 }
        }
    }
    #[cfg(not(target_os = "macos"))]
    { 0 }
}
/// Background sampler for peak `phys_footprint`. Spawns a thread that
/// polls `phys_footprint_bytes()` every ~2 ms and updates a shared
/// atomic. The kernel does not expose a direct "peak `phys_footprint`"
/// — unlike `resident_size_peak` in `TASK_VM_INFO` — so polling is
/// the only way to catch mid-phase transients that are `MADV_FREE`'d
/// before the next explicit sample point.
///
/// Not started by default; call `start_peak_sampler()` once at
/// process init if you want peak tracking. Overhead is negligible
/// (~1-5 µs per poll on macOS, 500 Hz → <0.25% CPU of one core).
/// `peak_phys_footprint_seen()` reads the current high-water mark.
#[cfg(feature = "probe")]
// NOT const: the macOS branch spawns the sampler thread (E0015 there).
#[allow(clippy::missing_const_for_fn)]
pub fn start_peak_sampler() {
    #[cfg(target_os = "macos")]
    {
        use std::sync::atomic::Ordering;
        // Idempotent — only spawns once.
        static STARTED: std::sync::atomic::AtomicBool =
            std::sync::atomic::AtomicBool::new(false);
        if STARTED.swap(true, Ordering::AcqRel) {
            return;
        }
        std::thread::Builder::new()
            .name("azul-peak-sampler".to_string())
            .spawn(|| loop {
                let now = phys_footprint_bytes();
                let prev = PEAK_PHYS_FOOTPRINT.load(Ordering::Relaxed);
                if now > prev {
                    PEAK_PHYS_FOOTPRINT.store(now, Ordering::Relaxed);
                }
                std::thread::sleep(std::time::Duration::from_micros(250));
            })
            .ok();
    }
}
#[cfg(feature = "probe")]
static PEAK_PHYS_FOOTPRINT: std::sync::atomic::AtomicU64 =
    std::sync::atomic::AtomicU64::new(0);
/// Read the peak `phys_footprint` seen by the background sampler.
/// Returns 0 if `start_peak_sampler` was never called.
#[cfg(feature = "probe")]
pub fn peak_phys_footprint_seen() -> u64 {
    PEAK_PHYS_FOOTPRINT.load(std::sync::atomic::Ordering::Relaxed)
}
/// Reset the global peak high-water mark to the current `phys_footprint`.
/// Paired with `peak_phys_footprint_seen()` so a caller can record
/// "peak during phase X" — call `reset_peak()` at phase entry, then
/// `peak_phys_footprint_seen()` at phase exit. The 500 Hz background
/// sampler runs continuously either way.
#[cfg(feature = "probe")]
pub fn reset_peak() {
    let now = phys_footprint_bytes();
    PEAK_PHYS_FOOTPRINT.store(now, std::sync::atomic::Ordering::Relaxed);
}
/// Record a phase's peak footprint into the probe event stream.
/// Call at phase exit after `reset_peak()` at phase entry. Emits an
/// RSS-kind event with `bytes = peak seen during phase`.
#[cfg(feature = "probe")]
#[inline]
pub fn sample_phase_peak(label: &'static str) {
    let peak = PEAK_PHYS_FOOTPRINT.load(std::sync::atomic::Ordering::Relaxed);
    Probe::sample_rss(label, peak);
}
#[cfg(not(feature = "probe"))]
#[inline]
19344
pub const fn reset_peak() {}
#[cfg(not(feature = "probe"))]
#[inline]
19245
pub const fn sample_phase_peak(_label: &'static str) {}
#[cfg(not(feature = "probe"))]
#[inline]
2
#[must_use] pub const fn malloc_heap_bytes() -> u64 { 0 }
/// Emit one `{"ev":"phase","label":L,"heap":N,"call":C}` line to the
/// JSONL file named by `AZ_PROFILE_OUT=<path>`. Only fires when
/// `AZ_PROFILE=heap,jsonl` is set *and* the path is given.
///
/// Each call auto-increments a monotonic `call` id so downstream
/// analyzers can group phases belonging to a single `regenerate_layout`
/// invocation.
///
/// `label` convention: `start` at function entry; `<step>` after each
/// phase completes; `end` at function exit. Heap Δ between adjacent
/// labels within the same call-id is the bytes retained by that phase.
///
/// Zero overhead when flags aren't set (two atomic loads). Zero overhead
/// when the `probe` feature is off (no-op stub).
#[cfg(feature = "probe")]
pub fn emit_phase_heap(label: &str) {
    use std::io::Write;
    if !heap_jsonl_enabled() { return; }
    let Some(p) = azul_core::profile::out_path() else { return };
    static CALL_ID: std::sync::atomic::AtomicU64 =
        std::sync::atomic::AtomicU64::new(0);
    // Auto-increment on every "start" label; "end" and intermediates reuse
    // the current id so all phases in one regenerate_layout invocation share
    // a call number.
    static CURRENT_CALL: std::sync::atomic::AtomicU64 =
        std::sync::atomic::AtomicU64::new(0);
    let call_id = if label == "start" {
        let next = CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
        CURRENT_CALL.store(next, std::sync::atomic::Ordering::Relaxed);
        next
    } else {
        CURRENT_CALL.load(std::sync::atomic::Ordering::Relaxed)
    };
    let heap = malloc_heap_bytes();
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(p)
    {
        drop(writeln!(
            f,
            r#"{{"ev":"phase","call":{call_id},"label":"{label}","heap":{heap}}}"#
        ));
    }
}
#[cfg(not(feature = "probe"))]
#[inline]
10
pub const fn emit_phase_heap(_label: &str) {}
/// Like [`emit_phase_heap`] but attaches a numeric payload (e.g., a cache
/// size) to the JSONL record under the `"extra"` field.
///
/// Gated behind `AZ_PROFILE=heap,jsonl,detail` — the `detail` token opts
/// in to fine-grained probes that produce extra per-step records (one
/// per intermediate step inside a phase). Without `detail`, only the
/// coarser phase probes from [`emit_phase_heap`] fire.
#[cfg(feature = "probe")]
pub fn emit_phase_heap_extra(label: &str, extra: u64) {
    use std::io::Write;
    if !heap_jsonl_enabled() { return; }
    if !azul_core::profile::detail_enabled() { return; }
    let Some(p) = azul_core::profile::out_path() else { return };
    let heap = malloc_heap_bytes();
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(p)
    {
        drop(writeln!(
            f,
            r#"{{"ev":"phase","call":0,"label":"{label}","heap":{heap},"extra":{extra}}}"#
        ));
    }
}
#[cfg(not(feature = "probe"))]
#[inline]
10
pub const fn emit_phase_heap_extra(_label: &str, _extra: u64) {}
/// Both `heap` and `jsonl` tokens active in `AZ_PROFILE` — the combination
/// that enables JSONL heap-probe emission. Either alone is a no-op.
#[cfg(feature = "probe")]
#[inline]
fn heap_jsonl_enabled() -> bool {
    let f = azul_core::profile::flags();
    f.heap && f.jsonl
}
/// Returns true iff `AZ_PROFILE=detail` is active. Kept as a public
/// re-export so downstream crates can write `azul_layout::probe::detail_enabled()`
/// without pulling in `azul_core::profile` directly.
#[cfg(feature = "probe")]
#[inline]
#[must_use] pub fn detail_enabled() -> bool {
    azul_core::profile::detail_enabled()
}
#[cfg(not(feature = "probe"))]
#[inline]
101
#[must_use] pub const fn detail_enabled() -> bool { false }
#[cfg(test)]
#[allow(let_underscore_drop, clippy::too_many_lines)]
mod autotest_generated {
    use super::*;
    /// Build a `&'static str` with arbitrary (possibly hostile) contents.
    /// Leaks — fine for a test binary, and the only way to feed adversarial
    /// text into the `&'static str` APIs (`Probe::span`, `sample_rss`, ...).
    fn leak(s: String) -> &'static str {
        Box::leak(s.into_boxed_str())
    }
    /// Clear this thread's event buffer so a test's assertions hold even when
    /// the suite runs with `--test-threads=1` (all tests on one thread share
    /// the same thread-local `EVENTS`). Also force recording ON: these tests
    /// assert on drained events, and without `AZ_PROFILE` in the environment
    /// the lazy gate would otherwise leave every span inert.
    fn reset() {
        Probe::set_recording(true);
        Probe::drop_events();
        assert_eq!(Probe::peek_len(), 0, "drop_events must leave an empty buffer");
    }
    fn span_ns(ev: &Event) -> Option<u64> {
        match ev.kind {
            EventKind::Span { dur_ns } => Some(dur_ns),
            EventKind::Rss { .. } => None,
        }
    }
    fn rss_bytes(ev: &Event) -> Option<u64> {
        match ev.kind {
            EventKind::Rss { bytes } => Some(bytes),
            EventKind::Span { .. } => None,
        }
    }
    // ---------------------------------------------------------------
    // enabled() / cfg invariants
    // ---------------------------------------------------------------
    #[test]
    fn enabled_matches_the_compiled_imp() {
        // `Probe::enabled()` is the single runtime source of truth for
        // "events actually get buffered"; it must track the cfg that selects
        // the real `imp` (probe on, not wasm, not web_lift).
        let expected = cfg!(all(
            feature = "probe",
            not(target_family = "wasm"),
            not(feature = "web_lift")
        ));
        assert_eq!(Probe::enabled(), expected);
        assert_eq!(imp::enabled(), expected);
    }
    #[test]
    fn enabled_is_pure_and_idempotent() {
        let first = Probe::enabled();
        for _ in 0..1000 {
            assert_eq!(Probe::enabled(), first);
        }
    }
    // ---------------------------------------------------------------
    // span / drain round-trips
    // ---------------------------------------------------------------
    #[test]
    fn span_round_trips_name_through_drain() {
        reset();
        {
            let _g = Probe::span("autotest_span_round_trip");
        }
        let events = Probe::drain();
        if Probe::enabled() {
            assert_eq!(events.len(), 1);
            assert_eq!(events[0].name, "autotest_span_round_trip");
            assert!(span_ns(&events[0]).is_some(), "span guard must emit EventKind::Span");
        } else {
            assert!(events.is_empty(), "no-op imp must never buffer events");
        }
        assert_eq!(Probe::peek_len(), 0, "drain must empty the buffer");
    }
    #[test]
    fn nested_spans_drop_inner_first_and_outer_duration_is_the_larger() {
        reset();
        {
            let _outer = Probe::span("outer");
            {
                let _inner = Probe::span("inner");
            }
        }
        let events = Probe::drain();
        if !Probe::enabled() {
            assert!(events.is_empty());
            return;
        }
        assert_eq!(events.len(), 2);
        // Drop order is inner-then-outer, so the buffer order is the same.
        assert_eq!(events[0].name, "inner");
        assert_eq!(events[1].name, "outer");
        let inner = span_ns(&events[0]).expect("inner is a span");
        let outer = span_ns(&events[1]).expect("outer is a span");
        // The outer span strictly encloses the inner one in wall-clock time.
        assert!(
            outer >= inner,
            "outer span ({outer} ns) must cover the inner one ({inner} ns)"
        );
    }
    #[test]
    fn forgotten_span_guard_records_nothing() {
        reset();
        core::mem::forget(Probe::span("forgotten"));
        let events = Probe::drain();
        assert!(
            events.is_empty(),
            "a leaked guard never runs Drop, so it must not emit an event"
        );
    }
    #[test]
    fn many_spans_do_not_lose_or_reorder_events() {
        reset();
        const N: usize = 10_000;
        let names: Vec<&'static str> = (0..N).map(|i| leak(format!("phase_{i}"))).collect();
        for &name in &names {
            drop(Probe::span(name));
        }
        if Probe::enabled() {
            assert_eq!(Probe::peek_len(), N);
        } else {
            assert_eq!(Probe::peek_len(), 0);
        }
        let events = Probe::drain();
        if Probe::enabled() {
            assert_eq!(events.len(), N);
            for (i, ev) in events.iter().enumerate() {
                assert_eq!(ev.name, names[i], "event order must be emission order");
            }
        } else {
            assert!(events.is_empty());
        }
        assert_eq!(Probe::peek_len(), 0);
    }
    #[test]
    fn span_survives_hostile_unicode_and_huge_names() {
        reset();
        let hostile: Vec<&'static str> = vec![
            "",
            "\0embedded\0nul\0",
            "\n\r\t",
            "{}{:?}{0}%s%n",           // format-string-looking payloads
            "🦀👨‍👩‍👧‍👦🇩🇪",         // emoji + ZWJ sequence + flag
            "مرحبا بالعالم",           // RTL
            "e\u{0301}\u{0301}\u{0301}", // stacked combining marks
            leak("A".repeat(100_000)), // huge
            leak("\u{1F4A9}".repeat(10_000)),
        ];
        for &name in &hostile {
            drop(Probe::span(name));
        }
        let events = Probe::drain();
        if Probe::enabled() {
            assert_eq!(events.len(), hostile.len());
            for (ev, name) in events.iter().zip(hostile.iter()) {
                assert_eq!(ev.name, *name, "name must round-trip byte-for-byte");
            }
            // Formatting the hostile names must not panic either.
            print_drained_events("hostile-names", &events);
        } else {
            assert!(events.is_empty());
        }
    }
    #[test]
    fn drain_is_empty_the_second_time() {
        reset();
        drop(Probe::span("once"));
        let first = Probe::drain();
        let second = Probe::drain();
        if Probe::enabled() {
            assert_eq!(first.len(), 1);
        }
        assert!(second.is_empty(), "a drained buffer must stay drained");
    }
    // ---------------------------------------------------------------
    // sample_rss: numeric boundaries + exact round-trip
    // ---------------------------------------------------------------
    #[test]
    fn sample_rss_round_trips_every_numeric_boundary() {
        reset();
        let boundaries: [u64; 8] = [
            0,
            1,
            u64::from(u32::MAX),
            u64::from(u32::MAX) + 1,
            1 << 63,
            u64::MAX - 1,
            u64::MAX,
            0xDEAD_BEEF_DEAD_BEEF,
        ];
        for b in boundaries {
            Probe::sample_rss("bytes", b);
        }
        let events = Probe::drain();
        if !Probe::enabled() {
            assert!(events.is_empty());
            return;
        }
        assert_eq!(events.len(), boundaries.len());
        for (ev, expected) in events.iter().zip(boundaries.iter()) {
            assert_eq!(
                rss_bytes(ev),
                Some(*expected),
                "RSS byte counts must survive the buffer unchanged (no saturation)"
            );
        }
    }
    #[test]
    fn sample_rss_zero_is_recorded_not_skipped() {
        reset();
        Probe::sample_rss("zero", 0);
        let events = Probe::drain();
        if Probe::enabled() {
            assert_eq!(events.len(), 1, "a 0-byte checkpoint is still a checkpoint");
            assert_eq!(rss_bytes(&events[0]), Some(0));
            assert_eq!(events[0].name, "zero");
        } else {
            assert!(events.is_empty());
        }
    }
    // ---------------------------------------------------------------
    // peek_len / drop_events
    // ---------------------------------------------------------------
    #[test]
    fn peek_len_tracks_pushes_and_drop_events_clears() {
        reset();
        assert_eq!(Probe::peek_len(), 0);
        for i in 0..64u64 {
            Probe::sample_rss("tick", i);
        }
        if Probe::enabled() {
            assert_eq!(Probe::peek_len(), 64);
        } else {
            assert_eq!(Probe::peek_len(), 0);
        }
        Probe::drop_events();
        assert_eq!(Probe::peek_len(), 0, "drop_events must clear the buffer");
        assert!(
            Probe::drain().is_empty(),
            "drop_events must discard, not stash, the events"
        );
    }
    #[test]
    fn drop_events_on_an_empty_buffer_is_a_no_op() {
        reset();
        for _ in 0..100 {
            Probe::drop_events();
            assert_eq!(Probe::peek_len(), 0);
        }
    }
    #[test]
    fn peek_len_is_side_effect_free() {
        reset();
        Probe::sample_rss("keep", 7);
        let expected = if Probe::enabled() { 1 } else { 0 };
        for _ in 0..100 {
            assert_eq!(Probe::peek_len(), expected, "peek must not consume events");
        }
        let events = Probe::drain();
        assert_eq!(events.len(), expected);
    }
    // ---------------------------------------------------------------
    // thread-locality
    // ---------------------------------------------------------------
    #[test]
    fn event_buffer_is_per_thread() {
        reset();
        Probe::sample_rss("main_thread", 1);
        let child_len = std::thread::spawn(|| {
            // A fresh thread starts with an empty buffer, even though the
            // parent just pushed an event.
            assert_eq!(Probe::peek_len(), 0, "buffers must not be shared across threads");
            Probe::sample_rss("child_thread", 2);
            let drained = Probe::drain();
            for ev in &drained {
                assert_eq!(ev.name, "child_thread", "child must only see its own events");
            }
            drained.len()
        })
        .join()
        .expect("probe calls must not panic on a spawned thread");
        let events = Probe::drain();
        if Probe::enabled() {
            assert_eq!(child_len, 1);
            assert_eq!(events.len(), 1, "the child's drain must not touch our buffer");
            assert_eq!(events[0].name, "main_thread");
        } else {
            assert_eq!(child_len, 0);
            assert!(events.is_empty());
        }
    }
    // ---------------------------------------------------------------
    // imp:: (private) parity with the public facade
    // ---------------------------------------------------------------
    #[test]
    fn imp_facade_parity() {
        reset();
        {
            let _g = imp::open("imp_open");
        }
        imp::sample_rss("imp_rss", u64::MAX);
        let len = imp::peek_len();
        assert_eq!(len, Probe::peek_len());
        let events = imp::drain();
        assert_eq!(events.len(), len);
        assert_eq!(imp::peek_len(), 0);
        if Probe::enabled() {
            assert_eq!(events[0].name, "imp_open");
            assert_eq!(rss_bytes(&events[1]), Some(u64::MAX));
        } else {
            assert!(events.is_empty());
        }
        imp::drop_events();
        assert_eq!(imp::peek_len(), 0);
    }
    // ---------------------------------------------------------------
    // print_drained_events: the formatter is the panic-prone one
    // ---------------------------------------------------------------
    #[test]
    fn print_drained_events_empty_slice_does_not_panic() {
        // The doc comment claims it "Panics if the collected timing-sample
        // list is empty" — the implementation early-returns instead. Pin the
        // safe behaviour.
        print_drained_events("empty", &[]);
        print_drained_events("", &[]);
    }
    #[test]
    fn print_drained_events_rss_only_has_no_span_rows() {
        // With zero spans the row list is empty; the `ns.last().unwrap()` in
        // the row builder must never be reached.
        let events = [
            Event { name: "a", kind: EventKind::Rss { bytes: 0 }, depth: 0 },
            Event { name: "b", kind: EventKind::Rss { bytes: u64::MAX }, depth: 0 },
            Event { name: "c", kind: EventKind::Rss { bytes: 1 }, depth: 0 },
        ];
        print_drained_events("rss-only", &events);
    }
    #[test]
    fn print_drained_events_p99_index_is_in_bounds_for_every_sample_count() {
        // p99 is `ns[(n - 1) * 99 / 100]` — an off-by-one here is an
        // out-of-bounds index. Walk the counts where it would bite.
        for n in [1usize, 2, 3, 99, 100, 101, 199, 200, 201, 1000] {
            let events: Vec<Event> = (0..n)
                .map(|i| Event {
                    depth: 0,
                    name: "phase",
                    kind: EventKind::Span { dur_ns: i as u64 },
                })
                .collect();
            print_drained_events("p99", &events);
        }
    }
    #[test]
    fn print_drained_events_saturating_totals_do_not_panic() {
        // Summing u64::MAX durations overflows u64; the impl accumulates in
        // u128 and truncates for display, so this must not panic in a debug
        // build (overflow checks are on for `cargo test`).
        let events = [
            Event { name: "huge", kind: EventKind::Span { dur_ns: u64::MAX }, depth: 0 },
            Event { name: "huge", kind: EventKind::Span { dur_ns: u64::MAX }, depth: 0 },
            Event { name: "huge", kind: EventKind::Span { dur_ns: u64::MAX }, depth: 0 },
            Event { name: "zero", kind: EventKind::Span { dur_ns: 0 }, depth: 0 },
        ];
        print_drained_events("overflowing-total", &events);
    }
    #[test]
    fn print_drained_events_rss_delta_handles_full_u64_swing() {
        // The delta is computed in i128; a MAX -> 0 -> MAX swing is the worst
        // case for a naive i64/u64 subtraction.
        let events = [
            Event { name: "peak", kind: EventKind::Rss { bytes: u64::MAX }, depth: 0 },
            Event { name: "trough", kind: EventKind::Rss { bytes: 0 }, depth: 0 },
            Event { name: "peak_again", kind: EventKind::Rss { bytes: u64::MAX }, depth: 0 },
        ];
        print_drained_events("delta-swing", &events);
    }
    #[test]
    fn print_drained_events_hostile_labels_and_names() {
        let big = leak("x".repeat(65_536));
        let events = [
            Event { name: "", kind: EventKind::Span { dur_ns: 1 }, depth: 0 },
            Event { name: "{}{:?}", kind: EventKind::Span { dur_ns: 2 }, depth: 0 },
            Event { name: big, kind: EventKind::Span { dur_ns: u64::MAX }, depth: 0 },
            Event { name: "🦀\u{0301}\0", kind: EventKind::Rss { bytes: 1 }, depth: 0 },
        ];
        print_drained_events(big, &events);
        print_drained_events("\0\n{}", &events);
    }
    #[test]
    fn print_drained_events_accepts_a_real_drain() {
        reset();
        {
            let _a = Probe::span("layout");
            let _b = Probe::span("layout");
        }
        Probe::sample_rss("after", 4096);
        let events = Probe::drain();
        print_drained_events("real-drain", &events);
    }
    // ---------------------------------------------------------------
    // monotonic_now_nanos
    // ---------------------------------------------------------------
    #[test]
    fn monotonic_now_nanos_never_goes_backwards() {
        let mut prev = monotonic_now_nanos();
        for _ in 0..10_000 {
            let now = monotonic_now_nanos();
            assert!(now >= prev, "clock went backwards: {prev} -> {now}");
            prev = now;
        }
    }
    #[test]
    fn monotonic_now_nanos_is_monotonic_across_threads() {
        // The `OnceLock<Instant>` launch stamp is process-global, so a value
        // read on another thread is comparable with one read here.
        let before = monotonic_now_nanos();
        let mid = std::thread::spawn(monotonic_now_nanos)
            .join()
            .expect("monotonic_now_nanos must not panic off the main thread");
        let after = monotonic_now_nanos();
        assert!(before <= mid && mid <= after, "{before} <= {mid} <= {after}");
    }
    // ---------------------------------------------------------------
    // sample_peak_rss / sample_phase_peak / reset_peak
    // ---------------------------------------------------------------
    #[test]
    fn sample_peak_rss_emits_exactly_one_labelled_event() {
        reset();
        sample_peak_rss("autotest_peak_rss");
        let events = Probe::drain();
        if Probe::enabled() {
            // `sample_peak_rss` deliberately wraps its /proc (or mach) read in
            // a `probe_rss_sample_cost` span so the profiler's own cost shows
            // up as a line in its own report. So the drain holds TWO events,
            // and the assertion has to be about the labelled Rss sample, not
            // about the buffer length — the old `events.len() == 1` could only
            // ever pass on the `!Probe::enabled()` path, i.e. never with the
            // `probe` feature actually on, which is the only configuration
            // where this test tests anything.
            let rss: Vec<&Event> = events
                .iter()
                .filter(|ev| ev.name == "autotest_peak_rss")
                .collect();
            assert_eq!(rss.len(), 1, "drained: {events:?}");
            assert!(
                rss_bytes(rss[0]).is_some(),
                "sample_peak_rss must emit an Rss-kind event"
            );
            assert!(
                events
                    .iter()
                    .any(|ev| ev.name == "probe_rss_sample_cost"),
                "the self-measurement span must still be recorded: {events:?}"
            );
        } else {
            assert!(events.is_empty());
        }
    }
    #[test]
    fn sample_phase_peak_emits_exactly_one_labelled_event() {
        reset();
        sample_phase_peak("autotest_phase_peak");
        let events = Probe::drain();
        if Probe::enabled() {
            assert_eq!(events.len(), 1);
            assert_eq!(events[0].name, "autotest_phase_peak");
            assert!(rss_bytes(&events[0]).is_some());
        } else {
            assert!(events.is_empty());
        }
    }
    #[test]
    fn reset_peak_is_repeatable_and_side_effect_free_on_the_event_buffer() {
        reset();
        for _ in 0..100 {
            reset_peak();
        }
        assert_eq!(
            Probe::peek_len(),
            0,
            "reset_peak touches an atomic, it must not push events"
        );
    }
    #[test]
    fn hint_purge_allocator_is_repeatable_and_emits_nothing() {
        reset();
        for _ in 0..50 {
            hint_purge_allocator();
        }
        assert_eq!(Probe::peek_len(), 0, "purging must not push probe events");
    }
    // ---------------------------------------------------------------
    // malloc_heap_bytes / detail_enabled (both cfg worlds)
    // ---------------------------------------------------------------
    /// Platforms where `malloc_heap_bytes` is expected to return a real
    /// figure. This used to be macOS alone, which is exactly why the FFI leak
    /// regression could only ever be measured there.
    const HEAP_BYTES_IS_REAL: bool = cfg!(all(
        feature = "probe",
        any(
            target_os = "macos",
            all(target_os = "linux", target_env = "gnu")
        ),
        not(miri)
    ));
    #[test]
    fn malloc_heap_bytes_actually_tracks_live_heap() {
        if !HEAP_BYTES_IS_REAL {
            // Unsupported target (or the `probe` feature is off, where the
            // stub is a `const fn -> 0`). Say so by measurement, not by faith.
            assert_eq!(malloc_heap_bytes(), 0);
            assert_eq!(malloc_heap_bytes(), 0);
            return;
        }
        // A probe that returns a plausible constant is worse than one that
        // returns nothing, because it reads as evidence. Prove it MOVES, and
        // moves in the right direction by roughly the right amount.
        //
        // 8 MiB: far above allocator bookkeeping noise, and above glibc's
        // MMAP_THRESHOLD only if that has been tuned up — so ask for it as
        // many smaller blocks that are certain to come from the heap proper
        // rather than a fresh mmap that `uordblks` would not count.
        const BLOCK: usize = 64 * 1024;
        const BLOCKS: usize = 128;
        const TOTAL: u64 = (BLOCK * BLOCKS) as u64;
        let before = malloc_heap_bytes();
        assert!(before > 0, "a live process holds a non-zero heap");
        let mut ballast: Vec<Vec<u8>> = Vec::with_capacity(BLOCKS);
        for _ in 0..BLOCKS {
            // Touch it: a Vec that is never written may not be committed.
            ballast.push(vec![0xAB_u8; BLOCK]);
        }
        let during = malloc_heap_bytes();
        drop(ballast);
        let after = malloc_heap_bytes();
        assert!(
            during >= before + TOTAL / 2,
            "allocating {TOTAL} B moved the probe by only {} B \
             (before={before}, during={during}) — it is not measuring the heap",
            during.saturating_sub(before),
        );
        assert!(
            after < during - TOTAL / 2,
            "freeing {TOTAL} B left the probe at {after} B (during={during}) — \
             it does not see frees, so it cannot distinguish a leak from churn",
        );
    }
    #[test]
    fn detail_enabled_is_deterministic() {
        let first = detail_enabled();
        for _ in 0..100 {
            assert_eq!(detail_enabled(), first, "flag reads are cached, must not flap");
        }
        if !cfg!(feature = "probe") {
            assert!(!first, "the no-probe stub is a const `false`");
        }
    }
    // ---------------------------------------------------------------
    // emit_phase_heap / emit_phase_heap_extra (no-op unless
    // AZ_PROFILE=heap,jsonl + AZ_PROFILE_OUT; must never panic regardless)
    // ---------------------------------------------------------------
    #[test]
    fn emit_phase_heap_survives_hostile_labels() {
        reset();
        let huge = "L".repeat(65_536);
        let labels: Vec<&str> = vec![
            "",
            "start",
            "start", // repeated: exercises the call-id auto-increment
            "end",
            "\"quote\"", // would corrupt the emitted JSON if flags were on
            "back\\slash",
            "new\nline",
            "\0nul",
            "🦀 unicode",
            &huge,
        ];
        for l in &labels {
            emit_phase_heap(l);
        }
        assert_eq!(Probe::peek_len(), 0, "JSONL emission must not touch the span buffer");
    }
    #[test]
    fn emit_phase_heap_extra_survives_numeric_boundaries() {
        reset();
        for extra in [0u64, 1, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
            emit_phase_heap_extra("autotest_extra", extra);
            emit_phase_heap_extra("", extra);
        }
        assert_eq!(Probe::peek_len(), 0);
    }
    // ---------------------------------------------------------------
    // Event / EventKind value type
    // ---------------------------------------------------------------
    #[test]
    fn event_is_copy_and_clone_preserving_payload() {
        let span = Event { name: "n", kind: EventKind::Span { dur_ns: u64::MAX }, depth: 0 };
        let rss = Event { name: "n", kind: EventKind::Rss { bytes: u64::MAX }, depth: 0 };
        let span_copy = span; // Copy
        #[allow(clippy::clone_on_copy)]
        let rss_clone = rss.clone();
        assert_eq!(span_ns(&span_copy), Some(u64::MAX));
        assert_eq!(rss_bytes(&rss_clone), Some(u64::MAX));
        // Span and Rss must not be confusable even with identical payloads.
        assert!(span_ns(&rss_clone).is_none());
        assert!(rss_bytes(&span_copy).is_none());
        // Debug must not panic on the extremes.
        let _ = format!("{span:?}{rss:?}");
    }
    // ---------------------------------------------------------------
    // probe-only platform readers
    // ---------------------------------------------------------------
    #[cfg(feature = "probe")]
    #[test]
    fn peak_rss_bytes_is_monotonic_and_agrees_with_the_pub_wrapper() {
        // ru_maxrss is a high-water mark, so it can only move up.
        let first = peak_rss_bytes_self();
        let pubbed = peak_rss_bytes_pub();
        let second = peak_rss_bytes_self();
        assert!(pubbed >= first, "peak RSS must never decrease: {first} -> {pubbed}");
        assert!(second >= pubbed, "peak RSS must never decrease: {pubbed} -> {second}");
        if cfg!(unix) && !cfg!(miri) {
            assert!(first > 0, "getrusage on a live unix process must report some RSS");
        }
    }
    #[cfg(feature = "probe")]
    #[test]
    fn current_rss_bytes_does_not_panic_and_is_self_consistent() {
        let (footprint, virt) = current_rss_bytes();
        if cfg!(all(target_os = "macos", not(miri))) {
            assert!(footprint > 0, "macOS must report a non-zero footprint");
            assert!(virt >= footprint || virt == 0);
        }
        // Repeated sampling must stay panic-free (foreign-fn call each time).
        for _ in 0..100 {
            let _ = current_rss_bytes();
        }
    }
    #[cfg(feature = "probe")]
    #[test]
    fn phys_footprint_bytes_is_zero_off_macos() {
        let v = phys_footprint_bytes();
        if cfg!(all(target_os = "macos", not(miri))) {
            assert!(v > 0);
        } else {
            assert_eq!(v, 0, "documented: returns 0 on non-macOS / under miri");
        }
    }
    #[cfg(feature = "probe")]
    #[test]
    fn start_peak_sampler_is_idempotent() {
        // Documented as "Idempotent — only spawns once"; calling it in a loop
        // must not spawn 200 threads or panic.
        for _ in 0..200 {
            start_peak_sampler();
        }
        let _ = peak_phys_footprint_seen();
    }
    #[cfg(feature = "probe")]
    #[test]
    fn peak_phys_footprint_seen_is_readable_without_a_sampler() {
        // Documented: "Returns 0 if start_peak_sampler was never called."
        // Other tests in this binary may have started it / reset it, so only
        // the non-macOS path (where phys_footprint is always 0) is assertable.
        let seen = peak_phys_footprint_seen();
        if !cfg!(target_os = "macos") {
            assert_eq!(seen, 0, "no phys_footprint source off macOS => peak stays 0");
        }
    }
    #[cfg(feature = "probe")]
    #[test]
    fn heap_jsonl_enabled_matches_the_profile_flags() {
        let f = azul_core::profile::flags();
        assert_eq!(
            heap_jsonl_enabled(),
            f.heap && f.jsonl,
            "either token alone must be a no-op"
        );
        let first = heap_jsonl_enabled();
        for _ in 0..100 {
            assert_eq!(heap_jsonl_enabled(), first, "flags are cached, must not flap");
        }
    }
}
// ---------------------------------------------------------------------------
// RSS CENSUS — the mapping-level breakdown, in-process.
//
// This reproduces what scripts/RSS_MAP_2026_08_07.md established by reading
// /proc/<pid>/smaps by hand: where the process's resident memory actually is,
// as opposed to what the engine's own object walk can see. The two answer
// different questions and the gap between them IS the finding — the engine
// walks its caches and reaches ~33% of RSS; the rest is framebuffers, fonts,
// binary, libraries, allocator retention, and any cache the APPLICATION owns.
//
// Deliberately NOT behind the `probe` feature: the memory report should work
// on a stock build, and reading one file per report is not a cost worth
// gating.
// ---------------------------------------------------------------------------
/// Resident memory grouped the way the RSS map groups it. All figures are
/// **KiB**, matching what `smaps` reports (it prints "kB" but means KiB —
/// conflating that with decimal MB is a 4.9% error and has produced at least
/// three wrong conclusions in this project's own analysis).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RssCensus {
    pub heap_kib: u64,
    /// Anonymous mappings. Large allocations go here rather than `[heap]`
    /// because glibc serves anything above `MMAP_THRESHOLD` with `mmap` — a
    /// full-window pixmap lands here and never appears in `[heap]`.
    pub anon_kib: u64,
    pub binary_kib: u64,
    pub shared_libs_kib: u64,
    pub font_files_kib: u64,
    /// `memfd:azul-fb` — the Wayland shared-memory framebuffer pool.
    pub framebuffer_kib: u64,
    pub stacks_kib: u64,
    pub other_kib: u64,
    pub total_kib: u64,
    pub shared_lib_mappings: usize,
    pub font_mappings: usize,
}
impl RssCensus {
    /// Sum of the categories. Equals `total_kib` unless a category was missed,
    /// so a caller can assert the census is exhaustive rather than trusting it.
    #[must_use]
1
    pub const fn categorised_kib(&self) -> u64 {
1
        self.heap_kib
1
            + self.anon_kib
1
            + self.binary_kib
1
            + self.shared_libs_kib
1
            + self.font_files_kib
1
            + self.framebuffer_kib
1
            + self.stacks_kib
1
            + self.other_kib
1
    }
}
/// Read `/proc/self/smaps` and group resident pages by what backs them.
///
/// Returns `None` off Linux or if `smaps` is unreadable. Costs one file read
/// and a linear scan; `smaps` is a few hundred KB on a process this size.
// Off Linux every `#[cfg]` arm below collapses to `None`, so clippy sees a
// function that could be `const` — and says so as an error under the extreme
// lint set, making `cargo clippy` red on every Mac while CI's ubuntu job is
// green. The Linux body reads a file; it cannot be const. Allow it here rather
// than let the gate mean two different things on two machines.
#[allow(clippy::missing_const_for_fn)]
#[must_use]
1
pub fn rss_census() -> Option<RssCensus> {
    #[cfg(not(target_os = "linux"))]
    {
        None
    }
    #[cfg(target_os = "linux")]
    {
1
        let text = std::fs::read_to_string("/proc/self/smaps").ok()?;
1
        let mut c = RssCensus::default();
        // The mapping name is the 6th whitespace field of a header line; the
        // `Rss:` line that follows belongs to it.
1
        let mut name = String::new();
2825
        for line in text.lines() {
2825
            if let Some(rest) = line.strip_prefix("Rss:") {
113
                let kib: u64 = rest
113
                    .split_whitespace()
113
                    .next()
113
                    .and_then(|v| v.parse().ok())
113
                    .unwrap_or(0);
113
                c.total_kib += kib;
113
                if name.is_empty() {
79
                    c.anon_kib += kib;
79
                } else if name.contains("memfd:azul-fb") {
                    c.framebuffer_kib += kib;
34
                } else if name == "[heap]" {
1
                    c.heap_kib += kib;
33
                } else if name.starts_with("[stack") || name == "[vdso]" || name == "[vvar]" {
3
                    c.stacks_kib += kib;
30
                } else if std::path::Path::new(name.as_str())
30
                    .extension()
30
                    .is_some_and(|e| {
24
                        ["ttf", "ttc", "otf", "pfb"]
24
                            .iter()
87
                            .any(|w| e.eq_ignore_ascii_case(w))
24
                    })
3
                {
3
                    c.font_files_kib += kib;
3
                    c.font_mappings += 1;
27
                } else if name.contains(".so") {
21
                    c.shared_libs_kib += kib;
21
                    c.shared_lib_mappings += 1;
21
                } else if std::env::current_exe()
6
                    .ok()
6
                    .and_then(|p| p.to_str().map(|s| name == s))
6
                    .unwrap_or(false)
5
                {
5
                    c.binary_kib += kib;
5
                } else {
1
                    c.other_kib += kib;
1
                }
2712
            } else if let Some(first) = line.split_whitespace().next() {
                // Header lines start with an address range `hex-hex`.
2712
                if first.len() > 8 && first.contains('-') && !line.ends_with(':') {
113
                    name = line.split_whitespace().nth(5).unwrap_or("").to_string();
2599
                }
            }
        }
1
        Some(c)
    }
1
}
#[cfg(test)]
mod rss_census_tests {
    use super::*;
    /// The census must account for every resident page it counted. A category
    /// sum that falls short of the total means a mapping shape we do not
    /// recognise is being silently dropped — which is how a memory report
    /// starts under-reporting without anyone noticing.
    #[test]
    #[cfg(target_os = "linux")]
1
    fn census_is_exhaustive_and_sees_this_process() {
1
        let Some(c) = rss_census() else {
            // smaps unreadable (containers, hardened kernels). Not a failure.
            return;
        };
1
        assert!(
1
            c.total_kib > 0,
            "a running test process has resident memory; reading smaps returned none"
        );
1
        assert_eq!(
1
            c.categorised_kib(),
            c.total_kib,
            "every counted page must land in exactly one category — \
             {} KiB of {} KiB did not",
            c.total_kib - c.categorised_kib(),
            c.total_kib
        );
        // A Rust test binary always has a heap and shared libraries; if either
        // is zero the header/Rss pairing has broken.
1
        assert!(c.heap_kib > 0 || c.anon_kib > 0, "no heap and no anon mappings — parse is wrong");
1
    }
    /// Off Linux the census is honest about being unavailable rather than
    /// returning a zeroed struct that reads as "no memory used".
    #[test]
    #[cfg(not(target_os = "linux"))]
    fn census_is_none_off_linux() {
        assert!(rss_census().is_none());
    }
}
// ---------------------------------------------------------------------------
// ALLOCATOR STATS — live vs freed-but-held.
//
// The RSS census says where the process's pages are. It CANNOT say how much
// of `[heap]` is live data and how much is memory the program freed but the
// allocator kept. That distinction is load-bearing for this codebase: the CSS
// clone churn, the ~32 MiB a window resize costs, and the "transient 5.2 MB"
// in the RSS map are ALL freed-but-unreturned, and until now the report could
// not tell them from live data — which is exactly the confusion that made a
// 2 MB peak look like 5 MB of live footprint.
// ---------------------------------------------------------------------------
/// glibc's `struct mallinfo2`. All fields `size_t`, in bytes.
///
/// `mallinfo` (the old one) uses `int` and silently WRAPS past 2 GB, which is
/// why only `mallinfo2` is used here.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct MallInfo2 {
    arena: usize,
    ordblks: usize,
    smblks: usize,
    hblks: usize,
    hblkhd: usize,
    usmblks: usize,
    fsmblks: usize,
    uordblks: usize,
    fordblks: usize,
    keepcost: usize,
}
/// What the allocator is holding, in bytes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AllocatorStats {
    /// Live: handed to the program and not yet freed. THIS is the number to
    /// compare against an object walk.
    pub live_bytes: u64,
    /// Freed by the program, still held by the allocator. Counts toward RSS
    /// and toward `[heap]`, but is not data — it is what a churn-heavy
    /// startup leaves behind.
    pub free_in_arena_bytes: u64,
    /// `hblkhd` — space in mmapped regions. Served by `mmap` rather than the
    /// arena (glibc does this above `MMAP_THRESHOLD`), so it lands in
    /// `[anon]` and NOT in `[heap]` — which is why a full-window pixmap
    /// never appears in the heap figure.
    ///
    /// Note for anyone testing this: an allocation whose result is never read
    /// is DELETED by LLVM at `-O`, and then none of these counters move. A
    /// first attempt here concluded the field was broken on that basis. With
    /// the allocation forced to exist, a 64 MiB `Vec` moves `hblks` by +1 and
    /// this field by +67 112 960 (64 MiB + a 4 KiB header), and drops back on
    /// free. `arena` and `live_bytes` correctly stay flat, because an mmapped
    /// block is not an arena block.
    pub mmapped_bytes: u64,
    /// Total arena size. `live + free_in_arena` should approximate it.
    pub arena_bytes: u64,
    /// Trailing space that `malloc_trim` could return to the OS.
    pub releasable_bytes: u64,
}
impl AllocatorStats {
    /// Freed-but-held as a share of the arena. High means churn, not data.
    #[must_use]
1
    pub fn fragmentation_pct(&self) -> f64 {
1
        if self.arena_bytes == 0 {
            0.0
        } else {
1
            100.0 * self.free_in_arena_bytes as f64 / self.arena_bytes as f64
        }
1
    }
}
/// Query the allocator, or `None` if it cannot be asked.
///
/// Looked up with `dlsym` rather than declared `extern`, deliberately.
/// `mallinfo2` only exists in glibc >= 2.33; an `extern` declaration would
/// make the BINARY FAIL TO LINK on musl, on older glibc, and on macOS. A
/// runtime lookup degrades to `None` instead, and the report says the
/// allocator could not be queried rather than printing zeros — a zero here
/// would read as "no memory held", which is the worst possible wrong answer.
/// Ask glibc to return free heap pages to the OS. Returns `Some(true)` if it
/// released anything, `Some(false)` if it had nothing to release, `None` if
/// `malloc_trim` is unavailable (musl, macOS, older glibc).
///
/// WHY THIS EXISTS. §26 of `scripts/RSS_MAP_2026_08_07.md` established that a
/// window resize costs ~+62 MB of transient PEAK and only ~+2.6 MB of
/// RETAINED memory — the RSS that stays behind is glibc's arena holding pages
/// it no longer needs, not objects anyone owns. Nothing that reduces retained
/// bytes can move it; returning the pages is the only lever that acts on it
/// directly.
///
/// `dlsym` rather than an `extern` declaration, for the same reason as
/// `allocator_stats`: an `extern` block would make the BINARY FAIL TO LINK
/// wherever the symbol is absent, turning a missing optimisation into a
/// missing build.
// Off Linux every `#[cfg]` arm below collapses to `None`, so clippy sees a
// function that could be `const` — and says so as an error under the extreme
// lint set, making `cargo clippy` red on every Mac while CI's ubuntu job is
// green. The Linux body reads a file; it cannot be const. Allow it here rather
// than let the gate mean two different things on two machines.
#[allow(clippy::missing_const_for_fn)]
#[must_use]
pub fn malloc_trim() -> Option<bool> {
    #[cfg(not(all(unix, not(target_os = "macos"))))]
    {
        None
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        unsafe extern "C" {
            fn dlsym(handle: *mut core::ffi::c_void, symbol: *const u8)
                -> *mut core::ffi::c_void;
        }
        let sym = unsafe { dlsym(core::ptr::null_mut(), c"malloc_trim".as_ptr().cast()) };
        if sym.is_null() {
            return None;
        }
        type MallocTrimFn = unsafe extern "C" fn(usize) -> i32;
        let f: MallocTrimFn = unsafe { core::mem::transmute(sym) };
        // pad = 0: keep nothing back. A non-zero pad would leave a cushion for
        // the next spike, which is a tuning question this measurement has no
        // basis to answer yet.
        Some(unsafe { f(0) } != 0)
    }
}
// Off Linux every `#[cfg]` arm below collapses to `None`, so clippy sees a
// function that could be `const` — and says so as an error under the extreme
// lint set, making `cargo clippy` red on every Mac while CI's ubuntu job is
// green. The Linux body reads a file; it cannot be const. Allow it here rather
// than let the gate mean two different things on two machines.
#[allow(clippy::missing_const_for_fn)]
#[must_use]
5
pub fn allocator_stats() -> Option<AllocatorStats> {
    #[cfg(not(all(unix, not(target_os = "macos"))))]
    {
        None
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        // RTLD_DEFAULT is NULL on glibc: search the global symbol scope.
        unsafe extern "C" {
            fn dlsym(handle: *mut core::ffi::c_void, symbol: *const u8)
                -> *mut core::ffi::c_void;
        }
5
        let sym = unsafe { dlsym(core::ptr::null_mut(), c"mallinfo2".as_ptr().cast()) };
5
        if sym.is_null() {
            return None;
5
        }
        type MallInfo2Fn = unsafe extern "C" fn() -> MallInfo2;
5
        let f: MallInfo2Fn = unsafe { core::mem::transmute(sym) };
5
        let mi = unsafe { f() };
5
        Some(AllocatorStats {
5
            live_bytes: mi.uordblks as u64,
5
            free_in_arena_bytes: mi.fordblks as u64,
5
            mmapped_bytes: mi.hblkhd as u64,
5
            arena_bytes: mi.arena as u64,
5
            releasable_bytes: mi.keepcost as u64,
5
        })
    }
5
}
#[cfg(test)]
mod allocator_stats_tests {
    use super::*;
    /// On a glibc host the allocator must answer, and its numbers must be
    /// self-consistent. A test process has always allocated something, so a
    /// zero `live_bytes` means the struct layout is wrong — which is the
    /// failure mode a hand-written `#[repr(C)]` mirror invites.
    #[test]
    #[cfg(all(unix, not(target_os = "macos")))]
1
    fn allocator_stats_are_self_consistent_or_absent() {
1
        let Some(a) = allocator_stats() else {
            // musl, or glibc < 2.33. Absence is a valid answer.
            return;
        };
1
        assert!(
1
            a.live_bytes > 0,
            "a running test process has live allocations; 0 means the \
             mallinfo2 struct layout is wrong"
        );
1
        assert!(
1
            a.arena_bytes >= a.free_in_arena_bytes,
            "free-in-arena ({}) cannot exceed the arena ({})",
            a.free_in_arena_bytes,
            a.arena_bytes
        );
1
        let pct = a.fragmentation_pct();
1
        assert!((0.0..=100.0).contains(&pct), "fragmentation {pct} out of range");
1
    }
    /// Allocating must move the numbers, and must move the RIGHT one.
    ///
    /// This test was written asserting that a 4 MiB `Vec` raises
    /// `live_bytes`, and it FAILED — correctly. glibc serves anything above
    /// `MMAP_THRESHOLD` (128 KiB by default) with `mmap`, so a large
    /// allocation lands in `hblkhd`/`mmapped_bytes` and never touches
    /// `uordblks`/`live_bytes` at all. That split is the whole reason a
    /// full-window pixmap shows up in `[anon]` rather than `[heap]`, and it
    /// is worth pinning rather than discovering again.
    #[test]
    #[cfg(all(unix, not(target_os = "macos")))]
1
    fn arena_and_mmap_allocations_move_the_right_counters() {
        // ARENA allocations are tracked, and that is what this feature needs:
        // the live-vs-freed-but-held split for churn. Verified, pinned.
        // ARENA allocations move `live_bytes`. 32 MiB of 16 KiB blocks, so
        // the delta dominates whatever other tests are doing concurrently.
1
        let Some(before_small) = allocator_stats() else { return };
2048
        let small: Vec<Vec<u8>> = (0..2048).map(|_| vec![7u8; 16 * 1024]).collect();
1
        let small = core::hint::black_box(small);
1
        let Some(after_small) = allocator_stats() else { return };
1
        let grew = after_small.live_bytes.saturating_sub(before_small.live_bytes);
1
        assert!(
1
            grew > 16 * 1024 * 1024,
            "32 MiB of arena allocations must raise live_bytes by well over \
             16 MiB; saw {grew} ({} -> {})",
            before_small.live_bytes,
            after_small.live_bytes
        );
1
        drop(small);
        // A LARGE allocation goes to mmap instead, moving `mmapped_bytes`
        // while leaving the arena counters alone.
        //
        // `black_box` is load-bearing. Without it LLVM deletes an allocation
        // whose result is never read, no counter moves, and the test "proves"
        // the field is broken — which is exactly the wrong conclusion an
        // earlier version of this test reached.
1
        let Some(before_big) = allocator_stats() else { return };
1
        let mut big: Vec<u8> = vec![7u8; 64 * 1024 * 1024];
1
        big[12345] = 9;
1
        let big = core::hint::black_box(big);
1
        let Some(after_big) = allocator_stats() else { return };
1
        assert!(
1
            after_big.mmapped_bytes > before_big.mmapped_bytes,
            "a 64 MiB allocation must raise mmapped_bytes ({} -> {})",
            before_big.mmapped_bytes,
            after_big.mmapped_bytes
        );
        // NOT asserted: that `live_bytes` is UNCHANGED across the mmap.
        // It should be — an mmapped block is not an arena block — but
        // `mallinfo2` is PROCESS-GLOBAL and the test harness runs tests in
        // parallel, so other threads' allocations move it between the two
        // samples. That assertion passed alone and failed in the full suite.
        // Only deltas large enough to dominate concurrent noise (64 MiB) are
        // safe to assert here.
1
        drop(big);
1
    }
    #[test]
    #[cfg(feature = "probe")] // without the probe the const stub returns "cb:?" by design
    fn fn_name_resolution_never_collapses_to_a_bare_question_mark() {
        // The static-link fallback law: two DIFFERENT functions must get
        // DIFFERENT span names even when dladdr cannot name them — "cb:?"
        // for everything made the per-callback panels a single useless bar.
        // #[inline(never)] + distinct bodies: release-mode ICF merges
        // identical functions into ONE address, which is not what this
        // test is about.
        #[inline(never)]
        fn f_one() -> u32 { std::hint::black_box(1) }
        #[inline(never)]
        fn f_two() -> u32 { std::hint::black_box(2) }
        let a = Probe::span_for_fn(f_one as usize);
        let b = Probe::span_for_fn(f_two as usize);
        drop(a);
        drop(b);
        let names = super::imp::resolve_fn_name(f_one as usize);
        let names2 = super::imp::resolve_fn_name(f_two as usize);
        assert_ne!(names, "cb:?", "unresolved symbol must fall back to an address form");
        assert_ne!(names, names2, "distinct fns must resolve to distinct span names");
        assert!(
            names.starts_with("cb:"),
            "span name keeps the cb: family prefix: {names}"
        );
        // With addr2line installed (Linux), the DEBUG-symbol fallback must
        // recover the REAL name — the test binary carries debuginfo.
        if cfg!(target_os = "linux")
            && std::process::Command::new("addr2line")
                .arg("--version")
                .output()
                .is_ok_and(|o| o.status.success())
        {
            assert!(
                names.contains("f_one"),
                "addr2line fallback must name the function, got: {names}"
            );
        }
    }
}
/// The resolved name of a callback function pointer — the same ladder the
/// `cb:` spans use (`dladdr` → `addr2line` → module-relative offset →
/// address). Cached; the returned string lives for the process.
///
/// The action journal names handlers with this, so a problem report reads
/// `cb:on_save_clicked` rather than a bare pointer.
// const only in the no-`probe` stub config; the enabled resolver is not const
#[allow(clippy::missing_const_for_fn)]
#[must_use]
11
pub fn callback_name(fn_ptr: usize) -> &'static str {
11
    imp::resolve_fn_name(fn_ptr)
11
}