fix(recorder): retry an EOS the stalled track refused, and end it sooner - #762
Draft
wagenet wants to merge 3 commits into
Draft
fix(recorder): retry an EOS the stalled track refused, and end it sooner#762wagenet wants to merge 3 commits into
wagenet wants to merge 3 commits 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>
…ntinues splitmuxsink releases a GOP only once every one of its sink pads has advanced past it, so a track that stops delivering freezes the whole recording — and, through the tee that feeds the recorder, every other branch of that source with it. A participant whose microphone dies takes down their own video and recording, and the program output too if they are the only live source. A watchdog thread ends such a track with EOS, which is what takes a pad out of that wait. A GAP event does not: splitmuxsink ignores it on a non-reference stream, so the track has to end rather than idle. Buffers arriving afterwards are dropped at the block boundary, so the seat's encoder — which feeds the vision mixer through the same tee — is not stopped along with the recording. The trigger is the whole recording being frozen rather than one quiet input. Whichever track stops, the muxer blocks and every other input backs up behind it within a second, so a single quiet input cannot tell a dead track from a loaded machine. The track that is ended is the one whose last muxed buffer is furthest behind in running time, which is the one splitmuxsink is waiting for. Running time rather than PTS, because a WHIP seat's video arrives with a timestamp offset its audio does not have, and only running time can rank the two against each other. Ending a track makes aacparse drain a partial frame with no PTS, which mp4mux answers with "Buffer has no PTS" and an error that takes the seat's video with it. Such a buffer is dropped at the muxer's sink pad; it is not something the muxer can accept in any case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A track ended on a refused EOS was retired before the push was attempted, so one refusal took it off the watchdog for good and the recording stayed frozen. Retire it only once the EOS has landed, and retry on later polls. `gst_pad_push_event` answers a refusal with one bare `false`. On a src pad a sticky event is refused when the pad is flushing, no longer activated, already carrying an EOS or unlinked, or when the peer is in one of those states — all of them a branch being taken down, none of them distinguishable from the return value, and a failed push leaves the sticky EOS behind so the same pad refuses every retry. Add the splitmuxsink sink pad as a second route: it is the pad the muxer is waiting on, no upstream teardown can invalidate it, and the stalled track is the one nothing is pushing into, so its stream lock is free. End the track on the spread between tracks rather than only on every input falling silent. splitmuxsink goes on taking the live tracks into its queues after one stops, so waiting for the backpressure to reach every input costs the time those queues hold — 32s in the field log this comes from, against a 5s timeout — and the program output is frozen throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #757. GitHub cannot base a PR on a branch in the fork, so the diff below includes
757's commit; the change to review is the tip commit.
In the field the #757 watchdog fired correctly, its EOS was refused, and the recording stayed
frozen —
rec_p3:video_input_0 refused the EOS that would end its track.Why
push_eventreturned falseTraced through
gst_pad_push_eventin GStreamer 1.28.6. A sticky, serialized event on a src padis refused in exactly five states: the pad is flushing, the pad is no longer activated
(
GST_PAD_MODE_NONE), the pad already carries a sticky EOS, the pad is unlinked (push_stickyforgives
NOT_LINKEDfor every event except EOS), or the peer is flushing, already at EOS, orout of its parent. A busy downstream is not among them —
gst_pad_send_event_uncheckedtakesthe peer's stream lock, so a blocked branch blocks the caller. A refusal is therefore positive
evidence that the branch was coming apart, but says nothing about which of the five it was.
I could not reproduce a refusal from the WHIP teardown. On a headless rig I got the field's
ordering — session cleanup 5s before the watchdog fired, against 4s in the field — and the EOS
was accepted both times (
sent=true, from a temporary probe). That matches the code: a WHIPsession lives in its own
gst::Pipeline, and tearing it down starves the slot'sappsrcin themain pipeline without flushing anything below it. So the flushing-pad mechanism named in the
report is plausible but unproven, and this fix does not depend on which state it was.
The change
A refused EOS no longer retires the track.
retiredis set insideend_stalled_track, andonly after delivery; the watchdog retries on later polls.
A second route out of the wait. A failed push still leaves the sticky EOS on the pad, so that
pad refuses every retry — retrying alone would not have helped. The EOS now falls back to the
splitmuxsink sink pad: the pad the muxer is actually waiting on, which no upstream teardown can
invalidate, and whose stream lock is free because the stalled track is by definition the one
nothing is pushing into. The block's input stays first choice, since it lets the parser drain its
last frame on the way past.
The stall is detected on the spread between tracks. The 5s timeout was never the reason it
took 32s:
frozenrequired all live tracks to be quiet, and splitmuxsink kept taking thesurviving track into its internal queues for another 27s. Ending on the lag instead — the
furthest-behind track is quiet and the recording has moved at least the timeout past it —
fires while the others are still delivering. An overloaded box stalls every track at the same
running time, so the spread stays flat and this does not fire; the all-quiet rule stays for
tracks that stop together, where no spread ever opens.
I did not wire the recorder to WHIP session teardown. An output block cannot know which of its
tracks a given session feeds, and the spread is a direct measurement of the thing that matters —
the muxer waiting — rather than a proxy for one cause of it.
Tests
Full suite on macOS with
STROM_REQUIRE_GST_PLUGINS=1: green, nothing skipped. No new elements,so no CI package change. Each new test checked against the reverted code:
a_track_that_refused_the_eos_is_not_retiredan_unlinked_input_falls_back_to_the_muxer_pada_track_the_recording_has_moved_on_without_is_the_one_endeda_stalled_track_whose_branch_is_coming_apart_still_ends0 video buffers reached the muxer in 5 sThe integration test unlinks the stopped track's branch to make the input refuse — the one of
the five states a test can arrange without racing a real teardown — and asserts the refusal
before relying on it, so it cannot silently stop testing anything.
Two caveats. I could not build a rig that reproduces the 32s delay: in mine even an AAC track
backs up at the muxer within 5s, so old and new rules both recovered in 7s. The 27s is from the
field log's own numbers and the rule change is guarded by unit tests, not by a timing test.
End-to-end on the rig (WHIP seat SIGKILLed, so cleanup runs with no DELETE) the dead track is
ended 7s after the kill, the live track is left alone, and the file keeps growing through the
teardown that follows.
Out of scope
GStreamer-CRITICAL: Trying to dispose element bin31, but it is in PAUSED instead of the NULL statecomes from the WHIP session cleanup path. Per CLAUDE.md that is a P0 and the usual causeis a strong
gst::Element/gst::Binreference captured in a signal-handler closure. I did notlook for it and have no view on where it is — worth its own issue.
🤖 Generated with Claude Code