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
- 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
Assessment
This issue has not been assessed yet.