From c40705790af0d1527e0517d44aa06d0940f0b998 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sun, 7 Jun 2026 22:19:24 +0900 Subject: [PATCH] fix(audio,macos): address PR #27 review findings - ios_voice_unit.rs: add producer_shutdown AtomicBool flag (macOS only). The macOS start path spawns a tokio producer task that holds clones of Arc>, Arc>, and the output gain/muted atomics, then loops on a 20 ms tokio interval. Without a shutdown signal the task runs forever on engine stop/restart and leaks all four Arcs every cycle. Drop now stores 'true' on the flag; the producer checks it at the top of each tick and exits, releasing its clones within at most one 20 ms tick. - macos/Runner/Release.entitlements: strengthen the existing justification comment for com.apple.security.network.server. Document the specific failure mode (tokio::net::UdpSocket::bind -> sandbox 'network-outbound deny' -> EPERM) and explain why network.client alone does not cover bind()-then-sendto. The entitlement is required, not over-broad. cargo check (host + aarch64-apple-darwin): clean cargo test -p chanora_audio --lib: 133 passed flutter test: 186 passed, 2 skipped dart analyze: clean --- .../macos/Runner/Release.entitlements | 9 ++++--- crates/chanora_audio/src/ios_voice_unit.rs | 26 ++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/chanora_flutter/macos/Runner/Release.entitlements b/apps/chanora_flutter/macos/Runner/Release.entitlements index 78feed2..f4614b6 100644 --- a/apps/chanora_flutter/macos/Runner/Release.entitlements +++ b/apps/chanora_flutter/macos/Runner/Release.entitlements @@ -4,9 +4,12 @@ + - including the ephemeral 0.0.0.0:0 that tsclientlib's + tokio::net::UdpSocket::bind() issues for outbound traffic - as a + server operation. Without this entitlement UdpSocket::bind fails + with EPERM (sandbox_init: 'network-outbound deny') and the TS3 + connect never starts. network.client alone is insufficient because + it gates connect()-style flows, not bind()-then-sendto. --> com.apple.security.app-sandbox com.apple.security.network.client diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index 6f707a7..522776d 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -551,6 +551,13 @@ pub struct IosVoiceUnit { // wrapper's own Drop calls AudioComponentInstanceDispose // after stop returns. unit: Option, + // macOS-only: producer task (spawned in start_macos) polls + // this on every 20 ms tick and exits when set. Without it + // the tokio task captures `Arc>` + + // `Arc>` and runs forever, leaking on every + // engine stop/restart cycle. + #[cfg(target_os = "macos")] + producer_shutdown: Arc, } impl IosVoiceUnit { @@ -847,6 +854,9 @@ impl IosVoiceUnit { // * iOS: keep the existing direct fill_buffer path — VPIO on iOS // requests 480-frame slices that ARE 20 ms aligned so the // cadence mismatch does not arise there. + #[cfg(target_os = "macos")] + let producer_shutdown = Arc::new(AtomicBool::new(false)); + #[cfg(target_os = "macos")] { // 100 ms capacity = 9600 stereo f32. Sized so that the 60 ms @@ -865,6 +875,8 @@ impl IosVoiceUnit { let output_gain_for_render = params.output_gain.clone(); let output_muted_for_render = params.output_muted.clone(); + let producer_shutdown_for_task = producer_shutdown.clone(); + tokio::spawn(async move { let mut pull_scratch: Vec = vec![0.0; PULL_SAMPLES]; let mut interval = @@ -874,6 +886,9 @@ impl IosVoiceUnit { ); loop { interval.tick().await; + if producer_shutdown_for_task.load(Ordering::Relaxed) { + break; + } match handler_for_producer.try_lock() { Ok(mut h) => { pull_scratch.fill(0.0); @@ -1193,7 +1208,11 @@ impl IosVoiceUnit { ), } - Ok(Self { unit: Some(unit) }) + Ok(Self { + unit: Some(unit), + #[cfg(target_os = "macos")] + producer_shutdown, + }) } /// Restart the audio unit after route change handling. @@ -1237,6 +1256,11 @@ impl IosVoiceUnit { impl Drop for IosVoiceUnit { fn drop(&mut self) { + // Signal the macOS producer task to exit on its next tick + // (up to 20 ms) so it releases its handler / ring clones. + #[cfg(target_os = "macos")] + self.producer_shutdown.store(true, Ordering::Relaxed); + // Stop the audio unit so the render callback no longer // fires. The coreaudio-rs wrapper's own Drop calls // AudioComponentInstanceDispose afterwards.