Skip to content

fix(windows): don't instantiate capture filters while enumerating cameras (leaks ~43 handles + 1 thread per poll) - #2129

Open
aacarcrash wants to merge 2 commits into
CapSoftware:mainfrom
aacarcrash:fix/directshow-enumeration-leak
Open

fix(windows): don't instantiate capture filters while enumerating cameras (leaks ~43 handles + 1 thread per poll)#2129
aacarcrash wants to merge 2 commits into
CapSoftware:mainfrom
aacarcrash:fix/directshow-enumeration-leak

Conversation

@aacarcrash

@aacarcrash aacarcrash commented Aug 15, 2026

Copy link
Copy Markdown

Fixes the underlying cause of the Windows instability in #2115. Supersedes my earlier attempt in #2117 (see the correction there — that patch was real but treated a symptom on the Media Foundation side; this is the leak that was actually killing the process).

The bug

VideoInputDevice::new() called IMoniker::BindToObject for every device during plain enumeration. That instantiates the camera's DirectShow capture filter and opens the device through its KS driver, and those resources are not reclaimed when the filter is released.

Camera enumeration runs continuously — spawn_devices_snapshot_emitter every 500 ms→5 s, plus the frontend's 5 s listVideoDevices poll — so the leak is unbounded for the life of the process.

Measurement

Windows 11, 7 camera devices present (1 physical + 6 virtual: Quest Link ×4, SpoutCam, OBS Virtual Camera). Calling cap_camera::list_cameras() once per second, nothing else running:

RSS handles threads
before 24 → 30 MB 851 → 2184 21 → 52
after 16 → 17 MB 356 → 358 10 → 10

+43 handles and +1 thread per enumeration, scaling with device count.

Splitting the two halves of get_devices() isolates it — Media Foundation is flat (317 handles, 9 threads, unchanged), DirectShow accounts for all of it.

In the running desktop app the same growth was ~9 handles/second, monotonic. After the fix, handles and threads stay flat across a session.

Why this produces the crashes in #2115

Thousands of leaked threads (1 MB reserved stack each) and tens of thousands of handles per hour explain the whole symptom cluster, and why it looks random:

  • 0xC000070A / STATUS_INVALID_HANDLE on a threadpool wait — handle exhaustion
  • 0xC0000005 reading inside devenum.dll
  • thread 'tokio-runtime-worker' has overflowed its stack
  • users reporting the app degrades the longer it runs, and is fine right after launch

It also explains the reporting bias: the leak is per device per poll, so a laptop with one webcam leaks ~6 handles per poll while a machine with Quest Link, OBS Virtual Camera and similar leaks 7× that. Multi-camera Windows setups get hit hard; a typical dev machine barely shows it.

The fix

Enumeration only needs the moniker's property bag (BindToStorage) for name, id and model id. BindToObject is deferred until something actually needs the filter, pin or stream config — formats() or start_capturing() — and cached in a OnceCell, so behaviour is unchanged for real consumers.

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; callers are unchanged. Only the crate's own example needed updating.

Reproducing it yourself

crates/camera-windows/examples/enumeration_leak.rs is included:

cargo run --release -p cap-camera-windows --example enumeration_leak -- ds 30

Run with mf, ds or both and watch the process's handle/thread counts (Get-Process). Before this change ds climbs steadily and mf is flat; after, both are flat. Happy to drop the example from the PR if you'd rather not carry it.

Not addressed here

The polling itself is still aggressive — two independent loops re-enumerating every 2.5–5 s forever, each doing a full MF + DirectShow scan. With this fix that is no longer a leak, but caching results and refreshing on WM_DEVICECHANGE instead would remove a lot of steady-state work. Happy to do that separately if you want it.

🤖 Generated with Claude Code

Greptile Summary

The PR defers DirectShow capture-filter instantiation until formats or capture are requested, avoiding resource growth during ordinary Windows camera enumeration.

  • Adds lazy, cached binding of each device’s filter, output pin, and stream configuration.
  • Updates the DirectShow CLI example for the optional accessor contract.
  • Adds a diagnostic example that repeatedly exercises Media Foundation, DirectShow, or combined enumeration.

Confidence Score: 4/5

The PR appears safe to merge, with only redundant comments in the new diagnostic example requiring non-blocking cleanup.

The lazy DirectShow binding path and current callers remain coherent, while the sole accepted concern is maintainability-only commentary in the diagnostic example.

Files Needing Attention: crates/camera-windows/examples/enumeration_leak.rs

Important Files Changed

Filename Overview
crates/camera-directshow/src/lib.rs Replaces eager DirectShow filter creation with lazy OnceCell-backed binding while preserving the existing format and capture paths.
crates/camera-directshow/examples/cli.rs Adapts the example to handle output-pin binding failure and reuse the successfully bound pin.
crates/camera-windows/examples/enumeration_leak.rs Adds an enumeration leak diagnostic; several branch comments redundantly narrate adjacent code contrary to repository guidance.
Prompt To Fix All With AI
### Issue 1
crates/camera-windows/examples/enumeration_leak.rs:20-21
**Redundant branch narration**

The comments above the `mf`, `ds`, and fallback branches only restate the immediately following calls, adding documentation that must be kept synchronized without providing non-obvious context.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(windows): don't instantiate capture ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

  • Context used - AGENTS.md (source)

…eras

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 <noreply@anthropic.com>
Comment on lines +20 to +21

for i in 1..=iterations {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redundant branch narration

The comments above the mf, ds, and fallback branches only restate the immediately following calls, adding documentation that must be kept synchronized without providing non-obvious context.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/camera-windows/examples/enumeration_leak.rs
Line: 20-21

Comment:
**Redundant branch narration**

The comments above the `mf`, `ds`, and fallback branches only restate the immediately following calls, adding documentation that must be kept synchronized without providing non-obvious context.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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 <noreply@anthropic.com>
@aacarcrash

Copy link
Copy Markdown
Author

Valid — fixed in the latest push.

Removed the narration: the example's match-arm comments (the module doc already says what the modes do) and the doc comments on bound() and filter(), which just restated their signatures.

Kept one comment, on BoundFilter: that binding opens the camera through its KS driver and costs a thread plus handles that aren't reclaimed on release. That's the non-obvious platform behaviour the whole change exists for, and without it the lazy binding looks like arbitrary indirection someone could reasonably "simplify" back into new().

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.

1 participant