8a: assert_no_alloc on audio-thread callbacks

Wraps the three audio-thread `process` callbacks
(`capture_process`, `playback_process`, `tap_process`) with
`assert_no_alloc::assert_no_alloc(|| inner(...))`. The
`headroom-cli` binary installs `AllocDisabler` as `#[global_allocator]`
so any allocation inside one of those blocks during debug builds
aborts the process with "memory allocation of N bytes failed".

Each callback was renamed to `*_inner` to keep the thin wrapper
function pointer stable for pipewire-rs's `process(fn_ptr)`.

`assert_no_alloc`'s `disable_release` is on by default — release
builds get the system allocator unwrapped and the macros become
no-ops, so the audio thread pays zero runtime cost in production.

Verified

  Positive smoke (5 s of 1 kHz sine through processed): daemon
  stays up across thousands of capture/playback/tap callbacks. No
  abort. Audio threads are alloc-free as designed.

  Negative smoke (temporarily inserted `Vec::with_capacity(1024)`
  inside `capture_process_inner`): daemon aborts (SIGABRT, exit
  134) on the first audio callback with the expected
  "memory allocation of 1024 bytes failed" stderr message —
  confirming the harness is wired correctly and not silently a
  no-op. Sanity-check alloc reverted before commit.

  185 tests pass; clippy clean at -D warnings --all-targets.
This commit is contained in:
atagen 2026-05-21 16:21:53 +10:00
parent 8af6dff98d
commit 9220143db7
6 changed files with 43 additions and 2 deletions

8
Cargo.lock generated
View file

@ -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",

View file

@ -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 }

View file

@ -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)]

View file

@ -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 }

View file

@ -428,8 +428,14 @@ fn build_format_pod_bytes() -> Result<Vec<u8>, 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 {

View file

@ -234,7 +234,12 @@ fn build_format_pod_bytes() -> Result<Vec<u8>, 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;
};