diff --git a/Cargo.lock b/Cargo.lock index a3283c8..134095e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert_no_alloc" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ca83137a482d61d916ceb1eba52a684f98004f18e0cafea230fe5579c178a3" + [[package]] name = "autocfg" version = "1.5.0" @@ -633,6 +639,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" name = "headroom-cli" version = "0.1.0" dependencies = [ + "assert_no_alloc", "clap", "crossbeam-channel", "crossterm", @@ -660,6 +667,7 @@ dependencies = [ name = "headroom-core" version = "0.1.0" dependencies = [ + "assert_no_alloc", "bytemuck", "criterion", "crossbeam-channel", diff --git a/crates/headroom-cli/Cargo.toml b/crates/headroom-cli/Cargo.toml index ee5e7c9..49dd87c 100644 --- a/crates/headroom-cli/Cargo.toml +++ b/crates/headroom-cli/Cargo.toml @@ -18,6 +18,7 @@ headroom-client = { workspace = true } headroom-core = { workspace = true } headroom-ipc = { workspace = true } +assert_no_alloc = { workspace = true } clap = { workspace = true } crossbeam-channel = { workspace = true } crossterm = { workspace = true } diff --git a/crates/headroom-cli/src/main.rs b/crates/headroom-cli/src/main.rs index ae79f21..9e6865e 100644 --- a/crates/headroom-cli/src/main.rs +++ b/crates/headroom-cli/src/main.rs @@ -14,6 +14,16 @@ use std::process::ExitCode; use clap::{Parser, Subcommand, ValueEnum}; use headroom_client::{Client, ClientError, Route, Topic}; +// Wrap the system allocator so audio-thread `assert_no_alloc` blocks +// in headroom-core can detect any allocation. In debug builds an +// allocation inside such a block aborts the process — exactly what +// we want when the daemon is exercised under `cargo run` or under +// the test harness. In release builds the wrapper is a no-op +// (assert_no_alloc's default `disable_release` feature), so there's +// zero overhead in production. +#[global_allocator] +static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler; + /// Headroom CLI. #[derive(Debug, Parser)] #[command(version, about, long_about = None)] diff --git a/crates/headroom-core/Cargo.toml b/crates/headroom-core/Cargo.toml index f22230c..3e59697 100644 --- a/crates/headroom-core/Cargo.toml +++ b/crates/headroom-core/Cargo.toml @@ -43,6 +43,12 @@ notify-debouncer-mini = { workspace = true } # Slow AGC loop (Phase 4 closing piece). ebur128 = { workspace = true } +# Audio-thread allocation guard. In debug builds the `AllocDisabler` +# global allocator panics if anything inside an `assert_no_alloc!` +# block tries to allocate; in release builds the macro is a no-op +# (zero overhead). Wraps each audio-thread `process` callback. +assert_no_alloc = { workspace = true } + # Optional journald logging — not wired yet. # tracing-journald = { workspace = true } diff --git a/crates/headroom-core/src/pw/filter.rs b/crates/headroom-core/src/pw/filter.rs index 1d654dd..d3ecee1 100644 --- a/crates/headroom-core/src/pw/filter.rs +++ b/crates/headroom-core/src/pw/filter.rs @@ -428,8 +428,14 @@ fn build_format_pod_bytes() -> Result, DaemonError> { Ok(bytes) } -/// Capture process callback. Realtime-thread, allocation-free. +/// Capture process callback. Realtime-thread, allocation-free — +/// guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds +/// so any inadvertent allocation aborts immediately. fn capture_process(stream: &pipewire::stream::StreamRef, state: &mut CaptureState) { + assert_no_alloc::assert_no_alloc(|| capture_process_inner(stream, state)); +} + +fn capture_process_inner(stream: &pipewire::stream::StreamRef, state: &mut CaptureState) { let Some(mut buffer) = stream.dequeue_buffer() else { return; // Out of buffers; pipewire is queueing for us. }; @@ -520,8 +526,13 @@ fn drain_audio_commands(state: &mut PlaybackState) { } } -/// Playback process callback. Realtime-thread, allocation-free. +/// Playback process callback. Realtime-thread, allocation-free — +/// guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds. fn playback_process(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) { + assert_no_alloc::assert_no_alloc(|| playback_process_inner(stream, state)); +} + +fn playback_process_inner(stream: &pipewire::stream::StreamRef, state: &mut PlaybackState) { drain_audio_commands(state); let Some(mut buffer) = stream.dequeue_buffer() else { diff --git a/crates/headroom-core/src/pw/tap.rs b/crates/headroom-core/src/pw/tap.rs index 4cec494..3f959fe 100644 --- a/crates/headroom-core/src/pw/tap.rs +++ b/crates/headroom-core/src/pw/tap.rs @@ -234,7 +234,12 @@ fn build_format_pod_bytes() -> Result, DaemonError> { /// Audio-thread `process` callback. Allocation-free, bounded by the /// block length. Computes `peak` and `mean_sq` over the interleaved /// samples and pushes one [`MeasurementSample`] to the controller. +/// Guarded by [`assert_no_alloc::assert_no_alloc`] in debug builds. fn tap_process(stream: &pipewire::stream::StreamRef, state: &mut TapState) { + assert_no_alloc::assert_no_alloc(|| tap_process_inner(stream, state)); +} + +fn tap_process_inner(stream: &pipewire::stream::StreamRef, state: &mut TapState) { let Some(mut buffer) = stream.dequeue_buffer() else { return; };