Skip to content

fix(recorder): stop an input with no data holding the pipeline out of PLAYING - #750

Open
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:wagenet/recorder-idle-input
Open

fix(recorder): stop an input with no data holding the pipeline out of PLAYING#750
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:wagenet/recorder-idle-input

Conversation

@wagenet

@wagenet wagenet commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The stall

A sink only completes READY->PAUSED once it has prerolled a buffer. A recorder whose input never carries data therefore leaves its splitmuxsink at 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 splitmuxsink starts 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:

before after
gst_state from GET /api/flows/{id} Paused, indefinitely Playing, from flow start, before any publisher connects
recorder on the live input 0 bytes 12.9 MB, gst-discoverer reports a valid 24 s H.264 MP4
recorders on the four idle inputs 0-byte file each no file

That 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.rs builds real recorder blocks through RecorderBuilder, including the element-setup hooks that request the splitmuxsink pads:

  • 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 stays Async/Paused and 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_test and pipeline_lifecycle_test. cargo fmt --check and cargo clippy --tests are clean.

Every element the tests need (splitmuxsink, mp4mux, x264enc, h264parse, videotestsrc, appsrc, fakesink, queue) is in the CI package list, and they honour STROM_REQUIRE_GST_PLUGINS so a missing element fails rather than skips.

🤖 Generated with Claude Code

… 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>
@wagenet
wagenet marked this pull request as ready for review September 3, 2026 13:47

@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: 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:355prepare_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:412let splitmuxsink_weak = splitmuxsink.downgrade(); captured by the move closure; recorder.rs:456let 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.

RadiusLOCAL. 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 & CIBuild (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

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