Repository navigation
Conversation
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>
52bf350 to
5e1d87c
Compare
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment — right diagnosis, right layer, and the regression test is the real thing rather than a reconstruction. Two other appsinks are built the same way this one was; one of them attaches to a running pipeline too, and neither is covered.
Requested changes
- Say in the body whether
backend/src/gst/thumbnail_tap.rs:292—` .sync(false)`— is the same defect. That appsink takesGstBaseSink'sasync=truedefault and is attached lazily to a live flow bybackend/src/gst/pipeline/effects/misc.rs:102—` pub fn capture_block_thumbnail(`— so an ASYNC_START lands on the main flow pipeline rather than a session one. Only one branch attaches at a time, so it should preroll and recover rather than strand — but that is thePaused -> Paused (pending: Playing)transition your own "separate finding" section calls a deadlock risk. SPECULATIVE (not verified): I have not reproduced a stall there.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
The only behavioural change is the appsink's async |
CONFIRMED |
backend/src/blocks/builtin/whip.rs:645 — ` .async_(false)` — everything else in attach_session_branch is the old block moved verbatim; the fakesink beside it already carried async=false |
| The test calls the production function, not a copy | CONFIRMED |
backend/tests/whip_session_branch_test.rs:120 — ` attach_session_branch(&pipeline, pad, name)` — the extraction exists so the test can reach it, which is what CLAUDE.md's Tests rule asks for |
| The test executed in CI rather than skipping | CONFIRMED |
Check (Linux) at 5e1d87c7: both_session_branches_keep_running_when_attached_in_succession ... ok. The skip guard is defused — .github/workflows/ci.yml:149 — ` STROM_REQUIRE_GST_PLUGINS: 1` — turns a missing element into a failure on that job |
| The test fails with the fix reverted | UNVERIFIED |
your 20-of-20 is a claim I cannot reproduce: no cargo and no GStreamer here, so nothing was built or run. The assertions pin the right things — both appsinks in Playing, both past five samples — so the mechanism is coherent |
| Sinks other than these two are unaffected | UNVERIFIED |
see the requested change; backend/src/blocks/builtin/mediaplayer/bridge.rs:347 — ` .sync(false)` — is the third instance, but its pads arrive while the internal pipeline is still transitioning to PLAYING, not after, so the precondition looks absent there |
Diagnosis — The mechanism is stated precisely and the instrumented DBGSTATE pair matches it: a sink left in READY with its tee in PAUSED is what an uncompleted ASYNC cycle looks like from outside. Killing the shared-timestamp-offset hypothesis, and explaining why the correlation was real but backwards, is the kind of thing this repo has no other place to record. Coverage is BOUNDED to the two sinks this function builds; the bound that matters is not other WHIP code but other dynamic attachment sites — requested change 1. Deferring the splitmuxsink work on a 9/18-against-7/18 measurement is the right cut.
Radius — SHARED: one caller, but it runs for every pad of every WHIP session, and a WHIP seat feeds the recorder and the vision mixer. Arguing it: async=false only removes the sink from preroll accounting, and both sinks here are already sync=false drains nothing waits on for timing. A regression shows up as a WHIP publisher's audio or video missing — the bug's own symptom, so the test is also the detector.
Tests & CI — green at 5e1d87c7: Check (Linux), both Linux builds, WASM, API contract. macOS/Windows skipping; no cfg or platform code in the diff. The body says "Not run: CI" — it has since run green.
Confidence: MED
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>
|
Claude here, replying from Peter's account. Requested change 1 is addressed in 3973b08, and the body now has a section on it.
No separate regression test. Reaching On your Ran after the thumbnail change: |
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — supersedes my review of 5e1d87c7, and the verdict changes from Comment. Both rows that held it there are now closed by reading, not by assertion, and requested change 1 was not merely answered but fixed: the thumbnail tap carries the same async off. Everything settled at 5e1d87c7 still stands — the extraction of attach_session_branch, the test calling production code, the STROM_REQUIRE_GST_PLUGINS defusal of the skip guard — and is not re-raised here.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The adjacent instance I flagged is fixed | CONFIRMED |
backend/src/gst/thumbnail_tap.rs:299 — .async_(false) |
| "The other two appsinks are unaffected" | CONFIRMED |
those four are every AppSink::builder() in backend/src. The analyzer's is a static block element — backend/src/blocks/builtin/audioanalyzer.rs:221 — (appsink_id.clone(), appsink_element), — built before the flow starts; the Media Player's only sees pads after a drop to READY — backend/src/blocks/builtin/mediaplayer/state.rs:214 — ` pipeline.set_state(gst::State::Ready).map_err( |
| The test's assertions pin the fixed property | CONFIRMED |
backend/tests/whip_session_branch_test.rs:179 — (gst::State::Playing, gst::State::Playing), — the reported failure leaves the second sink in Ready on a single buffer, which is precisely what this asserts against |
| An unfixed sink answers that attach with ASYNC_START | EXTERNAL |
GstBaseSink's async=true preroll contract, assumed rather than verified here. Granting it, the row above is what makes the test a guard rather than a demonstration |
| The guard executed at this head | CONFIRMED |
Check (Linux) job 102948205746 at 3973b087: test both_session_branches_keep_running_when_attached_in_succession ... ok, with no skip line in the log |
Diagnosis — unchanged and still right. What the new commit adds is reach. The defect needs a sink with async=true attached into a pipeline that has already settled, and both sites in this tree where that happens are now covered. The two remaining appsinks are safe by precondition rather than by luck, and the citations above name which precondition each leans on — worth recording, because either becomes a live instance the day its attachment point moves.
Radius — SHARED, marginally wider than at 5e1d87c7 because the second site attaches to the main flow pipeline rather than a session one. Arguing it: both sinks are already sync=false, drop=true, max-buffers=1 drains that nothing waits on for timing, so async=false only removes them from preroll accounting rather than changing what they deliver. A regression would surface as the bug's own symptom — a WHIP seat missing one of its two streams — which the new test detects.
Not blocking, but worth a follow-up issue — the thumbnail tap fix ships unguarded, and the body says why (reaching activate_branch needs a live flow whose tee already carries caps). The splitmuxsink finding the body records is a separate defect of the same shape and is still open.
Tests & CI — run 34500027210 at 3973b087: Check (Linux), both Build (Linux) jobs, Check & Build (WASM) and API Contract Check all pass. Build (macOS) and Build (Windows) skipping; the diff carries no cfg or platform-specific code, so that is not a gap. Nothing was built or run locally for this review — every CI statement above is read off the concluded run.
Confidence: HIGH
|
Claude here, with independent confirmation from a different rig: two WHIP seats (headless
Fisher's exact (two-sided): p = 0.0071 by seat, p = 0.013 by trial. All 8 failures match the description here: One extra observation: a single stranded seat kept the whole flow out of PLAYING. The flow reached PLAYING in 0 of the 7 affected trials and 135 of the 135 unaffected ones. This was originally reported as "WHIP seat video never decodes" (4 of 54 seats), because that rig only counted decoded video frames and missed the audio case. |
|
Written by Claude (AI) on behalf of @srperens. Both commits (the session appsink and the thumbnail tap) 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; only the conflict next to Suggest closing this once #957 merges. |
|
Closing in favour of #957, which carries this work (see the comment above). |
… 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>
What was broken
On a five-seat meeting rig, a WHIP publisher failed to start roughly two runs in five. ICE completed, the seat came up, and then one of its two streams — sometimes audio, sometimes video — silently never arrived. The recorder and the vision mixer then waited on a track that would never come, so the seat was dead either way.
Cause
Each WHIP session runs in its own pipeline, already PLAYING by the time
whipserversrcadds its pads. Every pad gets atee -> fakesink + appsinkbranch added and synced into that running pipeline.The appsink was built with
sync=falseand nothing else, so it keptGstBaseSink's defaultasync=true. A sink in that mode will not complete its state change until it prerolls a buffer, and it announces the wait with ASYNC_START, which takes the pipeline back to PAUSED. The pads arrive one after the other within a millisecond, so the second branch is added while the first is still waiting — and the second never finishes. It is left at READY with its tee at PAUSED, accepts exactly one buffer, and blocks its streaming thread permanently.Instrumented failure, six seconds after both pads appeared:
The audio appsink's
new_samplehad fired zero times. Nothing was logged, becausesync_state_with_parentreports the state change as accepted, not completed, and returnedOk(())for all three elements.The fix is to turn
asyncoff, which thefakesinknext to it already did.Which base
The failure reproduces on
upstream/main, not only on our stack:Each trial is a fresh server process, fresh data directory, fresh port, one headless
whipclientsinkpublisher; the verdict is whether the recording grew by more than 200 KB across an 8-second window. Fisher's exact on 7/18 against 0/18 gives p = 0.0069. The rig flow usesbuiltin.audioenc, which exists only on the fork, so the upstream arms carry a cherry-pick of that block and nothing else; the fix itself is on plainupstream/main.How we got here
The two modes were one bug.
Mode A (video never decodes) and Mode B (video links, recording stays empty) are the same defect from either side: the stream whose pad arrives second is the one that stalls.
The baseline data lines up exactly. The
Computed shared ts-offset ... from <audio|video> streamline names the stream that delivered first, and in all 7 failures the stranded stream is the other one — 4 sessions where audio came first and video never decoded, 3 the reverse. Each failure has exactly one of the twodecodebin ... pad linkedlines; all 11 successes have both.The shared-timestamp-offset hypothesis.
Killed. The offset is correct for both streams, since both leave the same session pipeline on one time base. That log line reports which pad delivered first, and the first pad is the one that starts. The correlation was real; the direction was wrong.
Regression test
backend/tests/whip_session_branch_test.rsattaches two branches back to back to a running pipeline throughattach_session_branch— the function the pad-added handler now calls, extracted so the test exercises the real code rather than a copy. Both pads carry media before their branch is attached, and the attaches are sequential on one thread, matching production on both counts.With the fix reverted the second branch is left in READY having taken one buffer while the first runs normally, which is the rig's failure exactly. Twenty runs of each arm: 20 of 20 fail reverted, 20 of 20 pass with the fix. One round suffices; the three are insurance against a host where it turns probabilistic. Needs only
tee,fakesink,appsink,videotestsrc,audiotestsrc, all in the CI package list.The same defect in the thumbnail tap
thumbnail_tap.rsbuilds its appsink the same way and attaches the branch to a flow pipeline that is already running. Fixed here too, one line.Every precondition is present. The activation guard admits the pipeline in PAUSED with PLAYING pending, which is the window where an earlier branch's async wait is unresolved. 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. Two attaches inside one unresolved cycle is the WHIP failure again, on the main flow pipeline rather than a session one.
No separate regression test. Reaching
activate_branchneeds a live flow whose tee already carries caps, and the mechanism is the onewhip_session_branch_testguards.The other two appsinks are unaffected. The Media Player bridge only ever gets pads while its internal pipeline is moving from READY to PLAYING, on first start and on a file switch, which returns to READY first — both sinks are counted in one async cycle rather than interrupting a settled one. The audio analyzer's appsink is a static block element, built before the flow starts and never attached dynamically.
A separate finding, not fixed here
The recorder's
splitmuxsinkis unlocked by whichever track produces caps first and then still has to preroll the pads of the tracks that have not. It has noasync-handling, so that preroll drops the running flow pipeline back to PAUSED, stopping every live source in it. Visible in every trial on both bases asPaused -> Paused (pending: Playing).A healthy seat recovers, so this was a symptom here rather than the cause, but it is a real deadlock for a flow where a track genuinely never arrives. Setting
async-handlingremoves the transition from all 18 logs; on its own it did not change the failure rate (9/18 against a 7/18 baseline), so it does not belong in this PR.What I ran
cargo test --test whip_session_branch_test— 20 runs pass; 20 fail with the fix reverted.cargo test --test whip_idle_slot_test --test recorder_idle_input_test --test recorder_unfed_track_test --test pipeline_lifecycle_test --test jitterbuffer_mute_test— all pass.cargo fmt --all -- --check,cargo clippy --all-targets,cargo check --all-targets— clean after the thumbnail change.Not run: the rest of the suite. The thumbnail path has no automated coverage and I did not exercise it by hand.
🤖 Generated with Claude Code