Skip to content

Eliminate permit_alloc sites on the RT audio path #239

@OpenSauce

Description

@OpenSauce

Follow-up to #237 / #238. The assertion infrastructure landed with six permit_alloc escape hatches in audio/engine.rs and a handful of known dealloc-on-RT cases. This issue tracks the proper fixes.

Allocations on RT thread (currently permit_alloc'd)

In Engine::process() per audio block

  • audio/peak_meter.rs:62Arc::new(PeakMeterInfo) per block. Replace Arc<ArcSwap<PeakMeterInfo>> with three atomics (AtomicU32 × 2 + AtomicBool) — PeakMeterInfo is plain data, no need for refcounting. ~30 line change. Permit at engine.rs:174.
  • audio/pitch_shifter.rs — Vec scratch buffers in process_block. Preallocate in PitchShifter::new to the max buffer size. Permit at engine.rs:165.
  • audio/recorder.rs:47Vec::with_capacity(samples.len()) per block. Preallocate in Recorder::new. Permit at engine.rs:180.

In Engine::handle_messages()

  • audio/engine.rs:296Box::new(old_convolver) to type-erase for RtDropHandle::retire's Box<dyn Send>. Hold convolver as Box<Convolver> on the engine side so mem::replace returns the box directly.

  • audio/engine.rs:333 — same pattern for samplers. Change samplers: Samplerssamplers: Box<Samplers>.

  • audio/engine.rs:381 (handle_pitch_shift) — PitchShifter::new(...) constructs FFT scratch buffers on the RT thread when SetPitchShift is drained. Move the construction off the RT thread: build a PitchShifter in EngineHandle::set_pitch_shift (GUI side) and ship the constructed instance over the channel as EngineMessage::SetPitchShift(Box<PitchShifter>) (or Option<Box<PitchShifter>> so the 0 semitones bypass case can ship None).

    Cost of the Box wraps is negligible: Samplers and Convolver are already heap-heavy internally (FFT planners, IR buffers); one pointer indirection per process() (not per sample) and 16 bytes in Engine. Idiomatic — every Stage is already Box<dyn Stage>.

    Alternative: typed retire helpers on RtDropHandle (one per concrete type), keeping internal storage as Box<dyn Send> but moving the boxing to GUI-thread code. Cleaner separation of concerns.

Deallocations on RT thread (not currently caught)

These slip past assert_no_alloc because the surrounding retire is permit_alloc'd, but they're real. Should be folded into the same fix.

  • SetSamplers arm*new_samplers derefs the Box; the empty Box<Samplers> shell drops on RT. Goes away if we hold samplers as Box<Samplers> (above).
  • SwapIrConvolver arm — same pattern; *prepared leaves the Box<PreparedIr> shell + PreparedIr.name: String to drop on RT. Hold convolver as Box, retire PreparedIr whole.
  • SetInputFilters armself.input_highpass = hp; directly assigns; the previous Option<Box<dyn Stage>> filter drops on RT. Use mem::replace + rt_drop.retire.

Class of issue: warm-up blindness in tests

The assert_engine_alloc_free helper in rustortion-core/tests/no_alloc.rs runs one engine.process() before the assertion to amortise first-call setup. That call also drains any pending EngineMessage, hiding allocations performed by handle_messages from the audit. The pitch_shifter_does_not_allocate test was restructured in #238 to send SetPitchShift inside the asserted scope, which exposed the PitchShifter::new allocation listed above.

Other message handlers should be audited the same way as part of fixing the offenders above:

  • AddStageVec::insert may grow AmplifierChain.stages. AmplifierChain has no with_capacity API.
  • ReplaceStage, RemoveStage — chain operations + retire.
  • SetTunerEnabled(true) — first toggle may grow tuner buffer (extend_from_slice).
  • StartRecording — sender-side Recorder::new is GUI-thread (fine) but verify the handler itself is alloc-free.

Test coverage gaps to close alongside

  • Wrap engine_tuner_enabled_no_output (standalone) once tuner.process no longer grows its buffer.
  • Wrap samplers_preserve_tone_signal (standalone) — currently isolated to Samplers; trivially wrappable.
  • Add a test for metronome-enabled processing.
  • Add tests for the message arms listed above (AddStage, ReplaceStage, etc.) that send the message inside the asserted scope so warm-up blindness can't hide regressions.

Out of scope (separate PRs)

  • Plugin (RustortionPlugin::process) — flip nih-plug's assert_process_allocs feature.
  • Standalone JACK callback (ProcessHandler::process) — wrap in assert_no_alloc in dev/debug builds.

Done when

All six permit_alloc calls are deleted from engine.rs, the dealloc-on-RT cases are routed through rt_drop.retire, the warm-up-blind message arms above have dedicated tests, and the test gaps above are closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions