Affects: partytracks 0.0.54 through 0.0.56 (current latest) — verified by diffing the shipped source maps: the combineLatest([pushedTrackData$, …]) gating in push() is unchanged in 0.0.56.
Summary
When a video track is published with a placeholder and later replaced by pushing a new MediaStreamTrack into the pusher's src$, the replacement is not applied until the initial track has actually sent data. If the initial track never delivers a frame, every subsequent replaceTrack is queued forever: the sender stays bound to a frame-less track and the published video is dark for the entire session — with no error and no signal.
Where
In push(), src$ changes are applied inside a combineLatest with pushedTrackData$ (which resolves via waitForTransceiverToSendData). combineLatest emits only once every source has emitted, so until the transceiver reports its first sent bytes, src$ values accumulate without effect. The dependency is not just "the publish id is delayed" — the replace pipeline itself is gated.
Why the initial track can be frame-less (real-world case)
A common placeholder pattern is an off-DOM canvas + canvas.captureStream(fps). In Chromium, frame delivery for captureStream(fps) of an off-DOM canvas is compositor-dependent: with the window occluded, during startup, or nondeterministically on cold loads, the compositor never samples the canvas and the track emits zero frames (we observed this on ~40% of joins in production). Zero frames → waitForTransceiverToSendData never resolves → the gate never opens → the real camera track pushed into src$ is never applied.
Repro sketch
- Create an off-DOM canvas,
stream = canvas.captureStream(10), don't attach the canvas, ideally run with the window occluded (or headless) so the compositor skips it.
- Publish the stream's video track with partytracks (
push() / pusher with src$).
- Once "connected", push a live camera track into
src$.
- On loads where the placeholder delivered no frames, the camera track is never applied: remote
framesDecoded stays 0 for the whole call; no error is surfaced.
Expected
replaceTrack (an RTCRtpSender capability that needs no renegotiation) should be applied regardless of whether the currently-bound track has sent bytes — or at minimum, the gate should time out / surface an error instead of silently wedging the sender.
Workaround we ship
Placeholder as captureStream(0) + an explicit track.requestFrame() per paint (delivers frames unconditionally, compositor or not), plus a watchdog that warns when a push stalls. This masks the gate but the ordering dependency remains and will bite anyone whose initial track can stall.
Affects: partytracks 0.0.54 through 0.0.56 (current latest) — verified by diffing the shipped source maps: the
combineLatest([pushedTrackData$, …])gating inpush()is unchanged in 0.0.56.Summary
When a video track is published with a placeholder and later replaced by pushing a new
MediaStreamTrackinto the pusher'ssrc$, the replacement is not applied until the initial track has actually sent data. If the initial track never delivers a frame, every subsequentreplaceTrackis queued forever: the sender stays bound to a frame-less track and the published video is dark for the entire session — with no error and no signal.Where
In
push(),src$changes are applied inside acombineLatestwithpushedTrackData$(which resolves viawaitForTransceiverToSendData).combineLatestemits only once every source has emitted, so until the transceiver reports its first sent bytes,src$values accumulate without effect. The dependency is not just "the publish id is delayed" — the replace pipeline itself is gated.Why the initial track can be frame-less (real-world case)
A common placeholder pattern is an off-DOM canvas +
canvas.captureStream(fps). In Chromium, frame delivery forcaptureStream(fps)of an off-DOM canvas is compositor-dependent: with the window occluded, during startup, or nondeterministically on cold loads, the compositor never samples the canvas and the track emits zero frames (we observed this on ~40% of joins in production). Zero frames →waitForTransceiverToSendDatanever resolves → the gate never opens → the real camera track pushed intosrc$is never applied.Repro sketch
stream = canvas.captureStream(10), don't attach the canvas, ideally run with the window occluded (or headless) so the compositor skips it.push()/ pusher withsrc$).src$.framesDecodedstays 0 for the whole call; no error is surfaced.Expected
replaceTrack(an RTCRtpSender capability that needs no renegotiation) should be applied regardless of whether the currently-bound track has sent bytes — or at minimum, the gate should time out / surface an error instead of silently wedging the sender.Workaround we ship
Placeholder as
captureStream(0)+ an explicittrack.requestFrame()per paint (delivers frames unconditionally, compositor or not), plus a watchdog that warns when a push stalls. This masks the gate but the ordering dependency remains and will bite anyone whose initial track can stall.