Skip to content

fix(audio): prune dead followers in AudioOutputRouter (#3660)#3661

Merged
jensenpat merged 1 commit into
mainfrom
fix/audio-output-router-prune-dead-followers
Jun 19, 2026
Merged

fix(audio): prune dead followers in AudioOutputRouter (#3660)#3661
jensenpat merged 1 commit into
mainfrom
fix/audio-output-router-prune-dead-followers

Conversation

@ten9876

@ten9876 ten9876 commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #3630, closes #3660. AudioOutputRouter never pruned followers whose QPointer guard had gone null — a destroyed sink lingered in m_followers as a no-op closure iterated (and skipped) on every setCurrentDevice() fan-out.

Harmless for the current register-once-at-startup use (two lifetime-owned followers: ClientPuduMonitor, QsoRecorder), but it would accumulate unbounded once dynamically created/destroyed followers land — e.g. a future per-receiver or WebSDR-sourced sink per the audio-sink-factory roadmap.

How

  • Each follower now carries an optional liveness predicate alongside its apply callback. The QPointer-guarded template addFollower(T*) sets it ([guarded]{ return !guarded.isNull(); }); raw std::function followers leave it null and are treated as always alive (the caller owns their lifetime).
  • setCurrentDevice() prunes dead followers with std::erase_if after the fan-out, operating on the live vector — so a follower added re-entrantly during the loop is alive and kept. The snapshot fan-out and re-entrancy behavior are unchanged.

Public API (addFollower(std::function), addFollower(T*)) is unchanged; the two overloads now share an internal registerFollower(apply, alive).

Test

audio_output_router_test extended: registers a live + a heap follower, deletes the heap one, fans out, and asserts the dead follower is pruned (followerCount 2 → 1) while the live one is kept and still updated. 14/14 (was 9/9). Full app builds clean.

Refs #3306

🤖 Generated with Claude Code

Follow-up to #3630. Dead followers (QPointer guard gone null) were never
removed from the registry — they lingered as no-op entries iterated on every
fan-out. Harmless for the current register-once-at-startup use (two lifetime-
owned followers), but it would accumulate unbounded once dynamically
created/destroyed followers land (per the audio-sink-factory roadmap).

Each follower now carries an optional liveness predicate, set by the
QPointer-guarded template overload (raw std::function followers stay always-
alive — the caller owns their lifetime). setCurrentDevice() prunes followers
whose guard is null after the fan-out, on the live vector, so a follower added
re-entrantly during the loop is kept.

Test: audio_output_router_test extended to assert a destroyed follower is
pruned while a live one is kept and still updated. 14/14.

Closes #3660

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ten9876 ten9876 requested review from a team as code owners June 19, 2026 12:05
@ten9876 ten9876 added the enhancement Improvement to existing feature label Jun 19, 2026
@ten9876

ten9876 commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the triage on #3660, which suggested doing both (1) prune dead guarded followers and (2) add a handle-based removeFollower() for the raw std::function overload — this PR does (1) and intentionally defers (2). Rationale:

  • All production followers use the guarded T* overload (m_finalMonitor, m_qsoRecorder in MainWindow), which this PR auto-prunes on fan-out. The raw std::function overload is currently used only in tests — no production caller registers a guardless lambda that would need explicit removal.
  • The triage's rationale for (2) was "future dynamic per-receiver / WebSDR sinks." Those would be QObjects registered via the guarded path, so they're already covered by the auto-prune here — they wouldn't need removeFollower() either.
  • Adding removeFollower()/handles now would be API surface + id-tracking for a usage pattern that doesn't exist. YAGNI: it can be added when a guardless dynamic follower actually appears, with a real caller to design against. The code documents the boundary: "Raw std::function followers have no alive predicate and are never pruned (the caller owns their lifetime)."

One shape note: rather than the triage's std::function<bool(const QAudioDevice&)> (apply returns liveness), I kept apply a pure void side-effect and added a separate std::function<bool()> alive predicate — same pruning semantics, but apply and liveness stay separate concerns.

Net: the documented leak (dead guarded followers accumulating) is fully fixed and covers 100% of current + foreseeable QObject sinks; explicit deregistration is deferred as YAGNI.

@aethersdr-agent aethersdr-agent Bot left a comment

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.

Reviewed the diff and the surrounding code — this looks good to me. Clean, correct, and well-tested. Thanks @ten9876! 🙏

What I verified

  • Re-entrancy is preserved correctly. The fan-out still iterates the followers snapshot, and std::erase_if runs afterward on the live m_followers. A follower added re-entrantly during the loop isn't in the snapshot but is in the live vector with a non-null guard, so it's kept — matching the comment. Pruning-after-fan-out is the right ordering.
  • The alive/null distinction is sound. Guarded followers self-skip via if (guarded) during apply and are pruned when their QPointer goes null; raw std::function followers carry a null alive predicate and are never pruned (f.alive && !f.alive() short-circuits). That matches the caller-owns-lifetime contract.
  • No API breakage. Both real call sites (MainWindow.cpp:1027-1028) use the guarded addFollower(T*) template overload, and the public signatures are unchanged. std::erase_if is C++20 and resolves through the already-included <vector>.
  • The test earns its keep — it asserts the dead follower is pruned (followerCount 2→1) and the live one is still updated, not just "no crash."

One minor, non-blocking observation

Pruning is opportunistic — it only fires inside setCurrentDevice(). In the future dynamic-sink world the PR is preparing for, sinks that are created and destroyed between device changes will linger in m_followers (as harmless no-ops) until the next fan-out. That's bounded by the additions-per-interval rather than truly unbounded, and it's consistent with the deliberate QPointer-no-deregister design, so I think it's the right tradeoff for now — just worth being aware of if a high-churn sink ever lands. No change needed.

Nice incremental hardening ahead of the audio-sink-factory roadmap.


🤖 aethersdr-agent · cost: $4.8619 · model: claude-opus-4-8

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Defensive review (as an author of the original AudioOutputRouter #3630) — no blockers; LGTM.

Correctness verified line-by-line:

  • Prune predicate f.alive && !f.alive() short-circuits correctly: raw std::function followers (empty alive) are never pruned (caller owns lifetime); QPointer-guarded followers are pruned exactly when the guard nulls.
  • Prune runs after the snapshot fan-out on the live vector, so a follower added re-entrantly during the loop is alive and kept — the original snapshot/reentrancy contract is preserved.
  • Zero behavior change for today's two lifetime-owned followers (their QPointer never nulls during operation); this is pure future-proofing for dynamic followers, as described.
  • Public API unchanged; the shared private registerFollower path is clean.

Checks:

  • std::erase_if(vector) is valid (project is C++20).
  • All CI green on Windows/macOS/Linux + CodeQL + analyze.
  • Built and ran the test locally: 14/14, including the new prune assertion and the pre-existing add-during-fan-out reentrancy case (still passes → prune doesn't regress reentrancy).

Minor non-blocking nit: the template captures guarded in two lambdas (apply + alive); a single shared QPointer would be marginally tidier, but this is correct and negligible. Not worth holding the merge.

@jensenpat jensenpat merged commit 84144e5 into main Jun 19, 2026
6 checks passed
@jensenpat jensenpat deleted the fix/audio-output-router-prune-dead-followers branch June 19, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AudioOutputRouter: prune dead (QPointer-null) followers / add deregistration

2 participants