Skip to content

fix(windows): stop camera enumeration leaking device resources and pause idle polling in the tray - #2143

Open
Hona wants to merge 10 commits into
CapSoftware:mainfrom
Hona:idle-device-polling
Open

fix(windows): stop camera enumeration leaking device resources and pause idle polling in the tray#2143
Hona wants to merge 10 commits into
CapSoftware:mainfrom
Hona:idle-device-polling

Conversation

@Hona

@Hona Hona commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #2132. Supersedes #2129 — full credit to @aacarcrash for first isolating the DirectShow BindToObject leak and its measurement methodology; this PR applies the same deferred-binding idea as one consistent design across both camera backends, fixes the Media Foundation half (which #2129 did not cover and which leaks memory and handles on its own), and stops the polling that drives the leak while Cap idles in the tray. With all three commits, repeated camera enumeration is completely flat and a hidden Cap does no device work at all.

What #2132 measured

Cap 0.5.9 idle in the tray on Windows: 0.52–1.21 CPU cores, and after 4h16m: 3,345 threads, 223,563 handles, 3.43 GB private memory (~11 MB/min) — roughly one leaked thread per 4.64 s, matching the device-poll cadence.

Re-verified on the shipped 0.5.9 binary for this PR: the leak rate is identical whether the window is visible or hidden in the tray (0.38 vs 0.37 threads/s, ~29 handles/s both) — hiding to tray changes nothing about polling today.

Root causes (four, stacked)

  1. DirectShow (cap-camera-directshow): VideoInputDevice::new() called IMoniker::BindToObject for every device during plain enumeration, instantiating each capture filter through its KS driver. ~1 thread + ~33 handles leaked per enumeration on this machine (3 DS devices) — the half fix(windows): don't instantiate capture filters while enumerating cameras (leaks ~43 handles + 1 thread per poll) #2129 diagnosed.
  2. Media Foundation (cap-camera-mediafoundation): DeviceSourcesIterator::next() called ActivateObject::<IMFMediaSource>() per device per enumeration, opening the physical device; sources were never Shutdown. The IMFActivate array from MFEnumDeviceSources was never released nor CoTaskMemFree'd (the crate had no Drop impls at all), and GetAllocatedString buffers in name()/id() were never freed. ~31 handles + ~0.69 MB leaked per enumeration (2 MF devices).
  3. A no-op probe re-opened paired devices (cap-camera-windows): the MF/DS dedup in get_devices() called mf_device.formats() per pair per enumeration to guard a branch that pushes a clone of the same MF entry and swap_removes the original — the list is provably identical whether the branch runs or not, and the DS twin is dropped either way. The probe cost a full device activation per pair per poll and decided nothing.
  4. The polling never stops when Cap is hidden: spawn_devices_snapshot_emitter enumerates every 5 s forever (its only consumer is the DevicesUpdated UI-picker event), and the frontend's 5 s refetchIntervals cannot pause because Tauri on Windows never flips document.visibilityState for hidden windows (window.hide() doesn't call CoreWebView2Controller.SetIsVisible(false) since the #9415 revert — see [bug] visibilitychange event not fired tauri-apps/tauri#9524, [bug] document.visibilityState broken tauri-apps/tauri#10592), so TanStack Query's built-in pause-while-hidden never engages.

The fix

One shape for both camera backends — enumeration constructs devices from cheap identity data only, and the expensive OS object is deferred behind a OnceLock until formats or capture genuinely need it, then cached:

  • cap-camera-directshow: VideoInputDevice holds moniker + prop_bag + OnceLock<BoundFilter> (filter, capture pin, stream config). new() only does BindToStorage. media_types() and start_capturing() bind lazily; output_pin() returns windows_core::Result<&IPin>; the unused filter()/stream_config() accessors are gone.
  • cap-camera-mediafoundation: Device holds activate + OnceLock<IMFMediaSource>. Drop for DeviceSourcesIterator releases each IMFActivate and frees the array; name()/id() free their GetAllocatedString buffers; the Deref/DerefMut → IMFMediaSource impls are gone (nothing used them, and a deref that lazily opens a camera would be a hidden side effect).
  • cap-camera-windows: the dedup keeps the DirectShow twin as a stored fallback on the merged Media Foundation entry instead of probing MF formats per poll. Twins pair one-to-one (each DS device attaches to the first MF entry that has no fallback yet, so identical devices don't pile onto one entry). The MF-vs-DS decision moves to use time: formats() tries MF first, and whenever MF fails to activate or reports nothing it shuts the failed source down — with or without a DS twin, so repeated format requests never accumulate half-open sources and exclusive drivers accept the DS bind that follows — then returns the DS twin's formats; start_capturing accepts DS formats on a merged device. A format that matches no backend available on the device now returns StartCapturingError::FormatMismatch instead of hitting the old todo!() panic. This restores the original intent of the old probe (prefer DS when MF is unusable — the shipped branch was a no-op) without opening any device during enumeration, so capture cards and virtual cameras that enumerate under MF but only work through DS keep working.
  • apps/desktop (Rust): the snapshot emitter skips enumeration while no webview window is visible — same guard style as the existing sleep check. Recording is unaffected: the in-progress window is visible during recording, and disconnect detection is owned by the dedicated camera/mic watchers either way. hide_main_window also emits main-window-hidden (only after the hide actually succeeded), and every main-window hide path routes through it: close-to-tray, target picker (both entry points), Upgrade/ModeSelect/Onboarding window swaps, instant-recording open, recording-window teardown when Settings or an editor takes over, and the non-Windows recording-start Close behaviour. The Windows recording-start path minimizes rather than hides (pre-existing DirectComposition workaround) and keeps native minimize semantics.
  • apps/desktop (TS): the main window bridges its real hide/focus signals into TanStack's focusManager — pause on main-window-hidden; on focus it hands control back to TanStack's own visibilitychange detection (setFocused(undefined)) so platforms where that signal works, like macOS minimize, keep pausing natively. Frontend-initiated hides (e.g. starting an Instant recording hides the main window from its own JS) don't go through the Rust helper and can happen after an unrelated blur already fired (shell.open steals focus while the window is still visible), so they pause explicitly through a hideCurrentWindow() util that all main-window self-hide sites use. The bridge additionally re-checks isVisible() on blur as a safety net for any future hide path that bypasses both helpers, with a focus-generation guard so a stale visibility result can't pause a window that regained focus mid-check. Pauses engage only after a hide succeeds. Fail-open in every direction: a missed hide site just means today's behavior, and push-driven DevicesUpdated refetches still land while paused.

Side fix: a device that fails to bind/activate no longer disappears (DS) or truncates the rest of the device list (MF) during enumeration — it stays listed and the error surfaces at formats()/start_capturing(), where selection already shows an error dialog.

Measurements

i7-12800H, 20 logical cores, 3 DirectShow / 2 Media Foundation devices, 60 enumerations at 1/s, sampled externally:

run threads handles private MB
mf before +1 +1,842 +41.5
ds before +44 +1,982 +9.2
both before +53 +3,858 +49.0
mf after 0 0 0
ds after 0 0 0
both after 0 0 0

Per-enumeration wall time stays flat at 3–9 ms across the run after the fix.

Why idle CPU reached a full core: hammering the old get_devices() for 4 minutes reached 1,259 threads / 92,884 handles / 1.16 GB, and CPU for the identical workload doubled from 0.20 to 0.42 cores as the leaked threads and handles accumulated. The 0.52–1.21 cores reported in #2132 after 4h16m (3,345 threads) sits on the same curve.

Repro

Included example (crates/camera-windows/examples/enumeration_leak.rs):

cargo run --release -p cap-camera-windows --example enumeration_leak -- both 60

mf / ds isolate the two halves. Watch threads/handles/private bytes externally (Process Explorer, or Get-Process -Id <pid> in a loop); the printed per-enumeration wall time also degrades as the leak accumulates on unfixed builds.

Behavior notes

  • A device that appears in both backends now transparently falls back to DirectShow when its Media Foundation registration is unusable, instead of showing a broken picker entry. DS-only ghost registrations (dead virtual cameras with no working bind at all) still appear and fail at selection — inherent to not instantiating filters during enumeration; the old MF path had the worse bug of truncating the list.
  • An MF source that turns out to be unusable is now fully Shutdown when the DS fallback takes over. Sources that are genuinely used (successful formats read or capture) are still released without Shutdown on drop; doing that safely needs ownership through the capture handle, and stop_capturing() doesn't await the engine's stopped event, so a drop-time ShutdownObject would race live teardown. Bounded (a few per session, only on real use — zero when idle); left as a follow-up.
  • If Windows denies foreground activation on a restore path that shows the main window without focus, its intervals stay paused until first click; device data still refreshes via the DevicesUpdated push.
  • Minimize (as opposed to hide) keeps pre-existing polling behavior on Windows, where a minimized window still reports visibilityState === 'visible'; on macOS the restored native detection pauses minimized windows as before.

Verification

  • cargo check --all-targets clean for cap-camera-directshow, cap-camera-mediafoundation, cap-camera-windows, cap-camera; cargo check -p cap-desktop clean; cargo fmt --all; Biome clean on the touched TS.
  • Leak evals above measured on this branch on Windows 11, plus an independent line-by-line regression review of COM refcount balance, clone semantics, event plumbing, and TanStack pause/resume behavior against the pinned query-core 5.101.2 source.

Copilot AI lite review requested due to automatic review settings August 24, 2026 00:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Hona Hona changed the title fix(windows): stop idle camera polling leaking MF device resources and CPU in the tray fix(windows): stop camera enumeration leaking device resources and pause idle polling in the tray Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

high system usage when idle

2 participants