Skip to content

Add max_concurrent_captures_global to cap peak captures across all instances - #70

Draft
HoneyHazard wants to merge 4 commits into
tsowell:mainfrom
HoneyHazard:max-concurrent-captures-global
Draft

Add max_concurrent_captures_global to cap peak captures across all instances#70
HoneyHazard wants to merge 4 commits into
tsowell:mainfrom
HoneyHazard:max-concurrent-captures-global

Conversation

@HoneyHazard

@HoneyHazard HoneyHazard commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ Full Disclosure: Drafted with AI assistance (Claude); reviewed by me briefly. I am neither a RUST developer nor pipewire expert. If I should stop making these PRs into your wonderful project, please let me know. ⚠️

That being said, I hope these can be helpful and useful additions that users could appreciate.


Depends on #66 (max_concurrent_captures) and #69 (capture-stream disconnect fix) - this branch is stacked on both, so the diff shown here includes their changes too until they merge. Please review/merge those first if possible; happy to rebase this once they land.

#66 added max_concurrent_captures to cap how many nodes one instance actively monitors at once. This adds a second, optional setting, max_concurrent_captures_global, that extends the same idea across every running wiremix instance:

  • Counts "wiremix-capture"-named nodes across the whole PipeWire graph (not just this instance's own capturing_objects) and refuses to start a new capture once that count reaches the configured max.
  • This works without any custom IPC between instances, because the PipeWire graph itself is already shared, live state that every connected client (including every wiremix instance) can see.
  • Best-effort by nature: there's no cross-process locking, so two instances racing to start a capture at the same moment can both see room and both proceed, transiently exceeding the budget until the graph settles. It also can't make non-cooperating processes (upstream wiremix without this option, or anything else that happens to use the "wiremix-capture" node name) back off - only instances that also set this option consider each other.
  • Composes with max_concurrent_captures - both are checked independently, so whichever is more restrictive applies.

What made this hard to get right, and what #69 and the self-lag fix here actually address:

Simply counting the live graph state and comparing it to the configured max isn't enough on its own, for two separate reasons I found through live testing against a real, busy PipeWire graph (not just the mock-based unit tests):

  1. Orphaned capture nodes on restart (fixed in Disconnect evicted capture streams before dropping them #69, a prerequisite for this to behave well over time): every time a capture gets renewed after CaptureEligibility::NeedsRestart, the old capture stream needs to be properly disconnected before being replaced. It wasn't - StreamRegistry::add_stream()'s eviction path skipped the disconnect() call that StreamRegistry::remove() already did, so repeated restarts (flapping nodes, or normal churn from streams reconnecting) could leave a growing number of orphaned nodes in the graph, inflating what every instance perceives as the current global count.

  2. This instance's own captures being invisible to its own count during a startup burst (fixed here): node_capture_start() is fire-and-forget over an async channel to a separate PipeWire thread, and handle_events() drains a whole burst of CaptureEligibility::Eligible events synchronously (its while let Ok(event) = self.rx.try_recv() loop) before any of that burst's own just-issued captures can round-trip back into local state. So a flood of eligible nodes at once (e.g. at startup) sees the same pre-burst graph count for the entire burst, letting a single instance blow well past its own configured budget - confirmed live: one instance with max_concurrent_captures_global = 20 and no other instances contending ended up with 28 of its own active captures. capturing_objects.len() has no such lag (it updates the instant this instance decides to capture something, before any round trip), so folding it in as a floor closes the gap.

With both fixes in place, live-tested repeatedly: a test instance configured with max_concurrent_captures_global = 20 landed at exactly 20 of its own captures at startup, stayed exactly at 20 under sustained artificial restart-churn (repeated pw-cat connects/disconnects to force CaptureEligibility::NeedsRestart), and cleanly returned to baseline with nothing orphaned on shutdown - verified via pw-dump, cross-referencing each "wiremix-capture" node's owning client/PID against the running instance.

# Shared across every running instance that also sets this option
#max_concurrent_captures_global = 8

Tested:

  • cargo test --release: 150/150 passing
  • cargo fmt --check / cargo clippy -- -D warnings / cargo doc (matching this repo's CI): all clean
  • Live-verified against a real, busy PipeWire graph (methodology described above) - not just the mock-based unit tests, since the actual bugs found here were both in real async/graph-state timing that mocks don't reproduce
  • Verified leaving max_concurrent_captures_global unset reproduces stock behavior exactly

Each node wiremix monitors for peak levels gets its own dedicated
PipeWire capture stream (wirehose/stream.rs::capture_node). Every one of
those is a real client object the session manager has to track and
policy-link, and that cost scales with how many exist at once - not
with anything CPU-throttleable, since it's driven by stream count, not
per-quantum processing. lazy_capture already limits this to on-screen
nodes, but on views where many nodes are visible simultaneously (a
busy Output Devices tab, a tall terminal), that alone doesn't bound the
concurrent stream count.

Adds max_concurrent_captures: Option<usize> (unset = current unbounded
behavior). When set and more nodes are eligible for capture than the
cap allows, which ones are actually captured rotates on a fixed 3s
interval (deliberately much slower than render cadence - rotating every
frame would create more stream churn than not capping at all) so every
eligible node eventually gets sampled rather than whichever ones
happened to become eligible first holding their slot indefinitely.
Meters for nodes outside the active window keep showing their last
captured value until their next turn, rather than resetting to zero.

The cap is enforced as a hard invariant directly in start_capture()
(not just in the rotation logic), so it can never be transiently
exceeded even if several nodes become eligible at once before the next
rotation tick runs.

Verified live against the real PipeWire graph (not just unit tests):
with --max-concurrent-captures 2, `pw-dump` showed exactly 2
wiremix-capture streams at any moment, and the actual target node IDs
fully changed after the 3s interval elapsed - confirming both the cap
and the rotation are real, not just passing in isolation.

Tested: cargo test (148/148, including 4 new tests covering the cap
being enforced by start_capture, rotation respecting the cap, the
active window actually advancing between rotations, and the no-op case
where fewer nodes are eligible than the cap), cargo fmt --check /
cargo clippy -- -D warnings / cargo doc (matching wiremix's CI) all
clean.
StreamRegistry::add_stream() evicted an existing stream from its map
without calling disconnect() on it first, unlike remove(), which
already did. Dropping the evicted StreamRc alone destroys the
client-side stream object (pw_stream_destroy) without ever calling
pw_stream_disconnect, so the corresponding PipeWire node can be left
registered in the graph until something else tears it down.

This path is hit every time a capture is renewed after
CaptureEligibility::NeedsRestart, since that calls start_capture()
again for an object_id that already has an active stream.
…stances

Extends max_concurrent_captures with a second, optional cap that counts
"wiremix-capture" nodes across the whole PipeWire graph rather than
just this instance's own captures - a best-effort budget shared by
every running wiremix instance that also sets this option.

The naive approach of gating solely on the live graph reading
undercounts this instance's own just-issued captures: node_capture_start()
is fire-and-forget over an async channel to a separate PipeWire thread,
and handle_events() drains a whole burst of eligibility events
synchronously before any of that burst's own captures can round-trip
back into local state. Combining the graph reading with
capturing_objects.len() (always exact and lag-free, since it updates
the instant this instance decides to capture something) closes that
gap without changing the inherent, documented cross-instance
best-effort behavior.

Depends on the capture-stream disconnect fix (separate PR) - without
it, repeated CaptureEligibility::NeedsRestart churn can leave orphaned
capture nodes in the graph that inflate what every instance perceives
as the current global count.
The previous design jumped the entire max-sized capture window forward
every fixed 3-second wall-clock interval: every currently-captured
object went stale for the full interval, then all of them changed at
once. That felt frozen, then jumpy, rather than real-time - and the
3-second constant had no relationship to fps or eligible-object count,
so coverage latency could be far worse than the interval itself
suggested (a fixed max per rotation, however many objects are waiting).

Replaced with two changes:

- Slide the window by exactly one object per tick instead of jumping
  it by the full window size, so at most one capture starts and one
  stops per tick and everything else is left alone - a continuous
  trickle instead of a batch swap.

- Tie the tick itself to the render loop's own frame cadence (every
  Nth actually-rendered frame) instead of an independent wall-clock
  Duration, so rotation speed - and the PipeWire stream churn it
  costs - scales automatically with whatever fps a user has
  configured, with no second unrelated timer to reason about.
  rotate_capturing() is now only ever called in step with a rendered
  frame (moved its call site accordingly), so counting frames is
  exact.

Verified live against the real PipeWire graph: sampled which specific
objects a running instance was capturing every ~300ms and confirmed
the pair slides by one each time rather than jumping to an unrelated
pair.
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