Skip to main content

azul_layout/
probe.rs

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
15use 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))]
37mod imp {
38    use std::cell::RefCell;
39    use std::time::Instant;
40
41    thread_local! {
42        static EVENTS: RefCell<Vec<super::Event>> = const { RefCell::new(Vec::new()) };
43    }
44
45    /// RAII guard that records its name + elapsed nanos on drop.
46    pub struct Span {
47        pub(crate) name: &'static str,
48        pub(crate) start: Instant,
49    }
50
51    impl Drop for Span {
52        fn drop(&mut self) {
53            let dur_ns = self.start.elapsed().as_nanos() as u64;
54            // try_with (not with): the lifted-to-wasm web backend has no real
55            // TLS, so `with` hits panic_access_error. These probe accesses are
56            // inlined into layout_dom_recursive/layout_document, so they can't
57            // be stubbed at the symbol level — use the non-panicking access.
58            let _ = EVENTS.try_with(|cell| {
59                cell.borrow_mut().push(super::Event {
60                    name: self.name,
61                    kind: super::EventKind::Span { dur_ns },
62                });
63            });
64        }
65    }
66
67    pub(super) fn open(name: &'static str) -> Span {
68        Span { name, start: Instant::now() }
69    }
70
71    pub(super) fn sample_rss(label: &'static str, bytes: u64) {
72        // try_with: see Span::drop — no real TLS in the lifted wasm backend.
73        let _ = EVENTS.try_with(|cell| {
74            cell.borrow_mut().push(super::Event {
75                name: label,
76                kind: super::EventKind::Rss { bytes },
77            });
78        });
79    }
80
81    pub(super) fn drain() -> Vec<super::Event> {
82        EVENTS
83            .try_with(|cell| core::mem::take(&mut *cell.borrow_mut()))
84            .unwrap_or_default()
85    }
86
87    pub(super) fn drop_events() {
88        let _ = EVENTS.try_with(|cell| cell.borrow_mut().clear());
89    }
90
91    pub(super) fn peek_len() -> usize {
92        EVENTS.try_with(|cell| cell.borrow().len()).unwrap_or(0)
93    }
94
95    pub(super) fn enabled() -> bool {
96        true
97    }
98}
99
100#[cfg(any(
101    not(feature = "probe"),
102    target_family = "wasm",
103    feature = "web_lift"
104))]
105mod imp {
106    #[derive(Debug)]
107    pub struct Span;
108
109    impl Drop for Span {
110        #[inline]
111        fn drop(&mut self) {}
112    }
113
114    #[inline]
115    pub(super) const fn open(_name: &'static str) -> Span {
116        Span
117    }
118
119    #[inline]
120    pub(super) const fn sample_rss(_label: &'static str, _bytes: u64) {}
121
122    #[inline]
123    pub(super) const fn drain() -> Vec<super::Event> {
124        Vec::new()
125    }
126
127    #[inline]
128    pub(super) const fn drop_events() {}
129
130    #[inline]
131    pub(super) const fn peek_len() -> usize { 0 }
132
133    #[inline]
134    pub(super) const fn enabled() -> bool {
135        false
136    }
137}
138
139/// Drained probe event. `Vec<Event>` is what consumers walk to render
140/// trace summaries; the order is the order events fired in.
141#[derive(Copy, Debug, Clone)]
142pub struct Event {
143    pub name: &'static str,
144    pub kind: EventKind,
145}
146
147#[derive(Copy, Debug, Clone)]
148pub enum EventKind {
149    /// A timed scope's wall-clock duration.
150    Span { dur_ns: u64 },
151    /// A labelled RSS checkpoint.
152    Rss { bytes: u64 },
153}
154
155/// Re-exported guard. Held by the caller of [`Probe::span`].
156pub use imp::Span;
157
158/// Probe API. All methods are no-ops without the `probe` feature.
159#[derive(Copy, Clone, Debug)]
160pub struct Probe {
161    _no_construct: PhantomData<()>,
162}
163
164impl Probe {
165    /// Open a timed span. The returned guard records its name + nanos
166    /// on drop into the thread-local event buffer.
167    #[inline]
168    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
169    #[allow(clippy::missing_const_for_fn)]
170    #[must_use] pub fn span(name: &'static str) -> Span {
171        imp::open(name)
172    }
173
174    /// Record an RSS checkpoint with the given label + byte count. The
175    /// caller supplies the bytes (this module does not depend on
176    /// platform RSS readers) so consumers can use whatever measurement
177    /// helper they own.
178    #[inline]
179    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
180    #[allow(clippy::missing_const_for_fn)]
181    pub fn sample_rss(label: &'static str, bytes: u64) {
182        imp::sample_rss(label, bytes);
183    }
184
185    /// Drain the per-thread event buffer.
186    #[inline]
187    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
188    #[allow(clippy::missing_const_for_fn)]
189    #[must_use] pub fn drain() -> Vec<Event> {
190        imp::drain()
191    }
192
193    /// Discard the per-thread event buffer without allocating a `Vec` to
194    /// hand back. Used by long-running harnesses (e.g. `AZ_E2E_TEST`) that
195    /// want to prevent the thread-local buffer from inflating RSS during
196    /// thousands of layout passes without actually needing the events.
197    #[inline]
198    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
199    #[allow(clippy::missing_const_for_fn)]
200    pub fn drop_events() {
201        imp::drop_events();
202    }
203
204    /// Current number of events in the per-thread buffer. Cheap to call.
205    #[inline]
206    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
207    #[allow(clippy::missing_const_for_fn)]
208    #[must_use] pub fn peek_len() -> usize {
209        imp::peek_len()
210    }
211
212    /// Whether the `probe` feature is compiled in.
213    #[inline]
214    // const only in the no-`probe` stub config; enabled `imp::` calls are non-const
215    #[allow(clippy::missing_const_for_fn)]
216    #[must_use] pub fn enabled() -> bool {
217        imp::enabled()
218    }
219}
220
221/// Same monotonic clock used by `font::parsed::monotonic_now_nanos` for
222/// LRU stamping. Re-exported here so any caller that wants raw nanos
223/// without going through a span guard has one source of truth.
224#[inline]
225#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker cast
226pub fn monotonic_now_nanos() -> u64 {
227    use std::sync::OnceLock;
228    use std::time::Instant;
229    static LAUNCH: OnceLock<Instant> = OnceLock::new();
230    let start = LAUNCH.get_or_init(Instant::now);
231    start.elapsed().as_nanos() as u64
232}
233
234/// Format drained probe events as a per-phase timing table to stderr.
235///
236/// Groups `EventKind::Span` by name and prints count / total / avg / p99 /
237/// max in µs. `EventKind::Rss` checkpoints print in wall-clock order with
238/// deltas so allocator purges are visible.
239///
240/// Sorted by total-ns descending so the slowest phase is on top — ideal
241/// for spotting which phase spiked during a stuttering frame.
242///
243/// Called by `AZ_PROFILE=cpu` dumps (both initial layout and relayout),
244/// and also by external consumers like `servo-shot --azul-trace`.
245#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
246/// # Panics
247///
248/// Panics if the collected timing-sample list is empty.
249pub fn print_drained_events(label: &str, events: &[Event]) {
250    use std::collections::BTreeMap;
251
252    if events.is_empty() {
253        if Probe::enabled() {
254            eprintln!("[CPU] {label}: no events recorded this pass");
255        } else {
256            // Feature absent or target-family disabled (WASM): show "???"
257            // instead of a misleading "compile with feature=probe" hint.
258            eprintln!(
259                "[CPU] {label}: probe unavailable on this target (timings = ???)"
260            );
261        }
262        return;
263    }
264
265    let mut spans: BTreeMap<&'static str, Vec<u64>> = BTreeMap::new();
266    let mut rss_marks: Vec<(&'static str, u64)> = Vec::new();
267    for ev in events {
268        match ev.kind {
269            EventKind::Span { dur_ns } => spans.entry(ev.name).or_default().push(dur_ns),
270            EventKind::Rss { bytes } => rss_marks.push((ev.name, bytes)),
271        }
272    }
273
274    let mut rows: Vec<(&'static str, usize, u64, u64, u64, u64)> = spans
275        .into_iter()
276        .map(|(name, mut ns)| {
277            ns.sort_unstable();
278            let n = ns.len();
279            let total: u128 = ns.iter().map(|&x| u128::from(x)).sum();
280            let avg = (total / n.max(1) as u128) as u64;
281            let p99 = ns[(n.saturating_sub(1) * 99) / 100];
282            let max = *ns.last().unwrap();
283            (name, n, total as u64, avg, p99, max)
284        })
285        .collect();
286    rows.sort_by(|a, b| b.2.cmp(&a.2));
287
288    eprintln!("[CPU] === {label} ({} phases) ===", rows.len());
289    eprintln!(
290        "[CPU] {:<28}  {:>5}  {:>10}  {:>9}  {:>9}  {:>9}",
291        "phase", "n", "total(µs)", "avg(µs)", "p99(µs)", "max(µs)"
292    );
293    for (name, n, total, avg, p99, max) in &rows {
294        eprintln!(
295            "[CPU] {:<28}  {:>5}  {:>10.1}  {:>9.2}  {:>9.2}  {:>9.2}",
296            name,
297            n,
298            (*total as f64) / 1_000.0,
299            (*avg as f64) / 1_000.0,
300            (*p99 as f64) / 1_000.0,
301            (*max as f64) / 1_000.0,
302        );
303    }
304    if !rss_marks.is_empty() {
305        eprintln!("[CPU]   -- RSS checkpoints (wall-clock order) --");
306        let mut prev: Option<u64> = None;
307        for (lbl, bytes) in &rss_marks {
308            let delta = prev
309                .map(|p| {
310                    let diff = i128::from(*bytes) - i128::from(p);
311                    if diff >= 0 {
312                        format!("  (Δ +{:.2} MiB)", diff as f64 / 1_048_576.0)
313                    } else {
314                        format!("  (Δ -{:.2} MiB)", -diff as f64 / 1_048_576.0)
315                    }
316                })
317                .unwrap_or_default();
318            eprintln!(
319                "[CPU]   {:<28}  {:.2} MiB{}",
320                lbl,
321                *bytes as f64 / 1_048_576.0,
322                delta
323            );
324            prev = Some(*bytes);
325        }
326    }
327}
328
329/// Convenience wrapper: sample the process's **current** resident set
330/// (not peak) via `task_info` on macOS / `/proc/self/statm` on Linux and
331/// push it into the probe event buffer under the given label.
332///
333/// Using current RSS (not `getrusage.ru_maxrss`) is essential so that
334/// allocator purges are visible — peak RSS only moves up. Name kept as
335/// `sample_peak_rss` for backwards compatibility with existing
336/// checkpoint labels; semantically it is "sample current".
337#[inline]
338// const only without the `probe` feature; enabled path calls non-const RSS readers
339#[allow(clippy::missing_const_for_fn)]
340pub fn sample_peak_rss(label: &'static str) {
341    // [WEB-LIFT 2026-06-11] also no-op under web_lift: current_rss_bytes/
342    // peak_rss_bytes_self are mach syscalls (task_info/getrusage) —
343    // out-of-image and unliftable. See the `imp` cfg note above.
344    #[cfg(all(feature = "probe", not(feature = "web_lift")))]
345    {
346        let (current, _virt) = current_rss_bytes();
347        let bytes = if current != 0 { current } else { peak_rss_bytes_self() };
348        Probe::sample_rss(label, bytes);
349    }
350    #[cfg(any(not(feature = "probe"), feature = "web_lift"))]
351    let _ = label;
352}
353
354#[cfg(feature = "probe")]
355pub fn peak_rss_bytes_pub() -> u64 { peak_rss_bytes_self() }
356
357#[cfg(feature = "probe")]
358fn peak_rss_bytes_self() -> u64 {
359    #[cfg(unix)]
360    unsafe {
361        let mut ru: libc::rusage = core::mem::zeroed();
362        if libc::getrusage(libc::RUSAGE_SELF, &mut ru) != 0 {
363            return 0;
364        }
365        let raw = ru.ru_maxrss as u64;
366        if cfg!(target_os = "macos") { raw } else { raw.saturating_mul(1024) }
367    }
368    #[cfg(not(unix))]
369    {
370        0
371    }
372}
373
374/// Ask the active global allocator to return freed pages to the OS.
375///
376/// - With `allocator_mimalloc` feature: calls `mi_collect(true)`, which
377///   aggressively returns pages (matches `az_purge_allocator` in azul-dll).
378/// - With `allocator_jemalloc` feature: calls `mallctl("arena.0.purge")`.
379/// - Otherwise on macOS: falls back to `malloc_zone_pressure_relief`
380///   which drains the system zone (no-op when a third-party allocator
381///   is the global one — hence the explicit feature flags above).
382/// - Other platforms with default allocator: no-op.
383///
384/// Call after major allocations are freed (e.g. after a layout pass).
385#[inline]
386// const only on the default-allocator no-op path (e.g. Linux); the mimalloc /
387// jemalloc / macOS `malloc_zone_pressure_relief` bodies call non-const fns
388#[allow(clippy::missing_const_for_fn)]
389pub fn hint_purge_allocator() {
390    #[cfg(feature = "allocator_mimalloc")]
391    {
392        // Aggressive purge — returns arenas to the OS when possible.
393        unsafe {
394            libmimalloc_sys::mi_collect(true);
395        }
396        static PURGE_TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
397        if *PURGE_TRACE.get_or_init(azul_core::profile::memory_enabled) {
398            let (rss, _) = current_rss_bytes();
399            eprintln!("[PURGE] mi_collect(true) called — current rss={:.2} MiB", rss as f64 / 1048576.0);
400        }
401        return;
402    }
403    #[cfg(feature = "allocator_jemalloc")]
404    {
405        // Purge all arenas. `arena.<i>.purge` with i = MALLCTL_ARENAS_ALL.
406        unsafe {
407            let _ = tikv_jemalloc_sys::mallctl(
408                b"arena.4096.purge\0".as_ptr() as *const _,
409                core::ptr::null_mut(),
410                core::ptr::null_mut(),
411                core::ptr::null_mut(),
412                0,
413            );
414        }
415        return;
416    }
417    #[cfg(all(target_os = "macos", not(miri), not(any(feature = "allocator_mimalloc", feature = "allocator_jemalloc"))))]
418    {
419        extern "C" {
420            fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize;
421        }
422        unsafe {
423            malloc_zone_pressure_relief(core::ptr::null_mut(), 0);
424        }
425    }
426    // glibc's equivalent of malloc_zone_pressure_relief. Without this the
427    // Linux default-allocator path was the "no-op" the doc comment describes,
428    // so a purge-then-measure sequence could never show pages coming back.
429    #[cfg(all(
430        target_os = "linux",
431        target_env = "gnu",
432        not(miri),
433        not(any(feature = "allocator_mimalloc", feature = "allocator_jemalloc"))
434    ))]
435    {
436        // Declared here rather than via `libc::malloc_trim`: this function is
437        // NOT gated on the `probe` feature (that is what pulls in libc), and
438        // the macOS arm above declares `malloc_zone_pressure_relief` the same
439        // way for the same reason.
440        extern "C" {
441            fn malloc_trim(pad: usize) -> core::ffi::c_int;
442        }
443        unsafe {
444            malloc_trim(0);
445        }
446    }
447}
448
449/// Sample the process's "real" memory footprint (not peak).
450/// Returns (footprint_bytes, virtual_bytes). On macOS this is
451/// `phys_footprint` from `TASK_VM_INFO` — matches Activity Monitor
452/// "Memory" and `vmmap`'s "Physical footprint" line, and excludes
453/// shared library text pages that would otherwise inflate RSS
454/// without costing the process anything uniquely. On Linux this
455/// falls back to `/proc/self/statm` resident size (no direct
456/// equivalent; the shared-lib inflation is much smaller there).
457/// More useful than `getrusage.ru_maxrss` which only moves upward.
458#[cfg(feature = "probe")]
459pub fn current_rss_bytes() -> (u64, u64) {
460    // Miri cannot call the mach `task_info` foreign function; memory profiling
461    // is meaningless under Miri anyway, so report zero.
462    #[cfg(miri)]
463    return (0, 0);
464    #[cfg(all(target_os = "macos", not(miri)))]
465    {
466        // Prefer phys_footprint (TASK_VM_INFO). Fall back to
467        // resident_size (MACH_TASK_BASIC_INFO) if the bigger struct
468        // isn't populated for some reason.
469        let pf = phys_footprint_bytes();
470        #[repr(C)]
471        struct MachTaskBasicInfo {
472            virtual_size: u64,
473            resident_size: u64,
474            resident_size_max: u64,
475            user_time: [u32; 2],
476            system_time: [u32; 2],
477            policy: i32,
478            suspend_count: i32,
479        }
480        const MACH_TASK_BASIC_INFO: u32 = 20;
481        extern "C" {
482            fn mach_task_self() -> u32;
483            fn task_info(
484                target: u32, flavor: u32,
485                info: *mut core::ffi::c_void, count: *mut u32,
486            ) -> i32;
487        }
488        unsafe {
489            let mut info: MachTaskBasicInfo = core::mem::zeroed();
490            let mut count = (core::mem::size_of::<MachTaskBasicInfo>() / 4) as u32;
491            let kr = task_info(
492                mach_task_self(),
493                MACH_TASK_BASIC_INFO,
494                &mut info as *mut _ as *mut core::ffi::c_void,
495                &mut count,
496            );
497            if kr == 0 {
498                let rss = if pf != 0 { pf } else { info.resident_size };
499                (rss, info.virtual_size)
500            } else {
501                (pf, 0)
502            }
503        }
504    }
505    // The doc comment above has always promised a `/proc/self/statm` fallback
506    // on Linux. Until 2026-07-29 this arm returned (0, 0) for every non-macOS
507    // target, so `sample_peak_rss` silently fell back to ru_maxrss (peak-only,
508    // never decreases) and every allocator-purge measurement on Linux read as
509    // "no memory was returned".
510    #[cfg(all(target_os = "linux", not(miri)))]
511    {
512        // statm fields are in pages: size resident shared text lib data dt.
513        let Ok(statm) = std::fs::read_to_string("/proc/self/statm") else {
514            return (0, 0);
515        };
516        let mut it = statm.split_ascii_whitespace();
517        let size: u64 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
518        let resident: u64 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
519        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
520        let page = if page > 0 { page as u64 } else { 4096 };
521        (
522            resident.saturating_mul(page),
523            size.saturating_mul(page),
524        )
525    }
526    #[cfg(not(any(target_os = "macos", all(target_os = "linux", not(miri)))))]
527    { (0, 0) }
528}
529
530/// Heap bytes currently held by the libc allocator (`mstats.bytes_used`).
531///
532/// Unlike RSS, this is what *Rust* allocations plus anything else going
533/// through the default malloc zone is actually holding — mmap regions
534/// for thread stacks, GL buffers, file-mapped fonts, etc. are NOT counted.
535/// A leak that shows up here points to a genuine heap retention (an Arc
536/// chain never dropped, a Vec never shrunk, a `Box<T>` forgotten).
537///
538/// - **macOS**: `mstats().bytes_used`.
539/// - **Linux/glibc**: `mallinfo2().uordblks` — the same quantity, total
540///   bytes currently handed out by malloc. Resolved with `dlsym` rather
541///   than linked directly, because `mallinfo2` is glibc 2.33+ and a hard
542///   link reference would break the build on older distros for the sake of
543///   an opt-in diagnostic. Falls back to the `c_int`-based `mallinfo()`,
544///   which is exact below 2 GiB of live heap.
545/// - Everything else: 0.
546///
547/// CAVEAT (Linux): glibc accounts the **main arena only**. Allocations made
548/// on other threads' arenas — and azul spawns font scout/builder threads —
549/// are invisible here. A rising number is proof of a leak; a flat one is
550/// not proof of its absence. Cross-check with [`current_rss_bytes`].
551///
552/// This returned 0 on every non-macOS target until 2026-07-29, which is the
553/// only reason `dll/tests/leak_regression.rs` is `cfg(target_os = "macos")`:
554/// the leak was never macOS-specific, the *instrument* was.
555#[cfg(feature = "probe")]
556pub fn malloc_heap_bytes() -> u64 {
557    #[cfg(target_os = "macos")]
558    {
559        #[repr(C)]
560        struct Mstats {
561            bytes_total: usize,
562            chunks_used: usize,
563            bytes_used: usize,
564            chunks_free: usize,
565            bytes_free: usize,
566        }
567        extern "C" {
568            fn mstats() -> Mstats;
569        }
570        unsafe { mstats().bytes_used as u64 }
571    }
572    #[cfg(all(target_os = "linux", target_env = "gnu", not(miri)))]
573    {
574        type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2;
575        static MALLINFO2: std::sync::OnceLock<Option<Mallinfo2Fn>> =
576            std::sync::OnceLock::new();
577        let resolved = MALLINFO2.get_or_init(|| unsafe {
578            // RTLD_DEFAULT is NULL on glibc; the libc crate doesn't define
579            // the constant for linux-gnu, so spell it out.
580            let sym = libc::dlsym(
581                core::ptr::null_mut(),
582                b"mallinfo2\0".as_ptr().cast::<core::ffi::c_char>(),
583            );
584            if sym.is_null() {
585                None
586            } else {
587                Some(core::mem::transmute::<
588                    *mut core::ffi::c_void,
589                    Mallinfo2Fn,
590                >(sym))
591            }
592        });
593        return match resolved {
594            Some(mallinfo2) => unsafe { mallinfo2().uordblks as u64 },
595            // Pre-2.33 glibc. `uordblks` is a signed int that wraps past
596            // 2 GiB; clamp rather than report a negative byte count.
597            None => unsafe { libc::mallinfo().uordblks.max(0) as u64 },
598        };
599    }
600    #[cfg(not(any(
601        target_os = "macos",
602        all(target_os = "linux", target_env = "gnu", not(miri))
603    )))]
604    { 0 }
605}
606
607/// Sample the Mach `phys_footprint` — the memory metric Activity
608/// Monitor and `vmmap`'s "Physical footprint" line display. Unlike
609/// `resident_size`, this excludes shared library text pages and
610/// other kernel-mapped regions that inflate the traditional RSS
611/// number without actually costing the process anything. For a
612/// short-lived headless render this is a much more honest figure:
613/// on a ~20 MiB ru_maxrss run, phys_footprint is typically ~8 MiB.
614/// Returns 0 on non-macOS or if the Mach call fails.
615///
616/// There's no direct "peak phys_footprint" field; track the max
617/// across calls in application code if you need it.
618#[cfg(feature = "probe")]
619pub fn phys_footprint_bytes() -> u64 {
620    // Miri cannot call the mach `task_info` foreign function.
621    #[cfg(miri)]
622    return 0;
623    #[cfg(all(target_os = "macos", not(miri)))]
624    {
625        // TASK_VM_INFO = 22; the struct is large (~88 u32 counts ≈ 352 B)
626        // and phys_footprint lives near the end, so we have to read the
627        // whole thing. Layout is from osfmk/mach/task_info.h.
628        #[repr(C)]
629        struct TaskVmInfo {
630            virtual_size: u64,
631            region_count: u32,
632            page_size: u32,
633            resident_size: u64,
634            resident_size_peak: u64,
635            device: u64,
636            device_peak: u64,
637            internal: u64,
638            internal_peak: u64,
639            external: u64,
640            external_peak: u64,
641            reusable: u64,
642            reusable_peak: u64,
643            purgeable_volatile_pmap: u64,
644            purgeable_volatile_resident: u64,
645            purgeable_volatile_virtual: u64,
646            compressed: u64,
647            compressed_peak: u64,
648            compressed_lifetime: u64,
649            phys_footprint: u64,
650            // there are more fields after this, but we don't need them
651            _rest: [u64; 12],
652        }
653        const TASK_VM_INFO: u32 = 22;
654        extern "C" {
655            fn mach_task_self() -> u32;
656            fn task_info(
657                target: u32, flavor: u32,
658                info: *mut core::ffi::c_void, count: *mut u32,
659            ) -> i32;
660        }
661        unsafe {
662            let mut info: TaskVmInfo = core::mem::zeroed();
663            let mut count = (core::mem::size_of::<TaskVmInfo>() / 4) as u32;
664            let kr = task_info(
665                mach_task_self(),
666                TASK_VM_INFO,
667                &mut info as *mut _ as *mut core::ffi::c_void,
668                &mut count,
669            );
670            if kr == 0 { info.phys_footprint } else { 0 }
671        }
672    }
673    #[cfg(not(target_os = "macos"))]
674    { 0 }
675}
676
677/// Background sampler for peak phys_footprint. Spawns a thread that
678/// polls `phys_footprint_bytes()` every ~2 ms and updates a shared
679/// atomic. The kernel does not expose a direct "peak phys_footprint"
680/// — unlike `resident_size_peak` in TASK_VM_INFO — so polling is
681/// the only way to catch mid-phase transients that are MADV_FREE'd
682/// before the next explicit sample point.
683///
684/// Not started by default; call `start_peak_sampler()` once at
685/// process init if you want peak tracking. Overhead is negligible
686/// (~1-5 µs per poll on macOS, 500 Hz → <0.25% CPU of one core).
687/// `peak_phys_footprint_seen()` reads the current high-water mark.
688#[cfg(feature = "probe")]
689pub fn start_peak_sampler() {
690    #[cfg(target_os = "macos")]
691    {
692        use std::sync::atomic::Ordering;
693        // Idempotent — only spawns once.
694        static STARTED: std::sync::atomic::AtomicBool =
695            std::sync::atomic::AtomicBool::new(false);
696        if STARTED.swap(true, Ordering::AcqRel) {
697            return;
698        }
699        std::thread::Builder::new()
700            .name("azul-peak-sampler".to_string())
701            .spawn(|| loop {
702                let now = phys_footprint_bytes();
703                let prev = PEAK_PHYS_FOOTPRINT.load(Ordering::Relaxed);
704                if now > prev {
705                    PEAK_PHYS_FOOTPRINT.store(now, Ordering::Relaxed);
706                }
707                std::thread::sleep(std::time::Duration::from_micros(250));
708            })
709            .ok();
710    }
711}
712
713#[cfg(feature = "probe")]
714static PEAK_PHYS_FOOTPRINT: std::sync::atomic::AtomicU64 =
715    std::sync::atomic::AtomicU64::new(0);
716
717/// Read the peak `phys_footprint` seen by the background sampler.
718/// Returns 0 if `start_peak_sampler` was never called.
719#[cfg(feature = "probe")]
720pub fn peak_phys_footprint_seen() -> u64 {
721    PEAK_PHYS_FOOTPRINT.load(std::sync::atomic::Ordering::Relaxed)
722}
723
724/// Reset the global peak high-water mark to the current phys_footprint.
725/// Paired with `peak_phys_footprint_seen()` so a caller can record
726/// "peak during phase X" — call `reset_peak()` at phase entry, then
727/// `peak_phys_footprint_seen()` at phase exit. The 500 Hz background
728/// sampler runs continuously either way.
729#[cfg(feature = "probe")]
730pub fn reset_peak() {
731    let now = phys_footprint_bytes();
732    PEAK_PHYS_FOOTPRINT.store(now, std::sync::atomic::Ordering::Relaxed);
733}
734
735/// Record a phase's peak footprint into the probe event stream.
736/// Call at phase exit after `reset_peak()` at phase entry. Emits an
737/// RSS-kind event with `bytes = peak seen during phase`.
738#[cfg(feature = "probe")]
739#[inline]
740pub fn sample_phase_peak(label: &'static str) {
741    let peak = PEAK_PHYS_FOOTPRINT.load(std::sync::atomic::Ordering::Relaxed);
742    Probe::sample_rss(label, peak);
743}
744
745#[cfg(not(feature = "probe"))]
746#[inline]
747pub const fn reset_peak() {}
748
749#[cfg(not(feature = "probe"))]
750#[inline]
751pub const fn sample_phase_peak(_label: &'static str) {}
752
753#[cfg(not(feature = "probe"))]
754#[inline]
755#[must_use] pub const fn malloc_heap_bytes() -> u64 { 0 }
756
757/// Emit one `{"ev":"phase","label":L,"heap":N,"call":C}` line to the
758/// JSONL file named by `AZ_PROFILE_OUT=<path>`. Only fires when
759/// `AZ_PROFILE=heap,jsonl` is set *and* the path is given.
760///
761/// Each call auto-increments a monotonic `call` id so downstream
762/// analyzers can group phases belonging to a single `regenerate_layout`
763/// invocation.
764///
765/// `label` convention: `start` at function entry; `<step>` after each
766/// phase completes; `end` at function exit. Heap Δ between adjacent
767/// labels within the same call-id is the bytes retained by that phase.
768///
769/// Zero overhead when flags aren't set (two atomic loads). Zero overhead
770/// when the `probe` feature is off (no-op stub).
771#[cfg(feature = "probe")]
772pub fn emit_phase_heap(label: &str) {
773    use std::io::Write;
774    if !heap_jsonl_enabled() { return; }
775    let Some(p) = azul_core::profile::out_path() else { return };
776    static CALL_ID: std::sync::atomic::AtomicU64 =
777        std::sync::atomic::AtomicU64::new(0);
778    // Auto-increment on every "start" label; "end" and intermediates reuse
779    // the current id so all phases in one regenerate_layout invocation share
780    // a call number.
781    static CURRENT_CALL: std::sync::atomic::AtomicU64 =
782        std::sync::atomic::AtomicU64::new(0);
783    let call_id = if label == "start" {
784        let next = CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
785        CURRENT_CALL.store(next, std::sync::atomic::Ordering::Relaxed);
786        next
787    } else {
788        CURRENT_CALL.load(std::sync::atomic::Ordering::Relaxed)
789    };
790    let heap = malloc_heap_bytes();
791    if let Ok(mut f) = std::fs::OpenOptions::new()
792        .create(true)
793        .append(true)
794        .open(p)
795    {
796        let _ = writeln!(
797            f,
798            r#"{{"ev":"phase","call":{},"label":"{}","heap":{}}}"#,
799            call_id, label, heap
800        );
801    }
802}
803
804#[cfg(not(feature = "probe"))]
805#[inline]
806pub const fn emit_phase_heap(_label: &str) {}
807
808/// Like [`emit_phase_heap`] but attaches a numeric payload (e.g., a cache
809/// size) to the JSONL record under the `"extra"` field.
810///
811/// Gated behind `AZ_PROFILE=heap,jsonl,detail` — the `detail` token opts
812/// in to fine-grained probes that produce extra per-step records (one
813/// per intermediate step inside a phase). Without `detail`, only the
814/// coarser phase probes from [`emit_phase_heap`] fire.
815#[cfg(feature = "probe")]
816pub fn emit_phase_heap_extra(label: &str, extra: u64) {
817    use std::io::Write;
818    if !heap_jsonl_enabled() { return; }
819    if !azul_core::profile::detail_enabled() { return; }
820    let Some(p) = azul_core::profile::out_path() else { return };
821    let heap = malloc_heap_bytes();
822    if let Ok(mut f) = std::fs::OpenOptions::new()
823        .create(true)
824        .append(true)
825        .open(p)
826    {
827        let _ = writeln!(
828            f,
829            r#"{{"ev":"phase","call":0,"label":"{}","heap":{},"extra":{}}}"#,
830            label, heap, extra
831        );
832    }
833}
834
835#[cfg(not(feature = "probe"))]
836#[inline]
837pub const fn emit_phase_heap_extra(_label: &str, _extra: u64) {}
838
839/// Both `heap` and `jsonl` tokens active in `AZ_PROFILE` — the combination
840/// that enables JSONL heap-probe emission. Either alone is a no-op.
841#[cfg(feature = "probe")]
842#[inline]
843fn heap_jsonl_enabled() -> bool {
844    let f = azul_core::profile::flags();
845    f.heap && f.jsonl
846}
847
848/// Returns true iff `AZ_PROFILE=detail` is active. Kept as a public
849/// re-export so downstream crates can write `azul_layout::probe::detail_enabled()`
850/// without pulling in `azul_core::profile` directly.
851#[cfg(feature = "probe")]
852#[inline]
853pub fn detail_enabled() -> bool {
854    azul_core::profile::detail_enabled()
855}
856
857#[cfg(not(feature = "probe"))]
858#[inline]
859#[must_use] pub const fn detail_enabled() -> bool { false }
860
861#[cfg(test)]
862#[allow(let_underscore_drop, clippy::too_many_lines)]
863mod autotest_generated {
864    use super::*;
865
866    /// Build a `&'static str` with arbitrary (possibly hostile) contents.
867    /// Leaks — fine for a test binary, and the only way to feed adversarial
868    /// text into the `&'static str` APIs (`Probe::span`, `sample_rss`, ...).
869    fn leak(s: String) -> &'static str {
870        Box::leak(s.into_boxed_str())
871    }
872
873    /// Clear this thread's event buffer so a test's assertions hold even when
874    /// the suite runs with `--test-threads=1` (all tests on one thread share
875    /// the same thread-local `EVENTS`).
876    fn reset() {
877        Probe::drop_events();
878        assert_eq!(Probe::peek_len(), 0, "drop_events must leave an empty buffer");
879    }
880
881    fn span_ns(ev: &Event) -> Option<u64> {
882        match ev.kind {
883            EventKind::Span { dur_ns } => Some(dur_ns),
884            EventKind::Rss { .. } => None,
885        }
886    }
887
888    fn rss_bytes(ev: &Event) -> Option<u64> {
889        match ev.kind {
890            EventKind::Rss { bytes } => Some(bytes),
891            EventKind::Span { .. } => None,
892        }
893    }
894
895    // ---------------------------------------------------------------
896    // enabled() / cfg invariants
897    // ---------------------------------------------------------------
898
899    #[test]
900    fn enabled_matches_the_compiled_imp() {
901        // `Probe::enabled()` is the single runtime source of truth for
902        // "events actually get buffered"; it must track the cfg that selects
903        // the real `imp` (probe on, not wasm, not web_lift).
904        let expected = cfg!(all(
905            feature = "probe",
906            not(target_family = "wasm"),
907            not(feature = "web_lift")
908        ));
909        assert_eq!(Probe::enabled(), expected);
910        assert_eq!(imp::enabled(), expected);
911    }
912
913    #[test]
914    fn enabled_is_pure_and_idempotent() {
915        let first = Probe::enabled();
916        for _ in 0..1000 {
917            assert_eq!(Probe::enabled(), first);
918        }
919    }
920
921    // ---------------------------------------------------------------
922    // span / drain round-trips
923    // ---------------------------------------------------------------
924
925    #[test]
926    fn span_round_trips_name_through_drain() {
927        reset();
928        {
929            let _g = Probe::span("autotest_span_round_trip");
930        }
931        let events = Probe::drain();
932        if Probe::enabled() {
933            assert_eq!(events.len(), 1);
934            assert_eq!(events[0].name, "autotest_span_round_trip");
935            assert!(span_ns(&events[0]).is_some(), "span guard must emit EventKind::Span");
936        } else {
937            assert!(events.is_empty(), "no-op imp must never buffer events");
938        }
939        assert_eq!(Probe::peek_len(), 0, "drain must empty the buffer");
940    }
941
942    #[test]
943    fn nested_spans_drop_inner_first_and_outer_duration_is_the_larger() {
944        reset();
945        {
946            let _outer = Probe::span("outer");
947            {
948                let _inner = Probe::span("inner");
949            }
950        }
951        let events = Probe::drain();
952        if !Probe::enabled() {
953            assert!(events.is_empty());
954            return;
955        }
956        assert_eq!(events.len(), 2);
957        // Drop order is inner-then-outer, so the buffer order is the same.
958        assert_eq!(events[0].name, "inner");
959        assert_eq!(events[1].name, "outer");
960        let inner = span_ns(&events[0]).expect("inner is a span");
961        let outer = span_ns(&events[1]).expect("outer is a span");
962        // The outer span strictly encloses the inner one in wall-clock time.
963        assert!(
964            outer >= inner,
965            "outer span ({outer} ns) must cover the inner one ({inner} ns)"
966        );
967    }
968
969    #[test]
970    fn forgotten_span_guard_records_nothing() {
971        reset();
972        core::mem::forget(Probe::span("forgotten"));
973        let events = Probe::drain();
974        assert!(
975            events.is_empty(),
976            "a leaked guard never runs Drop, so it must not emit an event"
977        );
978    }
979
980    #[test]
981    fn many_spans_do_not_lose_or_reorder_events() {
982        reset();
983        const N: usize = 10_000;
984        let names: Vec<&'static str> = (0..N).map(|i| leak(format!("phase_{i}"))).collect();
985        for &name in &names {
986            drop(Probe::span(name));
987        }
988        if Probe::enabled() {
989            assert_eq!(Probe::peek_len(), N);
990        } else {
991            assert_eq!(Probe::peek_len(), 0);
992        }
993        let events = Probe::drain();
994        if Probe::enabled() {
995            assert_eq!(events.len(), N);
996            for (i, ev) in events.iter().enumerate() {
997                assert_eq!(ev.name, names[i], "event order must be emission order");
998            }
999        } else {
1000            assert!(events.is_empty());
1001        }
1002        assert_eq!(Probe::peek_len(), 0);
1003    }
1004
1005    #[test]
1006    fn span_survives_hostile_unicode_and_huge_names() {
1007        reset();
1008        let hostile: Vec<&'static str> = vec![
1009            "",
1010            "\0embedded\0nul\0",
1011            "\n\r\t",
1012            "{}{:?}{0}%s%n",           // format-string-looking payloads
1013            "🦀👨‍👩‍👧‍👦🇩🇪",         // emoji + ZWJ sequence + flag
1014            "مرحبا بالعالم",           // RTL
1015            "e\u{0301}\u{0301}\u{0301}", // stacked combining marks
1016            leak("A".repeat(100_000)), // huge
1017            leak("\u{1F4A9}".repeat(10_000)),
1018        ];
1019        for &name in &hostile {
1020            drop(Probe::span(name));
1021        }
1022        let events = Probe::drain();
1023        if Probe::enabled() {
1024            assert_eq!(events.len(), hostile.len());
1025            for (ev, name) in events.iter().zip(hostile.iter()) {
1026                assert_eq!(ev.name, *name, "name must round-trip byte-for-byte");
1027            }
1028            // Formatting the hostile names must not panic either.
1029            print_drained_events("hostile-names", &events);
1030        } else {
1031            assert!(events.is_empty());
1032        }
1033    }
1034
1035    #[test]
1036    fn drain_is_empty_the_second_time() {
1037        reset();
1038        drop(Probe::span("once"));
1039        let first = Probe::drain();
1040        let second = Probe::drain();
1041        if Probe::enabled() {
1042            assert_eq!(first.len(), 1);
1043        }
1044        assert!(second.is_empty(), "a drained buffer must stay drained");
1045    }
1046
1047    // ---------------------------------------------------------------
1048    // sample_rss: numeric boundaries + exact round-trip
1049    // ---------------------------------------------------------------
1050
1051    #[test]
1052    fn sample_rss_round_trips_every_numeric_boundary() {
1053        reset();
1054        let boundaries: [u64; 8] = [
1055            0,
1056            1,
1057            u64::from(u32::MAX),
1058            u64::from(u32::MAX) + 1,
1059            1 << 63,
1060            u64::MAX - 1,
1061            u64::MAX,
1062            0xDEAD_BEEF_DEAD_BEEF,
1063        ];
1064        for b in boundaries {
1065            Probe::sample_rss("bytes", b);
1066        }
1067        let events = Probe::drain();
1068        if !Probe::enabled() {
1069            assert!(events.is_empty());
1070            return;
1071        }
1072        assert_eq!(events.len(), boundaries.len());
1073        for (ev, expected) in events.iter().zip(boundaries.iter()) {
1074            assert_eq!(
1075                rss_bytes(ev),
1076                Some(*expected),
1077                "RSS byte counts must survive the buffer unchanged (no saturation)"
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    fn sample_rss_zero_is_recorded_not_skipped() {
1084        reset();
1085        Probe::sample_rss("zero", 0);
1086        let events = Probe::drain();
1087        if Probe::enabled() {
1088            assert_eq!(events.len(), 1, "a 0-byte checkpoint is still a checkpoint");
1089            assert_eq!(rss_bytes(&events[0]), Some(0));
1090            assert_eq!(events[0].name, "zero");
1091        } else {
1092            assert!(events.is_empty());
1093        }
1094    }
1095
1096    // ---------------------------------------------------------------
1097    // peek_len / drop_events
1098    // ---------------------------------------------------------------
1099
1100    #[test]
1101    fn peek_len_tracks_pushes_and_drop_events_clears() {
1102        reset();
1103        assert_eq!(Probe::peek_len(), 0);
1104        for i in 0..64u64 {
1105            Probe::sample_rss("tick", i);
1106        }
1107        if Probe::enabled() {
1108            assert_eq!(Probe::peek_len(), 64);
1109        } else {
1110            assert_eq!(Probe::peek_len(), 0);
1111        }
1112        Probe::drop_events();
1113        assert_eq!(Probe::peek_len(), 0, "drop_events must clear the buffer");
1114        assert!(
1115            Probe::drain().is_empty(),
1116            "drop_events must discard, not stash, the events"
1117        );
1118    }
1119
1120    #[test]
1121    fn drop_events_on_an_empty_buffer_is_a_no_op() {
1122        reset();
1123        for _ in 0..100 {
1124            Probe::drop_events();
1125            assert_eq!(Probe::peek_len(), 0);
1126        }
1127    }
1128
1129    #[test]
1130    fn peek_len_is_side_effect_free() {
1131        reset();
1132        Probe::sample_rss("keep", 7);
1133        let expected = if Probe::enabled() { 1 } else { 0 };
1134        for _ in 0..100 {
1135            assert_eq!(Probe::peek_len(), expected, "peek must not consume events");
1136        }
1137        let events = Probe::drain();
1138        assert_eq!(events.len(), expected);
1139    }
1140
1141    // ---------------------------------------------------------------
1142    // thread-locality
1143    // ---------------------------------------------------------------
1144
1145    #[test]
1146    fn event_buffer_is_per_thread() {
1147        reset();
1148        Probe::sample_rss("main_thread", 1);
1149
1150        let child_len = std::thread::spawn(|| {
1151            // A fresh thread starts with an empty buffer, even though the
1152            // parent just pushed an event.
1153            assert_eq!(Probe::peek_len(), 0, "buffers must not be shared across threads");
1154            Probe::sample_rss("child_thread", 2);
1155            let drained = Probe::drain();
1156            for ev in &drained {
1157                assert_eq!(ev.name, "child_thread", "child must only see its own events");
1158            }
1159            drained.len()
1160        })
1161        .join()
1162        .expect("probe calls must not panic on a spawned thread");
1163
1164        let events = Probe::drain();
1165        if Probe::enabled() {
1166            assert_eq!(child_len, 1);
1167            assert_eq!(events.len(), 1, "the child's drain must not touch our buffer");
1168            assert_eq!(events[0].name, "main_thread");
1169        } else {
1170            assert_eq!(child_len, 0);
1171            assert!(events.is_empty());
1172        }
1173    }
1174
1175    // ---------------------------------------------------------------
1176    // imp:: (private) parity with the public facade
1177    // ---------------------------------------------------------------
1178
1179    #[test]
1180    fn imp_facade_parity() {
1181        reset();
1182        {
1183            let _g = imp::open("imp_open");
1184        }
1185        imp::sample_rss("imp_rss", u64::MAX);
1186        let len = imp::peek_len();
1187        assert_eq!(len, Probe::peek_len());
1188        let events = imp::drain();
1189        assert_eq!(events.len(), len);
1190        assert_eq!(imp::peek_len(), 0);
1191        if Probe::enabled() {
1192            assert_eq!(events[0].name, "imp_open");
1193            assert_eq!(rss_bytes(&events[1]), Some(u64::MAX));
1194        } else {
1195            assert!(events.is_empty());
1196        }
1197        imp::drop_events();
1198        assert_eq!(imp::peek_len(), 0);
1199    }
1200
1201    // ---------------------------------------------------------------
1202    // print_drained_events: the formatter is the panic-prone one
1203    // ---------------------------------------------------------------
1204
1205    #[test]
1206    fn print_drained_events_empty_slice_does_not_panic() {
1207        // The doc comment claims it "Panics if the collected timing-sample
1208        // list is empty" — the implementation early-returns instead. Pin the
1209        // safe behaviour.
1210        print_drained_events("empty", &[]);
1211        print_drained_events("", &[]);
1212    }
1213
1214    #[test]
1215    fn print_drained_events_rss_only_has_no_span_rows() {
1216        // With zero spans the row list is empty; the `ns.last().unwrap()` in
1217        // the row builder must never be reached.
1218        let events = [
1219            Event { name: "a", kind: EventKind::Rss { bytes: 0 } },
1220            Event { name: "b", kind: EventKind::Rss { bytes: u64::MAX } },
1221            Event { name: "c", kind: EventKind::Rss { bytes: 1 } },
1222        ];
1223        print_drained_events("rss-only", &events);
1224    }
1225
1226    #[test]
1227    fn print_drained_events_p99_index_is_in_bounds_for_every_sample_count() {
1228        // p99 is `ns[(n - 1) * 99 / 100]` — an off-by-one here is an
1229        // out-of-bounds index. Walk the counts where it would bite.
1230        for n in [1usize, 2, 3, 99, 100, 101, 199, 200, 201, 1000] {
1231            let events: Vec<Event> = (0..n)
1232                .map(|i| Event {
1233                    name: "phase",
1234                    kind: EventKind::Span { dur_ns: i as u64 },
1235                })
1236                .collect();
1237            print_drained_events("p99", &events);
1238        }
1239    }
1240
1241    #[test]
1242    fn print_drained_events_saturating_totals_do_not_panic() {
1243        // Summing u64::MAX durations overflows u64; the impl accumulates in
1244        // u128 and truncates for display, so this must not panic in a debug
1245        // build (overflow checks are on for `cargo test`).
1246        let events = [
1247            Event { name: "huge", kind: EventKind::Span { dur_ns: u64::MAX } },
1248            Event { name: "huge", kind: EventKind::Span { dur_ns: u64::MAX } },
1249            Event { name: "huge", kind: EventKind::Span { dur_ns: u64::MAX } },
1250            Event { name: "zero", kind: EventKind::Span { dur_ns: 0 } },
1251        ];
1252        print_drained_events("overflowing-total", &events);
1253    }
1254
1255    #[test]
1256    fn print_drained_events_rss_delta_handles_full_u64_swing() {
1257        // The delta is computed in i128; a MAX -> 0 -> MAX swing is the worst
1258        // case for a naive i64/u64 subtraction.
1259        let events = [
1260            Event { name: "peak", kind: EventKind::Rss { bytes: u64::MAX } },
1261            Event { name: "trough", kind: EventKind::Rss { bytes: 0 } },
1262            Event { name: "peak_again", kind: EventKind::Rss { bytes: u64::MAX } },
1263        ];
1264        print_drained_events("delta-swing", &events);
1265    }
1266
1267    #[test]
1268    fn print_drained_events_hostile_labels_and_names() {
1269        let big = leak("x".repeat(65_536));
1270        let events = [
1271            Event { name: "", kind: EventKind::Span { dur_ns: 1 } },
1272            Event { name: "{}{:?}", kind: EventKind::Span { dur_ns: 2 } },
1273            Event { name: big, kind: EventKind::Span { dur_ns: u64::MAX } },
1274            Event { name: "🦀\u{0301}\0", kind: EventKind::Rss { bytes: 1 } },
1275        ];
1276        print_drained_events(big, &events);
1277        print_drained_events("\0\n{}", &events);
1278    }
1279
1280    #[test]
1281    fn print_drained_events_accepts_a_real_drain() {
1282        reset();
1283        {
1284            let _a = Probe::span("layout");
1285            let _b = Probe::span("layout");
1286        }
1287        Probe::sample_rss("after", 4096);
1288        let events = Probe::drain();
1289        print_drained_events("real-drain", &events);
1290    }
1291
1292    // ---------------------------------------------------------------
1293    // monotonic_now_nanos
1294    // ---------------------------------------------------------------
1295
1296    #[test]
1297    fn monotonic_now_nanos_never_goes_backwards() {
1298        let mut prev = monotonic_now_nanos();
1299        for _ in 0..10_000 {
1300            let now = monotonic_now_nanos();
1301            assert!(now >= prev, "clock went backwards: {prev} -> {now}");
1302            prev = now;
1303        }
1304    }
1305
1306    #[test]
1307    fn monotonic_now_nanos_is_monotonic_across_threads() {
1308        // The `OnceLock<Instant>` launch stamp is process-global, so a value
1309        // read on another thread is comparable with one read here.
1310        let before = monotonic_now_nanos();
1311        let mid = std::thread::spawn(monotonic_now_nanos)
1312            .join()
1313            .expect("monotonic_now_nanos must not panic off the main thread");
1314        let after = monotonic_now_nanos();
1315        assert!(before <= mid && mid <= after, "{before} <= {mid} <= {after}");
1316    }
1317
1318    // ---------------------------------------------------------------
1319    // sample_peak_rss / sample_phase_peak / reset_peak
1320    // ---------------------------------------------------------------
1321
1322    #[test]
1323    fn sample_peak_rss_emits_exactly_one_labelled_event() {
1324        reset();
1325        sample_peak_rss("autotest_peak_rss");
1326        let events = Probe::drain();
1327        if Probe::enabled() {
1328            assert_eq!(events.len(), 1);
1329            assert_eq!(events[0].name, "autotest_peak_rss");
1330            assert!(
1331                rss_bytes(&events[0]).is_some(),
1332                "sample_peak_rss must emit an Rss-kind event"
1333            );
1334        } else {
1335            assert!(events.is_empty());
1336        }
1337    }
1338
1339    #[test]
1340    fn sample_phase_peak_emits_exactly_one_labelled_event() {
1341        reset();
1342        sample_phase_peak("autotest_phase_peak");
1343        let events = Probe::drain();
1344        if Probe::enabled() {
1345            assert_eq!(events.len(), 1);
1346            assert_eq!(events[0].name, "autotest_phase_peak");
1347            assert!(rss_bytes(&events[0]).is_some());
1348        } else {
1349            assert!(events.is_empty());
1350        }
1351    }
1352
1353    #[test]
1354    fn reset_peak_is_repeatable_and_side_effect_free_on_the_event_buffer() {
1355        reset();
1356        for _ in 0..100 {
1357            reset_peak();
1358        }
1359        assert_eq!(
1360            Probe::peek_len(),
1361            0,
1362            "reset_peak touches an atomic, it must not push events"
1363        );
1364    }
1365
1366    #[test]
1367    fn hint_purge_allocator_is_repeatable_and_emits_nothing() {
1368        reset();
1369        for _ in 0..50 {
1370            hint_purge_allocator();
1371        }
1372        assert_eq!(Probe::peek_len(), 0, "purging must not push probe events");
1373    }
1374
1375    // ---------------------------------------------------------------
1376    // malloc_heap_bytes / detail_enabled (both cfg worlds)
1377    // ---------------------------------------------------------------
1378
1379    /// Platforms where `malloc_heap_bytes` is expected to return a real
1380    /// figure. This used to be macOS alone, which is exactly why the FFI leak
1381    /// regression could only ever be measured there.
1382    const HEAP_BYTES_IS_REAL: bool = cfg!(all(
1383        feature = "probe",
1384        any(
1385            target_os = "macos",
1386            all(target_os = "linux", target_env = "gnu")
1387        ),
1388        not(miri)
1389    ));
1390
1391    #[test]
1392    fn malloc_heap_bytes_actually_tracks_live_heap() {
1393        if !HEAP_BYTES_IS_REAL {
1394            // Unsupported target (or the `probe` feature is off, where the
1395            // stub is a `const fn -> 0`). Say so by measurement, not by faith.
1396            assert_eq!(malloc_heap_bytes(), 0);
1397            assert_eq!(malloc_heap_bytes(), 0);
1398            return;
1399        }
1400
1401        // A probe that returns a plausible constant is worse than one that
1402        // returns nothing, because it reads as evidence. Prove it MOVES, and
1403        // moves in the right direction by roughly the right amount.
1404        //
1405        // 8 MiB: far above allocator bookkeeping noise, and above glibc's
1406        // MMAP_THRESHOLD only if that has been tuned up — so ask for it as
1407        // many smaller blocks that are certain to come from the heap proper
1408        // rather than a fresh mmap that `uordblks` would not count.
1409        const BLOCK: usize = 64 * 1024;
1410        const BLOCKS: usize = 128;
1411        const TOTAL: u64 = (BLOCK * BLOCKS) as u64;
1412
1413        let before = malloc_heap_bytes();
1414        assert!(before > 0, "a live process holds a non-zero heap");
1415
1416        let mut ballast: Vec<Vec<u8>> = Vec::with_capacity(BLOCKS);
1417        for _ in 0..BLOCKS {
1418            // Touch it: a Vec that is never written may not be committed.
1419            ballast.push(vec![0xAB_u8; BLOCK]);
1420        }
1421        let during = malloc_heap_bytes();
1422
1423        drop(ballast);
1424        let after = malloc_heap_bytes();
1425
1426        assert!(
1427            during >= before + TOTAL / 2,
1428            "allocating {TOTAL} B moved the probe by only {} B \
1429             (before={before}, during={during}) — it is not measuring the heap",
1430            during.saturating_sub(before),
1431        );
1432        assert!(
1433            after < during - TOTAL / 2,
1434            "freeing {TOTAL} B left the probe at {after} B (during={during}) — \
1435             it does not see frees, so it cannot distinguish a leak from churn",
1436        );
1437    }
1438
1439    #[test]
1440    fn detail_enabled_is_deterministic() {
1441        let first = detail_enabled();
1442        for _ in 0..100 {
1443            assert_eq!(detail_enabled(), first, "flag reads are cached, must not flap");
1444        }
1445        if !cfg!(feature = "probe") {
1446            assert!(!first, "the no-probe stub is a const `false`");
1447        }
1448    }
1449
1450    // ---------------------------------------------------------------
1451    // emit_phase_heap / emit_phase_heap_extra (no-op unless
1452    // AZ_PROFILE=heap,jsonl + AZ_PROFILE_OUT; must never panic regardless)
1453    // ---------------------------------------------------------------
1454
1455    #[test]
1456    fn emit_phase_heap_survives_hostile_labels() {
1457        reset();
1458        let huge = "L".repeat(65_536);
1459        let labels: Vec<&str> = vec![
1460            "",
1461            "start",
1462            "start", // repeated: exercises the call-id auto-increment
1463            "end",
1464            "\"quote\"", // would corrupt the emitted JSON if flags were on
1465            "back\\slash",
1466            "new\nline",
1467            "\0nul",
1468            "🦀 unicode",
1469            &huge,
1470        ];
1471        for l in &labels {
1472            emit_phase_heap(l);
1473        }
1474        assert_eq!(Probe::peek_len(), 0, "JSONL emission must not touch the span buffer");
1475    }
1476
1477    #[test]
1478    fn emit_phase_heap_extra_survives_numeric_boundaries() {
1479        reset();
1480        for extra in [0u64, 1, u64::MAX / 2, u64::MAX - 1, u64::MAX] {
1481            emit_phase_heap_extra("autotest_extra", extra);
1482            emit_phase_heap_extra("", extra);
1483        }
1484        assert_eq!(Probe::peek_len(), 0);
1485    }
1486
1487    // ---------------------------------------------------------------
1488    // Event / EventKind value type
1489    // ---------------------------------------------------------------
1490
1491    #[test]
1492    fn event_is_copy_and_clone_preserving_payload() {
1493        let span = Event { name: "n", kind: EventKind::Span { dur_ns: u64::MAX } };
1494        let rss = Event { name: "n", kind: EventKind::Rss { bytes: u64::MAX } };
1495        let span_copy = span; // Copy
1496        #[allow(clippy::clone_on_copy)]
1497        let rss_clone = rss.clone();
1498        assert_eq!(span_ns(&span_copy), Some(u64::MAX));
1499        assert_eq!(rss_bytes(&rss_clone), Some(u64::MAX));
1500        // Span and Rss must not be confusable even with identical payloads.
1501        assert!(span_ns(&rss_clone).is_none());
1502        assert!(rss_bytes(&span_copy).is_none());
1503        // Debug must not panic on the extremes.
1504        let _ = format!("{span:?}{rss:?}");
1505    }
1506
1507    // ---------------------------------------------------------------
1508    // probe-only platform readers
1509    // ---------------------------------------------------------------
1510
1511    #[cfg(feature = "probe")]
1512    #[test]
1513    fn peak_rss_bytes_is_monotonic_and_agrees_with_the_pub_wrapper() {
1514        // ru_maxrss is a high-water mark, so it can only move up.
1515        let first = peak_rss_bytes_self();
1516        let pubbed = peak_rss_bytes_pub();
1517        let second = peak_rss_bytes_self();
1518        assert!(pubbed >= first, "peak RSS must never decrease: {first} -> {pubbed}");
1519        assert!(second >= pubbed, "peak RSS must never decrease: {pubbed} -> {second}");
1520        if cfg!(unix) && !cfg!(miri) {
1521            assert!(first > 0, "getrusage on a live unix process must report some RSS");
1522        }
1523    }
1524
1525    #[cfg(feature = "probe")]
1526    #[test]
1527    fn current_rss_bytes_does_not_panic_and_is_self_consistent() {
1528        let (footprint, virt) = current_rss_bytes();
1529        if cfg!(all(target_os = "macos", not(miri))) {
1530            assert!(footprint > 0, "macOS must report a non-zero footprint");
1531            assert!(virt >= footprint || virt == 0);
1532        }
1533        // Repeated sampling must stay panic-free (foreign-fn call each time).
1534        for _ in 0..100 {
1535            let _ = current_rss_bytes();
1536        }
1537    }
1538
1539    #[cfg(feature = "probe")]
1540    #[test]
1541    fn phys_footprint_bytes_is_zero_off_macos() {
1542        let v = phys_footprint_bytes();
1543        if cfg!(all(target_os = "macos", not(miri))) {
1544            assert!(v > 0);
1545        } else {
1546            assert_eq!(v, 0, "documented: returns 0 on non-macOS / under miri");
1547        }
1548    }
1549
1550    #[cfg(feature = "probe")]
1551    #[test]
1552    fn start_peak_sampler_is_idempotent() {
1553        // Documented as "Idempotent — only spawns once"; calling it in a loop
1554        // must not spawn 200 threads or panic.
1555        for _ in 0..200 {
1556            start_peak_sampler();
1557        }
1558        let _ = peak_phys_footprint_seen();
1559    }
1560
1561    #[cfg(feature = "probe")]
1562    #[test]
1563    fn peak_phys_footprint_seen_is_readable_without_a_sampler() {
1564        // Documented: "Returns 0 if start_peak_sampler was never called."
1565        // Other tests in this binary may have started it / reset it, so only
1566        // the non-macOS path (where phys_footprint is always 0) is assertable.
1567        let seen = peak_phys_footprint_seen();
1568        if !cfg!(target_os = "macos") {
1569            assert_eq!(seen, 0, "no phys_footprint source off macOS => peak stays 0");
1570        }
1571    }
1572
1573    #[cfg(feature = "probe")]
1574    #[test]
1575    fn heap_jsonl_enabled_matches_the_profile_flags() {
1576        let f = azul_core::profile::flags();
1577        assert_eq!(
1578            heap_jsonl_enabled(),
1579            f.heap && f.jsonl,
1580            "either token alone must be a no-op"
1581        );
1582        let first = heap_jsonl_enabled();
1583        for _ in 0..100 {
1584            assert_eq!(heap_jsonl_enabled(), first, "flags are cached, must not flap");
1585        }
1586    }
1587}