Skip to content

fix(recorder): record a stalled track again once it resumes - #927

Closed
wagenet wants to merge 8 commits into
Eyevinn:mainfrom
wagenet:wagenet/recorder-track-resume
Closed

wagenet wants to merge 8 commits into
Eyevinn:mainfrom
wagenet:wagenet/recorder-track-resume

Conversation

@wagenet

@wagenet wagenet commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

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: a track that stalls does not hold up the others, and comes back in the same file (an_audio_stall_holds_nothing_up_and_audio_returns_in_the_same_file, a_video_that_comes_back_resumes_on_a_keyframe_without_waiting_a_gop). #967 and #762 are stacked on this PR and carry the same note.

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.

Description

Written by Claude.

A recorder track that stalls and then resumes is now recorded again, in a new file, without restarting the flow.

The stall watchdog ends a track that has run dry while another backs up behind it, so one dead input cannot freeze the recording. Until now that was permanent. On a live rig the program video stalled for about 7 s, the watchdog ended it, and the recording stayed audio-only for three hours.

Why a new file

The current file cannot take the track back. splitmuxsink copies a pad's EOS into every later fragment and keeps it across state changes, mp4mux refuses request pads once a file has started, GAP events do not release splitmuxsink's wait, and repeated-frame filler is per codec. A new file works for every track and container, at one extra file per recovery.

What changed

  • Each video track has a parser ahead of the point where it is parked. Parking, the keyframe cut and the stash read keyframes from DELTA_UNIT, which tsdemux output (SRT passthrough) never sets. An h264parse or h265parse that lives for the whole recording marks keyframes and repeats SPS/PPS before each one.
  • Ending a track parks its input. An IDLE probe unlinks the input and sends the EOS into its chain, so splitmuxsink's pad ends as before while the input itself never carries EOS. While parked, a probe drops buffers (upstream sees no flow error; the tee also feeds the vision mixer) and keeps everything from the first keyframe of the current run of data.
  • A track is back once its run spans 3 s, with no gap longer than the 5 s stall timeout. A longer gap starts the kept buffers over. So a source that sends a frame now and then stays out, instead of starting a new file and freezing the other tracks again each time, while a source slow enough to be recorded before a stall is recorded after one.
  • On a resume, start_next_file switches files:
    1. It parks the recording tracks, which closes the current file, and catches that file's EOS before the pipeline sees it. A video track still being recorded is parked at its next keyframe, after a key-unit request, so neither file loses frames. With no keyframe within 15 s it is ended instead: splitmuxsink opens no file without its primary video.
    2. It takes splitmuxsink to NULL and requests fresh pads, since only a fresh pad clears splitmuxsink's per-pad EOS record, then relinks every track carrying data through a new parser and queue and restarts splitmuxsink. start-index keeps the _%05d numbering.
    3. Each track replays what it kept, on its own thread, then goes live. The new file starts on the run's first keyframe, so the 3 s wait loses no video, even from a remote encoder whose GOP is longer than the stall timeout. From the moment the switch takes a track, a gap no longer drops what it kept, and frames arriving during the replay follow it. The stall check keeps running, so a track that dies during the switch is still ended.
  • Kept buffers are capped at 32 MiB per track; a full stash drops its oldest GOP. The park probe takes a lock and can allocate, an exception to the buffer-probe rule that applies only while a track is out. The keyframe cut is a buffer probe present only during a switch.
  • A track whose input refuses to push its caps and segment, as one that took an EOS while parked does, gets them straight into the new chain. Buffers without a segment leave splitmuxsink unable to place them.
  • A flow stop waits out a relink. Blocks can register a pre-stop hook, which the pipeline manager runs before setting the pipeline to NULL, both in stop() and on drop. The recorder's hook closes a gate: a relink under way is waited for (10 s at most, then the stop goes ahead with a warning), and none starts after it. The watchdog and every wait in the switch give up once the gate is closed, so a stop during a wait logs no timeout warnings. The switch still holds the pipeline only while relinking, and the stop now waits for it to let go.
  • The hook then sends FLUSH_START into splitmuxsink. Opening a file overwrites the STOPPED state that PAUSED→READY sets, so a queue thread inside splitmuxsink can go back to waiting after the state change and setting NULL never returns. A flush releases that wait. In production stop() gives up after its 30 s deadline rather than hanging; the new stop-during-relink test, which sets NULL directly, hung on it.
  • Probes discard events with Handled rather than Drop: before GStreamer 1.24.8 (CI has 1.24.2) a dropped event is freed twice and logs a CRITICAL. That was the park probe's RECONFIGURE and the switch's EOS catch, seven per switch, plus two test stand-ins (an encoder ignoring key-unit requests, a source whose EOS is swallowed). Buffers are still dropped.
  • The parked input drops the RECONFIGURE that relinking sends upstream, so an encoder does not answer it with an ALLOCATION query that sits in the new queue and makes it read as drained to the watchdog.
  • If the old file is not written out within 10 s, the switch waits until the file sink is idle before closing it, since stopping mp4mux mid-write posts an error.
  • A track still quiet, or whose source really sent EOS, stays out of the new file.

Costs and gaps

  • Each stall still freezes the other tracks for the 5 s it takes to detect. On main that happens once, since the track never comes back. Here a source that returns for 3 s or more and drops out again costs it once per dropout, and a new file each time.
  • A returning track reaches the file 3 s after it is back, or at its first keyframe if that comes later, and the file starts on that keyframe. Above about 70 Mbit/s the 32 MiB cap is reached during that wait, and the new file starts a GOP or two later.
  • A switch waits up to 15 s for the recorded video's keyframe. A keyframe inside a buffer list is not split out.
  • A video that comes back in another codec still fails the flow, as on main (Recorder: a video track that changes codec fails the flow #931). With max_size_time_secs set, an mp4 recording still fails the flow on a video stall longer than about one split, as on main (Recorder: mp4 with max_size_time fails the flow on a long video stall #932).
  • A video left out of a switch for want of a keyframe, which then breaks for over 5 s during that switch, carries the break into the file that takes it back (Recorder: a video left out of a file switch keeps a break it should drop #939).
  • The segment fallback for a refused push is not guarded: the refusal without EOS was seen once in about 20 runs of one test, as a hang at stop, and not reproduced since.
  • async-handling is untested; live pipelines ignore a child's ASYNC_START. A connected track whose first caps arrive during a restart could reactivate splitmuxsink early.
  • Not guarded, each passing every test when reverted: replaying tracks on separate threads (replaying audio first in turn hangs the slow-storage test, but the recorder replays video first); dropping RECONFIGURE (its only test was a track back for one frame, which no longer resumes); parking a track once when the keyframe cut and its fallback both fire; the stall check skipping a track still replaying.
  • Not run on the live rig.
  • fix(recorder): retry an EOS the stalled track refused #762 also changes end_stalled_track. This should land first; fix(recorder): retry an EOS the stalled track refused #762 then needs a rebase, since its test file moved in test: consolidate the integration tests and share their helpers #912.

Related Issue

None. Found on a live rig recording.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have run cargo fmt --all
  • I have run cargo clippy --workspace --all-targets -- -D warnings
  • I have run cargo test --workspace
  • I have updated documentation as needed
  • I have added tests for new functionality

Tests

Ran locally on macOS with GStreamer 1.28.6:

  • A track_resume module in backend/tests/recorder_test.rs stalls and resumes real x264enc and avenc_aac sources through RecorderBuilder: video and audio resumes, two stalls in a row, a 10 s GOP that ignores key-unit requests (returning and still recording), a track back for one frame, a keyframe every 6 s, a frame every 4 s, a track that stops again as the new file takes it, a 5.5 s break while the switch closes the old file, a track that dies during the switch, a video that stops before the keyframe the switch waits for, storage blocked for 14 s, video with no keyframe flags (returning, cut, and no loss at a short GOP), a stop during a switch, a stop while the switch relinks, a stop while a track is out, a real EOS, and an EOS during the switch. After merging main: recorder_test 30/30, recorder_stall_test 3/3, pipeline_lifecycle_test 4/4.

  • Linux, in an Ubuntu 24.04 container with CI's package list (GStreamer 1.24.2): recorder_test 30/30 and recorder_stall_test 3/3, with no CRITICALs. At 125e7c1 the same run logged 131, matching CI's 130.

  • Unit tests: the stop gate (a stop waits for a relink and none starts after it; a stop gives up on a relink that never ends), plan_next_file, the stash's keyframe, overflow, run and replay rules, and the parked input answering OK upstream.

  • Revert checks, each failing its test:

    Reverted Result
    never starting a new file eleven resume tests fail
    the 3 s run the one-frame and keyframe-every-6-s tests start new files
    the run measured to now instead of to its last frame the one-frame test starts a new file
    restarting kept buffers after a gap the keyframe-every-6-s test starts new files
    a 3 s gap limit instead of the stall timeout the frame-every-4-s test never resumes
    dropping kept buffers after a gap once the switch has the track the 5.5 s break test loses the video
    clearing a full stash instead of its oldest GOP; dropping a frame that arrives during the replay; keeping a run's start across a forgiven gap unit tests fail
    the EOS catch early EOS
    a real EOS treated as a resume the recording loops into empty files
    the EOS pushed into a parked chain the stop-while-out test hangs
    the parked-EOS forwarding the EOS-during-switch test fails
    discarding the stash instead of replaying it the two-stalls, stops-again and stops-before-keyframe tests fail
    the idle check before closing a late file the slow-storage test fails with mp4mux's error
    parking the recorded video at once the long-GOP recorded-track test fails
    the stall check during replay the dies-during-switch test fails
    ending a video with no keyframe in time the stops-before-keyframe test fails, as with no time limit
    no keyframe parser the three no-flags tests fail
    holding the pipeline through the switch the stop-during-switch test fails
    the caps sent straight to a chain after an EOS the EOS-during-switch test fails with a flow error
    the stop gate (closing it returns at once) the stop-while-relinking test fails the leak check, 4/4
    the FLUSH_START at stop the stop-while-relinking test hangs, 3/6
  • cargo test --workspace: 1335 passed, 0 failed, 8 ignored.

  • Adversarial scratch tests that held: resumes in mpegts and mkv, three tracks with one finished, two video tracks, five stalls in a row, a resolution change while out, real tsdemux output, a real EOS during the keyframe cut, a source flapping 4 s on and 6 s off, a stop during a blocked replay, and a track ended during the replay that comes back later. The codec change and splitting cases above fail as on main.

🤖 Generated with Claude Code

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>
@wagenet
wagenet force-pushed the wagenet/recorder-track-resume branch from 47b89a7 to 6871cf9 Compare September 30, 2026 20:21
wagenet and others added 3 commits September 30, 2026 15:19
…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>
@srperens

srperens commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Noted as a draft, so I am holding off on a full review until it is marked ready for review. If you want one before then, say so here or request me as a reviewer.

@srperens srperens mentioned this pull request Oct 1, 2026
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>

@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: Request changes. Recovering in a new file is the right approach; the body rules out each in-file alternative. The healthy-path probes keep to CLAUDE.md, and all 32 recorder tests ran in CI. But one claim in the body fails for a stop that lands in step 3 of the switch, and the new tests multiply a GStreamer CRITICAL by about 130 that nobody has attributed.

Requested changes

  1. Close the step-3 window, or narrow the claim and guard it. In start_next_file, backend/src/blocks/builtin/recorder.rs:1106 — let Some(bin) = splitmuxsink takes the pipeline after a single state check, backend/src/blocks/builtin/recorder.rs:1109 — .filter(|b| b.current_state() >= gst::State::Paused). It holds the pipeline until backend/src/blocks/builtin/recorder.rs:1217 — drop(bin);, across splitmuxsink's NULL and the relinking. A stop_flow whose drop lands in that window fails its leak check, backend/src/state/mod.rs:1526 — let pipeline_survived = pipeline_weak.upgrade().is_some();. That logs the ERROR CLAUDE.md treats as P0, and the relink then runs against a pipeline that is going to NULL. Either make stop wait for an in-flight switch before the drop, or state the window and add a test that stops inside step 3.
  2. Attribute the CRITICALs. Check (Linux) at 125e7c1 prints gst_mini_object_unref: assertion 'mini_object != NULL' failed 130 times in recorder_test, mostly in bursts of 7–8 while track_resume runs. The same binary on #944, which is based on main, prints it once. Say whether these come from the switch or from the test sources. If they come from the switch, fix them.

Claims

Claim Verdict Evidence
A flow stopped mid-switch is not kept alive past the leak check CONTRADICTED Holds during the waits. Fails in step 3 (item 1). The guarding test covers only the keyframe wait: backend/tests/recorder_test.rs:2519 — /// reports a leak if anything still holds it. A stop while a switch waits for
The healthy path adds no locking probe CONFIRMED The only permanent BUFFER probes are the retired-input probe and the intake probe, and both use atomics only. The park, cut and write-tracking probes exist only while a track is out or a switch runs
The new closures hold no strong element references CONFIRMED The caps probes, the keyframe park and WatchedTrack hold WeakRefs. The replay threads are scoped and joined before start_next_file returns
The resume tests ran CONFIRMED Check (Linux), run 36870108266: test result: ok. 32 passed, 183 s, under STROM_REQUIRE_GST_PLUGINS: 1

Lifetime: the watchdog thread lives as long as the flow. It holds a strong splitmuxsink for a whole switch, from backend/src/blocks/builtin/recorder.rs:1391 — let Some(sink) = splitmuxsink.upgrade() else {; that can be 15 s or more, but it does not keep the pipeline alive. A stop during a wait makes the next two warnings report timeouts that never happened.

Radius: LOCAL. One block. Every recorder flow with two or more tracks runs the new watchdog paths.

Overlaps: #762 also changes end_stalled_track, and the body asks to land first. Both conflict textually. #944 assumes that only the first file can start before its reference track. A switch here restarts splitmuxsink, which breaks that assumption, so whichever lands second must treat every restart as a first file (see my review there). #967 changes the stop path that item 1 touches.

Tests & CI: all checks green at 125e7c1, which is 25 commits behind main. macOS and Windows were skipped, and nothing here is platform-specific. recorder.rs is now 2927 lines. CLAUDE.md asks for a split above 1500 lines, best done after this and #944 land.

Confidence: MED

wagenet and others added 3 commits October 6, 2026 17:52
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>
@wagenet

wagenet commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Written by Claude.

Both addressed, and main is merged in.

1. A stop during step 3. Stop now waits for an in-flight switch. Blocks can register a pre-stop hook, which the pipeline manager runs before setting NULL (in stop() and on drop). The recorder's hook closes a gate that the relink holds from taking bin until splitmuxsink is reactivated: a relink under way is waited for (10 s at most), and none starts after it. Every wait in the switch and the watchdog loop return once the gate is closed, so a stop mid-wait no longer logs timeout warnings either.

Waiting for the relink surfaced a splitmuxsink hang: opening a file overwrites the STOPPED state that PAUSED→READY sets, so a queue thread goes back to waiting and setting NULL never returns. The hook sends FLUSH_START into splitmuxsink, which releases it.

New test, a_flow_stopped_while_the_switch_relinks_leaves_nothing_holding_its_pipeline, holds the relink open from pad-removed and stops on another thread. With the gate disabled it fails the leak check 4/4; without the flush it hangs 3/6; with both it passes every run.

2. The CRITICALs. All of them were probes returning Drop for an event, which GStreamer frees twice before 1.24.8. Seven per switch came from the switch itself (the park probe's RECONFIGURE, the EOS catch); the rest from two test stand-ins, including main's one in recorder_stall_test. Those now return Handled. In an Ubuntu 24.04 container with CI's packages: 131 before, 0 after, recorder_test 30/30 and recorder_stall_test 3/3.

Also changed: a replay whose segment push is refused for any reason now sends the sticky events to the new chain directly, not only after EOS. I saw the refusal once without EOS, as a hang at stop, and could not reproduce it, so that path has no guard. The PR body covers it.

@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.

Supersedes my review of 2026-10-05 at 125e7c1. That Request changes verdict does not stand: both requested changes are done.

Verdict: Comment. The stop gate closes the step-3 window, and the CRITICALs are gone from recorder_test. But #967 adds a second "block work before NULL" hook at the same line of stop(). Merged in the wrong order, the two cancel each other out, so a maintainer has to choose the shape.

Requested changes (or a maintainer decision instead)

  1. Agree one ordered stop hook with #967 before either PR lands. This PR adds self.run_pre_stop_hooks(); where #967 adds self.run_stop_drains();, directly after self.volume_ramps.clear(); in backend/src/gst/pipeline/lifecycle.rs. That is a textual conflict, and the order you resolve it in changes behaviour:

    • Flush first, then drain. The hook's FLUSH_START puts splitmuxsink's sink pads into flushing. #967's EOS, which it sends into the queue in front of each pad, is then discarded. Every recorder stop waits out #967's 5 s timeout and the file is still unfinished, which is the bug #967 fixes.
    • Drain first, then gate. A drain can run during a relink, and #967 aims its EOS at whatever queue peer() finds while this PR is replacing those queues.

    The order that works for both is: close the gate, then drain, then flush. Neither PR can express that on its own. Option: land this first and give #967's drain a slot between the gate and the flush. Or land #967 first and rebase this onto register_stop_drain. Or wait, and merge the two APIs into one.

Claims

Claim Verdict Evidence
A stop no longer lands inside the relink CONFIRMED bin is taken only after the gate is entered and released before the gate is left (citations below). The gate's SeqCst store-then-load on each side rules out both sides missing each other
The new tests run in CI and guard the stop window CONFIRMED Check (Linux) at e5c4ab3: recorder_test reported 30 passed in 173 s, including a_flow_stopped_while_the_switch_relinks_leaves_nothing_holding_its_pipeline. The two RelinkGate unit tests and pipeline_lifecycle_test also ran
The CRITICALs came from the switch, and Handled removes them CONFIRMED The same job printed gst_mini_object_unref 0 times in recorder_test, against 130 at 125e7c1. 125 of the 129 in this job are in output_refusal_test, which this diff does not touch. I have not attributed those 125
FLUSH_START releases a splitmuxsink queue that NULL cannot EXTERNAL Depends on splitmuxsink internals. The assumption is the author's observed hang without it.

Lines on this head:
backend/src/blocks/builtin/recorder.rs:1220 — let Some(relinking) = gate.enter() else {
backend/src/blocks/builtin/recorder.rs:1334 — drop(bin);
backend/src/blocks/builtin/recorder.rs:1341 — drop(relinking);
backend/src/blocks/builtin/recorder.rs:2318 — pad.send_event(gst::event::FlushStart::new());
backend/src/gst/pipeline/lifecycle.rs:274 — self.run_pre_stop_hooks();

Radius: SHARED. This PR now adds a BlockBuildContext API and a step to PipelineManager::stop() and Drop. Both are no-ops for flows without a recorder, because the hook list is empty. PreStopFn is FnOnce, and a flow restart builds a new PipelineManager (start_flow), so a closed gate never outlives its flow.

Overlaps: #967 (the same code path, and the same design decision, described above). #944: the restart-as-first-file conflict from my previous review still stands. #762: this PR's body still asks to land first.

Tests & CI: Linux, WASM and API checks are green at e5c4ab3. macOS and Windows were skipped, and nothing here is platform-specific.

Confidence: HIGH

@srperens
srperens dismissed their stale review October 7, 2026 08:09

Superseded by my re-review at e5c4ab3: both requested changes were addressed.

@wagenet

wagenet commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Written by Claude.

Agreed on the order: close the gate, then drain, then flush. The plan is to land this PR first and resolve the order in #967's rebase:

  • The recorder's pre-stop closure splits in two. Closing the gate stays a pre-stop hook. The FLUSH_START moves to a new hook that runs after the drains.
  • stop() runs pre-stop hooks, then run_stop_drains(), then the post-drain hooks. Drop runs the same steps without the drain.
  • fix(recorder): finish the current file when the flow stops #967's drain stays its own API rather than going into the recorder's closure. That keeps every block's drain in parallel under one shared 5 s deadline. With one closure per recorder, recorders would drain one after another.

This PR does not change. The rebase also needs a test for a stop that comes after a switch while one track is still out. A retired track may never carry EOS in the current file, so the drain's completion check could wait out its timeout.

@srperens

srperens commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Comment by Claude (AI), on behalf of @srperens.

Thank you for this, and for the adversarial tests behind it: #931, #932 and #939 came out of them, and they shaped what we built next. The diagnosis is right. The trouble is splitmuxsink itself: it waits for every pad at each GOP, and it keeps a pad's EOS for good. A recovery built around it has to take splitmuxsink apart to bring a track back.

In #1034 a stalled track comes back in the same file, with a gap where it was missing. We measured splitmuxsink freeze the video intake for an entire 30 s audio stall, where the live muxers kept writing. #932 also goes away, because nothing reserves header room.

Where we're heading. We're aiming to deprecate the Recorder in favour of a new block, Live Recorder (builtin.liverecorder), in #1034. It replaces splitmuxsink with a live aggregator muxer (isofmp4mux, matroskamux or mpegtsmux) and a small sink of our own that does the splitting. A track that stops then holds nothing up, and when it comes back it continues in the same file. There is no watchdog, no ended track, and no new file per stall. Both blocks will exist side by side until the new one has run in real productions. The Recorder stays as it is in the meantime.

We'd really like your help with this:

  • Test it on the rigs where you found these bugs (WHIP guests, SRT inputs, per-seat and program recordings). Swap builtin.recorder for builtin.liverecorder; the pads and properties are the same. It builds and runs on GStreamer 1.24 and later, like the rest of Strom.
  • Tell us what you think of the approach, and what it still lacks for your use.
  • Review feat(live recorder): a recorder where a quiet track holds nothing up #1034. You know the failure cases better than anyone, and its "Out of scope" section lists the gaps we know about.

@wagenet
wagenet marked this pull request as draft October 7, 2026 18:31
srperens added a commit that referenced this pull request Oct 8, 2026
…ataless track held back

From @wagenet's run of the #927 stall cases on #1034:

- Frames lost upstream of the recorder leave the first frames back as
  deltas against frames the file never got; with a split, a file could
  open on them. When the keepalive first fills a video track's gap, the
  track now drops until a keyframe and asks upstream for one with a
  force-key-unit. An encoder that went on encoding through the stall used
  that request up, so it is asked again when frames start arriving and
  are still being dropped. A Strom encoder then answers with its next
  frame instead of a GOP later.
- A stop before a connected but dataless track was released wrote no
  file: the drain only ran once a file was open. It now always sends EOS,
  which also ends the muxer's wait for the dataless track.

For that drain to get its data through, the fragment sink is now a plain
element with a sink pad rather than a BaseSink. A BaseSink with no buffer
by PLAYING still counts as not prerolled, and a first buffer arriving
while the pipeline heads for PAUSED waited in preroll for a PLAYING that
never came. The sink never synced to the clock, so nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Oct 8, 2026
…1034)

* feat(live recorder): prototype a recorder where a quiet track holds nothing up

A new block, builtin.liverecorder, next to the Recorder. It records to
fragmented MP4 through isofmp4mux, a live aggregator, instead of
splitmuxsink:

- A track that stops does not stop the others or anything upstream, and
  continues in the same file when it returns, with a gap where it was
  missing. No watchdog, no ended tracks, no new file per stall.
- A quiet track gets GAP events, so the muxer keeps cutting fragments
  while the video is gone and the audio keeps reaching the disk.
- A connected track that carries nothing for 20 s is released from the
  muxer. Until then the other tracks wait in their queues, which answer
  ALLOCATION queries themselves, so an encoder is never held up.
- A small BaseSink writes the fragments and starts a new file at a
  fragment boundary, with the init segment first. A split due at the
  muxer's last fragment keeps that fragment in the current file.
- The sink is not async, so a late first buffer does not pull the flow
  out of PLAYING.

Experimental: mp4 only, and a track that arrives after the 20 s release
is not recorded until the flow restarts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(live recorder): start each file at zero and finish the file on stop

Each split file now starts at zero. The muxer numbers time through the
whole recording, so the second file said it started at 38 s and players
showed a 46 s file holding 8 s. The sink moves every tfdt in a file back
by the file's start, the same instant for every track, in each track's
own timescale. Values change in place; no box changes size.

A flow stop no longer drops the fragment the muxer is still holding.
When the pipeline is on its way to READY or NULL, the sink sends EOS
into each track's queue at its PLAYING -> PAUSED step, while it still
renders, and waits up to 3 s for the muxer to hand the rest over. A
pause does not trigger it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(live recorder): write MKV, MPEG-TS and TS passthrough as well

The Live Recorder now takes the Recorder's container property:

- mkv: matroskamux, streamable. A file can start at a cluster that
  starts on a keyframe; the header comes from the caps' streamheader.
  The sink moves every cluster timestamp back so each file starts at
  zero.
- mpegts: mpegtsmux with default alignment, so the keyframe's first
  packet is flagged and a file can start there after PAT and PMT from
  the streamheader.
- ts_passthrough: the Recorder's own passthrough, unchanged.

Both muxers are live aggregators, so a quiet track holds nothing up and
comes back in the same file, as with MP4. Fragmented MP4 cannot carry
MP3 or DTS; the refusal says to pick mkv or mpegts for those.

The sink now buffers its writes and flushes at every cut point and at
least every 500 ms, so the audio keeps reaching the disk while a stalled
video provides no cut points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(live recorder): split now starts the next file at the next cut point

A requested split was held back a fragment like a timed one, so the new
file, and its name in the editor, showed up to two GOPs after the click.
Only a split the policy makes can coincide with a stop and leave the
muxer's tail alone in a file; one the operator asks for goes at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: take gst-plugin-isobmff from a fork that builds on GStreamer 1.24

gst-plugin-isobmff 0.15 builds gstreamer-audio with v1_26 for a single
function, gst_audio::reorder_channels_with_reorder_map(), so Strom stopped
building on Ubuntu 24.04 (GStreamer 1.24.2): Linux CI and the Linux
release builds, whose binaries install.sh puts on the distro's GStreamer.

The fork (srperens/gst-plugin-isobmff, tag v0.15.4-strom1) is the
published 0.15.4 source plus one commit that does that reordering in Rust
and builds gstreamer-audio with v1_18. Nothing in the dependency tree
enables v1_26 any more. Drop the patch once upstream no longer needs 1.26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(live recorder): refuse mkv before GStreamer 1.26, and stop dropping events

Linux CI (GStreamer 1.24.2) showed two things the macOS runs (1.28) could
not:

- matroskamux became a GstAggregator only in 1.26. Before that it is
  GstCollectPads-based and waits for every track, so a stalled video kept
  the audio out of the file, the freeze this block exists to avoid. The
  block now refuses mkv on older GStreamer with an error that names what
  is needed and to use mp4 or mpegts. The mkv tests check that refusal on
  older GStreamer and the behaviour on 1.26 and later.
- The retired-track probe dropped events. Before GStreamer 1.24.8 a
  dropped event is freed twice, which logged gst_mini_object_unref
  CRITICALs. Events are now discarded with Handled; buffers are still
  dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(live recorder): accept audio-only Matroska

matroskamux labels a recording without video audio/x-matroska, which the
fragment sink's template refused, so an mkv Live Recorder with no video
tracks failed with not-negotiated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(live recorder): resume video on a keyframe, and finish a file a dataless track held back

From @wagenet's run of the #927 stall cases on #1034:

- Frames lost upstream of the recorder leave the first frames back as
  deltas against frames the file never got; with a split, a file could
  open on them. When the keepalive first fills a video track's gap, the
  track now drops until a keyframe and asks upstream for one with a
  force-key-unit. An encoder that went on encoding through the stall used
  that request up, so it is asked again when frames start arriving and
  are still being dropped. A Strom encoder then answers with its next
  frame instead of a GOP later.
- A stop before a connected but dataless track was released wrote no
  file: the drain only ran once a file was open. It now always sends EOS,
  which also ends the muxer's wait for the dataless track.

For that drain to get its data through, the fragment sink is now a plain
element with a sink pad rather than a BaseSink. A BaseSink with no buffer
by PLAYING still counts as not prerolled, and a first buffer arriving
while the pipeline heads for PAUSED waited in preroll for a PLAYING that
never came. The sink never synced to the clock, so nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(live recorder): carry the MPEG-TS PCR on a PID of its own

mpegtsmux puts the PCR on the first video stream, so a video stall left
a hole in the clock reference as long as the stall. The file's PTS were
right, but tsdemux rebases across a PCR gap: in @wagenet's run a 10 s
recording with a 3 s video stall read as 7.4 s, audio included, for any
GStreamer-based reader, Strom's media player among them.

prog-map PCR_1=(int)0x100 gives the program a PCR-only PID, which
mpegtsmux keeps writing whatever the tracks do. Supported since before
GStreamer 1.24. The mpegts stall test now checks the gaps and coverage
through tsdemux like the other containers; without the PCR PID it reads
a largest video gap of 0.47 s for a 4 s stall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Peter Wagenet <peter.wagenet@gmail.com>
@wagenet

wagenet commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Comment by Claude (AI), on behalf of @wagenet.

Closing. Live Recorder (builtin.liverecorder, #1034) does not have the problem this PR fixes: a stalled track leaves a gap and returns in the same file, so nothing needs a second file or a splitmuxsink teardown. The branch wagenet/recorder-track-resume stays as it is, in case Live Recorder fails in a real production and this approach is needed again.

Thanks @srperens for the pointer to #1034 and for the earlier reviews.

@wagenet wagenet closed this Oct 9, 2026
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