From f0742d34f582ab4232cfbc74e84701e5952ecd4b Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 24 Oct 2026 01:08:48 +0900 Subject: [PATCH 1/4] fix(au): re-initialize after a restored ClassInfo on the next Reset Logic Pro initializes an AudioUnit and restores its saved ClassInfo afterwards. VST3 and CLAP re-run initialize() inside their state setters, but the AU wrapper only deserialized the parameters, so a plug-in that reads its state in initialize() (latency mode, engine choice) kept the defaults for the whole session. set_class_info is not serialised against render, so it now only flags the unit; the next Reset (serialised, main thread) runs the initialize body again, including the latency update and its notification. A failed re-initialization leaves the unit uninitialized, like a failed Initialize. --- src/wrapper/au/wrapper.rs | 116 +++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index 7f6153e1..b6cbe323 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -192,6 +192,13 @@ pub struct Wrapper { /// `Uninitialize` / first construction. Render is a no-op when false. initialized: AtomicBool, + /// Set when the host restores `ClassInfo` on an initialized unit. Hosts such as Logic Pro + /// initialize first and restore the saved state afterwards, so a plug-in that reads its + /// state in `initialize()` (latency, model choice) would keep running on the defaults. + /// VST3/CLAP re-initialize inside their state setters; here `set_class_info` is not + /// serialised against render, so the re-initialization waits for the next `Reset`. + reinit_pending: AtomicBool, + /// Host-controlled bypass (`kAudioUnitProperty_BypassEffect`). When set, /// a declared BYPASS parameter delegates processing to the plugin, which /// owns its latency-aligned dry path. Without one, render skips processing. @@ -538,6 +545,7 @@ impl Wrapper

{ tail_seconds_bits: AtomicU64::new(pack_f64(0.0)), last_render_error: AtomicI32::new(au::noErr), initialized: AtomicBool::new(false), + reinit_pending: AtomicBool::new(false), bypass: AtomicBool::new(false), offline_render: AtomicBool::new(false), bypass_param_idx, @@ -779,6 +787,17 @@ impl Wrapper

{ /// from those and forward to `Plugin::initialize`. unsafe extern "C" fn initialize(self_ptr: *mut c_void) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; + // SAFETY: AU forbids Initialize concurrent with Render. + unsafe { this.initialize_plugin() } + } + + /// The body of `Initialize`, shared with the deferred re-initialization in `Reset`. + /// + /// SAFETY: the caller must be on a path the host serialises against render + /// (`Initialize` or `Reset`). + unsafe fn initialize_plugin(&self) -> au::OSStatus { + let this = self; + this.reinit_pending.store(false, Ordering::Release); let n_ch = this.n_channels().max(1); let chans = NonZeroU32::new(n_ch).expect("n_ch is clamped to at least one"); @@ -799,7 +818,7 @@ impl Wrapper

{ sink: this.sink.clone(), _marker: PhantomData, }; - // SAFETY: AU forbids Initialize concurrent with Render. + // SAFETY: guaranteed by the caller. let plugin = unsafe { this.plugin_mut() }; let ok = plugin.initialize(&selected_layout, &buffer_config, &mut ctx); if !ok { @@ -861,6 +880,22 @@ impl Wrapper

{ _element: au::AudioUnitElement, ) -> au::OSStatus { let this = unsafe { Self::from_ptr(self_ptr) }; + if this.initialized.load(Ordering::Acquire) + && this.reinit_pending.swap(false, Ordering::AcqRel) + { + // State restored since the last initialization: run it again so the plug-in sees + // the restored values (this also resets it). + // SAFETY: AU calls Reset on the main thread, serialised against render. + let status = unsafe { this.initialize_plugin() }; + if status != au::noErr { + // The plug-in refused its restored state: render stays silent until the host + // initializes it again, as after a failed `Initialize`. + this.initialized.store(false, Ordering::Release); + unsafe { this.plugin_mut() }.deactivate(); + } + this.midi_input.clear(); + return status; + } // SAFETY: AU calls Reset on the main thread, serialised against render. unsafe { this.plugin_mut() }.reset(); this.midi_input.clear(); @@ -1042,6 +1077,10 @@ impl Wrapper

{ } } + if self.initialized.load(Ordering::Acquire) { + self.reinit_pending.store(true, Ordering::Release); + } + au::noErr } @@ -3242,6 +3281,7 @@ mod tests { history: [[f32; 16]; 2], position: usize, calls: usize, + inits: usize, } impl Default for DelayedBypass { @@ -3254,6 +3294,7 @@ mod tests { history: [[0.0; 16]; 2], position: 0, calls: 0, + inits: 0, } } } @@ -3280,6 +3321,7 @@ mod tests { _: &BufferConfig, context: &mut impl InitContext, ) -> bool { + self.inits += 1; context.set_latency_samples(16); true } @@ -4095,6 +4137,78 @@ mod tests { ); } + #[test] + fn restored_class_info_reinitializes_on_the_next_reset() { + type TestWrapper = Wrapper>; + let wrapper = TestWrapper::new() as *mut c_void; + let inits = || unsafe { TestWrapper::from_ptr(wrapper).plugin_mut() }.inits; + let class_info = || { + let mut dict_ptr: *mut c_void = ptr::null_mut(); + let mut size = std::mem::size_of::<*mut c_void>() as u32; + assert_eq!( + unsafe { + TestWrapper::get_property( + wrapper, + au::kAudioUnitProperty_ClassInfo, + au::kAudioUnitScope_Global, + 0, + &mut dict_ptr as *mut *mut c_void as *mut c_void, + &mut size, + ) + }, + au::noErr + ); + unsafe { CFDictionary::::wrap_under_create_rule(dict_ptr as _) } + }; + let restore = |dict: &CFDictionary| { + let ptr = dict.as_concrete_TypeRef() as *const c_void; + assert_eq!( + unsafe { + TestWrapper::set_property( + wrapper, + au::kAudioUnitProperty_ClassInfo, + au::kAudioUnitScope_Global, + 0, + &ptr as *const *const c_void as *const c_void, + std::mem::size_of::<*const c_void>() as u32, + ) + }, + au::noErr + ); + }; + let reset = || unsafe { TestWrapper::reset(wrapper, au::kAudioUnitScope_Global, 0) }; + let saved = class_info(); + + // Before Initialize the restored state is read by the first initialization anyway. + restore(&saved); + assert_eq!(reset(), au::noErr); + assert_eq!(inits(), 0); + + // Logic's order: Initialize, then the saved ClassInfo. + assert_eq!(unsafe { TestWrapper::initialize(wrapper) }, au::noErr); + assert_eq!(inits(), 1); + restore(&saved); + assert_eq!(inits(), 1, "ClassInfo is not serialised against render"); + assert_eq!(reset(), au::noErr); + assert_eq!( + inits(), + 2, + "the next Reset re-initializes with the restored state" + ); + assert_eq!(reset(), au::noErr); + assert_eq!(inits(), 2, "a plain Reset does not"); + + // A fresh Initialize consumes a pending restore. + restore(&saved); + assert_eq!(unsafe { TestWrapper::uninitialize(wrapper) }, au::noErr); + assert_eq!(unsafe { TestWrapper::initialize(wrapper) }, au::noErr); + assert_eq!(inits(), 3); + assert_eq!(reset(), au::noErr); + assert_eq!(inits(), 3); + + assert_eq!(unsafe { TestWrapper::close(wrapper) }, au::noErr); + } + #[test] fn classify_unit_decibels() { assert_eq!(classify_unit("dB"), au::kAudioUnitParameterUnit_Decibels); From 2e7fe8635178312dd878dc7a60f9e0b9ecaeae19 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 24 Oct 2026 01:37:46 +0900 Subject: [PATCH 2/4] fix(au): re-initialize right when a restored ClassInfo arrives Reset alone was too late: Logic restores ClassInfo at project load but calls Reset only around a bounce, so ordinary playback kept the state read before the restore (the first bounce too). set_class_info now re-initializes immediately behind a gate that render takes for each slice: a render that finds the gate held by the restore outputs silence instead of touching the plug-in, and a restore that finds a render in flight waits up to 250 ms before leaving the work to the next Reset. --- src/wrapper/au/wrapper.rs | 169 +++++++++++++++++++++++++++++++++----- 1 file changed, 147 insertions(+), 22 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index b6cbe323..cbe420b8 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -41,8 +41,9 @@ use std::marker::PhantomData; use std::mem; use std::num::NonZeroU32; use std::ptr; -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use au_sys as au; use core_foundation::array::CFArray; @@ -192,13 +193,18 @@ pub struct Wrapper { /// `Uninitialize` / first construction. Render is a no-op when false. initialized: AtomicBool, - /// Set when the host restores `ClassInfo` on an initialized unit. Hosts such as Logic Pro + /// Set when a restored `ClassInfo` could not re-initialize the plug-in right away (a render + /// held it for too long); the next `Reset` does it instead. Hosts such as Logic Pro /// initialize first and restore the saved state afterwards, so a plug-in that reads its - /// state in `initialize()` (latency, model choice) would keep running on the defaults. - /// VST3/CLAP re-initialize inside their state setters; here `set_class_info` is not - /// serialised against render, so the re-initialization waits for the next `Reset`. + /// state in `initialize()` (latency, model choice) would otherwise keep running on the + /// defaults. VST3/CLAP re-initialize inside their state setters too. reinit_pending: AtomicBool, + /// Excludes render from the re-initialization in `set_class_info`, which, unlike + /// `Initialize` and `Reset`, the host does not serialise against render. One of the + /// `GATE_*` states; a render that finds the gate taken outputs silence. + plugin_gate: AtomicU8, + /// Host-controlled bypass (`kAudioUnitProperty_BypassEffect`). When set, /// a declared BYPASS parameter delegates processing to the plugin, which /// owns its latency-aligned dry path. Without one, render skips processing. @@ -455,6 +461,15 @@ struct ParamEntry { /// `{ presetNumber: -1, presetName: CFSTR("Untitled") }`. const DEFAULT_PRESET_NAME: &str = "Untitled"; +/// `Wrapper::plugin_gate` states. +const GATE_IDLE: u8 = 0; +const GATE_RENDERING: u8 = 1; +const GATE_REINIT: u8 = 2; + +/// How long a restored `ClassInfo` waits for an in-flight render before leaving the +/// re-initialization to the next `Reset`. +const RESTORE_GATE_TIMEOUT: Duration = Duration::from_millis(250); + /// `Send` + `Sync` justification: /// /// - All publicly mutable fields are atomic or behind a `Mutex`. @@ -546,6 +561,7 @@ impl Wrapper

{ last_render_error: AtomicI32::new(au::noErr), initialized: AtomicBool::new(false), reinit_pending: AtomicBool::new(false), + plugin_gate: AtomicU8::new(GATE_IDLE), bypass: AtomicBool::new(false), offline_render: AtomicBool::new(false), bypass_param_idx, @@ -791,7 +807,21 @@ impl Wrapper

{ unsafe { this.initialize_plugin() } } - /// The body of `Initialize`, shared with the deferred re-initialization in `Reset`. + /// Re-run the initialization after a state restore. A plug-in that refuses its restored + /// state leaves the unit uninitialized (render outputs silence) until the host initializes + /// it again, as after a failed `Initialize`. + /// + /// SAFETY: same as `initialize_plugin`, or with `plugin_gate` held. + unsafe fn reinitialize_after_restore(&self) -> au::OSStatus { + let status = unsafe { self.initialize_plugin() }; + if status != au::noErr && self.initialized.swap(false, Ordering::AcqRel) { + unsafe { self.plugin_mut() }.deactivate(); + } + self.midi_input.clear(); + status + } + + /// The body of `Initialize`, shared with the re-initialization after a state restore. /// /// SAFETY: the caller must be on a path the host serialises against render /// (`Initialize` or `Reset`). @@ -886,15 +916,7 @@ impl Wrapper

{ // State restored since the last initialization: run it again so the plug-in sees // the restored values (this also resets it). // SAFETY: AU calls Reset on the main thread, serialised against render. - let status = unsafe { this.initialize_plugin() }; - if status != au::noErr { - // The plug-in refused its restored state: render stays silent until the host - // initializes it again, as after a failed `Initialize`. - this.initialized.store(false, Ordering::Release); - unsafe { this.plugin_mut() }.deactivate(); - } - this.midi_input.clear(); - return status; + return unsafe { this.reinitialize_after_restore() }; } // SAFETY: AU calls Reset on the main thread, serialised against render. unsafe { this.plugin_mut() }.reset(); @@ -1078,7 +1100,30 @@ impl Wrapper

{ } if self.initialized.load(Ordering::Acquire) { - self.reinit_pending.store(true, Ordering::Release); + // The plug-in read its state in `initialize()`, before this restore. Re-initialize + // now with render shut out; a render already in flight is short, so wait for it. + let deadline = Instant::now() + RESTORE_GATE_TIMEOUT; + loop { + match self.plugin_gate.compare_exchange( + GATE_IDLE, + GATE_REINIT, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + // SAFETY: the gate excludes render; the host serialises the other + // lifecycle calls with property sets on its main thread. + let status = unsafe { self.reinitialize_after_restore() }; + self.plugin_gate.store(GATE_IDLE, Ordering::Release); + return status; + } + Err(_) if Instant::now() < deadline => std::thread::yield_now(), + Err(_) => { + self.reinit_pending.store(true, Ordering::Release); + break; + } + } + } } au::noErr @@ -2108,6 +2153,45 @@ impl Wrapper

{ } unsafe fn render_audio( + self_ptr: *mut c_void, + io_action_flags: *mut au::AudioUnitRenderActionFlags, + in_time_stamp: *const au::AudioTimeStamp, + in_output_bus_number: au::UInt32, + in_number_frames: au::UInt32, + io_data: *mut au::AudioBufferList, + ) -> au::OSStatus { + let this = unsafe { Self::from_ptr(self_ptr) }; + if this + .plugin_gate + .compare_exchange( + GATE_IDLE, + GATE_RENDERING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + // A restored state is re-initializing the plug-in + if !io_data.is_null() { + unsafe { zero_buffer_list(io_data, in_number_frames) }; + } + return au::noErr; + } + let status = unsafe { + Self::render_audio_gated( + self_ptr, + io_action_flags, + in_time_stamp, + in_output_bus_number, + in_number_frames, + io_data, + ) + }; + this.plugin_gate.store(GATE_IDLE, Ordering::Release); + status + } + + unsafe fn render_audio_gated( self_ptr: *mut c_void, _io_action_flags: *mut au::AudioUnitRenderActionFlags, in_time_stamp: *const au::AudioTimeStamp, @@ -4138,7 +4222,7 @@ mod tests { } #[test] - fn restored_class_info_reinitializes_on_the_next_reset() { + fn restored_class_info_reinitializes_the_plugin() { type TestWrapper = Wrapper>; let wrapper = TestWrapper::new() as *mut c_void; let inits = || unsafe { TestWrapper::from_ptr(wrapper).plugin_mut() }.inits; @@ -4188,23 +4272,64 @@ mod tests { assert_eq!(unsafe { TestWrapper::initialize(wrapper) }, au::noErr); assert_eq!(inits(), 1); restore(&saved); - assert_eq!(inits(), 1, "ClassInfo is not serialised against render"); - assert_eq!(reset(), au::noErr); assert_eq!( inits(), 2, - "the next Reset re-initializes with the restored state" + "a restore re-initializes with the restored state" ); assert_eq!(reset(), au::noErr); assert_eq!(inits(), 2, "a plain Reset does not"); + // A render holding the plug-in defers the re-initialization to the next Reset. + let this = unsafe { TestWrapper::from_ptr(wrapper) }; + this.plugin_gate.store(GATE_RENDERING, Ordering::Release); + restore(&saved); + assert_eq!(inits(), 2); + this.plugin_gate.store(GATE_IDLE, Ordering::Release); + assert_eq!(reset(), au::noErr); + assert_eq!(inits(), 3); + assert_eq!(reset(), au::noErr); + assert_eq!(inits(), 3); + + // A render that finds the plug-in re-initializing outputs silence without touching it. + let mut channels = [[0.5f32; 8]; 2]; + let mut storage = vec![0u64; bl_byte_size(2).div_ceil(mem::size_of::())]; + let list = storage.as_mut_ptr() as *mut au::AudioBufferList; + unsafe { + (*list).mNumberBuffers = 2; + for (i, samples) in channels.iter_mut().enumerate() { + *(*list).mBuffers.as_mut_ptr().add(i) = au::AudioBuffer { + mNumberChannels: 1, + mDataByteSize: (samples.len() * mem::size_of::()) as u32, + mData: samples.as_mut_ptr() as *mut c_void, + }; + } + } + let calls = unsafe { TestWrapper::from_ptr(wrapper).plugin_mut() }.calls; + this.plugin_gate.store(GATE_REINIT, Ordering::Release); + let time = au::AudioTimeStamp { + mSampleTime: 0.0, + mFlags: au::kAudioTimeStampSampleTimeValid, + ..unsafe { mem::zeroed() } + }; + let status = unsafe { TestWrapper::render(wrapper, ptr::null_mut(), &time, 0, 8, list) }; + this.plugin_gate.store(GATE_IDLE, Ordering::Release); + assert_eq!(status, au::noErr); + assert!(channels.iter().flatten().all(|&x| x == 0.0)); + assert_eq!( + unsafe { TestWrapper::from_ptr(wrapper).plugin_mut() }.calls, + calls + ); + // A fresh Initialize consumes a pending restore. + this.plugin_gate.store(GATE_RENDERING, Ordering::Release); restore(&saved); + this.plugin_gate.store(GATE_IDLE, Ordering::Release); assert_eq!(unsafe { TestWrapper::uninitialize(wrapper) }, au::noErr); assert_eq!(unsafe { TestWrapper::initialize(wrapper) }, au::noErr); - assert_eq!(inits(), 3); + assert_eq!(inits(), 4); assert_eq!(reset(), au::noErr); - assert_eq!(inits(), 3); + assert_eq!(inits(), 4); assert_eq!(unsafe { TestWrapper::close(wrapper) }, au::noErr); } From 7e839ec530bc049e0f2c79b60a1a94495a247799 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 24 Oct 2026 20:38:12 +0900 Subject: [PATCH 3/4] build(standalone): rtrb 0.3.5 (RUSTSEC-2026-0274) rtrb 0.2.3 has a double free / use-after-free in ReadChunk::commit when an element's Drop panics. The standalone backend only uses RingBuffer::new, push and pop, which 0.3 keeps. --- Cargo.lock | 7 ++----- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db118282..15e8eba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5070,12 +5070,9 @@ dependencies = [ [[package]] name = "rtrb" -version = "0.2.3" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e704dd104faf2326a320140f70f0b736d607c1caa1b1748a6c568a79819109" -dependencies = [ - "cache-padded", -] +checksum = "fae8ee26b0371a29a77d2b2d6b3ae13aa81def6f9bf1b1b92a32d279a5e709b7" [[package]] name = "rustc-demangle" diff --git a/Cargo.toml b/Cargo.toml index af0ef0bf..2c832976 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,7 +113,7 @@ clap = { version = "4.1.8", features = ["derive", "wrap_help"], optional = true cpal = { version = "0.15", optional = true } jack = { version = "0.11.4", optional = true } midir = { version = "0.9.1", optional = true } -rtrb = { version = "0.2.2", optional = true } +rtrb = { version = "0.3.5", optional = true } # Used for the `vst3` feature vst3-sys = { git = "https://github.com/robbert-vdh/vst3-sys.git", branch = "fix/drop-box-from-raw", optional = true } From 565340dc4f9ad6c9b925c8ec2b393f9c476da46b Mon Sep 17 00:00:00 2001 From: unohee Date: Sun, 25 Oct 2026 14:08:12 +0900 Subject: [PATCH 4/4] fix(au): deliver main-thread SetProperty notifications before returning auval, and Logic's validation which runs it, sets MaximumFramesPerSlice on the main thread and checks its listener right after the call returns: "Changing max frames property did not fire off a property changed notification". Since e49332fc every notification waited for the 100 Hz run-loop timer. SetProperty now drains pending notifications before it returns when it runs on the main thread; other threads keep the timer. The wrapper borrow ends before the drain, so a listener that disposes the AU leaves nothing pointing into it. --- src/wrapper/au/wrapper.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/wrapper/au/wrapper.rs b/src/wrapper/au/wrapper.rs index cbe420b8..2c192fa6 100644 --- a/src/wrapper/au/wrapper.rs +++ b/src/wrapper/au/wrapper.rs @@ -1564,9 +1564,18 @@ impl Wrapper

{ in_data: *const c_void, in_data_size: au::UInt32, ) -> au::OSStatus { - let this = unsafe { Self::from_ptr(self_ptr) }; - let status = Self::set_property_impl(this, id, scope, element, in_data, in_data_size); - // The main-run-loop notifier delivers this after the setter returns. + let (status, notifications) = { + let this = unsafe { Self::from_ptr(self_ptr) }; + let status = Self::set_property_impl(this, id, scope, element, in_data, in_data_size); + (status, Arc::clone(&this.property_notifications)) + }; + // A host that sets a property on the main thread expects its listeners to have run by + // the time the call returns: auval (and Logic's validation, which runs it) fails with + // "Changing max frames property did not fire off a property changed notification" + // when delivery waits for the next timer tick. `drain` only calls out on the main + // thread; from any other thread the timer still delivers. The wrapper borrow ended + // above, so a listener that disposes the AU leaves nothing here pointing into it. + notifications.drain(); status }