fix(recorder): stop an input with no data holding the pipeline out of PLAYING - #750
fix(recorder): stop an input with no data holding the pipeline out of PLAYING#750wagenet wants to merge 1 commit into
Conversation
… PLAYING A sink only completes READY->PAUSED once it has prerolled a buffer, so a recorder whose input never carries data leaves its splitmuxsink at READY and the pipeline one state short of PLAYING. A flow where only some inputs are live has recorders in exactly that position: with a recorder per remote presenter, the ones who have not connected stop the flow running at all, so the presenters who are live are not recorded either. The splitmuxsink now starts with its state locked, sitting in NULL and writing no file. The caps probe that inserts a track's parser unlocks it and syncs it with the pipeline, immediately before linking that track in, so a recorder joins the pipeline when it has something to record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — same locked-then-unlock-on-caps technique already vetted in #749, applied to splitmuxsink; correct WeakRef usage in the probe closures, and the new tests drive the real RecorderBuilder path rather than reimplementing the topology.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
splitmuxsink is built locked, before any property is set on it |
CONFIRMED |
backend/src/blocks/builtin/recorder.rs:355 — prepare_idle_recording_sink(&splitmuxsink); runs immediately after .build(), before location/muxer are set |
The probe closures upgrade a WeakRef inside the callback rather than capturing a strong element reference |
CONFIRMED |
recorder.rs:412 — let splitmuxsink_weak = splitmuxsink.downgrade(); captured by the move closure; recorder.rs:456 — let splitmuxsink = match splitmuxsink_weak.upgrade() { — the strong ref is local to the callback body, matching CLAUDE.md's pipeline-object-in-closures rule |
| Unlock is idempotent across tracks (video and audio can both trigger it) | CONFIRMED |
backend/src/blocks/builtin/recorder.rs:66-71 set_locked_state(true) and backend/src/blocks/builtin/recorder.rs:72-81 set_locked_state(false) + sync_state_with_parent() are called unconditionally from each track's own caps probe (recorder.rs:609, :832); set_locked_state/sync_state_with_parent are idempotent GStreamer element calls, so a second track's probe re-calling them after the first is a no-op |
| New tests exercise the real build/link/setup path, not a reimplementation | CONFIRMED |
backend/tests/recorder_idle_input_test.rs:96-113 calls RecorderBuilder.build(...) and runs ctx.take_element_setups() (the same hooks PipelineManager runs), then links through real videotestsrc/x264enc/appsrc elements |
| New tests actually execute in CI rather than skipping | CONFIRMED |
.github/workflows/ci.yml:132 STROM_REQUIRE_GST_PLUGINS: 1; every element in backend/tests/recorder_idle_input_test.rs:22-30's REQUIRED list (splitmuxsink, mp4mux, x264enc, h264parse, videotestsrc, appsrc, fakesink, queue) is in ci.yml's base/good plugin packages |
Diagnosis — matches the WHIP fix's mechanism, at the layer that actually blocks: a splitmuxsink sink pad only completes preroll on real data, and a locked-NULL child is excluded from the pipeline's aggregate state entirely. Coverage is ABSOLUTE for this block: the lock/unlock pair is keyed on caps arrival per track, generalizing across any mix of connected video/audio tracks, not just the one-video-one-idle case the tests exercise. The PR body correctly scopes this as one of two independent causes of the same symptom — #749 covers the other (WHIP's own idle decodebins) — and doesn't overclaim past its own diff.
Radius — LOCAL. Confined to backend/src/blocks/builtin/recorder.rs; no strom-types or StromEvent change; queue elements between parser and sink are pre-existing and untouched, still at defaults. One SPECULATIVE (not verified): concurrent caps arrival on a video and an audio track calling activate_recording_sink from two different streaming threads at once relies on GstElement::set_locked_state/sync_state_with_parent being safe under concurrent calls — that's EXTERNAL (upstream GStreamer's own object-lock guarantee), not something this repo's code arbitrates.
Tests & CI — Build (Linux x86_64/ARM64), Check (Linux), Check & Build (WASM), API Contract Check, sccache preflight all green at c443981. Build (macOS)/Build (Windows) skip per the usual gate; no platform-cfg code in this diff, so not a coverage gap here.
Confidence: HIGH
The stall
A sink only completes READY->PAUSED once it has prerolled a buffer. A recorder whose input never carries data therefore leaves its
splitmuxsinkat READY, and a pipeline with any child still ASYNC never completes its own transition.A flow where only some inputs are live has recorders in exactly that position. With a recorder per remote presenter, the ones who have not connected hold the flow one state short of PLAYING, so the presenters who are live are not recorded either — the recordings come out 0 bytes rather than merely short.
The fix
The
splitmuxsinkstarts with its state locked: it sits in NULL, out of the pipeline's state changes, and writes no file. The caps probe that inserts a track's parser unlocks it and syncs it with the pipeline, immediately before linking that track in — so a recorder joins the pipeline exactly when it has something to record. Unlocking is idempotent across a recorder's tracks.A recorder whose input never carries data now leaves no file at all, where before it left an empty one.
Verification
Headless backend, five WHIP inputs each with its own recorder, one publisher connected:
gst_statefromGET /api/flows/{id}Paused, indefinitelyPlaying, from flow start, before any publisher connectsgst-discovererreports a valid 24 s H.264 MP4That run also had #749 applied, which fixes a second, independent cause of the same symptom in the WHIP Input block. Neither branch alone gets that flow to PLAYING; each removes one of the two causes, and the tests below cover this one on its own.
Tests
backend/tests/recorder_idle_input_test.rsbuilds real recorder blocks throughRecorderBuilder, including the element-setup hooks that request thesplitmuxsinkpads:recorder_without_data_does_not_block_playing— two recorders in one pipeline, one fed H.264 and one connected to a source that never pushes. The pipeline must reach PLAYING, the fed recorder must write a non-empty file, and the idle one must write nothing. Fails without the fix: the pipeline staysAsync/Pausedand neither recorder writes.recorder_with_data_still_records— a fed recorder runs to EOS and leaves a non-empty file. This is the counterpart: locking the sink for good would satisfy the first test and stop every recording.Ran locally on macOS: both new tests, plus
recorder_unfed_track_test,recorder_splitmux_threading_testandpipeline_lifecycle_test.cargo fmt --checkandcargo clippy --testsare clean.Every element the tests need (
splitmuxsink,mp4mux,x264enc,h264parse,videotestsrc,appsrc,fakesink,queue) is in the CI package list, and they honourSTROM_REQUIRE_GST_PLUGINSso a missing element fails rather than skips.🤖 Generated with Claude Code