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}