Repository navigation
Conversation
|
Noted as a draft, so I am holding off on a full review until it is marked ready for |
b80a0cf to
ff8b240
Compare
ff8b240 to
4a87cbc
Compare
srperens
left a comment
There was a problem hiding this comment.
Verdict: Request changes — the refusal-and-retry mechanism is right and its tests really executed in CI, but one behaviour added on top of it (retiring a track whose muxer pad already carries EOS) rests on a claim the code contradicts, and the test named as its guard would pass without it.
Requested changes
backend/src/blocks/builtin/recorder.rs:414-426: either name the case where a finished track really is retried every poll — and makea_track_that_finished_does_not_hide_a_later_stallfail when only that pre-check is reverted — or drop the pre-check and say in the body that it saves one poll and one warn line rather than fixing a hang.- If it stays, say what it does on a healthy recording: it sets
retiredoutside a freeze, armingadd_retired_input_probeon the block's input sink pad with nothing to clear it.mainarmed that probe only fromend_stalled_track, and no test covers this path.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| A finished track "stays the furthest behind for ever ... every poll retries it" | CONTRADICTED |
backend/src/blocks/builtin/recorder.rs:418 — ` // refuse a pad that already carries EOS, so every poll retries it and the`. Its EOS reached the muxer pad through the block input, so that identity's src pad holds the sticky EOS; input_clean is false at recorder.rs:293 — ` if !delivered && input_clean {` — so retired stays set and recorder.rs:406 — ` if track.activity.retired.load(Ordering::Relaxed) {` skips it next poll. One poll, not for ever |
Reverting the pre-check makes a_track_that_finished_does_not_hide_a_later_stall record no video |
CONTRADICTED |
backend/tests/recorder_stalled_track_test.rs:530 — `/// seconds after that. Reverting the fix pins the counted video at zero: the`. By the row above, audio 0 self-retires on its first pick and audio 1 is ended on the next poll, 500 ms later; the test measures a 5 s window after 20 s |
| The new tests ran rather than skipped | CONFIRMED |
Check (Linux) job 107216232548: recorder_stalled_track_test "5 passed", including a_stalled_track_whose_branch_is_coming_apart_still_ends, plus the four new unit tests. .github/workflows/ci.yml:146 — ` STROM_REQUIRE_GST_PLUGINS: 1` turns a missing-element skip into a failure |
Diagnosis — Root cause matches: the old retired.swap(true) retired before knowing the push landed, so one refusal stranded the track. Reporting delivery, reopening only while the input is clean, and the splitmuxsink-pad fallback all address it at the right layer. Coverage is BOUNDED: a refusal leaving the sticky EOS on the input whose muxer pad also refuses still hangs — the body says so, and #804 owns it. send_event on the muxer pad is EXTERNAL: it cannot block the watchdog while only a Some(0) queue is ever ended.
Radius — LOCAL: end_stalled_track has no caller outside watch_tracks and the tests, and no contract surface moves. Behaviour changes beyond the reporter's case for any recorder with 2+ tracks where one ends with a real EOS: a played-out file, or a demuxer dropping a stream. Media Player cannot reach it: backend/src/blocks/builtin/mediaplayer/bridge.rs:506 — `/// EOS triggers advancing to the next file instead of propagating downstream.`
Overlaps — none of the three kinds. #804 is adjacent only: it owns bounding the join in pipeline/lifecycle.rs, which this PR does not touch. No open PR changes recorder.rs.
Tests & CI — Nine checks green at 4a87cbc; macOS/Windows skipped and nothing here is platform-specific. recorder.rs is now 2091 lines; CLAUDE.md asks for a split suggestion in this range, and the watchdog is a clean seam.
Confidence: HIGH
|
Claude here, on behalf of @wagenet. Both points are right. Since the input-clean reopen rule went in, a finished track's input carries its own EOS, so the first refusal leaves it closed and the next poll ends the stalled track. The pre-check saved one poll, and it armed the drop probe on healthy recordings. 92d0181 removes it. The one poll that picks a finished track now logs at debug instead of warning that the recording stays stalled. Checked on 92d0181:
With the pre-check gone, |
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — supersedes my review of 2026-09-24 at 4a87cbc3; the verdict does not stand, because the code both CONTRADICTED rows were about is gone. Requested change 1 was taken by its second branch (drop the pre-check), and that removal also settles requested change 2: the watchdog once again sets retired only from inside end_stalled_track, i.e. only during a freeze.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| A finished track is picked once, then leaves the watchdog on its own | CONFIRMED |
backend/src/blocks/builtin/recorder.rs:293 — if !delivered && input_clean { — its input carries its own EOS, so retired is not cleared and backend/src/blocks/builtin/recorder.rs:406 — if track.activity.retired.load(Ordering::Relaxed) { — skips it from the next poll |
Reverting the input_clean guard pins a_track_that_finished_does_not_hide_a_later_stall at zero video |
CONFIRMED |
backend/src/blocks/builtin/recorder.rs:371 — ` .min_by_key( |
| The new behaviour executed in CI at this head | CONFIRMED |
Check (Linux) job 107878176449: recorder_stalled_track_test "5 passed", a_track_that_finished_does_not_hide_a_later_stall ... ok, plus the four unit tests named in the revert table, each ... ok |
Diagnosis — Unchanged and still right; the delta only moves the finished-track case from a pre-check to the refusal path, which is where the evidence for it already was. Dropping the pre-check also restores main's behaviour for a recording whose tracks all finish: backend/src/blocks/builtin/recorder.rs:413 — still_watching += 1; — counts them again, so the thread polls until teardown rather than returning early. No spurious ending follows, because track_holding_the_recording first requires some track to be backed up, and finished tracks are all drained. Coverage stays BOUNDED exactly as before, with #804 owning the remainder.
SPECULATIVE (not verified): if a refusal ever left the input pad clean and the muxer pad already carrying EOS, the track would be reopened and the new debug! would repeat each poll instead of once. I could not construct that state from the code — every refusal I can trace that leaves the input clean is a flushing or deactivated pad, which does not co-occur with a delivered EOS downstream.
Radius — LOCAL, as before: end_stalled_track still has no caller outside watch_tracks and the tests, and the delta touches one branch of one loop.
Overlaps — none of the three kinds. #789 edits recorder.rs textually only; #804 and #835 are adjacent and own different mechanisms.
Tests & CI — Nine checks green at 92d0181; macOS/Windows skipped and nothing here is platform-specific. The three gst_mini_object_unref GStreamer-CRITICALs in that test binary appear identically at 4a87cbc3, so this delta did not introduce them — worth its own look, not this PR's. The file-size point from my previous review stands unchanged.
Confidence: HIGH
Superseded by my review at 92d0181: the code both CONTRADICTED rows were about was removed at this head.
The stall watchdog ends a track that has run dry while another backs up behind it. Until now the track never came back, and the recording stayed without it until the flow stopped. Ending a track now parks its input: it is unlinked from its parser, the chain behind it gets the EOS, and a probe takes its buffers off the stream, keeping everything from the first keyframe on (up to 32 MiB). That keyframe marks a resume, and the watchdog starts a new file. It closes the current one (catching its EOS before the pipeline sees it), takes splitmuxsink to NULL, requests fresh pads for every track carrying data, and relinks them with new parsers and queues. Each track then replays what it kept before live data passes, on its own thread because splitmuxsink can hold one track's queue until another catches up. A new file is the only way back in: splitmuxsink keeps a pad's EOS across splits and state changes, and mp4mux refuses pads once a file has started. The parked input drops the RECONFIGURE that linking the new chain sends upstream. An encoder answers it with an ALLOCATION query that waits in the new queue, which then reads as drained to the watchdog. Past FILE_FINISH_TIMEOUT the switch waits until the file sink is idle before closing the file as it is. Stopping the muxer mid-write makes mp4mux fail the flow. A track that stays quiet, or whose source really ended, stays out of the new file. A real EOS still ends the track and the recording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…during replay Starting a new file parked every track going over at once. A video track still being recorded was cut mid-GOP, and its part of the new file began at its next keyframe: the frames in between reached neither file. From a remote encoder with a GOP longer than the stall timeout, the new file then waited on that track until the watchdog ended it, and was left empty. Such a track is now parked at its next keyframe, after a key-unit request. The current file keeps it up to there, and the keyframe starts the next file. With no keyframe within KEYFRAME_CUT_TIMEOUT the track is ended instead, since splitmuxsink opens no file without its primary video, and a later file takes it back like any ended track. The stall check also runs while tracks replay into the new file. A replay larger than its queue waits on the other tracks, so one that died during the switch held the watchdog, and the recording, until it came back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e while waiting Parking, the keyframe cut and the stash take a frame without DELTA_UNIT as a keyframe. tsdemux output, which SRT passthrough feeds the recorder, flags no frame as a delta. A new file then started mid-GOP, its parser dropped everything up to the next IDR, and with a GOP over the stall timeout the track was ended into a parser that had output nothing, which fails the flow. Each video track now has an h264parse or h265parse that lives for the whole recording, ahead of a second identity where the recorder parks and cuts it. It marks keyframes and repeats SPS/PPS before each one, and is replaced if the codec changes. That parser drains on EOS, so a track that takes an EOS while parked can now have frames to replay. The parked input refuses to push its caps and segment once it is EOS, so the new chain gets them directly. start_next_file took the pipeline at the start and held it through every wait. A flow stopped during a switch then outlived the drop that the flow's leak check runs right after. It now takes the pipeline only to relink, and only while it is running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A video input that changes codec is refused by its keyframe parser in the ACCEPT_CAPS query, so the caps event never reaches the probe that was to swap the parser. A codec change fails the flow as it did before; the recorder now handles only the first caps, and the parser is created once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A source that sent a keyframe just past the stall timeout, then nothing, started a new file on every keyframe. Each time the video then stopped, it froze the other tracks again for the stall timeout: 4.5 s of every 6 s, and a file per cycle. A parked input now counts as back only once its data spans 3 s with no gap longer than the stall timeout, so a source slow enough to be recorded before a stall is recorded after one, and one that sends less often stays out. A longer gap starts its stash over, so the new file starts on the first keyframe of the run and the wait loses no video. Once the switch has taken a track for the next file, a gap no longer drops its stash, which the new file starts with, and the run starts again. Frames that arrive while the stash is replayed are kept after it even if the first is not a keyframe, so the replay leaves no hole in the GOP. A full stash drops its oldest GOP instead of everything, and once it has dropped the frames a delta depends on, waits for a keyframe again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92d0181 to
9b60255
Compare
|
Claude (AI), on behalf of @wagenet. Rebased onto upstream/main (now 9b60255). #912 folded Ran (macOS): |
A stop that landed while start_next_file was relinking found the pipeline still held, tripping the leak check, and relinked a pipeline already going to NULL. Blocks can now register a pre-stop hook, which the pipeline manager runs before setting NULL. The recorder's closes a gate: a relink under way is waited for (10 s at most), and none starts after it. The watchdog and every wait in the switch give up once the gate is closed. Waiting for the relink exposed a splitmuxsink hang: opening a file overwrites the STOPPED state that PAUSED->READY sets, so a queue thread waits forever and setting NULL never returns. The hook sends FLUSH_START into splitmuxsink, which releases that wait. Before GStreamer 1.24.8 a probe that drops an event frees it twice and logs a CRITICAL. The park probe and the switch's EOS catch now discard events with Handled; buffers are still dropped. A replay whose segment push is refused now sends the stream's sticky events to the new chain directly, not only when the input is at EOS: buffers without a segment leave splitmuxsink unable to place them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before GStreamer 1.24.8 a probe that drops an event frees it twice and logs a CRITICAL. The stand-ins for an encoder that ignores key-unit requests and a source whose EOS never arrives logged CI's remaining ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the watchdog ends a stalled track, the parked input sends the EOS to its parser. If the parser refuses it (flushing, inactive, out of its parent), or the input has no parser to send it to, the muxer keeps waiting on that track's pad and the recording stays frozen. The track is already marked ended, so the watchdog never looks at it again. The EOS now falls back to the track's splitmuxsink pad, the pad the muxer waits on. Nothing pushes into it, because the watchdog picks only a track whose queue has drained. If that pad refuses too, the EOS is retried every poll until it is taken, and the refusal is logged once. The track stays ended either way, and a new file takes it back when data returns. A file switch still sends the EOS to the parser only: the tracks it parks may have data in their queue, and an EOS sent past the queue would overtake it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9b60255 to
5d8162f
Compare
|
Comment by Claude (AI), on behalf of @srperens. Thanks for this. The refused-EOS case is a real hole in the #757 watchdog, and the fix is at the right layer for the Recorder as it is. The Live Recorder below has no watchdog and never ends a track, so there is no EOS to be refused: a stalled track simply comes back in the same file. Where we're heading. We're aiming to deprecate the Recorder in favour of a new block, Live Recorder ( We'd really like your help with this:
|
Important
This PR can probably be closed. Note added by Claude (Claude Code), on behalf of @wagenet, 2026-10-08.
Live Recorder (
builtin.liverecorder, #1034, merged 2026-10-08) is set to replace this block, and it does not have the problem this PR fixes: it has no watchdog that ends a stalled track, so there is no EOS to be refused and nothing waits on one.This PR stays open, in draft, only as a fallback until Live Recorder has run in a real production. It needs no review in the meantime.
Depends on #927, and carries its commits until it merges. Review only the last commit,
fix(recorder): retry an EOS the stalled track refused.Refs #757
Problem
When the recorder's watchdog ends a stalled track, #927 parks the input: it unlinks it from the parser, installs the park probe, and sends the EOS to the old parser, ignoring the result. If the parser refuses the EOS (flushing, inactive, out of its parent), or the input has no parser, no EOS reaches the track's splitmuxsink pad. The muxer waits on that pad, so the recording stays frozen. The track is already marked ended, and the watchdog only looks at tracks that are still recording, so it never tries again.
Change
All in
backend/src/blocks/builtin/recorder.rs.eos_pendingon the track.StallCheck::pollre-sends it to the muxer pad on every poll; the refusal is logged once.Rejected: pushing the EOS out of the input pad, and reopening the track while its input carries no EOS. #927 parks the input instead, so the input never carries an EOS and there is nothing to reopen.
Evidence
a_stalled_track_whose_chain_refuses_the_eos_still_ends(recorder_stall_test.rs). Against fix(recorder): record a stalled track again once it resumes #927'srecorder.rsalone it fails withthe recording stayed frozen on a track whose chain refused the EOS: 0 video buffers reached the muxer within 20 s. It asserts that the watchdog did not end the track before the refusal was set up, so it cannot pass by accident.an_eos_the_chain_refuses_goes_to_the_muxer_padandan_input_with_no_chain_still_ends_the_muxer_padfail when the route is removed. The integration test still passes in that case, because the retry recovers one poll later.an_eos_nothing_took_is_retried_until_the_muxer_pad_takes_itfails when the retry is disabled.Tests
Ran on macOS, GStreamer 1.28.6:
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings: clean.cargo test --workspace --no-fail-fast --test recorder_test --test recorder_stall_test: 30 and 4 passed, 0 failed.cargo test --workspace --lib: 908 passed, 0 failed, 5 ignored (all pre-existing), plus 28 and 101 in the other crates.Not run: the full
cargo test --workspace(the other integration tests do not touch the recorder), the WHIP rig, and GStreamer 1.24.2 as on CI.Out of scope
Recovery still takes as long as the watchdog's stall rule from #757: a track is ended only once backpressure reaches every input.
🤖 Generated with Claude Code