cloudflare / cloudflare/partykit

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

Open
#414 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1.3k
Forks
79
PR merge metrics
No merged PRs in 30d

Description

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

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.