Skip to content

feat(whip): track a slot's liveness per medium - #792

Closed
wagenet wants to merge 11 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-per-medium-liveness
Closed

wagenet wants to merge 11 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-per-medium-liveness

Conversation

@wagenet

@wagenet wagenet commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Written by Claude (Claude Code) on behalf of @wagenet.

Stacked on #796, which carries #786. It is merged in because the report reaches operators through their block health scan. Reviewable content is the last commit only:

main
 └─ merge of #796  wagenet/unlinked-branch-health-pr  (carries #786)
     └─ feat(whip): track a slot's liveness per medium   ← this PR

Problem

A WHIP slot's audio and video are separate chains from the publisher's RTP session to the flow's consumers, but #756 gives the slot one output ActivityStamp for both. Either medium stamping it keeps the whole seat looking alive.

On the five-seat mix-minus rig a seat delivered no audio to the audio mixer, the vision mixer or the return router for its whole session while its video kept flowing, and every liveness signal reported it healthy: a commentator whose microphone has silently stopped reaching the mix.

Change

SlotOutput replaces the per-slot output stamp with one stamp per medium, written by the same tee probes. WhipSlotLiveness names any occupied slot medium that has stopped producing while its counterpart has not.

The verdict reaches block health through a new BlockLiveness hook on BlockBuildContext. Neither existing scan can see this failure: the pad-task scan needs a task that paused, the unformed-link scan needs a link that never formed, and a chain that is linked and never pushed to has neither. The scan applies a block's own verdict last, so a more specific failure still wins. The operator gets a BlockHealthChanged event, an entry in flow.block_health, and:

ERROR Block 'whip_p1' in flow 'race day' is not passing data: WHIP endpoint 'p1':
slot 0 has produced no audio for 5.6 s while its video is still flowing.
The pipeline still reports Playing

Against #756's design. Only the output side is split. #756's ingress stays one stamp: idle() wants the newest arrival of either medium and the first arrival of any, which a single stamp already gives. idle() reads SlotOutput on its newest medium, so the decode grace, the takeover tail handling and the StallSide label behave exactly as in #756. has_delivered_audio stays on ingress_audio.

Reap policy: unchanged, deliberately. Reaping on one dead medium drops the video the publisher is still delivering and throws a client that renegotiated its microphone away into a reconnect loop. A per-medium idle in takeover would also reopen the eviction of a healthy video-only publisher at 3.1 s: TAKEOVER_IDLE_THRESHOLD is 2 s because audio has a ~20 ms cadence, and it is only sound as a whole-session test because has_delivered_audio gates it. What to do about a dead microphone is a production decision.

Thresholds.

medium silent before reported
audio 2 s
video 10 s
never produced at all 10 s, from the counterpart's first buffer

A connected publisher sends audio every ~20 ms even when muted (WebRTC keeps the RTP stream running with silence), so two seconds of nothing is a broken chain. A screen share of an idle window legitimately goes many seconds between frames.

A medium is reported only when its counterpart outlived it by that medium's own budget. Without that, a seat losing its transport reads as half dead for the difference between the budgets; the rig showed a killed publisher reported as an audio failure for 5.6 s before the watchdog reaped it.

Evidence

  • Per-medium stamps are what make the report possible: a_slot_reports_the_medium_that_died_while_the_other_kept_flowing builds a real audio_video block, pushes buffers into the real per-slot appsrcs and reads the real reporter. Wiring both probes to the audio stamp (output.touch(Medium::Audio)) makes it fail. Run on the head before the rebase onto main.
  • A whole-seat drop is not reported as half dead: a_seat_that_stopped_altogether_is_not_reported_as_half_dead fails when the counterpart term is dropped (silent >= thresholds.stopped(medium)). Run on the head before the rebase onto main.
  • Quiet and empty slots are not reported: an_empty_or_wholly_idle_slot_is_not_reported.
  • A block's own verdict reaches the scan: a_blocks_own_failure_report_reaches_its_health.
  • Reap policy unchanged: a_session_whose_audio_died_keeps_its_slot. Passes before the change too, so it guards the decision, not the fix.
  • fix(whip): judge a session by the media it produces, not what arrives #756's guards pass unchanged against the split output, including a_predecessors_trailing_frames_do_not_cut_the_decode_grace_short and a_reaped_session_names_which_side_of_the_chain_stopped.
  • Rig, from the earlier build of this commit before it was redone on the current fix(whip): judge a session by the media it produces, not what arrives #756, not re-run: a video-only publisher was reported 14.1 s after slot allocation and kept its slot; a killed publisher produced no report and was reaped at 10 s; releasing the slot returned the block to ok.

Tests

Ran on this head, macOS: cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all -- --check, cargo test --test block_health_test --test whip_idle_slot_test --test whip_session_branch_test --test whip_slot_caps_reuse_test --test whip_slot_decoder_reuse_test --test whip_video_damage_test --test whip_whep_ice_transport_policy_test --test pipeline_lifecycle_test (27 passed, 0 failed), and the backend unit tests filtered to whip, health and stats:: (65 passed, 0 failed). The full workspace suite last ran on the head before the rebase (998 passed).

Not run: the headless rig and the full mix-minus rig with tone capture, the nvidia feature, the WASM clippy, the full workspace suite on this head. CI has not run on this head.

For the reviewer

  • A publisher that legitimately sends video only (screen share, no microphone) on an audio_video endpoint is reported after ~14 s and stays reported. It is the same code path as the rig failure, where audio never arrived either, so it cannot be suppressed without losing that case. The endpoint mode is the operator's declaration of what the seat carries, so I think reporting is right; a second opinion is welcome.
  • refactor(whip): split whip.rs and whip_session_manager.rs into sub-modules #854 (pure-move split of whip.rs and whip_session_manager.rs) touches the same files. Whichever lands second has to be rebased.

Out of scope

  • Telling "the publisher never sent audio" from "audio arrived and never left the slot's chain" needs per-medium ingress on the session, which the block cannot reach.

🤖 Generated with Claude Code

@wagenet wagenet closed this Sep 11, 2026
@wagenet wagenet reopened this Sep 11, 2026
@srperens srperens mentioned this pull request Sep 11, 2026
@srperens

Copy link
Copy Markdown
Collaborator

Noted as a draft, so I am holding off on a full review until it is marked ready for
review. If you want one before then, say so here or request me as a reviewer.

@wagenet
wagenet force-pushed the wagenet/whip-per-medium-liveness branch from 59a689c to 5ad1449 Compare September 12, 2026 04:02
@wagenet
wagenet force-pushed the wagenet/whip-per-medium-liveness branch from 5ad1449 to 148cbe9 Compare September 27, 2026 05:48
wagenet added a commit to wagenet/strom that referenced this pull request Sep 28, 2026
Both files were past the 1500-line threshold. This is a move: no item is
renamed, no public path changes, and each test moves with the code it
covers.

blocks/builtin/whip/
  mod.rs         block builders and re-exports
  slot_chain.rs  per-slot output chains (build_whipserversrc) and their
                 caps, decodebin and liveness-stamp helpers
  watchdog.rs    per-session inactivity watchdog
  session.rs     per-session whipserversrc pipeline and the
                 appsink-to-slot bridge
  output.rs      WHIP Output (whipclientsink, whipsink)
  definition.rs  block definitions

whip_session_manager/
  mod.rs          WhipSessionManager, session registry and cleanup
  endpoint.rs     WhipEndpointConfig and slot allocation
  activity.rs     ActivityStamp, SessionActivity, StallSide, SlotOutput
                  and WhipSlotLiveness
  takeover.rs     allocate_slot_or_take_over and its constants
  test_support.rs fixtures shared by the manager's tests

Generated, not hand-edited: a manifest places every item of the two
files, the compiler decides which items become pub(super), and unused
imports are pruned from its warnings. Three comments that named whip.rs
by path now name the file the item moved to.

Regenerated on the integration branch with wagenet/whip-split-tool's
manifest plus placements for the items Eyevinn#792 and Eyevinn#805 add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet
wagenet force-pushed the wagenet/whip-per-medium-liveness branch from 148cbe9 to b14db91 Compare September 28, 2026 15:43
@wagenet
wagenet force-pushed the wagenet/whip-per-medium-liveness branch 2 times, most recently from d859136 to 6b139d6 Compare September 29, 2026 22:14
wagenet added a commit to wagenet/strom that referenced this pull request Oct 1, 2026
Eyevinn#756, Eyevinn#792 and Eyevinn#805 drop AtomicU64 from whip.rs's imports once nothing
else there uses it. Naming it by path keeps this struct compiling in
either merge order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens

srperens commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Written by Claude (AI) on behalf of @srperens.

Not in #957. The per-medium report goes through the block-health hook from #786/#796, which is not on main, and we want to settle the block-health and failure-policy design first. This PR stays open for that discussion. #957 does keep the per-slot output stamp from #756 that this builds on.

@wagenet
wagenet force-pushed the wagenet/whip-per-medium-liveness branch from 6b139d6 to 71d7dc1 Compare October 2, 2026 15:41
@wagenet

wagenet commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Claude (AI), on behalf of @wagenet.

Rebuilt on main now that #756 has merged: main, then the #796 merge, then the per-medium commit. Conflicts were with #957 in whip.rs and whip_session_manager.rs. Both sides were kept: SlotDecodebin and VideoDamage from #957, and SlotOutput from this PR. One change goes beyond picking sides. #957's an_unstamped_buffer_never_reaches_the_slot_appsrc built its SessionActivity from an ActivityStamp. It now builds it from a SlotOutput, because this PR changed that constructor.

Ran: clippy -D warnings, fmt check, the block health, WHIP and pipeline lifecycle integration tests (27 passed), and the backend unit tests filtered to whip, health and stats (65 passed).

wagenet and others added 7 commits October 2, 2026 09:53
A pipeline branch can stop dead while the flow still reports Playing.
When a downstream push fails with a non-fatal flow return such as
not-negotiated, GstAggregator marks its sink pads flushing and pauses its
source pad task, and video encoders pause their output loop. Neither
posts to the bus, and pausing a pad task is not a state change, so every
element stays PLAYING and nothing surfaces through gst_state or the API.
GstBaseSrc is the exception - it posts a flow error before pausing - so
until now only source-side failures were visible.

Scan the running pipeline every two seconds for pads whose task is paused
on an element that is itself PLAYING, attribute each to its owning block
via the block_id:internal_id element namespace, and expose the result as
Flow::block_health. Log once per transition and emit BlockHealthChanged
so the failure is not limited to GST_DEBUG output. The flow list marks an
affected flow as Failed rather than Running.

Elements held below PLAYING are skipped: a WebRTC session bin paused
mid-setup has paused pad tasks legitimately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gst_base_src_loop pauses its pad task on the way out for every reason,
end of stream included, and leaves the element in PLAYING. Any flow with
a finite source would therefore report that block as failed forever once
it played out. Skip pads carrying the EOS flag: a pad that has seen EOS
is finished by definition, and the stall this detects pushes no EOS.

Require a block to look stalled on two consecutive scans before reporting
it. Pad tasks pause briefly for legitimate reasons - a flushing seek, a
dynamic bin being linked in - and a single sample turned those into a
logged error and a UI flip. Costs one poll interval of latency.

Drop blocks that disappear from the scan, rather than leaving them in the
failed set where no later iteration could clear them.

Handle BlockHealthChanged in the frontend. block_health rides on the Flow
payload, and nothing refetched the flow on this event, so the indicator
only appeared if the user happened to refresh.

The reported detail said "pad task stopped" while the condition detected
is paused; Stopped is explicitly not reportable here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scan walks the flow pipeline and the bins beneath it. Blocks that
build an isolated gst::Pipeline - WHIP ingest sessions, a Media Player's
decode chain - are siblings of that pipeline rather than children, so
iterate_recurse never enters them. Without that written down an Ok reads
as "this flow is passing data", which it does not mean.

The guard that skips elements below PLAYING cited a WHIP session bin as
its example. Those live in a separate pipeline and are never scanned;
a webrtcbin added for a new WHEP consumer is one that actually occurs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A queue whose source pad is left unconnected takes not-linked from its
loop and parks the task there for good, with the element still PLAYING.
Blocks build optional outputs this way - the vision mixer's multiview_out
is one - so any flow wiring only the program output reported its mixer as
permanently failed.

Found on a real WHIP -> vision mixer -> WHEP rig, where a healthy flow
reported "pad task paused on mixer:queue_mv_out:src" seconds after start.
An unlinked pad has no branch to report on, so require a peer.

The regression test reproduces it without a rig: a tee with one queued
branch left unconnected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A block that stops passing data is logged at error by the health scan;
the structured event log now matches it instead of logging at info.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A link the flow declares and construction cannot make is deferred to
pending_links, and the only thing that retries one is the pad-added
handler on its source element. A pad that is already there is never
added again, so a deferred link whose named source pad exists and has
no peer will not form for the life of the pipeline.

Nothing reported that. The stalled-pad-task scan looks for a task that
was paused, and where no link exists there is no task, while the
allow-not-linked tee every block output bus ends in absorbs the
not-linked return that would otherwise surface upstream. A vision mixer
whose output caps cannot cross gldownload leaves both video branches
unlinked exactly this way: the flow reaches Playing, carries audio, and
reports every block healthy with no video at all.

Scan pending_links alongside the pad tasks and fail the block owning the
source element. A source pad that has not appeared yet is untouched,
which is the case the deferral exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wagenet and others added 4 commits October 2, 2026 09:54
A vision mixer that loses both its distribution and multiview branches
reports two, and the pad-task scan above already names the first one it
finds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Record each link the linker gives up on, as the flow declares it, and
report those. Construction records a refused link whose source pad
exists or can be requested, since nothing retries it. The pad-added
handler records every attempt it makes on a pad that appears later, and
one pad that links is enough to form the link.

A pad's own state cannot answer this. A refused branch of a fan-out
starts from an auto-tee pad: it carries the tee's name, and a refused
request pad is released again. A late pad the handler cannot link is
given an auto-tee as its peer, so it has a peer like any linked pad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A slot's audio and video are separate chains from the publisher's RTP
session to the flow's consumers, but one output `ActivityStamp` covered
both. Either medium stamping it kept the whole seat looking alive, so a
commentator whose microphone stopped reaching the mix held its slot for
the rest of the session with every liveness signal green.

Split the slot's output record per medium (`SlotOutput`) and report the
difference. `WhipSlotLiveness` names an occupied slot medium that has
stopped producing while its counterpart has not, and the flow's block
health scan polls it through a new `BlockLiveness` hook: an unfed chain
pauses no pad task, so the scan cannot find it on its own.

The reap paths are unchanged. `SessionActivity::idle` reads the output on
its newest medium, so one dead medium does not cost a publisher the slot
its working medium is still filling, and the decode grace and stall-side
label behave as before. Ingress stays a single stamp: the session-level
readings already want the newest arrival of either medium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens

srperens commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

#983 has landed the same per-medium SlotOutput split, and uses it for reaping/takeover (a seat whose video died while audio flows is displaced). Your BlockLiveness health reporting isn't in it — this could rebase onto #983's SlotOutput and keep just the health hook.

🤖 Generated with Claude Code

@wagenet

wagenet commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Comment by Claude on behalf of @wagenet.

Replaced by #1025, which rebuilds this on the per-medium stamps #983 added. It only reports; reaping and takeover stay with #983, so this PR's a_session_whose_audio_died_keeps_its_slot is dropped. It also tells a publisher that stopped sending a medium apart from a medium that arrives but is never decoded, and gives the block health report structured causes. #796 is not underneath #1025 and stays its own PR.

@wagenet wagenet closed this Oct 7, 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.

2 participants