From a9a6b9d298b094f9e840c24ccdacf81b109ed895 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:26:18 +0100 Subject: [PATCH 1/5] fix(desktop): cache ScreenCaptureKit display validation in permission checks --- apps/desktop/src-tauri/src/permissions.rs | 113 +++++++++++++++++----- 1 file changed, 87 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src-tauri/src/permissions.rs b/apps/desktop/src-tauri/src/permissions.rs index d8475d42b9e..f2bbe78aad0 100644 --- a/apps/desktop/src-tauri/src/permissions.rs +++ b/apps/desktop/src-tauri/src/permissions.rs @@ -23,6 +23,13 @@ static MACOS_DOCK_VISIBILITY_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); static MACOS_PENDING_PANEL_WINDOWS: AtomicU32 = AtomicU32::new(0); #[cfg(target_os = "macos")] static MACOS_SCK_PERMISSION_MISMATCH_LOGGED: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "macos")] +static MACOS_SCK_DISPLAYS_VALIDATED: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "macos")] +static MACOS_SCK_LAST_VALIDATION_ATTEMPT: std::sync::Mutex> = + std::sync::Mutex::new(None); +#[cfg(target_os = "macos")] +const MACOS_SCK_VALIDATION_RETRY_INTERVAL: Duration = Duration::from_secs(5); #[cfg(target_os = "macos")] pub(crate) struct MacosPanelWindowActivationGuard { @@ -283,42 +290,84 @@ fn macos_permission_status(permission: &OSPermission, initial_check: bool) -> OS } } +// The SCShareableContent snapshot behind this validation materialises every +// window/app/display on the system (~1MB+ of ObjC objects per call). Polling +// callers (devices snapshot emitter every 5s, permission UIs down to 250ms) +// used to re-run it for the whole process lifetime, leaking the graph on +// pool-less tokio threads at ~15MB/min until macOS exhausted swap (issue +// #2023, the 82GB incident). A successful validation is cached for the rest +// of the process: runtime revocation is caught by the cheap CGPreflight gate, +// and macOS relaunches the app on screen-recording permission changes anyway. +// Failed validations retry at most once per MACOS_SCK_VALIDATION_RETRY_INTERVAL. #[cfg(target_os = "macos")] fn macos_screen_recording_available() -> bool { if !scap_screencapturekit::has_permission() { return false; } - let future = async { - match sc::ShareableContent::current().await { - Ok(content) => { - let display_count = content.displays().len(); - if display_count == 0 - && !MACOS_SCK_PERMISSION_MISMATCH_LOGGED.swap(true, Ordering::AcqRel) - { + if MACOS_SCK_DISPLAYS_VALIDATED.load(Ordering::Acquire) { + return true; + } + + // block_in_place covers the mutex acquisition too: waiters serialised + // behind an in-flight validation would otherwise park a tokio worker + // without telling the runtime. + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(macos_validate_sck_displays) + } else { + macos_validate_sck_displays() + } +} + +#[cfg(target_os = "macos")] +fn macos_validate_sck_displays() -> bool { + // Serialise validators: concurrent callers during the first startup check + // must wait for the in-flight validation rather than tripping the backoff + // and transiently reporting a granted permission as denied. + let mut last_attempt = MACOS_SCK_LAST_VALIDATION_ATTEMPT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if MACOS_SCK_DISPLAYS_VALIDATED.load(Ordering::Acquire) { + return true; + } + if let Some(attempted_at) = *last_attempt + && attempted_at.elapsed() < MACOS_SCK_VALIDATION_RETRY_INTERVAL + { + return false; + } + *last_attempt = Some(std::time::Instant::now()); + + let validated = objc2::rc::autoreleasepool(|_| { + tauri::async_runtime::block_on(async { + match sc::ShareableContent::current().await { + Ok(content) => { + let display_count = content.displays().len(); + if display_count == 0 + && !MACOS_SCK_PERMISSION_MISMATCH_LOGGED.swap(true, Ordering::AcqRel) + { + tracing::debug!( + window_count = content.windows().len(), + application_count = content.apps().len(), + "ScreenCaptureKit returned no displays despite CoreGraphics screen-recording permission" + ); + } + display_count > 0 + } + Err(error) => { tracing::debug!( - window_count = content.windows().len(), - application_count = content.apps().len(), - "ScreenCaptureKit returned no displays despite CoreGraphics screen-recording permission" + error = %error, + "ScreenCaptureKit shareable content unavailable during permission check" ); + false } - display_count > 0 - } - Err(error) => { - tracing::debug!( - error = %error, - "ScreenCaptureKit shareable content unavailable during permission check" - ); - false } - } - }; + }) + }); - if tokio::runtime::Handle::try_current().is_ok() { - tokio::task::block_in_place(|| tauri::async_runtime::block_on(future)) - } else { - tauri::async_runtime::block_on(future) + if validated { + MACOS_SCK_DISPLAYS_VALIDATED.store(true, Ordering::Release); } + validated } #[cfg(target_os = "macos")] @@ -374,6 +423,15 @@ where #[cfg(target_os = "macos")] async fn macos_wait_for_permission_update(permission: &OSPermission) -> bool { + // The user just interacted with the permission prompt; drop the SCK + // validation backoff so this poll loop sees fresh answers instead of a + // stale negative from up to 5s ago. + if matches!(permission, OSPermission::ScreenRecording) { + *MACOS_SCK_LAST_VALIDATION_ATTEMPT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } + macos_wait_for_permission_update_with( || macos_permission_status(permission, false).permitted(), || tokio::time::sleep(Duration::from_millis(200)), @@ -497,9 +555,12 @@ impl OSPermissionsCheck { #[tauri::command(async)] #[specta::specta] pub fn do_permissions_check(_initial_check: bool) -> OSPermissionsCheck { + // Pool-wrapped because this runs on tokio/tauri worker threads (which have + // no ambient NSAutoreleasePool) from polling callers; without it every + // autoreleased AVFoundation/AppKit temporary leaks for the process lifetime. #[cfg(target_os = "macos")] { - OSPermissionsCheck { + objc2::rc::autoreleasepool(|_| OSPermissionsCheck { screen_recording: macos_permission_status( &OSPermission::ScreenRecording, _initial_check, @@ -507,7 +568,7 @@ pub fn do_permissions_check(_initial_check: bool) -> OSPermissionsCheck { microphone: macos_permission_status(&OSPermission::Microphone, _initial_check), camera: macos_permission_status(&OSPermission::Camera, _initial_check), accessibility: macos_permission_status(&OSPermission::Accessibility, _initial_check), - } + }) } #[cfg(not(target_os = "macos"))] From 012d202652c93698440400461ea289dd57620ede Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:26:18 +0100 Subject: [PATCH 2/5] fix: drain autoreleased objects in macOS camera enumeration --- crates/camera-avfoundation/src/lib.rs | 49 +++++++++++++++------------ crates/camera/src/macos.rs | 25 ++++++++------ 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/crates/camera-avfoundation/src/lib.rs b/crates/camera-avfoundation/src/lib.rs index 00121793fb9..dcbc84a05b9 100644 --- a/crates/camera-avfoundation/src/lib.rs +++ b/crates/camera-avfoundation/src/lib.rs @@ -11,36 +11,41 @@ use std::{ }; use tracing::warn; +// Pool-wrapped: device polling calls this every few seconds from tokio threads +// that have no ambient NSAutoreleasePool, so the discovery session's +// autoreleased temporaries would otherwise leak for the process lifetime. pub fn list_video_devices() -> arc::R> { - let mut device_types = vec![av::CaptureDeviceType::built_in_wide_angle_camera()]; + objc::ar_pool(|| { + let mut device_types = vec![av::CaptureDeviceType::built_in_wide_angle_camera()]; - if api::macos_available("13.0") - && let Some(typ) = unsafe { av::CaptureDeviceType::desk_view_camera() } - { - device_types.push(typ); - } - - if api::macos_available("14.0") { - if let Some(typ) = unsafe { av::CaptureDeviceType::external() } { + if api::macos_available("13.0") + && let Some(typ) = unsafe { av::CaptureDeviceType::desk_view_camera() } + { device_types.push(typ); } - if let Some(typ) = unsafe { av::CaptureDeviceType::continuity_camera() } { - device_types.push(typ); + + if api::macos_available("14.0") { + if let Some(typ) = unsafe { av::CaptureDeviceType::external() } { + device_types.push(typ); + } + if let Some(typ) = unsafe { av::CaptureDeviceType::continuity_camera() } { + device_types.push(typ); + } + } else { + device_types.push(av::CaptureDeviceType::external_unknown()); } - } else { - device_types.push(av::CaptureDeviceType::external_unknown()); - } - let device_types = ns::Array::from_slice(&device_types); + let device_types = ns::Array::from_slice(&device_types); - let video_discovery_session = - av::CaptureDeviceDiscoverySession::with_device_types_media_and_pos( - &device_types, - Some(av::MediaType::video()), - av::CaptureDevicePos::Unspecified, - ); + let video_discovery_session = + av::CaptureDeviceDiscoverySession::with_device_types_media_and_pos( + &device_types, + Some(av::MediaType::video()), + av::CaptureDevicePos::Unspecified, + ); - video_discovery_session.devices() + video_discovery_session.devices() + }) } #[derive(Clone, Copy)] diff --git a/crates/camera/src/macos.rs b/crates/camera/src/macos.rs index be724e437b5..9fb729474b5 100644 --- a/crates/camera/src/macos.rs +++ b/crates/camera/src/macos.rs @@ -5,16 +5,21 @@ use cidre::*; use objc2_av_foundation::*; pub(super) fn list_cameras_impl() -> impl Iterator { - let devices = cap_camera_avfoundation::list_video_devices(); - devices - .iter() - .map(|d| CameraInfo { - device_id: d.unique_id().to_string(), - model_id: ModelID::from_avfoundation(d), - display_name: d.localized_name().to_string(), - }) - .collect::>() - .into_iter() + // ar_pool: called from pool-less tokio threads on a polling cadence; the + // unique_id/localized_name accessors autorelease NSStrings that would + // otherwise accumulate for the process lifetime. + objc::ar_pool(|| { + let devices = cap_camera_avfoundation::list_video_devices(); + devices + .iter() + .map(|d| CameraInfo { + device_id: d.unique_id().to_string(), + model_id: ModelID::from_avfoundation(d), + display_name: d.localized_name().to_string(), + }) + .collect::>() + }) + .into_iter() } impl CameraInfo { From d0ac07faacd89f794ff5a8273c68fa2cfa712644 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:26:18 +0100 Subject: [PATCH 3/5] fix: release NSScreen lookup key and pool display name and scale queries --- crates/scap-targets/src/platform/macos.rs | 45 ++++++++++++++--------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/crates/scap-targets/src/platform/macos.rs b/crates/scap-targets/src/platform/macos.rs index c3a3f5b2a30..a9a8183dd9d 100644 --- a/crates/scap-targets/src/platform/macos.rs +++ b/crates/scap-targets/src/platform/macos.rs @@ -114,7 +114,11 @@ impl DisplayImpl { } pub fn scale(&self) -> Option { - Some(unsafe { NSScreen::backingScaleFactor(self.as_ns_screen()?) }) + // ar pool: reached from pool-less tokio threads on a polling cadence + // (display lists, cursor info); drains the NSScreen temporaries. + objc::rc::autoreleasepool(|| { + Some(unsafe { NSScreen::backingScaleFactor(self.as_ns_screen()?) }) + }) } pub fn refresh_rate(&self) -> f64 { @@ -136,19 +140,21 @@ impl DisplayImpl { use objc::{msg_send, *}; use std::ffi::CStr; - unsafe { - if let Some(ns_screen) = self.as_ns_screen() { - let name: id = msg_send![ns_screen, localizedName]; - if !name.is_null() { - let name = CStr::from_ptr(NSString::UTF8String(name)) - .to_string_lossy() - .to_string(); - return Some(name); + objc::rc::autoreleasepool(|| { + unsafe { + if let Some(ns_screen) = self.as_ns_screen() { + let name: id = msg_send![ns_screen, localizedName]; + if !name.is_null() { + let name = CStr::from_ptr(NSString::UTF8String(name)) + .to_string_lossy() + .to_string(); + return Some(name); + } } } - } - None + None + }) } fn as_ns_screen(&self) -> Option<*mut objc::runtime::Object> { @@ -160,25 +166,28 @@ impl DisplayImpl { unsafe { let screens = NSScreen::screens(nil); let screen_count = NSArray::count(screens); + // init_str returns a +1 NSString; without the explicit release it + // leaked one key string per screen on every lookup. + let screen_number_key = NSString::alloc(nil).init_str("NSScreenNumber"); + let mut found = None; for i in 0..screen_count { let screen: *mut objc::runtime::Object = screens.objectAtIndex(i); let device_description = NSScreen::deviceDescription(screen); - let num = NSDictionary::valueForKey_( - device_description, - NSString::alloc(nil).init_str("NSScreenNumber"), - ) as id; + let num = NSDictionary::valueForKey_(device_description, screen_number_key) as id; let num_value: u32 = msg_send![num, unsignedIntValue]; if num_value == self.0.id { - return Some(screen); + found = Some(screen); + break; } } - } - None + let _: () = msg_send![screen_number_key, release]; + found + } } } From 0f0aec444d02bec1f8c381135d1de5c81d9635c8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:26:22 +0100 Subject: [PATCH 4/5] fix(recording): release CoreAudio device-name strings and unpin orphaned mic feed locks --- crates/recording/src/feeds/microphone.rs | 56 ++++++++++++++++++++-- crates/recording/src/sources/microphone.rs | 11 +++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs index 0d9709381df..6144e50df09 100644 --- a/crates/recording/src/feeds/microphone.rs +++ b/crates/recording/src/feeds/microphone.rs @@ -510,7 +510,7 @@ fn list_input_device_names() -> Vec { let default_id = macos_helpers::get_default_device_id(true); if let Some(name) = default_id - .and_then(|id| macos_helpers::get_device_name(id).ok()) + .and_then(macos_device_name_released) .filter(|name| !name.is_empty()) { names.insert(name, ()); @@ -521,7 +521,7 @@ fn list_input_device_names() -> Vec { for device_id in device_ids { if macos_helpers::get_audio_device_supports_scope(device_id, Scope::Input) .unwrap_or(false) - && let Ok(name) = macos_helpers::get_device_name(device_id) + && let Some(name) = macos_device_name_released(device_id) && !name.is_empty() { names.entry(name).or_insert(()); @@ -536,6 +536,44 @@ fn list_input_device_names() -> Vec { names.into_keys().collect() } +// coreaudio-rs's get_device_name never releases the CFString it copies out of +// AudioObjectGetPropertyData (twice on the CFStringGetCStringPtr fallback +// path), leaking one or two strings per device per call; the devices snapshot +// emitter calls this every 5s for the process lifetime. This variant hands the +// +1 ref to core-foundation, which releases it on drop. +#[cfg(target_os = "macos")] +fn macos_device_name_released(device_id: coreaudio::sys::AudioDeviceID) -> Option { + use core_foundation::{ + base::TCFType, + string::{CFString, CFStringRef}, + }; + use coreaudio::sys; + + let property_address = sys::AudioObjectPropertyAddress { + mSelector: sys::kAudioDevicePropertyDeviceNameCFString, + mScope: sys::kAudioDevicePropertyScopeOutput, + mElement: sys::kAudioObjectPropertyElementMaster, + }; + + let mut device_name: CFStringRef = std::ptr::null(); + let mut data_size = std::mem::size_of::() as u32; + let status = unsafe { + sys::AudioObjectGetPropertyData( + device_id, + &property_address, + 0, + std::ptr::null(), + &mut data_size, + (&mut device_name) as *mut CFStringRef as *mut _, + ) + }; + if status != sys::kAudioHardwareNoError as i32 || device_name.is_null() { + return None; + } + + Some(unsafe { CFString::wrap_under_create_rule(device_name) }.to_string()) +} + #[cfg(not(target_os = "macos"))] fn list_input_device_names() -> Vec { let host = cpal::default_host(); @@ -1333,7 +1371,19 @@ impl Message for MicrophoneFeed { async fn handle(&mut self, _: RemoveInput, _: &mut Context) -> Self::Reply { trace!("MicrophoneFeed.RemoveInput"); - let state = self.state.try_as_open()?; + // Callers routinely discard this reply; a locked feed silently keeps + // the cpal stream (and its per-callback allocations) alive, so make + // that path visible in logs. debug-level because deselecting the mic + // during a studio recording hits this legitimately. + let state = match self.state.try_as_open() { + Ok(state) => state, + Err(err) => { + debug!( + "Microphone feed RemoveInput deferred: feed is locked by an active consumer" + ); + return Err(err); + } + }; state.connecting = None; diff --git a/crates/recording/src/sources/microphone.rs b/crates/recording/src/sources/microphone.rs index a479b19dac3..c81d3cc9a59 100644 --- a/crates/recording/src/sources/microphone.rs +++ b/crates/recording/src/sources/microphone.rs @@ -46,6 +46,17 @@ pub struct Microphone { cancel: CancellationToken, } +// The detached bridge tasks hold clones of the feed lock and only exit via +// this token. If the pipeline is torn down without stop() (wedged muxer, +// error paths), a live-but-orphaned lock would keep the feed in State::Locked +// forever, making RemoveInput a silent no-op and pinning the cpal stream for +// the rest of the process. +impl Drop for Microphone { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + #[derive(Debug, Error)] pub enum MicrophoneSourceError { #[error("microphone actor not running")] From a2f4eadd1c6e47c2633772cc4a21f0eca581ea17 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:26:22 +0100 Subject: [PATCH 5/5] fix(desktop): cap mic volume meter window against frozen capture clocks --- apps/desktop/src-tauri/src/audio_meter.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/desktop/src-tauri/src/audio_meter.rs b/apps/desktop/src-tauri/src/audio_meter.rs index c383f375137..8070eb9e9b2 100644 --- a/apps/desktop/src-tauri/src/audio_meter.rs +++ b/apps/desktop/src-tauri/src/audio_meter.rs @@ -56,6 +56,22 @@ impl VolumeMeter { self.maxes.push(time, value); self.times.push_back(time); + // The duration-window eviction below stalls permanently when a + // device's capture clock is frozen or non-monotonic (duration_since + // yields Zero forever or None), and this meter lives for the whole + // process; without a hard cap it grows one entry per audio callback + // until quit. + const MAX_WINDOW_ENTRIES: usize = 512; + while self.times.len() > MAX_WINDOW_ENTRIES { + // A frozen clock produces duplicate keys that share one `maxes` + // entry; only drop that entry once no duplicate remains queued. + if let Some(front) = self.times.pop_front() + && !self.times.contains(&front) + { + self.maxes.remove(&front); + } + } + while let Some(time) = self .times .back()