Skip to main content

truce_core/
rt.rs

1//! Paranoid real-time allocation checking (the `rt-paranoid` feature).
2//!
3//! A wrapping global allocator (`RtCheckAlloc`) plus a thread-local
4//! "audio section" guard ([`RtSection`]). While the audio thread is
5//! inside a section, any allocation it makes is a real-time contract
6//! violation and gets recorded and reported.
7//!
8//! The section is entered around the single `plugin.process()` call in
9//! `chunked_process`, which every format wrapper and the test driver
10//! route through, so one guard covers all of them. The allocator is
11//! installed by the artifact (a plugin cdylib via `truce::plugin!`, or a
12//! test binary) with `truce::enable_rt_paranoid!`; a library cannot set
13//! a downstream binary's global allocator.
14//!
15//! Everything here is inert unless the `rt-paranoid` feature is on:
16//! [`RtSection::enter`] is a zero-sized no-op and [`allow_alloc`] just
17//! calls the closure, so release builds are unaffected.
18
19#[cfg(feature = "rt-paranoid")]
20mod imp {
21    use std::alloc::{GlobalAlloc, Layout, System};
22    use std::cell::Cell;
23    use std::sync::atomic::{AtomicU8, Ordering};
24
25    const MAX_FRAMES: usize = 32;
26
27    #[derive(Clone, Copy)]
28    struct FrameBuf {
29        ips: [usize; MAX_FRAMES],
30        len: usize,
31    }
32
33    impl FrameBuf {
34        const EMPTY: Self = Self {
35            ips: [0; MAX_FRAMES],
36            len: 0,
37        };
38    }
39
40    // Const-initialized so access never lazily allocates - critical,
41    // since these are read from inside the allocator hook.
42    thread_local! {
43        static DEPTH: Cell<u32> = const { Cell::new(0) };
44        static RECORDING: Cell<bool> = const { Cell::new(false) };
45        static VIOLATIONS: Cell<u32> = const { Cell::new(0) };
46        static FIRST: Cell<FrameBuf> = const { Cell::new(FrameBuf::EMPTY) };
47        // `Some(n)` while inside `audit`: section violations accumulate
48        // here and the normal report/panic is suppressed, so a test can
49        // assert on the count instead.
50        static AUDIT: Cell<Option<u32>> = const { Cell::new(None) };
51    }
52
53    /// What the checker does when the audio thread allocates inside a
54    /// `process` section.
55    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
56    pub enum Mode {
57        /// Log the count and a backtrace after the block; keep running.
58        Count,
59        /// Panic - fails the block, gating a whole test suite.
60        Panic,
61        /// Abort at the offending allocation (catch the live stack in a
62        /// debugger).
63        Trap,
64    }
65
66    impl Mode {
67        const fn to_u8(self) -> u8 {
68            match self {
69                Mode::Count => 0,
70                Mode::Panic => 1,
71                Mode::Trap => 2,
72            }
73        }
74        fn from_u8(v: u8) -> Self {
75            match v {
76                1 => Mode::Panic,
77                2 => Mode::Trap,
78                _ => Mode::Count,
79            }
80        }
81    }
82
83    // `MODE_UNINIT` until first read or `set_mode`, so the env var is
84    // consulted at most once and only when nothing set the mode
85    // programmatically. Backing it with an atomic (not a `OnceLock<Mode>`
86    // seeded from `env::var`) means the read on the audio thread is a
87    // plain load with no `String` allocation once a `set_mode` has run.
88    const MODE_UNINIT: u8 = u8::MAX;
89    static MODE: AtomicU8 = AtomicU8::new(MODE_UNINIT);
90
91    /// Set the reaction the checker takes on a violation, overriding
92    /// `TRUCE_RT_PARANOID`. Call before the first audio block (a test
93    /// harness, `main`, or a `#[ctor]`); the last call wins. Setting the
94    /// mode this way skips the one-time env read the default path does on
95    /// the audio thread.
96    pub fn set_mode(mode: Mode) {
97        MODE.store(mode.to_u8(), Ordering::Relaxed);
98    }
99
100    fn mode() -> Mode {
101        let v = MODE.load(Ordering::Relaxed);
102        if v != MODE_UNINIT {
103            return Mode::from_u8(v);
104        }
105        // No explicit `set_mode`: seed from `TRUCE_RT_PARANOID` once.
106        // `env::var` allocates, but this runs at most once (guarded by
107        // the compare-exchange) and never after a `set_mode`.
108        let seeded = mode_from_env().to_u8();
109        match MODE.compare_exchange(MODE_UNINIT, seeded, Ordering::Relaxed, Ordering::Relaxed) {
110            Ok(_) => Mode::from_u8(seeded),
111            Err(actual) => Mode::from_u8(actual),
112        }
113    }
114
115    fn mode_from_env() -> Mode {
116        match std::env::var("TRUCE_RT_PARANOID").as_deref() {
117            Ok("panic") => Mode::Panic,
118            Ok("trap" | "abort") => Mode::Trap,
119            _ => Mode::Count,
120        }
121    }
122
123    #[cfg(test)]
124    pub(crate) fn current_mode() -> Mode {
125        mode()
126    }
127
128    /// Guard around a real-time section (one `plugin.process()` call).
129    /// Nesting composes via a depth counter; the report fires only when
130    /// the outermost guard drops.
131    pub struct RtSection {
132        _private: (),
133    }
134
135    impl RtSection {
136        #[inline]
137        #[must_use]
138        pub fn enter() -> Self {
139            DEPTH.with(|d| d.set(d.get().wrapping_add(1)));
140            Self { _private: () }
141        }
142    }
143
144    impl Drop for RtSection {
145        fn drop(&mut self) {
146            let depth = DEPTH.with(|d| {
147                let n = d.get().wrapping_sub(1);
148                d.set(n);
149                n
150            });
151            // Report only on full exit, where DEPTH is 0 so the report's
152            // own allocations aren't re-flagged.
153            if depth == 0 {
154                let count = VIOLATIONS.with(|v| v.replace(0));
155                if count > 0 {
156                    // Inside `audit`, accumulate and stay quiet so the
157                    // test decides what the count means. Otherwise report
158                    // per the global mode.
159                    if AUDIT.with(Cell::get).is_some() {
160                        AUDIT.with(|a| a.set(a.get().map(|n| n.saturating_add(count))));
161                    } else {
162                        report(count);
163                    }
164                }
165            }
166        }
167    }
168
169    /// Run `f` and return `(result, allocations)` where `allocations` is
170    /// the number of audio-thread allocations made inside `process`
171    /// sections during `f`, with the normal per-section report/panic
172    /// suppressed. Same-thread only (the test driver runs `process` on
173    /// the calling thread). Underpins the `truce-test` audio-alloc
174    /// assertions.
175    pub fn audit<R>(f: impl FnOnce() -> R) -> (R, u32) {
176        let prev = AUDIT.with(|a| a.replace(Some(0)));
177        let r = f();
178        let count = AUDIT.with(|a| a.replace(prev)).unwrap_or(0);
179        (r, count)
180    }
181
182    /// Whether the checker is compiled in (the `rt-paranoid` feature).
183    /// A test asserting that code *does* allocate skips its assertion
184    /// when this is false, so it doesn't fail an ordinary build.
185    #[must_use]
186    pub fn is_active() -> bool {
187        true
188    }
189
190    /// Enter a section, run `f`, and return how many allocations it made,
191    /// skipping the reporting path so tests can assert on the count
192    /// directly. Only compiled for the crate's own tests.
193    #[cfg(test)]
194    pub(crate) fn count_allocs<R>(f: impl FnOnce() -> R) -> u32 {
195        DEPTH.with(|d| d.set(d.get().wrapping_add(1)));
196        VIOLATIONS.with(|v| v.set(0));
197        let _ = f();
198        let n = VIOLATIONS.with(|v| v.replace(0));
199        DEPTH.with(|d| d.set(d.get().wrapping_sub(1)));
200        n
201    }
202
203    /// Suspend checking for `f`, for a region inside `process` that must
204    /// legitimately allocate (a debug-only measurement, a first-block
205    /// lazy init). Restores on return or panic.
206    pub fn allow_alloc<R>(f: impl FnOnce() -> R) -> R {
207        struct Restore(u32);
208        impl Drop for Restore {
209            fn drop(&mut self) {
210                DEPTH.with(|d| d.set(self.0));
211            }
212        }
213        let _restore = Restore(DEPTH.with(|d| d.replace(0)));
214        f()
215    }
216
217    /// Called from the allocator hook. Records a violation when the
218    /// current thread is inside a section. Must not allocate: the
219    /// `RECORDING` re-entrancy flag makes any allocation triggered by
220    /// the recording path itself a no-op instead of infinite recursion.
221    #[inline]
222    fn note_alloc() {
223        if DEPTH.with(Cell::get) == 0 || RECORDING.with(Cell::get) {
224            return;
225        }
226        RECORDING.with(|r| r.set(true));
227        let n = VIOLATIONS.with(|v| {
228            let n = v.get().wrapping_add(1);
229            v.set(n);
230            n
231        });
232        if n == 1 {
233            capture_first();
234        }
235        if mode() == Mode::Trap {
236            // SIGABRT stops a debugger on the offending allocation with
237            // the live audio-thread stack.
238            std::process::abort();
239        }
240        RECORDING.with(|r| r.set(false));
241    }
242
243    /// Walk the stack into a fixed thread-local buffer. The raw address
244    /// walk does not allocate; symbol resolution is deferred to
245    /// `report`, which runs after the section with allocation allowed.
246    fn capture_first() {
247        let mut buf = FrameBuf::EMPTY;
248        backtrace::trace(|frame| {
249            if buf.len < MAX_FRAMES {
250                buf.ips[buf.len] = frame.ip() as usize;
251                buf.len += 1;
252                true
253            } else {
254                false
255            }
256        });
257        FIRST.with(|f| f.set(buf));
258    }
259
260    fn report(count: u32) {
261        use std::fmt::Write as _;
262
263        let buf = FIRST.with(|f| f.replace(FrameBuf::EMPTY));
264        // Resolve into a separate buffer so the "first allocation" header
265        // is only emitted when at least one frame resolves - macOS test
266        // builds without a dSYM resolve to nothing, and a dangling header
267        // reads as broken.
268        let mut frames = String::new();
269        for &ip in &buf.ips[..buf.len] {
270            backtrace::resolve(ip as *mut _, |s| {
271                let name = s.name().map(|n| n.to_string()).unwrap_or_default();
272                if name.starts_with("truce_core::rt") || name.starts_with("backtrace") {
273                    return; // skip our own hook / capture frames
274                }
275                match (s.filename(), s.lineno()) {
276                    (Some(file), Some(line)) => {
277                        let _ = write!(frames, "\n    {name} ({}:{line})", file.display());
278                    }
279                    _ if !name.is_empty() => {
280                        let _ = write!(frames, "\n    {name}");
281                    }
282                    _ => {}
283                }
284            });
285        }
286        let mut msg =
287            format!("truce rt-paranoid: {count} allocation(s) on the audio thread in process()");
288        if !frames.is_empty() {
289            msg.push_str("\n  first allocation:");
290            msg.push_str(&frames);
291        }
292        // Panicking in `RtSection::drop` while the thread is already
293        // unwinding (process itself panicked) would abort; downgrade to
294        // a log in that case.
295        match mode() {
296            Mode::Panic if !std::thread::panicking() => panic!("{msg}"),
297            _ => eprintln!("{msg}"),
298        }
299    }
300
301    /// Global allocator that flags allocations made on the audio thread
302    /// inside an [`RtSection`]. Delegates to [`System`] for the actual
303    /// allocation so the program keeps running (in `count` mode).
304    ///
305    /// Install it in the artifact with `truce::enable_rt_paranoid!`.
306    pub struct RtCheckAlloc;
307
308    impl RtCheckAlloc {
309        #[must_use]
310        pub const fn new() -> Self {
311            Self
312        }
313    }
314
315    impl Default for RtCheckAlloc {
316        fn default() -> Self {
317            Self::new()
318        }
319    }
320
321    // SAFETY: every method forwards to the global `System` allocator
322    // with the same arguments; `note_alloc` only reads/writes thread-
323    // local `Cell`s and never itself allocates (guarded by `RECORDING`),
324    // so it cannot violate the `GlobalAlloc` contract.
325    unsafe impl GlobalAlloc for RtCheckAlloc {
326        unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
327            note_alloc();
328            unsafe { System.alloc(layout) }
329        }
330        unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
331            note_alloc();
332            unsafe { System.alloc_zeroed(layout) }
333        }
334        unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
335            note_alloc();
336            unsafe { System.realloc(ptr, layout, new_size) }
337        }
338        unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
339            // Freeing on the audio thread is also non-RT, but flagging
340            // every drop is noisy (a value moved in from a prior block),
341            // so `dealloc` forwards silently - a future opt-in sub-mode.
342            unsafe { System.dealloc(ptr, layout) }
343        }
344    }
345}
346
347#[cfg(not(feature = "rt-paranoid"))]
348mod imp {
349    /// No-op real-time section guard. With `rt-paranoid` off this is a
350    /// zero-sized type whose `enter`/drop compile away.
351    pub struct RtSection {
352        _private: (),
353    }
354
355    impl RtSection {
356        #[inline]
357        #[must_use]
358        pub fn enter() -> Self {
359            Self { _private: () }
360        }
361    }
362
363    /// No-op with `rt-paranoid` off: just calls `f`.
364    #[inline]
365    pub fn allow_alloc<R>(f: impl FnOnce() -> R) -> R {
366        f()
367    }
368
369    /// No-op with `rt-paranoid` off: runs `f`, reports zero allocations.
370    #[inline]
371    pub fn audit<R>(f: impl FnOnce() -> R) -> (R, u32) {
372        (f(), 0)
373    }
374
375    /// The checker is not compiled in.
376    #[must_use]
377    #[inline]
378    pub fn is_active() -> bool {
379        false
380    }
381
382    /// What the checker does on a violation. Present with the feature off
383    /// so `set_mode` call sites compile unconditionally; the checker is
384    /// inert, so it has no effect.
385    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
386    pub enum Mode {
387        Count,
388        Panic,
389        Trap,
390    }
391
392    /// No-op with `rt-paranoid` off.
393    #[inline]
394    pub fn set_mode(_mode: Mode) {}
395}
396
397pub use imp::{Mode, RtSection, allow_alloc, audit, is_active, set_mode};
398
399#[cfg(feature = "rt-paranoid")]
400pub use imp::RtCheckAlloc;
401
402// Install the checking allocator for this crate's own test binary so the
403// mechanism can be exercised. A `#[global_allocator]` in a lib applies to
404// that lib's test/bench binaries only, never to downstream crates.
405#[cfg(all(test, feature = "rt-paranoid"))]
406#[global_allocator]
407static TEST_ALLOC: RtCheckAlloc = RtCheckAlloc::new();
408
409#[cfg(all(test, feature = "rt-paranoid"))]
410mod tests {
411    use super::allow_alloc;
412    use super::imp::count_allocs;
413    use std::hint::black_box;
414
415    #[test]
416    fn alloc_in_section_is_flagged() {
417        let n = count_allocs(|| {
418            let v: Vec<u8> = Vec::with_capacity(4096);
419            black_box(v.as_ptr());
420        });
421        assert!(n >= 1, "expected the in-section allocation to be flagged");
422    }
423
424    #[test]
425    fn no_alloc_in_section_is_clean() {
426        let n = count_allocs(|| {
427            let x = black_box(2) + black_box(3);
428            black_box(x);
429        });
430        assert_eq!(n, 0);
431    }
432
433    #[test]
434    fn allow_alloc_suppresses_flagging() {
435        let n = count_allocs(|| {
436            allow_alloc(|| {
437                let v: Vec<u8> = Vec::with_capacity(4096);
438                black_box(v.as_ptr());
439            });
440        });
441        assert_eq!(n, 0, "allow_alloc should suspend checking for its scope");
442    }
443
444    #[test]
445    fn shared_plugin_warms_the_mediation_lock() {
446        use super::imp::count_allocs;
447        use crate::wrapper::{lock_plugin, shared_plugin};
448
449        // `shared_plugin` locks once at construction so the first lock on
450        // the audio thread doesn't allocate (macOS std `Mutex` boxes its
451        // `pthread_mutex_t` lazily). Taking the lock here must be clean.
452        let shared = shared_plugin(vec![0u8; 16]);
453        let n = count_allocs(|| {
454            drop(lock_plugin(&shared));
455        });
456        assert_eq!(n, 0, "the mediation lock's first lock must be warmed");
457    }
458
459    #[test]
460    fn set_mode_overrides_the_default() {
461        use super::imp::current_mode;
462        use super::{Mode, set_mode};
463
464        // `Panic` is safe to leave briefly: `count_allocs` (the other
465        // tests) never routes through the report path. Restore `Count`
466        // so nothing else in the binary is affected. Never set `Trap` -
467        // it aborts the process.
468        set_mode(Mode::Panic);
469        assert_eq!(current_mode(), Mode::Panic);
470        set_mode(Mode::Count);
471        assert_eq!(current_mode(), Mode::Count);
472    }
473}