From b31d4dd34e01f602dbc7221ae5ae937f5e733aa3 Mon Sep 17 00:00:00 2001 From: Aakarsh Singh Date: Sat, 15 Aug 2026 22:57:23 +0700 Subject: [PATCH 1/2] fix(windows): don't instantiate capture filters while enumerating cameras Listing DirectShow devices called IMoniker::BindToObject on every device, which instantiates that camera's capture filter and opens the device through its KS driver. Those resources are not reclaimed when the filter is released, so every enumeration leaked handles and a thread. The desktop app enumerates continuously - spawn_devices_snapshot_emitter every 500ms-5s plus the frontend's 5s listVideoDevices poll - so the leak is unbounded for the life of the process. Measured on Windows 11 with 7 camera devices present (one physical, six virtual: Quest Link x4, SpoutCam, OBS Virtual Camera), calling cap_camera::list_cameras() once per second: before 851 -> 2184 handles, 21 -> 52 threads in ~30s after 356 -> 358 handles, 10 -> 10 threads, flat That is roughly +43 handles and +1 thread per enumeration, scaling with device count. Over an hour of normal app use it reaches thousands of threads and tens of thousands of handles, which shows up as progressive slowdown and then hard failures: STATUS_INVALID_HANDLE, access violations reading devenum.dll, and thread stack exhaustion. Fix: enumeration only needs the moniker's property bag (BindToStorage) for name, id and model id. Defer BindToObject until something actually needs the filter, pin or stream config - formats() or start_capturing(). The filter is cached in a OnceCell so behaviour is unchanged for real users of it. filter(), output_pin() and stream_config() now return Option because binding can fail for a device that is unplugged or in use; media_types() already returned Option and is unchanged for callers. Adds crates/camera-windows/examples/enumeration_leak.rs to reproduce and verify: run with 'mf', 'ds' or 'both' and watch handle/thread counts. Before this change 'ds' climbs and 'mf' is flat; after, both are flat. Co-Authored-By: Claude Fable 5 --- crates/camera-directshow/examples/cli.rs | 9 ++- crates/camera-directshow/src/lib.rs | 74 +++++++++++++------ .../examples/enumeration_leak.rs | 44 +++++++++++ 3 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 crates/camera-windows/examples/enumeration_leak.rs diff --git a/crates/camera-directshow/examples/cli.rs b/crates/camera-directshow/examples/cli.rs index 00161139dd8..0cc75850c30 100644 --- a/crates/camera-directshow/examples/cli.rs +++ b/crates/camera-directshow/examples/cli.rs @@ -42,7 +42,12 @@ mod windows { let device = selected.0; - let video_control = device.output_pin().cast::().ok(); + let output_pin = device + .output_pin() + .expect("failed to bind capture filter for selected device") + .clone(); + + let video_control = output_pin.cast::().ok(); let formats = device .media_types() @@ -65,7 +70,7 @@ mod windows { if let Some(video_control) = &video_control { let time_per_frame_list = video_control.time_per_frame_list( - device.output_pin(), + &output_pin, i as i32, SIZE { cx: width, diff --git a/crates/camera-directshow/src/lib.rs b/crates/camera-directshow/src/lib.rs index 9563e20b58b..0c376a283f3 100644 --- a/crates/camera-directshow/src/lib.rs +++ b/crates/camera-directshow/src/lib.rs @@ -2,7 +2,7 @@ #![allow(non_snake_case)] use std::{ - cell::RefCell, + cell::{OnceCell, RefCell}, ffi::{OsString, c_void}, mem::ManuallyDrop, ops::Deref, @@ -359,32 +359,57 @@ impl Iterator for VideoInputDeviceIterator { } } +/// The parts of a device that require actually instantiating the capture +/// filter. Binding these opens the camera through its KS driver, which costs a +/// thread and dozens of kernel handles that are not reclaimed on release - so +/// it is deferred until something genuinely needs a filter, pin or format. #[derive(Clone)] -pub struct VideoInputDevice { - moniker: IMoniker, - prop_bag: IPropertyBag, +struct BoundFilter { filter: IBaseFilter, output_pin: IPin, stream_config: IAMStreamConfig, } +#[derive(Clone)] +pub struct VideoInputDevice { + moniker: IMoniker, + prop_bag: IPropertyBag, + bound: OnceCell, +} + impl VideoInputDevice { fn new(moniker: IMoniker) -> windows_core::Result { + // Only BindToStorage here: the property bag is enough for name, id and + // model id, which is all plain enumeration ever reads. BindToObject is + // deliberately not called - see `bound()`. let prop_bag: IPropertyBag = unsafe { moniker.BindToStorage(None, None) }?; - let filter: IBaseFilter = unsafe { moniker.BindToObject(None, None) }?; + Ok(Self { + moniker, + prop_bag, + bound: OnceCell::new(), + }) + } + + /// Instantiates the capture filter on first use and caches it. + fn bound(&self) -> windows_core::Result<&BoundFilter> { + if let Some(bound) = self.bound.get() { + return Ok(bound); + } + + let filter: IBaseFilter = unsafe { self.moniker.BindToObject(None, None) }?; let output_pin = filter .get_pin(PINDIR_OUTPUT, PIN_CATEGORY_CAPTURE, GUID::zeroed()) .ok_or(E_FAIL)?; let stream_config = output_pin.cast::().ok().ok_or(E_FAIL)?; - Ok(Self { - moniker, - prop_bag, + let _ = self.bound.set(BoundFilter { filter, output_pin, stream_config, - }) + }); + + self.bound.get().ok_or_else(|| E_FAIL.into()) } pub fn name(&self) -> Option { @@ -410,22 +435,26 @@ impl VideoInputDevice { } pub fn media_types(&self) -> Option> { - self.stream_config + self.bound() + .ok()? + .stream_config .media_types() .map(|inner| VideoMediaTypesIterator { inner }) .ok() } - pub fn filter(&self) -> &IBaseFilter { - &self.filter + /// Binds the capture filter if it isn't bound yet; `None` if the device + /// can't be instantiated (unplugged, in use, driver error). + pub fn filter(&self) -> Option<&IBaseFilter> { + Some(&self.bound().ok()?.filter) } - pub fn stream_config(&self) -> &IAMStreamConfig { - &self.stream_config + pub fn stream_config(&self) -> Option<&IAMStreamConfig> { + Some(&self.bound().ok()?.stream_config) } - pub fn output_pin(&self) -> &IPin { - &self.output_pin + pub fn output_pin(&self) -> Option<&IPin> { + Some(&self.bound().ok()?.output_pin) } pub fn start_capturing( @@ -433,8 +462,11 @@ impl VideoInputDevice { format: &AMMediaType, callback: SinkCallback, ) -> Result { + let bound = self.bound().map_err(StartCapturingError::Other)?.clone(); + unsafe { - self.stream_config + bound + .stream_config .SetFormat(&**format) .map_err(StartCapturingError::Other)?; @@ -460,7 +492,7 @@ impl VideoInputDevice { .SetFiltergraph(&graph_builder) .map_err(StartCapturingError::ConfigureGraph)?; graph_builder - .AddFilter(&self.filter, None) + .AddFilter(&bound.filter, None) .map_err(StartCapturingError::ConfigureGraph)?; let sink_filter: IBaseFilter = sink_filter @@ -476,14 +508,14 @@ impl VideoInputDevice { .FindInterface( Some(&PIN_CATEGORY_CAPTURE), Some(&MEDIATYPE_Video), - &self.filter, + &bound.filter, &IAMStreamConfig::IID, &mut stream_config, ) .map_err(StartCapturingError::ConfigureGraph)?; graph_builder - .Connect(&self.output_pin, &input_sink_pin) + .Connect(&bound.output_pin, &input_sink_pin) .map_err(StartCapturingError::ConfigureGraph)?; media_control.Run().map_err(StartCapturingError::Run)?; @@ -491,7 +523,7 @@ impl VideoInputDevice { Ok(CaptureHandle { media_control, graph_builder, - output_capture_pin: self.output_pin, + output_capture_pin: bound.output_pin.clone(), input_sink_pin, }) } diff --git a/crates/camera-windows/examples/enumeration_leak.rs b/crates/camera-windows/examples/enumeration_leak.rs new file mode 100644 index 00000000000..4087159a860 --- /dev/null +++ b/crates/camera-windows/examples/enumeration_leak.rs @@ -0,0 +1,44 @@ +//! Splits the camera-enumeration leak between the Media Foundation and +//! DirectShow halves of `get_devices()`. +//! +//! Usage: enumeration_leak.exe [mf|ds|both] [iterations] +//! +//! Sample handles/threads from outside while it runs; whichever mode grows is +//! the leaking half. + +fn main() { + let mode = std::env::args().nth(1).unwrap_or_else(|| "both".into()); + let iterations: usize = std::env::args() + .nth(2) + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + + println!("pid {} mode={mode} iterations={iterations}", std::process::id()); + + let _ = cap_camera_directshow::initialize_directshow(); + let _ = cap_camera_mediafoundation::initialize_mediafoundation(); + + for i in 1..=iterations { + let n = match mode.as_str() { + // MF only: enumerate + activate IMFMediaSource per device, + // exactly as DeviceSourcesIterator does inside get_devices(). + "mf" => cap_camera_mediafoundation::DeviceSourcesIterator::new() + .map(|it| it.count()) + .unwrap_or(0), + + // DirectShow only: CoCreateInstance(CLSID_SystemDeviceEnum) plus + // BindToStorage/BindToObject per device, which instantiates each + // capture filter. + "ds" => cap_camera_directshow::VideoInputDeviceIterator::new() + .map(|it| it.count()) + .unwrap_or(0), + + _ => cap_camera_windows::get_devices().map(|d| d.len()).unwrap_or(0), + }; + + println!("{i}: {n} device(s)"); + std::thread::sleep(std::time::Duration::from_millis(1000)); + } + + println!("done"); +} From 42cf36e7c1a710b33a47796da23aea186c77ab25 Mon Sep 17 00:00:00 2001 From: Aakarsh Singh Date: Sat, 15 Aug 2026 23:24:58 +0700 Subject: [PATCH 2/2] chore: drop redundant comments per AGENTS.md comment policy Removes narration that restated the code it sat above (the example's match arms, and doc comments on bound()/filter()). Keeps only the BoundFilter note, which records the platform behaviour the fix exists for. Co-Authored-By: Claude Fable 5 --- crates/camera-directshow/src/lib.rs | 7 +------ crates/camera-windows/examples/enumeration_leak.rs | 5 ----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/crates/camera-directshow/src/lib.rs b/crates/camera-directshow/src/lib.rs index 0c376a283f3..e7ddbf6ff40 100644 --- a/crates/camera-directshow/src/lib.rs +++ b/crates/camera-directshow/src/lib.rs @@ -379,9 +379,7 @@ pub struct VideoInputDevice { impl VideoInputDevice { fn new(moniker: IMoniker) -> windows_core::Result { - // Only BindToStorage here: the property bag is enough for name, id and - // model id, which is all plain enumeration ever reads. BindToObject is - // deliberately not called - see `bound()`. + // BindToObject is deliberately not called here; see `BoundFilter`. let prop_bag: IPropertyBag = unsafe { moniker.BindToStorage(None, None) }?; Ok(Self { @@ -391,7 +389,6 @@ impl VideoInputDevice { }) } - /// Instantiates the capture filter on first use and caches it. fn bound(&self) -> windows_core::Result<&BoundFilter> { if let Some(bound) = self.bound.get() { return Ok(bound); @@ -443,8 +440,6 @@ impl VideoInputDevice { .ok() } - /// Binds the capture filter if it isn't bound yet; `None` if the device - /// can't be instantiated (unplugged, in use, driver error). pub fn filter(&self) -> Option<&IBaseFilter> { Some(&self.bound().ok()?.filter) } diff --git a/crates/camera-windows/examples/enumeration_leak.rs b/crates/camera-windows/examples/enumeration_leak.rs index 4087159a860..5051b0c8e15 100644 --- a/crates/camera-windows/examples/enumeration_leak.rs +++ b/crates/camera-windows/examples/enumeration_leak.rs @@ -20,15 +20,10 @@ fn main() { for i in 1..=iterations { let n = match mode.as_str() { - // MF only: enumerate + activate IMFMediaSource per device, - // exactly as DeviceSourcesIterator does inside get_devices(). "mf" => cap_camera_mediafoundation::DeviceSourcesIterator::new() .map(|it| it.count()) .unwrap_or(0), - // DirectShow only: CoCreateInstance(CLSID_SystemDeviceEnum) plus - // BindToStorage/BindToObject per device, which instantiates each - // capture filter. "ds" => cap_camera_directshow::VideoInputDeviceIterator::new() .map(|it| it.count()) .unwrap_or(0),