Skip to content

fix(whip): consolidate the WHIP Input fixes from #759, #893, #787, #901 and #943 - #957

Merged
srperens merged 9 commits into
mainfrom
whip-ingest-consolidation
Oct 2, 2026
Merged

srperens merged 9 commits into
mainfrom
whip-ingest-consolidation

Conversation

@srperens

@srperens srperens commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #759, #893, #787, #901, #943, #805, #792, #906, #914

Problem

Seven open PRs by @wagenet fix WHIP Input, and nearly all of them change the same two files (whip.rs, whip_session_manager.rs). Each merge made the rest conflict harder: after #756 and #763 landed, #759 needed its logic merged with main's, not just its text. This PR takes the WHIP Input fixes as one series, evaluated and tested against current main.

Each fix below was reproduced by a test that fails with the fix removed, and passes with it.

Change

Commits keep @wagenet as author where the code is his, in dependency order:

From Commit What it fixes
#759 fix(whip): stop an unstamped buffer at the session bridge A buffer with no PTS crossed the session → slot bridge; qtmux answered Buffer has no PTS and took the whole flow down. Now dropped and counted in gst::pipeline_bridge::SessionBridge, with the shared A/V offset set once per session (compare_exchange).
#893 fix(whip): keep a slot's video PTS moving forward A jitterbuffer burst released several frames on one PTS; h264parse stripped the PTS from all but the first, and the GPU vision mixer failed the flow (Need timestamped buffers!). A new frame at most 1 ms behind the previous one is moved 1 ns past it.
#787 fix(whip): stop a session's second stream stalling below PLAYING The session appsink kept async=true; the second pad's branch, added during the first one's preroll, stayed in READY and blocked for good. Rig: 7/18 frozen before, 0/18 after.
#787 fix(thumbnail): turn off async on the thumbnail tap appsink Same defect in the thumbnail tap's dynamically added appsink.
#901 fix(whip): give a reused slot a fresh video decoder (+ test) A reused slot kept the previous session's decoder: VP8 after H.264 stopped with not-negotiated, and Safari reuses decoded slowly or not at all on VideoToolbox. The video decodebin is restarted from an IDLE probe on reuse.
#943 fix(whip): ask for a keyframe when a running session's video breaks After video first decoded, an unrepaired loss left the decoder broken for the rest of the session. Two buffer probes (atomics only) raise a per-slot damage flag; a session thread asks for a keyframe, at most once a second and 10 times per episode.
— test(whip): guard that a reused slot reports its video as decoding See #805 below.

Adapted, not taken as is:

Left out:

Evidence

Each regression test was run with its fix removed:

Fix removed Fails
bridge replaced by a bare push_sample an_unstamped_buffer_never_reaches_the_slot_appsrc: "the unstamped buffer must not be here"
session_finished gate removed a_finished_session_stops_feeding_its_slot
PTS ordering bypassed video_frames_on_one_pts_reach_the_slot_in_order
1 ms bound removed video_b_frames_keep_their_pts, video_frame_far_ahead_does_not_hold_back_later_frames
async(false) removed both_session_branches_keep_running_when_attached_in_succession
decoder restart disabled reused_slot_decodes_through_a_fresh_video_decoder, reused_slot_accepts_a_different_codec (not-negotiated), and the decoding-flag assertion
depayloader damage probe removed a_lost_packet_damages_the_slot_until_the_next_keyframe, a_keyframe_that_lost_a_packet_does_not_repair_the_slot

Rig results quoted in the source PRs (7/18 → 0/18 for #787, 5/8 → 0/8 for #893, the #943 loss table) are from @wagenet's rig on the original branches and were not re-run on this branch.

Unverified: the Safari/VideoToolbox decoder reuse failure (#901) cannot run in CI; the fresh-decoder assertion stands in for it.

Tests

Ran (macOS, GStreamer 1.28, STROM_REQUIRE_GST_PLUGINS=1 for the WHIP tests): cargo test --workspace --no-fail-fast (1106 passed, 0 failed), cargo clippy --all-targets -- -D warnings, cargo fmt --check.

The new integration tests need x264enc, vp8enc, rtph264pay/depay and an H.264 decoder, all in CI's package list, and CI sets STROM_REQUIRE_GST_PLUGINS=1, so a missing element fails instead of skipping.

Not run: Linux, Windows, the WASM clippy. A rig run with real browsers follows on the integration branch.

For the reviewer

Out of scope

🤖 Generated with Claude Code

wagenet and others added 9 commits October 2, 2026 15:17
…x sees it

A WHIP session's appsink -> appsrc bridge forwarded buffers with no PTS.
A recorder downstream answers one with "Buffer has no PTS" from qtmux and
GST_FLOW_ERROR, which takes the whole flow down: on a live rig, a second
participant joining killed the first.

The slot bridge now goes through gst::pipeline_bridge::SessionBridge: a
buffer with no PTS is dropped and counted (warned on the first and every
hundredth), the shared A/V offset is set once per session with
compare_exchange and taken from the first stamped buffer, DTS is shifted
with PTS, and a sample without caps no longer panics.

From #759 (cfc4518, f62e777, 9ba9941), adapted to main:
- The slot callback keeps the session_finished gate from #753 and the
  ingress liveness stamp from #756, wrapped in install_slot_bridge so the
  wiring test drives the real callback.
- The Media Player half of #759 is left out. #945 replaced that bridge's
  timing with its own, so SessionBridge no longer fits there.

Co-Authored-By: Per Enstedt <per.enstedt@eyevinn.se>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A WHIP publisher joining a slot can take the whole flow down with a GPU
vision mixer downstream: "Need timestamped buffers!" from videoaggregator.
The session's jitterbuffer can release a run of packets from different
frames on one PTS; h264parse takes a PTS only when it differs from the
previous one, so the later frames of the run leave it with none, and a
browser stream has no framerate to derive one from.

A video packet that starts a new RTP frame and whose PTS is at most 1 ms
behind the previous frame's now gets that PTS plus 1 ns. Packets of one
frame keep sharing a PTS, and any other PTS (a B-frame, a step in the
sender's clock, a frame far ahead) passes through. On the rig, 5 of 8
runs of publish / switch camera / leave / rejoin died before, 0 of 8
after.

From #893 (745ce02, e90f52b, 2540522), adapted to the SessionBridge from
#759: the ordering runs on the session PTS before the bridge rebases it,
and copies the buffer only when the PTS moves.

Co-Authored-By: Per Enstedt <per.enstedt@eyevinn.se>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A WHIP session pipeline is already PLAYING when whipserversrc adds its
pads, and each pad gets a `tee -> fakesink + appsink` branch synced into
it. The appsink ran with `GstBaseSink`'s default `async=true`, so it
answered that state change with ASYNC and waited for a preroll. The pads
arrive one after the other within a millisecond, so the second branch
lands while the first is still waiting, and the second is left below
PLAYING: it takes a single buffer and then blocks its streaming thread for
good.

That seat's audio or video then never reaches the slot's appsrc, so its
decodebin exposes no pad, and the flow's recorder and mixer wait on a
track that will never arrive. Nothing in the log says so —
`sync_state_with_parent` reports the state change as accepted either way.

Turn `async` off, as the fakesink beside it already does. Measured on a
five-seat meeting rig, 18 fresh-server trials per arm: 7/18 seats failed
to start before, 0/18 after.

The branch construction moves into `attach_session_branch` so the
regression test builds the real thing rather than a copy of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thumbnail branch is attached to a flow pipeline that is already
running, and its appsink ran with GstBaseSink's default async=true, so it
answered that state change with ASYNC and waited for a preroll. The
activation guard admits the pipeline in PAUSED with PLAYING pending,
which is that wait, and a branch attached inside one is left below
PLAYING: it takes a single buffer and blocks its streaming thread.

Activation returns without waiting for a preroll, and a branch is created
per block from an HTTP request, so a client asking for several blocks'
thumbnails serializes on one mutex but not on the async cycles behind it.

Same defect and same fix as the WHIP session branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A WHIP Input slot keeps its decode chain while publishers come and go.
The video decoder that decodebin plugged for the first session kept
that session's reference frames, reorder queue and configuration, and a
later session with equal caps sends none to reconfigure it. On macOS,
VideoToolbox then decoded a second Safari publisher at a few frames per
second and flagged every frame of a third as an error, so the slot
showed no video while its audio played.

When a session claims a slot whose video decodebin has already run,
restart that decodebin from an IDLE probe on the slot appsrc: unlink,
set it to NULL, relink so the sticky events are sent again, and sync it
with the pipeline. The existing pad-added handler links the new chain
when data arrives. Audio keeps its running chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test reuses a slot with a VP8 publisher after an H.264 one and
asserts the VP8 session decodes. With the restart disabled, the kept
decoder refuses the VP8 caps and the slot's source stops with
not-negotiated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A WHIP session asked its publisher for a keyframe only until video first
decoded. After that, nothing asked again. A browser sends a keyframe only
when asked, so a loss the jitterbuffer could not repair left the slot's
decoder broken for the rest of the session: smeared pictures, or every
frame rejected with an error flag, until the watchdog tore the session
down and the guest rejoined.

The slot's decodebin now reports damage through a per-slot flag:

- a gap: the depayloader marks the first access unit after a sequence
  gap DISCONT; damaged until a keyframe with no gap in front of it
  leaves the depayloader (a repair keyframe can lose a packet too).
- a silent decoder: data lost with no gap (the depayloader drops a
  fragment it cannot place) makes the decoder reject every frame without
  a bus message. 30 access units in with no picture out counts as
  damage, cleared when pictures come out again.

Both are buffer probes that touch atomics only. A per-session thread on
the session side reads the flag and sends the keyframe request on the
whipserversrc pad, where it reaches the publisher: at once, then at most
once a second, 10 times per damage episode, and never on healthy video.
Each episode logs its recovery time and request count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyframe requester stops once the slot's video_decoding flag is set,
and that flag is set from decodebin's pad-added. Before the decoder
restart on reuse, pad-added fired once per flow, so every reconnecting
publisher was asked for the full request budget while its video decoded
fine (#805). The restart brings pad-added back for each session.

The reuse test now re-arms the flag as a new session does and asserts the
second session sets it again. With the restart disabled it fails with
"the second session decoded, but the slot never reported its video as
decoding".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens
srperens merged commit ca85fab into main Oct 2, 2026
9 checks passed
srperens added a commit that referenced this pull request Oct 2, 2026
* fix(mediaplayer): drop a buffer with no PTS at the bridge

The Media Player's internal -> main pipeline bridge forwarded a buffer
with no PTS as it was. A recorder downstream answers one with "Buffer has
no PTS" from qtmux and GST_FLOW_ERROR, which takes the whole flow down.
It is now dropped and counted, with a warning on the first and every
hundredth.

The same fix for WHIP Input is in #957; #759 carried both, but #945 gave
this bridge its own timing since, so it is fixed here on its own.

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

* fix(mediaplayer): place a DTS-only buffer by its DTS instead of dropping it

In passthrough the bridge carries encoded data, where a frame with a DTS
and no PTS is legal: an MPEG-TS PES between PTS stamps, or a parser that
cannot order B-frames. Dropping those broke the stream for every consumer.
Such a buffer is now placed by its DTS and leaves without a PTS; only a
buffer with neither is dropped.

The test strips the PTS that appsrc gives a DTS-only buffer, so the
bridge really sees one. It fails with the earlier drop-without-PTS rule
(the DTS-only buffer is missing) and with the original forward-everything
rule (the unstamped buffer arrives).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
wagenet added a commit to wagenet/strom that referenced this pull request Oct 2, 2026
Places the video damage probe, keyframe recovery logging, video PTS
ordering and SlotDecodebin items that Eyevinn#957 added to whip.rs and the
session manager. Rewrites the pipeline_bridge.rs comment that named
whip.rs. A file-level `use super::` import is now written as an absolute
path, since the new submodules sit one level below the source file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens
srperens deleted the whip-ingest-consolidation branch October 2, 2026 16:28
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