fix(windows): stop camera enumeration leaking device resources and pause idle polling in the tray - #2143
Open
Hona wants to merge 10 commits into
Open
fix(windows): stop camera enumeration leaking device resources and pause idle polling in the tray#2143Hona wants to merge 10 commits into
Hona wants to merge 10 commits into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2132. Supersedes #2129 — full credit to @aacarcrash for first isolating the DirectShow
BindToObjectleak 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)
cap-camera-directshow):VideoInputDevice::new()calledIMoniker::BindToObjectfor 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.cap-camera-mediafoundation):DeviceSourcesIterator::next()calledActivateObject::<IMFMediaSource>()per device per enumeration, opening the physical device; sources were neverShutdown. TheIMFActivatearray fromMFEnumDeviceSourceswas never released norCoTaskMemFree'd (the crate had noDropimpls at all), andGetAllocatedStringbuffers inname()/id()were never freed. ~31 handles + ~0.69 MB leaked per enumeration (2 MF devices).cap-camera-windows): the MF/DS dedup inget_devices()calledmf_device.formats()per pair per enumeration to guard a branch that pushes a clone of the same MF entry andswap_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.spawn_devices_snapshot_emitterenumerates every 5 s forever (its only consumer is theDevicesUpdatedUI-picker event), and the frontend's 5 srefetchIntervals cannot pause because Tauri on Windows never flipsdocument.visibilityStatefor hidden windows (window.hide()doesn't callCoreWebView2Controller.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
OnceLockuntil formats or capture genuinely need it, then cached:cap-camera-directshow:VideoInputDeviceholdsmoniker+prop_bag+OnceLock<BoundFilter>(filter, capture pin, stream config).new()only doesBindToStorage.media_types()andstart_capturing()bind lazily;output_pin()returnswindows_core::Result<&IPin>; the unusedfilter()/stream_config()accessors are gone.cap-camera-mediafoundation:Deviceholdsactivate+OnceLock<IMFMediaSource>.Drop for DeviceSourcesIteratorreleases eachIMFActivateand frees the array;name()/id()free theirGetAllocatedStringbuffers; theDeref/DerefMut → IMFMediaSourceimpls 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_capturingaccepts DS formats on a merged device. A format that matches no backend available on the device now returnsStartCapturingError::FormatMismatchinstead of hitting the oldtodo!()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_windowalso emitsmain-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-startClosebehaviour. 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'sfocusManager— pause onmain-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.opensteals focus while the window is still visible), so they pause explicitly through ahideCurrentWindow()util that all main-window self-hide sites use. The bridge additionally re-checksisVisible()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-drivenDevicesUpdatedrefetches 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:
mfbeforedsbeforebothbeforemfafterdsafterbothafterPer-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):mf/dsisolate the two halves. Watch threads/handles/private bytes externally (Process Explorer, orGet-Process -Id <pid>in a loop); the printed per-enumeration wall time also degrades as the leak accumulates on unfixed builds.Behavior notes
Shutdownwhen the DS fallback takes over. Sources that are genuinely used (successful formats read or capture) are still released withoutShutdownon drop; doing that safely needs ownership through the capture handle, andstop_capturing()doesn't await the engine's stopped event, so a drop-timeShutdownObjectwould race live teardown. Bounded (a few per session, only on real use — zero when idle); left as a follow-up.DevicesUpdatedpush.visibilityState === 'visible'; on macOS the restored native detection pauses minimized windows as before.Verification
cargo check --all-targetsclean forcap-camera-directshow,cap-camera-mediafoundation,cap-camera-windows,cap-camera;cargo check -p cap-desktopclean;cargo fmt --all; Biome clean on the touched TS.