cloudflare / cloudflare/realtimekit-ui
joinRoom fails on first attempt after socket join when room already has a peer; SDK error swallowed as "failed to parse error message"
- Dominant language
- TypeScript
- Stars
- 64
- Forks
- 22
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 5
Description
## Summary
First `MediaNodeClient.joinRoom` call after socket join fails for participants entering a room that already has another peer, with `[ERR0002] Failed to join room. Could not establish media connection.` in the setup screen. A retry ~750 ms later reliably succeeds. The underlying SDK error is swallowed by a protobuf-decode fallback in `@cloudflare/realtimekit` and surfaces as the unhelpful string `"failed to parse error message"`.
## Versions
- `@cloudflare/realtimekit` 1.4.0
- `@cloudflare/realtimekit-ui` 1.1.2
- Chromium 146 / macOS 15.7 (also reproduces on real Chrome — also reproduces with fake-media-stream args in headless harnesses)
## Symptom
User opens a participant link to a meeting that already has one peer in it (e.g. a host joined first). Setup screen renders normally, devices acquire correctly (`getUserMedia` succeeds), preset permissions report `canProduceAudio: "ALLOWED"` / `canProduceVideo: "ALLOWED"`. User clicks **Join**. Setup screen displays:
> [ERR0002]: {Client} Failed to join room. Could not establish media connection.
Clicking Join a second time succeeds without any other change. The participant lands in the call normally.
The host link on the same meeting succeeds on the first click when the host is the first peer in the room. (Untested but expected symmetric: if a participant joined first, the host link probably hits the same race.)
## Underlying SDK error (captured from otel/logs telemetry)
```
ERROR Error completing room join
message: "failed to parse error message"
stack: at ce.f (realtimekit/index.es.js:31953:21)
at ce.emit (realtimekit/index.es.js:31251:5)
at oO.MS (realtimekit/index.es.js:31737:88)
at D.onmessage (realtimekit/index.es.js:31633:64)
ERROR SelfController.mediaRoomJoin (same cause)
ERROR SelfController.joinRoom: media room join failed
```
Source of the wrapped error — [`realtimekit/dist/index.es.js:27276-27290`](https://www.npmjs.com/package/@cloudflare/realtimekit):
```js
const f = ({ id: E, payload: C }) => {
if (l === E) {
let _;
try {
const I = yw.fromBinary(C);
_ = new Error(I.errorMessage);
} catch (I) {
_ = new Error("failed to parse error message", { cause: I });
try {
const M = Eb.fromBinary(C);
_ = new Error(M.message);
} catch (M) {
_ = new Error("failed to parse error message", { cause: M });
}
}
c(_), d(v, f);
}
};
```
The SFU sends an error response back over the WebSocket in some protobuf shape that decodes as neither `yw` nor `Eb`. The SDK falls back to the generic `"failed to parse error message"` and loses the actual SFU-side reason. The `cause` field is populated with the protobuf-decode error, which is unhelpful.
The UI layer ([`rtk-setup-screen.js:63`](https://github.com/cloudflare/realtimekit-ui/blob/main/packages/realtimekit-ui/src/components/rtk-setup-screen/rtk-setup-screen.tsx)) reads `err.message` for the displayed banner, so the user only sees the meaningless wrapped string.
## Root cause (inferred)
Based on the captured trace, the failure happens specifically at the `MediaNodeClient.joinRoom` step, right after the socket-edge join succeeds and just after `Processing socket peers` enumerates one already-joined peer:
```
INFO Processed socket peers
INFO RoomSocketHandler.joinRoom_timing
INFO SelfController.joinMediaRoom
INFO MediaNodeClient.joinRoom
INFO SocketService.sendMessagePromise
INFO SocketService.sendMessagePromiseWithTimeout
INFO SocketService.sendMessagePromiseWithTimeout_timing
INFO SocketService.sendMessagePromise_timing
ERROR Error completing room join
```
84 ms between sendMessagePromise out and error in. Looks like a peer-sync race on the room-node side — the new peer's `joinMediaRoom` arrives before the room-node has finished setting up consumer subscriptions to the existing peers, and the room-node rejects it. After ~750 ms backoff, the retry succeeds.
## Reproduction
1. Create a RealtimeKit meeting with default settings (`record_on_start: true`, but unrelated)
2. Add a participant with `preset_name: "group_call_host"` and have them join via ``
3. Once the host is in the call, add a second participant with `preset_name: "group_call_participant"`
4. Open the participant's setup screen, type a name, click Join
5. Observe `[ERR0002]` banner. Click Join again — succeeds.
## Workaround in our app
We wrap `client.joinRoom` to silently retry up to 3 times with 750 ms backoff before the setup screen sees the failure. After the wrap, the first Join click consistently lands in the call. Code:
```js
const originalJoinRoom = this.client.joinRoom.bind(this.client)
this.client.joinRoom = async () => {
let lastErr = null
for (let attempt = 1; attempt <= 3; attempt++) {
try { return await originalJoinRoom() }
catch (err) {
lastErr = err
if (attempt < 3) await new Promise(r => setTimeout(r, 750))
}
}
throw lastErr
}
```
## Suggested fixes
1. **SDK** — in the `sendMessagePromise` error-decode path, if both protobuf decoders throw, set the outer `Error.message` to include the raw byte length / first bytes hex so the actual SFU response is at least debuggable. Or add a third decode shape if there's a newer protobuf schema we're missing.
2. **SDK / room-node** — internally retry the first `MediaNodeClient.joinRoom` on this specific failure, so consumers don't all have to ship the wrapper above.
3. **Room-node** — fix the peer-sync race so the first media-join after socket-join doesn't get rejected when other peers are already in the room.
4. **UI** — `rtk-setup-screen` could itself retry once on join failure before showing the banner, even without an SDK-level fix.
Contributor guide
Assessment
This issue has not been assessed yet.