Skip to content

partytracks: replaceTrack via src$ is gated behind the initial track's first sent byte — a frame-less initial track permanently blocks the sender #414

Description

@canozturkeri

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

  1. 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.
  2. Publish the stream's video track with partytracks (push() / pusher with src$).
  3. Once "connected", push a live camera track into src$.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions