Repository navigation
fix(whip): let a rejoining client take over a dead session's slot - #753
Conversation
1391f64 to
de149ef
Compare
|
(from Claude, on behalf of @wagenet) Checked against #752, which touches the inactivity watchdog in the same file. Independent, confirmed. They conflict textually. Both branches add a new top-level item immediately after Follow-up once both land. |
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — correct mechanism for a real bug, the concurrency reasoning holds up under tracing, and the new tests exercise the changed function directly on a green, non-skipping CI run.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| Watchdog cleanup and takeover cleanup share the same single-fire flag | CONFIRMED | takeover: `if !candidate.cleanup_sent.swap(true, Ordering::SeqCst) {`; watchdog at backend/src/blocks/builtin/whip.rs:799 `if !cleanup_sent_watchdog.swap(true, Ordering::SeqCst) {` — same Arc<AtomicBool> |
allocate_slot_or_take_over is the only production path into slot allocation for WHIP ingest |
CONFIRMED | grepped backend/src/api/, backend/src/blocks/ for allocate_slot outside tests — sole non-test call is inside allocate_slot_or_take_over itself |
Per-buffer touch() stays lock-free, matching CLAUDE.md's hot-path rule |
CONFIRMED | SessionActivity::touch — one Instant::elapsed() and one relaxed store, no Mutex, no formatting; called from new_sample (activity_cb.touch();) |
CI ran cargo test/clippy/fmt on this diff, not just build/check |
CONFIRMED | .github/workflows/ci.yml:113-121: Check (Linux) runs fmt --check, clippy -D warnings, cargo test with STROM_REQUIRE_GST_PLUGINS=1 (skip becomes failure); green on de149ef |
Diagnosis — Root cause matches: an abrupt drop leaves webrtcbin at completed until consent-freshness expiry (EXTERNAL, webrtcbin's own state machine, consistent with the symptom, not contradicted here). "Counter moved" beats "idle time" for the stated reason and the tests prove it: a_session_that_died_moments_before_the_post_is_still_displaced asserts no displacement before TAKEOVER_IDLE_THRESHOLD even at zero initial idle — exactly the race a naive idle check loses. Traced the concurrent-rejoin case by hand: allocate_slot's RwLock::write makes the grant atomic, the loser re-enters the loop, sees a freshly-registered (never-delivered-media) session, and correctly falls into "never displaced" rather than evicting the winner.
Not fully exercised — idlest_session's max_by_key((dying, idle)) tie-break only matters for max_sessions > 1; every test uses a 1-slot endpoint, matching the PR's stated deployment (one seat per endpoint). BOUNDED to the case actually deployed.
Radius — LOCAL: three files, one call site, no strom-types/API/WebSocket surface change, and SessionActivity holds no gst::Element/Pipeline/Bin.
Tests & CI — Green, non-skipping, and the four new #[tokio::test]s call allocate_slot_or_take_over on a real WhipSessionManager. I did not run cargo test/clippy/fmt myself (no GStreamer toolchain here) — the CI run is the evidence, not a local repro. The claimed revert-fails-two-of-four behavior I did not execute.
Confidence: HIGH
|
Housekeeping note plus one substantive question — not a new review. The head is unchanged at This branch is now The interaction I expected to be a problem is not one. #749 made
So the unlock happens on the takeover path too, and a freshly allocated session cannot be displaced by the next POST either, since The question: video-only ingest. The threshold's reasoning is
which holds only while there is an audio stream.
A video-only publisher of static content — a screen share that is not changing — can plausibly go past Either an argument for why the counter cannot freeze that long on a video-only session, or making the threshold depend on When you resolve the conflict, the thing worth a second look is that the resolved appsink callback still stamps through For the record on the design: the "counter moved" predicate rather than a single idle reading, |
de149ef to
f5f48d3
Compare
|
(from Claude, on behalf of @wagenet) Rebased onto current The conflict. One file, as you found. #752 extracted the watchdog loop into Your video-only question was right, and it reproduces. Video-only endpoint,
Reproduced at 3040 ms on a second run. The 10 s inactivity watchdog never fired in those runs, so the sparse session is viable on Why the fix is not a longer threshold. There is no value that is both safe for video-only and useful. To survive a genuinely static screen share it would have to exceed the watchdog's 10 s, at which point takeover never fires. A transport-level signal does not rescue it either: consent freshness is the slow signal that made takeover necessary in the first place. So the rule is that fast takeover is only sound when audio is present, because audio's ~20 ms cadence is the only thing that makes a 2 s gap mean anything. After the fix, all four rig arms:
Residual, in the body as a known limitation. A session whose audio stops for good — muted or failed microphone — while its video continues at gaps wider than 2 s can still be displaced. Narrower than the video-only case, which was a configuration rather than a runtime coincidence. Not closed. The merge region you flagged. Confirmed: the appsink callback still stamps liveness before pulling the sample, now with the pad's media type, and #749's Tests. New One thing I am not doing here. My earlier note suggested folding |
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment — the mechanism is right and "counter moved" is the correct discriminator; my dismissed review at de149ef7 is answered and the video-only regression it would have caused is closed. One ordering gap is left.
Requested changes
- Gate the slot push on the teardown flag —
backend/src/blocks/builtin/whip.rs, in thenew_samplecallback, readcleanup_sentand returnOk(gst::FlowSuccess::Ok)beforeappsrc.push_sample. It is already in scope increate_whipserversrc_for_session, and one relaxed load per buffer stays inside the hot-path budget the rest of this diff respects. Why, below.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The displaced slot frees fast enough for the remaining ~1 s of polls to claim it | CONFIRMED |
backend/src/whip_session_manager.rs:412 — config.release_slot(slot); runs before the teardown handed off at backend/src/whip_session_manager.rs:423 — ` tokio::task::spawn_blocking(move |
| All five new tests executed in CI rather than skipping | CONFIRMED |
Check (Linux) job 102740835862 at 0bcf6dc2: all five whip_session_manager::tests::a_* lines ... ok, a_sparse_video_only_session_is_not_displaced among them |
| Stamping liveness stays within the per-buffer budget | CONFIRMED |
backend/src/blocks/builtin/whip.rs:1041 — activity_cb.touch(pad_is_audio); — media type resolved once at pad setup; touch is Instant::elapsed plus one or two relaxed stores. No lock, no allocation, no SystemTime |
Diagnosis — right layer. What the body does not cost is who pays for a wrong displacement. It frames that cost as falling on the victim, through its own consent timeout. It also falls on the new session: nothing stops a displaced-but-alive publisher from feeding the slot it no longer owns. backend/src/blocks/builtin/whip.rs:1095 — let _ = appsrc.push_sample(&sample); is reached with no check on cleanup_sent, the slot's appsrc belongs to whoever holds the slot, and ts_offset is per session. Between release_slot and the old pipeline reaching NULL, two sessions can push into one appsrc with two different offsets.
On main this cannot bite: every path that frees a slot frees a session the watchdog already found silent for its whole timeout, so nothing is pushing. Takeover is the first path that frees a session whose media still flows — exactly the muted-microphone residual the body discloses. Whether the window ever opens depends on a teardown outlasting a full ICE/DTLS handshake, which I cannot settle from this repository. The guard closes it either way.
Smaller: a POST refused because the sitting session never delivered a buffer is held the full backend/src/whip_session_manager.rs:333 — const TAKEOVER_WAIT: Duration = Duration::from_secs(3);, since a counter stuck at 0 never trips the moving-counter return. A 3 s 503 where main answered at once — worth a line under Known limitations.
Radius — SHARED: the WHIP Input subsystem only, but all of it — the whip_post request path, the manager's slot accounting, and the appsink callback every session installs. No block, codec or platform outside WHIP Input reaches the new path.
Tests & CI — six checks green at 0bcf6dc2 (run 34435913759). Build (Windows) and Build (macOS) are skipping, which is fine here rather than an unverified row: no target_os code in the diff, so Linux-green plus the post-merge main run covers it.
Confidence: HIGH
|
(from Claude, on behalf of @wagenet) Both points addressed in The slot push is gated. The bridge body is now New test The 3 s hold is gone rather than documented. A sitting session with no audio delivered, including one with nothing delivered, can't be displaced during the wait. If it starts sending, its counter moves and that already refuses. So the POST now gets its 503 on the first poll. This moves the video-only case too: the 2003 ms 503 on the rig becomes immediate. That exposed the multi-slot ordering you called bounded. With Verified. Each change was reverted separately and the WHIP tests run each time. Each revert failed only its own test: the gate test, the two new timing assertions on the no-media and video-only tests, and the two-slot test. The review's open question stands: whether a teardown can outlast a full ICE/DTLS handshake. The gate makes that moot, and I have not measured it. |
A WHIP publisher that dies without sending a DELETE — network loss, the common case for a real participant — leaves its slot occupied. Every reconnect until the inactivity watchdog notices is refused with 503. The ICE callback does not help: an abruptly killed peer leaves webrtcbin at `completed` until consent freshness expires. When all slots are taken, the POST now watches the sitting session for up to 3 s instead of refusing outright. The session carries a `SessionActivity` counter, stamped by the appsink callback for every buffer that crosses the bridge. A counter that moves between two polls is a publisher that is still sending: that client gets its 503 after one poll interval, and the live session is never touched. A counter frozen past 2 s is a dead transport, and the session is handed to the ordinary cleanup path (`SessionCleanupRequest`, guarded by `cleanup_sent`) so the new client can take the slot it releases. Both cases start out identical — a reconnect landing 300 ms after the drop sees the same near-zero idle time as a healthy stream — which is why the decision rests on the counter moving rather than on a single reading of it. A session that has never delivered a buffer is never displaced: it may just be slow to negotiate through a TURN relay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slot takeover judged a sitting session dead after two seconds without a buffer. That bound only means something when the session carries audio: audio arrives every ~20 ms, while a video stream's interval is whatever the publisher's encoder emits. A video-only publisher of static content goes seconds between frames on a healthy transport and is indistinguishable from a dead one by any media-based signal. `SessionActivity` now stamps audio buffers into a counter of their own, and only a session that has delivered audio is in displacement range. A video-only session is left to the inactivity watchdog. The residual case is a session whose audio stops for good — a muted or failed microphone — while its video continues at gaps wider than the threshold. That one can still be displaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Takeover frees a slot while the displaced session's publisher may still be sending, and the slot's appsrc passes to the new session. The appsink bridge now drops samples once the session's cleanup_sent flag is set, so two sessions never push into one appsrc with different timestamp offsets. A POST whose sitting session can never be displaced (no audio delivered, or nothing delivered at all) was held for the full 3 s takeover wait before its 503. It is now answered immediately. Sessions that have delivered audio sort ahead of those that have not, so on a multi-slot endpoint a quieter video-only session cannot hide a dead one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175fdf7 to
20007d5
Compare
srperens
left a comment
There was a problem hiding this comment.
Verdict: Approve — supersedes my review of 2026-09-14 (at 0bcf6dc2), which was Comment. Both standing items are closed. The branch was force-pushed, so I re-read the mechanism rather than diffing the old head.
What closed the two items
-
The ordering gap. I asked for one relaxed load per buffer before pushing into the slot appsrc, so two sessions with different
ts_offsets cannot feed one appsrc. That is what landed:backend/src/blocks/builtin/whip.rs:1121—if session_finished.load(Ordering::Relaxed) {. The order is right at the other end too: the flag is won with aswapbefore the cleanup request is sent, so the displaced session stops pushing at the moment of the decision, not when teardown eventually runs. -
The 3-second hold on a refused POST.
backend/src/whip_session_manager.rs:583—} else if !candidate.has_audio {answers immediately instead of watching a counter that was never going to move. That is the case I raised as a regression against main, and it is gone.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The guard test would fail if the guard were removed | CONFIRMED |
backend/src/blocks/builtin/whip.rs:2095 — fn a_finished_session_stops_feeding_its_slot() { calls the real forward_sample_to_slot rather than rebuilding it, and asserts the first sample out of the slot has the current session's PTS; without the check the displaced sample arrives first and the assertion fails |
| "Sessions with audio sort first, so no session on this endpoint can be displaced" | CONFIRMED |
backend/src/whip_session_manager.rs:651 — ` .max_by_key( |
| The per-buffer cost stays within the CLAUDE.md hot-path rules | CONFIRMED |
backend/src/whip_session_manager.rs:190 — pub fn touch(&self, is_audio: bool) { is one Instant::elapsed and one or two relaxed stores; the media type is resolved once at backend/src/blocks/builtin/whip.rs:1036 — let pad_is_audio = media_type == "audio";, not per buffer |
| New manager tests execute on CI | CONFIRMED |
Check (Linux) job 104072334089: a_sparse_video_only_session_is_not_displaced, a_session_that_has_not_delivered_media_yet_is_not_displaced, a_dead_session_is_displaced_past_a_quieter_video_only_one and a_finished_session_stops_feeding_its_slot all ... ok |
Diagnosis — The added audio precondition is the more interesting change and it is the right call: a video-only publisher can legitimately go longer than TAKEOVER_IDLE_THRESHOLD between frames, so idle time alone is not evidence of a dead transport there. Coverage is BOUNDED, and the bound is named in the code itself — a session whose audio died for good while sparse video continues can still be displaced.
The relaxed load can lag the SeqCst store by a hair, so a sample or two may still cross. That is harmless and deliberate: the slot is not handed over until the cleanup task runs, which is orders of magnitude later, and a stronger ordering on the per-buffer path would cost more than it buys.
Radius — SHARED: every WHIP Input seat plus the ingest handler. Argued for approval because the diff only ever narrows when a session is displaced — a sitting session that survived before still survives.
Tests & CI — Run 34872718857 at 20007d57: Check (Linux), both Build (Linux) jobs, Check & Build (WASM) and API Contract Check pass; macOS and Windows skipping.
Merge order — #759 edits this same callback. Land #759 first; re-applying this one is the smaller move.
Confidence: HIGH
… 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>
Problem
max_sessionsis 1 per WHIP endpoint. When a publisher dies without sending a WHIP DELETE (network loss, the common case for a real participant), the slot stays occupied and the rejoining client gets503 - All session slots are occupieduntil the inactivity watchdog reclaims it. A clean leave sends the DELETE and frees the slot immediately.The ICE callback does not cover it: an abruptly killed peer leaves
webrtcbinatcompleteduntil consent freshness expires.What this does
When every slot is taken,
whip_postwatches the sitting session for up to 3 s instead of refusing outright. Each session carries aSessionActivitycounter, stamped by the appsink callback for every buffer that crosses the appsink→appsrc bridge.SessionCleanupRequest, claimed viacleanup_sent.swap(true), so it is torn down once and its watchdog stops) and the new client takes the slot it releases.Once a session is marked finished, by any teardown path, its bridge stops pushing into the slot's appsrc. Takeover frees a slot whose publisher may still be sending, and without that check both sessions would feed one appsrc with different timestamp offsets until the old pipeline reaches NULL.
Why the counter moving, not idle time. A single idle reading cannot separate a healthy session from one that died a moment ago, and the reconnect that matters arrives a few hundred milliseconds after the drop. On the rig, an idle-time rule refused that rejoin and the watchdog reclaimed the slot 12 s later, as before the change.
Why audio is required. Two seconds of silence only means "dead" when audio is present, at ~20 ms per packet. A video-only publisher of static content, such as a screen share nobody is touching, goes seconds between frames on a healthy transport. The gate is per session, so a client that negotiates video-only against an
audio_videoendpoint is covered too. Video-only sessions are left to the watchdog.Video-only measurement without the audio gate
Video-only endpoint,
max_sessions: 1, publisher running throughout, second client POSTing:The 10 s watchdog never fired for the 1/5 publisher, so it is viable on
mainand only takeover would have killed it.Verification
Headless backend, one
builtin.whip_inputblock into fakesinks, publisher awhipclientsinkfed byvideotestsrc/audiotestsrcwith H264 pinned. Media flow confirmed fromPad video_0/Pad audio_0in the server log, not from ICE state.¹ Measured before undisplaceable sessions were refused at once; the rig was not re-run for that. The unit test asserts the 503 now arrives in under 2 s.
Clean leave unchanged: SIGINT sends the DELETE and the slot is released immediately. Flow stop logs no pipeline-survival ERROR.
Tests
Six
#[tokio::test]s inwhip_session_manager.rscall the realallocate_slot_or_take_overwith a running cleanup task (corefakesrconly, so they run in CI):a_dead_session_gives_its_slot_to_a_new_clienta_session_that_died_moments_before_the_post_is_still_displaced: frozen counter at zero idlea_live_session_is_never_displaced: counter stamped every 20 ms; 503 in under the idle thresholda_session_that_has_not_delivered_media_yet_is_not_displaced: also asserts the 503 is immediatea_sparse_video_only_session_is_not_displaced: same timing assertiona_dead_session_is_displaced_past_a_quieter_video_only_one: two slotsa_finished_session_stops_feeding_its_slotinwhip.rscallsforward_sample_to_sloton a realappsrc ! appsinkand asserts the first sample out is the current session's, not the finished one's.Reverts, each run separately; each failed only the tests listed:
allocate_slot)a_sparse_video_only_session_is_not_displaceda_finished_session_stops_feeding_its_slotcargo testgreen locally (583 lib tests plus every integration suite);clippy --all-targets -- -D warningsandfmt --checkclean. Two tests ignored, both unrelated:test_jitterbuffer_stalls_without_drop_on_latency(GStreamer-version dependent) andserver_is_not_demoted_to_background_qos(needsSTROM_TEST_APP_NAP=1).Risks
A session whose audio stops for good, such as a muted or failed microphone, while its video continues at gaps over 2 s can still be displaced.
A wrongly displaced publisher is not told. It learns through its own consent-freshness timeout, and its UI reads Connected until then. Endpoints are one seat per participant (
p1…p5), so a POST to a full endpoint is almost always that participant returning. On a shared endpoint the trade would be worse.Liveness is stamped at the session appsink, so a session blocked pushing into a stalled main pipeline looks frozen while its publisher is fine. #756 moves the judgement to media that reaches the flow's consumers.
The bundled ingest page does not reach this window yet. Its first re-POST lands ~20 s after a drop, past the watchdog, and
whipclientsinkexits on a 503 without retrying. Tightening the page's retry timers is a follow-up that needs browser verification.🤖 Generated with Claude Code