Skip to content

fix(whip): give a reused slot a fresh video decoder - #901

Closed
wagenet wants to merge 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-slot-reuse
Closed

wagenet wants to merge 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-slot-reuse

Conversation

@wagenet

@wagenet wagenet commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

Opened by Claude (AI) on behalf of @wagenet.

Current behaviour. A WHIP Input slot keeps its decode chain while publishers come and go. When a second publisher reuses a slot, it decodes through the video decoder the first publisher used. On macOS with a Safari publisher, a reused slot shows no video while its audio plays. In a local repro, the second session decoded at about 7 fps with frames up to 908 ms late. VideoToolbox flagged every frame of the third session as an error, so the slot produced no video. On any platform, a publisher that negotiated a different codec (VP8 after H.264) stops the slot with not-negotiated.

Fix. When a session claims a slot whose video decodebin has already run, restart that decodebin, which gives the session a fresh decoder, as on a slot no session has used yet. Audio keeps its running chain. A stalled chain is not restarted; that case is filed as #906. Frames the previous session left queued in the slot appsrc still reach the new decoder, and can stop the new session's keyframe requests early; that case is filed as #914.

Root cause and evidence

The slot's video chain is appsrc → decodebin (rtph264depay → h264parse → decoder) → videoconvert → tee. On macOS the decoder is vtdechw. The chain is built once per slot at flow start, and activate_slot_decoders only re-synced the running decodebin on reuse.

Local repro with a Safari publisher on one WHIP Input slot: publish, WHIP DELETE, publish again, three sessions in all. Pad probes on the slot appsrc, videoconvert sink and output tee, plus GST_DEBUG=videodecoder:4, showed:

  • Frames reached the appsrc on time in every session.
  • Only session 1 configured the decoder (set_format with the stream's codec_data). The later sessions carry the same caps, so they reconfigure nothing.
  • Session 1 ran at 30 fps. Session 2 came out at about 7 fps, up to 908 ms late. In session 3, vtdechw0 reported Got frame N with an error flag for every frame from the first keyframe on.
  • Frames left in the decoder's reorder queue come out at the start of the next session. A check for "video is decoding" can read them as the new session's video.

Chrome (headless and headed) and whipclientsink publishers ran through the same reused decoder without a problem, so only Safari's stream trips it. I did not isolate which property of the Safari stream does it.

How the restart works. restart_decodebin installs an IDLE probe on the slot appsrc source pad, so no buffer is in flight. It then:

  1. unlinks the pad;
  2. sets the decodebin to NULL, which drops the plugged elements and its source pad;
  3. relinks, so the sticky events are sent again with the next buffer (stream-start, caps and segment, which the sink pad lost in NULL);
  4. syncs the decodebin with the pipeline.

The existing pad-added handler links the new source pad to videoconvert when data arrives. A decodebin still in NULL has not run yet (first claim since flow start) and is only unlocked and synced, as before.

The existing video_decoding flag also works on a reused slot now. Before, pad-added never fired again on reuse, so the keyframe requester always sent all 5 requests. It now stops after 1 or 2, as on a fresh slot, or earlier when the previous session left frames queued (#914).

Related draft PRs.

How to review

  • backend/src/whip_session_manager.rs: SlotDecodebin, restart_decodebin, and the branch in activate_slot_decoders.
  • backend/src/blocks/builtin/whip.rs: only marks the video decodebin restart_on_reuse.
  • backend/tests/whip_slot_decoder_reuse_test.rs: builds the slot chain through WHIPInputBuilder, runs two x264 sessions through one slot with allocate_slot/release_slot, and asserts that the second session decodes frames through a different decoder instance. A second test reuses the slot with VP8 after H.264 and asserts it decodes.

Related Issue

Follow-ups, not fixed here: #906 (stalled chain), #914 (frames queued by the previous session).

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have run cargo fmt --all
  • I have run cargo clippy --all-targets -- -D warnings (backend, the root default member)
  • I have run cargo test --workspace. Ran instead: cargo test --lib (724 passed) and --test whip_slot_decoder_reuse_test --test whip_idle_slot_test --test whip_slot_caps_reuse_test --test pipeline_lifecycle_test --test whip_whep_ice_transport_policy_test (all passed), on macOS.
  • I have updated documentation as needed (none needed)
  • I have added tests for new functionality

Tests actually run:

  • Both new tests pass: 5 of 5 runs for the first, 4 of 4 for both together. They also pass with avdec_h264 forced (the decoder Linux CI uses).
  • With the restart disabled, both fail:
    • the second session decoded through the first session's vtdechw1 instead of a fresh decoder
    • the VP8 session on a reused slot decoded 3 frames, with not-negotiated from appsrc_video_0

End to end:

🤖 Generated with Claude Code

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>
@srperens

Copy link
Copy Markdown
Collaborator

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

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>
@srperens

srperens commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

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

Both commits are now part of #957, which consolidates the WHIP Input fixes into one series against current main. You are the author, and the logic is unchanged apart from conflicts with #756's per-slot output stamps.

One addition: the reuse test now also asserts that a reused slot sets video_decoding again. Your restart brings pad-added back for every session, which is what #805 was after. With the restart disabled, the assertion fails. #906 and #914 stay open.

Suggest closing this once #957 merges.

@srperens

srperens commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Closing in favour of #957, which carries this work (see the comment above).

@srperens srperens closed this Oct 2, 2026
srperens added a commit that referenced this pull request Oct 2, 2026
… and #943 (#957)

* fix(whip): stop an unstamped buffer at the session bridge before qtmux 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>

* fix(whip): keep a slot's video PTS moving forward frame to frame

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>

* fix(whip): stop a session's second stream stalling below PLAYING

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>

* fix(thumbnail): turn off async on the thumbnail tap appsink

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>

* fix(whip): give a reused slot a fresh video decoder

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>

* test(whip): guard a codec change on a reused slot

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>

* fix(whip): ask for a keyframe when a running session's video breaks

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>

* test(whip): guard that a reused slot reports its video as decoding

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>

---------

Co-authored-by: Peter Wagenet <peter.wagenet@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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