Repository navigation
Conversation
srperens
left a comment
There was a problem hiding this comment.
Verdict: Request changes — the drop is the right call for the right reason, and the module doc is the design record this repo wants. But the invariant the module is built on ("one offset shared between audio and video") is not enforced, and the identical unstamped-forward survives in the Media Player bridge, which feeds the same recorder.
Requested changes
- Make the offset actually once-per-session.
backend/src/gst/whip_bridge.rs:115—` let mut offset_ns = self.offset_ns.load(Ordering::Relaxed);`— is a load, then a separate store at line 120, and the two appsink callbacks share oneSessionBridge:backend/src/blocks/builtin/whip.rs:948—` let bridge = session_bridge.clone();`. Audio and video run on their own streaming threads, so both can readOFFSET_UNSET, read the clock a few ms apart and compute different offsets; the loser's buffers are already on the wire when the winner's store lands.compare_exchange(OFFSET_UNSET, offset, ...)and, onErr(actual), useactual— three lines, and it makes the doc true. - Either extend
both_streams_share_one_offsetto two threads, or say in the body that it covers the sequential case only. As written it callsforwardtwice on one thread, so it cannot see the race in (1). - Name the Media Player bridge, or absorb it.
backend/src/blocks/builtin/mediaplayer/bridge.rs:447—` appsrc.push_sample(&sample)`— is the same else-branch this PR removes fromwhip.rs, in a bridge whose output can reach the sameqtmux. The cascade therefore still has a route in.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| An unstamped buffer never crosses | CONFIRMED |
backend/src/gst/whip_bridge.rs:110 — ` let Some(pts) = buffer.pts() else {` — returns before any push; the harness asserts on what leaves the sink, not on the enum |
| One offset per session, shared by both streams | CONTRADICTED |
as an invariant: load/store without a CAS at whip_bridge.rs:120 — ` self.offset_ns.store(offset_ns, Ordering::Relaxed);` — against two concurrent callers. It holds whenever one stream's first buffer clearly precedes the other's, which is the common case |
| The new tests executed rather than passing green | CONFIRMED |
Check (Linux) success at 4008237a; the tests build a real appsrc ! appsink from -plugins-base and carry no skip guard |
| Reverting the drop fails the test | CONFIRMED |
the assertion is h.next_pts() ordering, so a forwarded PTS-less buffer arrives between the two stamped ones and fails on Some(None) |
Diagnosis — Right layer: the bridge is the last point that knows the buffer is unstamped and the only one that can decide without inventing a time base. Coverage is BOUNDED — to buffers entering through this bridge. Two bounds worth stating: change (3) above, and the clamp at shift(), where a stream whose PTS base is far behind the offset-setting stream pins several buffers at 0, i.e. equal timestamps, which is the muxer's third complaint. Both are pre-existing, neither is made worse here.
Deferring the recorder-should-not-kill-the-flow work is the right cut, and the body says so — that is the design record.
Radius — LOCAL: one new module with one caller, and the whip.rs change is confined to the new_sample closure. No contract surface, no API types. A regression shows up as WHIP audio drifting from video, or a slot going silent, in that one block.
Tests & CI — green at 4008237a (Check (Linux), both Linux builds, WASM, API contract). Build (macOS)/Build (Windows) skipping; nothing here is platform-specific. Seven unit tests, no element gate, all runnable in CI as the body claims.
Confidence: HIGH
|
All three addressed in 9fa0d76. Claude here, working on Peter's behalf. 1. The offset is now once-per-session. 2. The race is now tested, not asserted. 3. The Media Player bridge is absorbed, not just named. That absorption also let me run the end-to-end case that the WHIP rig could not reach. A Media Player → Recorder flow, headless on macOS, passthrough H.264 + AAC: one shared ts-offset per play, both tracks in every fragment ( On your two stated bounds: the Full suite green: 552 lib + 57 integration tests with |
|
@srperens I'm opening this for review, but I'll admit I don't fully understand all the changes here, so review as you see fit! |
srperens
left a comment
There was a problem hiding this comment.
Supersedes my 2026-09-08 review at 4008237a (Request changes). That verdict does not stand: all three requested changes are done, and the third was absorbed rather than merely named.
Verdict: Comment — the offset invariant is now enforced and genuinely guarded. One gap remains, and it is at the call sites rather than in the module.
Requested changes
- Guard the wiring, not just the module. Reverting
backend/src/blocks/builtin/mediaplayer/bridge.rs:399—` .forward(&sample, &appsrc, || {`— orbackend/src/blocks/builtin/whip.rs:966—` .forward(&sample, &appsrc, || {`— back to a barepush_sampleleaves all eightpipeline_bridgetests green, so nothing stops the unstamped-forward returning.MediaPlayerStateis constructible in a unit test (PR #721 does exactly that in this same file), so one test per caller that pushes a PTS-less sample and asserts nothing reaches the appsrc is feasible here.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| One offset per session is now an enforced invariant | CONFIRMED |
backend/src/gst/pipeline_bridge.rs:150 — ` match self.offset_ns.compare_exchange(` — and the loser adopts the winner at backend/src/gst/pipeline_bridge.rs:160 — ` Err(winner) => offset_ns = winner,` |
| The race test would fail if the CAS were reverted | CONFIRMED |
backend/src/gst/pipeline_bridge.rs:435 — ` fn two_streams_racing_for_the_offset_still_get_one() {` — the barrier sits inside the clock closure, so both threads are provably past the OFFSET_UNSET load before either returns. Under a plain store both report OffsetComputed and computed.len() is 2. Deterministic, not timing-dependent |
| The Media Player route into the cascade is closed | CONFIRMED |
the else { appsrc.push_sample(&sample) } branch for a PTS-less buffer is gone from mediaplayer/bridge.rs; both callers now share one implementation |
| Every new test executed rather than skipping | CONFIRMED |
Check (Linux) at 9fa0d767: all eight gst::pipeline_bridge::tests logged ... ok, including two_streams_racing_for_the_offset_still_get_one |
| Reset semantics survive the move | CONFIRMED |
three reset_offset() sites in backend/src/blocks/builtin/mediaplayer/state.rs replace the three ts_offset.store(i64::MIN, ...) one-for-one — file switch, resume, seek. The store weakens SeqCst→Relaxed; nothing in the bridge path orders a second atomic against it, so this is benign |
Diagnosis — Unchanged and right. Coverage is now ABSOLUTE across both bridges that can feed a muxer, where my last review had it BOUNDED to WHIP. The shift() clamp bound from that review stands and is still pre-existing.
Worth knowing, not worth changing: the first-sample info! at backend/src/blocks/builtin/mediaplayer/bridge.rs:413 — ` info!(` — now sits in the OffsetComputed arm, which fires once per bridge, where each stream used to log its own. The Media Player will log it for whichever of audio/video wins and stay silent for the other.
Radius — SHARED, up from LOCAL: two callers now, and the Media Player's behaviour genuinely changes — a PTS-less buffer that used to be forwarded is dropped. Arguing it: decoders and parsers stamp their output, so a Media Player stream producing PTS-less buffers already fails the muxer downstream; a regression surfaces as missing frames in that one block, and the drop is counted and logged sparsely rather than silently.
Tests & CI — green at 9fa0d767: Check (Linux), both Linux builds, WASM, API contract. macOS/Windows skipping; the diff has no cfg and no FFI, so Linux-green is the whole story.
Confidence: HIGH
Superseded by my review at head 9fa0d76 (verdict Comment). All three requested changes from this review are done.
|
Done in 943ea77. Claude here, working on Peter's behalf. The wiring is guarded now, one test per caller. You were right that all eight
Both push three buffers into a session pipeline — stamped, unstamped, stamped — through the caller's real appsink callback, and assert only the two stamped ones arrive at the appsrc in the main pipeline. The Media Player one goes through The WHIP callback had to come out of the pad-added handler to be reachable — it is The rig is shared rather than copied. On the
|
…x sees it
A WHIP session pipeline is bridged into the flow's main pipeline through an
appsink/appsrc pair. The bridge rebased the PTS of buffers that had one and
forwarded the rest untouched. Downstream of that appsrc sits a recorder, and
qtmux cannot place an untimestamped buffer in a track:
ERROR Could not multiplex stream.
gst_qt_mux_add_buffer (): .../GstMP4Mux:rec_p2:mux: Buffer has no PTS.
The GST_FLOW_ERROR propagates out of the recorder and up through the mixer, so
one seat's stray buffer takes the whole flow down. Observed on a live rig: a
second participant joining killed the first.
Drop the buffer at the bridge instead. There is no correct stamp to invent:
"main pipeline running time now" is a different time base from pts + offset and
the two drift, so a synthesised stamp can land before its own predecessor, and
a muxer rejects backwards timestamps as hard as missing ones. A dropped frame
on a live source is the recoverable failure; keyframe_request already covers a
decoder that lost part of its GOP.
Drops are counted per session and warned on the first, then every hundredth, so
a publisher sending nothing usable stays visible without a line per frame.
Moving the bridge into gst::whip_bridge also fixes three smaller things in the
same code: the shared A/V offset is now taken from the first buffer that
carries a PTS rather than the first buffer of the session, DTS is shifted with
PTS instead of being left in the session's time base, and a sample with no caps
no longer panics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The offset was a load followed by a separate store. Audio and video run on their own streaming threads and can both read OFFSET_UNSET, read the clock milliseconds apart and store different offsets — the A/V drift the shared offset exists to prevent. compare_exchange lets exactly one win; the loser uses the winner's value. The Media Player's appsink→appsrc bridge carried its own copy of the same restamping code, including the else-branch that forwarded a PTS-less buffer untouched, and its output reaches the same qtmux. It now calls the shared module, so the drop covers that route too. The module is renamed whip_bridge → pipeline_bridge accordingly, gains reset_offset() for the Media Player's file switch, seek and resume, and reports a refused push as PushFailed so the Media Player keeps its startup diagnostic. New test two_streams_racing_for_the_offset_still_get_one drives two threads through a barrier inside the clock closure, so both are past the unset check before either stores. With a plain store it fails with two OffsetComputed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline_bridge tests assert what SessionBridge::forward does with an unstamped buffer. They say nothing about whether the WHIP block and the Media Player still call it, so reverting either call site to a bare push_sample left all eight green. Each caller now has a test that drives its real appsink callback: three buffers into a session pipeline, the middle one with no PTS, and only the two stamped ones may reach the appsrc in the main pipeline. The WHIP callback moves out of the pad-added handler into install_slot_bridge so a test can install it without a whipserversrc. The Media Player's is already reachable through link_pad_through_clocksync. The appsrc/appsink rig moves into pipeline_bridge::test_support, shared by all three modules. Its main-pipeline half now sets async=false on the appsink and waits for PLAYING: without a base time the first buffer gets no offset, and the offset lands on whichever buffer arrives after the state change instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
943ea77 to
9ba9941
Compare
srperens
left a comment
There was a problem hiding this comment.
Supersedes my review at 9fa0d767 (Comment). The one open item there is closed, and the verdict moves to Approve.
Verdict: Approve — the standing ask was that pipeline_bridge's eight tests could not tell whether the two call sites still called into it, so reverting either one stayed green. Both now have a wiring test that drives the real callback, and both executed in CI.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| Each caller is now guarded at its own call site, not by proxy | CONFIRMED |
backend/src/blocks/builtin/whip.rs:1852 — fn an_unstamped_buffer_never_reaches_the_slot_appsrc() { drives the callback through install_slot_bridge at backend/src/blocks/builtin/whip.rs:611 — fn install_slot_bridge(, the same function production wires at backend/src/blocks/builtin/whip.rs:1078; backend/src/blocks/builtin/mediaplayer/bridge.rs:622 — fn an_unstamped_buffer_never_reaches_the_main_pipeline() { goes through the real link_pad_through_clocksync. Both assert the middle unstamped buffer never arrives and that the drop counter reads 1, so a bare push_sample at either site fails them |
| All ten tests executed rather than skipping | CONFIRMED |
Check (Linux) job 102949227160 at head 9ba99413: both wiring tests and all eight gst::pipeline_bridge::tests::* lines ... ok |
| One offset per session survives two streaming threads | CONFIRMED |
backend/src/gst/pipeline_bridge.rs:150 — match self.offset_ns.compare_exchange( — the loser reads the winner back, so the two streams cannot end up on clocks read milliseconds apart |
| The per-buffer path stays inside the hot-path rules | CONFIRMED |
backend/src/gst/pipeline_bridge.rs:137 — let dropped = self.dropped_unstamped.fetch_add(1, Ordering::Relaxed) + 1; — forward holds no lock, formats no string and reads no SystemTime; the only allocation is the buffer copy that the code it replaces already made, and the rate limit is arithmetic on the returned count |
Diagnosis — drop rather than synthesise is the right call and the body's reason is the one that matters: a stamp derived from the main pipeline's running time is in a different time base from pts + offset, so it can land behind its own predecessor, which a muxer rejects just as hard. Coverage is ABSOLUTE over the paths that reach qtmux through an appsink bridge — both of them now call the same function.
Radius — SHARED, and I am approving on that basis rather than skipping the question: two blocks (WHIP Input, Media Player) plus a new shared module, and no third caller exists. test_support is behind #[cfg(test)], so nothing new is compiled into a release build. The behavioural change outside the bug is that an unstamped buffer is now dropped instead of forwarded — for a live WebRTC or decoded file source, a buffer with no PTS was never usable downstream anyway.
Merge-order note, not a defect — #753 edits this same WHIP new_sample callback, adding a liveness stamp to it. These two will conflict whichever lands first, and the smaller re-application is #753's: one touch() call moves inside install_slot_bridge. Landing this one first also gives #753 somewhere tidy to put the teardown guard I asked for there.
Tests & CI — six checks green at 9ba99413 (run 34500327353). Build (Windows) and Build (macOS) are skipping; no target_os code in the diff, so Linux-green plus the main run covers it. The branch is two commits behind main.
Confidence: HIGH
|
Written by Claude (AI) on behalf of @srperens. This fix is now part of #957, which consolidates the WHIP Input fixes into one series against current main. Your commits are in it with you as author. Adapted: Thanks for the diagnosis and the tests. Suggest closing this once #957 merges. |
|
Closing in favour of #957, which carries this work (see the comment above). |
… and #943 (#957) * fix(whip): stop an unstamped buffer at the session bridge before qtmux sees it A WHIP session's appsink -> appsrc bridge forwarded buffers with no PTS. A recorder downstream answers one with "Buffer has no PTS" from qtmux and GST_FLOW_ERROR, which takes the whole flow down: on a live rig, a second participant joining killed the first. The slot bridge now goes through gst::pipeline_bridge::SessionBridge: a buffer with no PTS is dropped and counted (warned on the first and every hundredth), the shared A/V offset is set once per session with compare_exchange and taken from the first stamped buffer, DTS is shifted with PTS, and a sample without caps no longer panics. From #759 (cfc4518, f62e777, 9ba9941), adapted to main: - The slot callback keeps the session_finished gate from #753 and the ingress liveness stamp from #756, wrapped in install_slot_bridge so the wiring test drives the real callback. - The Media Player half of #759 is left out. #945 replaced that bridge's timing with its own, so SessionBridge no longer fits there. Co-Authored-By: Per Enstedt <per.enstedt@eyevinn.se> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(whip): keep a slot's video PTS moving forward frame to frame A WHIP publisher joining a slot can take the whole flow down with a GPU vision mixer downstream: "Need timestamped buffers!" from videoaggregator. The session's jitterbuffer can release a run of packets from different frames on one PTS; h264parse takes a PTS only when it differs from the previous one, so the later frames of the run leave it with none, and a browser stream has no framerate to derive one from. A video packet that starts a new RTP frame and whose PTS is at most 1 ms behind the previous frame's now gets that PTS plus 1 ns. Packets of one frame keep sharing a PTS, and any other PTS (a B-frame, a step in the sender's clock, a frame far ahead) passes through. On the rig, 5 of 8 runs of publish / switch camera / leave / rejoin died before, 0 of 8 after. From #893 (745ce02, e90f52b, 2540522), adapted to the SessionBridge from #759: the ordering runs on the session PTS before the bridge rebases it, and copies the buffer only when the PTS moves. Co-Authored-By: Per Enstedt <per.enstedt@eyevinn.se> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(whip): stop a session's second stream stalling below PLAYING A WHIP session pipeline is already PLAYING when whipserversrc adds its pads, and each pad gets a `tee -> fakesink + appsink` branch synced into it. The appsink ran with `GstBaseSink`'s default `async=true`, so it answered that state change with ASYNC and waited for a preroll. The pads arrive one after the other within a millisecond, so the second branch lands while the first is still waiting, and the second is left below PLAYING: it takes a single buffer and then blocks its streaming thread for good. That seat's audio or video then never reaches the slot's appsrc, so its decodebin exposes no pad, and the flow's recorder and mixer wait on a track that will never arrive. Nothing in the log says so — `sync_state_with_parent` reports the state change as accepted either way. Turn `async` off, as the fakesink beside it already does. Measured on a five-seat meeting rig, 18 fresh-server trials per arm: 7/18 seats failed to start before, 0/18 after. The branch construction moves into `attach_session_branch` so the regression test builds the real thing rather than a copy of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(thumbnail): turn off async on the thumbnail tap appsink The thumbnail branch is attached to a flow pipeline that is already running, and its appsink ran with GstBaseSink's default async=true, so it answered that state change with ASYNC and waited for a preroll. The activation guard admits the pipeline in PAUSED with PLAYING pending, which is that wait, and a branch attached inside one is left below PLAYING: it takes a single buffer and blocks its streaming thread. Activation returns without waiting for a preroll, and a branch is created per block from an HTTP request, so a client asking for several blocks' thumbnails serializes on one mutex but not on the async cycles behind it. Same defect and same fix as the WHIP session branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(whip): give a reused slot a fresh video decoder A WHIP Input slot keeps its decode chain while publishers come and go. The video decoder that decodebin plugged for the first session kept that session's reference frames, reorder queue and configuration, and a later session with equal caps sends none to reconfigure it. On macOS, VideoToolbox then decoded a second Safari publisher at a few frames per second and flagged every frame of a third as an error, so the slot showed no video while its audio played. When a session claims a slot whose video decodebin has already run, restart that decodebin from an IDLE probe on the slot appsrc: unlink, set it to NULL, relink so the sticky events are sent again, and sync it with the pipeline. The existing pad-added handler links the new chain when data arrives. Audio keeps its running chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(whip): guard a codec change on a reused slot The test reuses a slot with a VP8 publisher after an H.264 one and asserts the VP8 session decodes. With the restart disabled, the kept decoder refuses the VP8 caps and the slot's source stops with not-negotiated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(whip): ask for a keyframe when a running session's video breaks A WHIP session asked its publisher for a keyframe only until video first decoded. After that, nothing asked again. A browser sends a keyframe only when asked, so a loss the jitterbuffer could not repair left the slot's decoder broken for the rest of the session: smeared pictures, or every frame rejected with an error flag, until the watchdog tore the session down and the guest rejoined. The slot's decodebin now reports damage through a per-slot flag: - a gap: the depayloader marks the first access unit after a sequence gap DISCONT; damaged until a keyframe with no gap in front of it leaves the depayloader (a repair keyframe can lose a packet too). - a silent decoder: data lost with no gap (the depayloader drops a fragment it cannot place) makes the decoder reject every frame without a bus message. 30 access units in with no picture out counts as damage, cleared when pictures come out again. Both are buffer probes that touch atomics only. A per-session thread on the session side reads the flag and sends the keyframe request on the whipserversrc pad, where it reaches the publisher: at once, then at most once a second, 10 times per damage episode, and never on healthy video. Each episode logs its recovery time and request count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(whip): guard that a reused slot reports its video as decoding The keyframe requester stops once the slot's video_decoding flag is set, and that flag is set from decodebin's pad-added. Before the decoder restart on reuse, pad-added fired once per flow, so every reconnecting publisher was asked for the full request budget while its video decoded fine (#805). The restart brings pad-added back for each session. The reuse test now re-arms the flag as a new session does and asserts the second session sets it again. With the restart disabled it fails with "the second session decoded, but the slot never reported its video as decoding". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Peter Wagenet <peter.wagenet@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mediaplayer): drop a buffer with no PTS at the bridge The Media Player's internal -> main pipeline bridge forwarded a buffer with no PTS as it was. A recorder downstream answers one with "Buffer has no PTS" from qtmux and GST_FLOW_ERROR, which takes the whole flow down. It is now dropped and counted, with a warning on the first and every hundredth. The same fix for WHIP Input is in #957; #759 carried both, but #945 gave this bridge its own timing since, so it is fixed here on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mediaplayer): place a DTS-only buffer by its DTS instead of dropping it In passthrough the bridge carries encoded data, where a frame with a DTS and no PTS is legal: an MPEG-TS PES between PTS stamps, or a parser that cannot order B-frames. Dropping those broke the stream for every consumer. Such a buffer is now placed by its DTS and leaves without a PTS; only a buffer with neither is dropped. The test strips the PTS that appsrc gives a DTS-only buffer, so the bridge really sees one. It fails with the earlier drop-without-PTS rule (the DTS-only buffer is missing) and with the original forward-everything rule (the unstamped buffer arrives). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The bug
A WHIP session runs in its own pipeline and is bridged into the flow's main pipeline through an
appsink/appsrcpair. The bridge rebased the PTS of buffers that carried one and forwarded the rest untouched:Downstream of that appsrc sits a recorder, and
qtmuxcannot place an untimestamped buffer in a track:The resulting
GST_FLOW_ERRORpropagates out of the recorder and up through the mixer, so one seat's stray buffer takes the whole flow down. The same incident logged errors fromwhip_p2:appsrc_audio_0,rec_p2:audio_0_queue,vmix:queue_1,vmix:appsrc_overlayand others. Observed on a live rig: a second participant joining killed the first.The Media Player carried a second copy of that same code, down to the else-branch, in a bridge whose output reaches the same
qtmux.The fix
Drop the buffer at the bridge. The
new_sampleclosure moves intogst::pipeline_bridge::SessionBridge, which owns the per-session timestamp offset and the drop count, and both blocks call it.Why drop rather than stamp from the pipeline clock. There is no correct value to invent. The main pipeline's running time now is a different time base from
pts + offset, and the two drift apart over a session, so a synthesised stamp can land before its own predecessor — and a muxer rejects backwards timestamps exactly as hard as missing ones. That is the same cascade by another route. A stamp that is merely a little wrong is worse still: it desynchronises audio from video for the rest of the session, silently. A dropped frame on a live source is the ordinary recoverable failure — the receiver already tolerates loss, and thekeyframe_requestmachinery already in this file recovers a decoder that lost part of its GOP.Not silent. Drops are counted per session and warned on the first, then every hundredth, so a publisher sending nothing usable stays visible without writing a line per frame.
One offset per session, enforced. Audio and video reach the bridge on their own streaming threads and can both find the offset unset, read the clock milliseconds apart, and store different offsets — the A/V drift the shared offset exists to prevent.
compare_exchangelets exactly one win; the loser reads back the winner's value.Three smaller things in the same code, fixed by the move:
sample.caps().unwrap()no longer panics on a sample without caps.Bounds, deliberately not in this PR
A recorder failing should degrade that seat, not kill the mixer, the program and every other participant. That is a larger piece of work than a bridge fix: the recorder needs its own bin with a bus handler that fails the seat and leaves the rest of the flow running, plus a way to surface a failed seat through the API and UI. Bundling it here would mix a two-line behavioural fix with a pipeline-topology change. Filing separately.
The clamp in
shift(). A stream whose PTS base sits far behind the offset-setting stream's has its first buffers pinned at 0 — equal timestamps, which is the muxer's third complaint after missing and backwards ones. Pre-existing, unchanged here, and it needs a policy for what a stream starting before the session's time origin should be worth. Stated in the module doc.Tests
backend/src/gst/pipeline_bridge.rs— 8 unit tests driving a realappsrc ! appsinkpair, so the assertion is on what actually comes out of the sink, not on a returned enum:an_unstamped_buffer_never_crosses_the_bridge— three samples in, stamped/unstamped/stamped; only the two stamped ones arrive, rebased.an_unstamped_first_buffer_is_dropped_and_does_not_fix_the_offset— the other route in: no offset yet, so the restamping path is never reached; the buffer must still not cross, and must not poison the offset for the one that follows.two_streams_racing_for_the_offset_still_get_one— two threads, with a barrier inside the clock closure so both are past the unset check before either stores. Exactly one may reportOffsetComputed, and both buffers must leave shifted by the same amount.both_streams_share_one_offset— the sequential case, one thread.a_missing_running_time_forwards_unadjusted_and_retries,dts_is_shifted_with_pts,a_negative_shift_clamps_to_zero,drops_are_logged_first_then_sparsely.Two more guard the wiring, one per caller. The module tests above cannot see a caller that stops calling
forward: reverting either call site to a barepush_sampleleft all eight green. Each of these drives its caller's real appsink callback — three buffers into a session pipeline, the middle one unstamped, and only the two stamped ones may reach the appsrc in the main pipeline:blocks::builtin::whip—an_unstamped_buffer_never_reaches_the_slot_appsrc. The callback moved out of the pad-added handler intoinstall_slot_bridgeso a test can install it without awhipserversrc.blocks::builtin::mediaplayer::bridge—an_unstamped_buffer_never_reaches_the_main_pipeline, throughlink_pad_through_clocksyncand a realclocksync.The
appsrc/appsinkrig is nowpipeline_bridge::test_support, shared by all three modules. Its main-pipeline half setsasync=falseon the appsink and waits for PLAYING: with no base time the first buffer gets no offset, and the offset lands on whichever buffer arrives after the state change instead.All three guards bite. Re-adding
push_sampleon the PTS-less path in the module fails 2 of the 8 withleft: Some(None)— the unstamped buffer arriving at the sink. Reverting thecompare_exchangeto a plain store fails the race test withgot [Some(OffsetComputed(9000000000)), Some(OffsetComputed(10000000000))]— two offsets a second apart, from the same input PTS. Reverting either call site toappsrc.push_sample(&sample)fails that caller's test on "the unstamped buffer must not be here". Real guards, not demonstrations.Uses only
appsrc/appsinkfromgstreamer1.0-plugins-base, already in.github/workflows/ci.yml. No element skip, nothing to add.What I ran
cargo test --features efpwithSTROM_REQUIRE_GST_PLUGINS=1— 554 lib + 57 integration tests, all pass.cargo clippy --all-targets --features efp,nvidia -- -D warnings— clean.cargo fmt --all --check— clean.whipclientsinkpublishers: both sessions' A/V pads bridged, exactly one shared ts-offset per session, zero unstamped drops on healthy traffic, zero mux errors, neither publisher killed the other.Buffer has no PTSand no mux error. Looping the playlist recomputes the offset, which exercisesreset_offset.What I could not run. I could not drive the Meeting Rig to PLAYING on
upstream/main— it needs #744 (builtin.audioenc), #749 (idle seats holding the pipeline out of PLAYING) and #750 (recorders with no data), all still open. Soqtmuxnever ran in my live WHIP sessions and I did not reproduce the WHIP cascade end to end. The Media Player run above does put a realqtmuxbehind a real bridge; the WHIP guard is the unit test.Conflicts to expect
new_sampleclosure (watchdog store toactivity.touch()). Small mechanical conflict, whichever lands second.whip.rshunk is elsewhere.🤖 Generated with Claude Code