Skip to content

fix(whip): stop slots without a publisher holding the pipeline out of PLAYING - #749

Open
wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-idle-seat-preroll-upstream
Open

fix(whip): stop slots without a publisher holding the pipeline out of PLAYING#749
wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-idle-seat-preroll-upstream

Conversation

@wagenet

@wagenet wagenet commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The stall

A WHIP Input block builds one decode chain per session slot at flow start — appsrc -> decodebin -> convert -> tee — whether or not anyone is publishing to that slot. A decodebin cannot complete READY->PAUSED until data arrives and it can typefind, and a pipeline with any child still ASYNC never completes its own transition.

A single slot with no publisher therefore held the whole flow one state short of PLAYING. In a production with five remote presenter inputs and one publisher connected, the mixer never composited, the recorders never left READY, and the input that was publishing decoded into a pipeline that was not running. Under GST_DEBUG=GST_STATES:5 all ten decodebins (5 inputs x video+audio) sat at current READY pending PAUSED, desired next PLAYING.

The fix

Each slot's decodebin gets two guards:

  • Locked state keeps an idle slot out of the pipeline's state changes entirely. It sits in NULL, holds no resources, and contributes nothing to the aggregated state.
  • async-handling=true makes it absorb its own ASYNC once unlocked, so a slot claimed by a session that then sends no media — an abandoned negotiation, a publisher whose video never negotiates — cannot pull a running pipeline back out of PLAYING.

WhipEndpointConfig::allocate_slot unlocks a slot's decodebins when a session claims it, during the SDP exchange and well ahead of the first RTP packet. Unlocking is idempotent, so a slot reused by a later session re-syncs a decodebin that is already running.

Neither guard hides a real preroll failure: a decodebin that errors still posts its ERROR to the pipeline bus.

Verification

Headless backend, five WHIP inputs, one publisher on p1:

before after
gst_state from GET /api/flows/{id} Paused, indefinitely Playing
recording 0 bytes grew 10.8 MB -> 14.8 MB while watched

GST_STATES:5 on the fixed run shows exactly two decodebins unlocking, at the moment slot 0 is allocated; the other eight stay locked in NULL.

Tests

backend/tests/whip_idle_slot_test.rs builds real WHIP Input blocks through WHIPInputBuilder and wires up the internal links they declare, rather than reconstructing the topology by hand:

  • idle_whip_slots_do_not_block_playing — five inputs, nobody publishing, pipeline must reach PLAYING.
  • allocated_slot_decodes_incoming_media — a slot claimed through allocate_slot, then fed real H.264, must produce decoded frames at that input's output tee. Without it, locking every decodebin for good would satisfy the first test and break WHIP input entirely.
  • allocated_slot_without_media_does_not_stall_a_running_pipeline — covers the async-handling guard.

Removing both guards fails all three; removing only async-handling fails the two allocation tests.

Every element the tests need (appsrc, decodebin, videoconvert, audioconvert, audioresample, tee, videotestsrc, x264enc, h264parse, appsink, fakesink) is in the CI package list, and they honour STROM_REQUIRE_GST_PLUGINS so a missing element fails rather than skips.

Ran locally on macOS: the three new tests, plus pipeline_lifecycle_test, jitterbuffer_mute_test, failed_start_teardown_test, pipeline_start_failure_test and openapi_test.

Not fixed here: recorders with no data

The same flow has a second, independent cause of the same symptom. With the WHIP slots fixed, a flow with a recorder per input still reports Paused: the splitmuxsink of every recorder whose input is not publishing sits at READY with no data to preroll, and sinks hold the pipeline by design.

That needs its own change in the recorder. async-handling=true on splitmuxsink lets media flow (the fed recorder grew to 5.8 MB) but leaves the pipeline reporting Paused, so the recorder needs the same locked-until-data treatment rather than a one-line property.

The A/B above therefore used a flow with a recorder on the publishing input only. Against a flow with a recorder on every input, this branch still shows Paused from that second cause.

🤖 Generated with Claude Code

@wagenet wagenet changed the title fix(whip): keep idle seats from holding the pipeline out of PLAYING fix(whip): stop slots without a publisher holding the pipeline out of PLAYING Sep 3, 2026
@wagenet
wagenet force-pushed the wagenet/whip-idle-seat-preroll-upstream branch from 1a5becd to 74446fe Compare September 3, 2026 05:13
… PLAYING

A WHIP Input block builds one decode chain per session slot at flow
start, whether or not anyone is publishing to that slot. A decodebin
cannot complete READY->PAUSED until data arrives and it can typefind, so
a slot with no publisher stays ASYNC forever, and a pipeline with any
child still ASYNC never completes its own transition.

One idle slot therefore held the whole flow one state short of PLAYING.
A production with five remote presenter inputs and a single publisher
did not run at all: the mixer never composited, the recorders never left
READY, and the input that was publishing decoded into a pipeline that
was not running. Connecting every input before starting was the only
workaround.

Each slot's decodebin is now built with its state locked, so an idle
slot sits in NULL and contributes nothing to the pipeline's aggregated
state, and with async-handling=true, so a slot claimed by a session that
then sends no media cannot drag a running pipeline back out of PLAYING
either. allocate_slot unlocks a slot's decodebins when a session claims
it, during the SDP exchange and well ahead of the first RTP packet.

Neither guard hides a real preroll failure: a decodebin that errors
still posts its ERROR message to the pipeline bus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wagenet
wagenet force-pushed the wagenet/whip-idle-seat-preroll-upstream branch from 74446fe to 2d306ea Compare September 3, 2026 05:15
@wagenet
wagenet marked this pull request as ready for review September 3, 2026 05:17

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

Verdict: Request changes — the fix logic is sound, but the new regression
test cannot pass in CI as submitted, and Check (Linux) is red for a
code-caused reason, not a flake.

Claims

Claim Verdict Evidence
Idle decodebins are built locked, with async-handling set, before they can go ASYNC CONFIRMED backend/src/blocks/builtin/whip.rs:196decodebin.set_property("async-handling", true); immediately followed by backend/src/blocks/builtin/whip.rs:197decodebin.set_locked_state(true);, called right after each audio/video decodebin is created
slot_decodebins holds weak refs, not strong refs, into the config struct CONFIRMED backend/src/whip_session_manager.rs:64pub slot_decodebins: Vec<Vec<gst::glib::WeakRef<gst::Element>>>, — matches the CLAUDE.md rule against capturing strong pipeline/element references
allocate_slot unlocks and syncs a claimed slot's decodebins, tolerating a torn-down pipeline CONFIRMED backend/src/whip_session_manager.rs:110let Some(decodebin) = weak.upgrade() else { guards backend/src/whip_session_manager.rs:114decodebin.set_locked_state(false);
The new test's REQUIRED element list omits nicesrc/nicesink, which WHIPInputBuilder::build needs CONFIRMED backend/tests/whip_idle_slot_test.rs:21const REQUIRED: &[&str] = &[ — the list through "fakesink", has no nice* entry, so plugins_available() reports available when it is not
CI's Linux jobs do not install the libnice GStreamer plugin the test needs CONFIRMED .github/workflows/ci.yml:89packages: libunwind-dev libssl-dev libcairo2-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav pkg-config cmake libclang-dev — no gstreamer1.0-nice; the identical list repeats at :309. Only the macOS job installs libnice-gstreamer (:574), and macOS build skips on PR
All three new tests fail (not skip) in CI on a real MissingPlugin panic, unrelated to the fix under test CONFIRMED CI log for run 33718105912/job 100531459386: WHIP Input block builds: MissingPlugin("WHIP Input needs WebRTC ICE support, but GStreamer has no nicesrc or nicesink..."), raised from the pre-existing, untouched backend/src/gst/ice_preflight.rs:80Err(BlockBuildError::MissingPlugin(ice_missing_message(
release_slot does not re-lock a slot's decodebins when a session disconnects CONFIRMED, non-blocking The diff for backend/src/whip_session_manager.rs has no hunk touching release_slot; async-handling set once at construction (prepare_idle_decodebin) is permanent, so a reused slot that goes idle again is still protected against pulling the pipeline out of PLAYING — locked-state was only ever the first-claim guard

Diagnosis — The production fix matches the failure mode in the PR body:
locking state at construction keeps an unclaimed decodebin out of the
pipeline's aggregate state, and async-handling=true stops a claimed-but-fed-
nothing decodebin from re-entering ASYNC and stalling PLAYING later. Both
guards are real and the test intends to exercise both moments. But the test
never gets that far: build_whip_inputs calls WHIPInputBuilder.build, which
runs the pre-existing require_ice_elements preflight (unrelated to this
diff) and returns MissingPlugin on a CI image with no libnice plugin — a gap
CLAUDE.md calls out directly: check ci.yml's package list before relying on
an element, and add anything missing in the same PR. This PR didn't, so all
three tests fail 100% of the time in Check (Linux), the only job that runs
them.

RadiusLOCAL. The production change is confined to whip.rs and
whip_session_manager.rs, uses WeakRef correctly, and touches no shared
types, endpoints, or StromEvent variants. The blast radius of the finding is
that merging as-is leaves Check (Linux) permanently red on main.

Tests & CICheck (Linux) fails: all three new tests in
whip_idle_slot_test.rs panic in build_whip_inputs before ever reaching
prepare_idle_decodebin/activate_slot_decoders. Build (Linux x86_64/ARM64), Check & Build (WASM), API Contract Check, sccache preflight are green; Build (macOS)/Build (Windows) skip as usual. Fix:
add gstreamer1.0-nice to both apt package lists (ci.yml:89 and :309) and
nicesrc/nicesink to the test's REQUIRED list, in the same PR.

Repo rulesWeakRef used correctly per the pipeline-object-in-closures
rule. No BUFFER probe. The test-package-list rule (Tests section of
CLAUDE.md) is the one this PR misses.

Confidence: HIGH

@srperens srperens mentioned this pull request Sep 3, 2026
whip_idle_slot_test needs nicesrc/nicesink for WHIP's webrtcbin. The
Linux jobs had every other GStreamer plugin package but this one;
macOS already installs libnice-gstreamer. Add it to check-linux and
both Linux build jobs, which must share an identical package list for
the apt cache key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

This supersedes my review of 2d306ea — the verdict does not stand. That review's sole blocker was Check (Linux) failing 100% of the time because CI had no libnice; 05b1456 adds gstreamer1.0-nice to the apt lists and the job is now green with the new tests actually executing. No backend code changed between the two heads.

Verdict: Comment — CI is fixed and the production logic still holds, but half of the previously requested change is still missing: the test's own REQUIRED list doesn't declare the ICE elements it transitively needs, so it would silently mis-report availability if gstreamer1.0-nice disappears from the package list again.

Requested changes

  1. Add "nicesrc", "nicesink" to REQUIRED in backend/tests/whip_idle_slot_test.rs:21-33, so plugins_available() and STROM_REQUIRE_GST_PLUGINS actually cover the dependency WHIPInputBuilder::build pulls in.

Claims

Claim Verdict Evidence
Check (Linux) now installs libnice and the 3 new tests execute for real, not skip CONFIRMED gh pr checks 749: Check (Linux) pass at 05b1456; job log — test idle_whip_slots_do_not_block_playing ... ok, allocated_slot_decodes_incoming_media ... ok, allocated_slot_without_media_does_not_stall_a_running_pipeline ... ok
Test's REQUIRED list declares every element the test's own code path needs CONTRADICTED backend/tests/whip_idle_slot_test.rs:21const REQUIRED: &[&str] = &[ through :33 has no nice* entry, but backend/src/blocks/builtin/whip.rs:78`ice_preflight::require_ice_elements("WHIP Input")?;` hard-gates the same build path the test drives
Idle decodebins are built locked, with async-handling set, before they can go ASYNC CONFIRMED backend/src/blocks/builtin/whip.rs:196`decodebin.set_property("async-handling", true);` and backend/src/blocks/builtin/whip.rs:197`decodebin.set_locked_state(true);`
allocate_slot unlocks and syncs a claimed slot's decodebins, tolerating a torn-down pipeline CONFIRMED backend/src/whip_session_manager.rs:110`let Some(decodebin) = weak.upgrade() else {` guards backend/src/whip_session_manager.rs:114`decodebin.set_locked_state(false);`

Diagnosis — Unchanged from my prior review: locking state at construction keeps an unclaimed decodebin out of the pipeline's aggregate state, async-handling=true stops a claimed-but-fed-nothing decodebin from re-entering ASYNC later, and the tests exercise both moments against the real WHIPInputBuilder. Coverage is BOUNDED to WHIP Input's pre-allocated slots — the PR body itself names the same-symptom recorder case as out of scope, covered separately in #750.

RadiusLOCAL: confined to whip.rs/whip_session_manager.rs, WeakRef used correctly per the pipeline-closure rule, no strom-types or StromEvent surface touched. Grepped other decodebin builders (aes67.rs, mpegtssrt_input.rs, whep.rs) — none pre-allocate idle per-slot decodebins the way WHIP Input does, so the fix's shape doesn't generalize to them.

Tests & CICheck (Linux), Build (Linux x86_64/ARM64), Check & Build (WASM), API Contract Check, sccache preflight all pass at 05b1456; Build (macOS)/Build (Windows) skip as usual. The REQUIRED-list gap above doesn't fail CI today — it's a latent gap in how the test reports its own dependencies, not a current red check.

Confidence: HIGH

@srperens
srperens dismissed their stale review September 3, 2026 14:31

Superseded by re-review at 05b1456: the CI-red blocker (missing gstreamer1.0-nice) is fixed, verdict no longer stands. Dismissing per protocol — the reason is the weaker/stale review, not disagreement over its original findings.

…nction

The new helper was inserted between build_whipserversrc's doc comment and
the function, so the whole block documented the helper and
build_whipserversrc was left undocumented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

Head moved to 461a1bc since my 05b1456 review. The only change (git diff 05b1456..461a1bc -- backend/src/blocks/builtin/whip.rs) moves prepare_idle_decodebin's doc comment onto its own function — no logic changed. Verdict stands: Comment. The one requested change from my last review is still open — backend/tests/whip_idle_slot_test.rs:21const REQUIRED: &[&str] = &[ still doesn't list nicesrc/nicesink, so the test would silently mis-report availability if gstreamer1.0-nice disappears from ci.yml again. CI is green at 461a1bc (Check (Linux), Build (Linux x86_64/ARM64), Check & Build (WASM), API Contract Check, sccache preflight; macOS/Windows skip as usual).

Confidence: HIGH

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