Rocket.Chat 8.4 shipped native voice calls. No Jitsi, no external VoIP providers, just click-to-call from any DM. Mobile clients worked fine: iOS and Android 4.72+ connected over TURN relay, audio came through, calls ended cleanly. The web client was a different story. Calls placed from Chrome would ring on the callee’s phone, get accepted, sit in silence for twelve seconds, and drop with timeout-remote-sdp. Every server-side indicator looked healthy. TURN was reachable, ICE candidates gathered correctly, the callee’s device registered acceptedAt. The problem was somewhere in the browser, but DevTools alone couldn’t show where.
This article covers rocketchat-voice-tester, a headless Chrome diagnostic tool I built to answer one question: is the voice call failure on my server or in the browser? It hooks into WebRTC at the constructor level, captures raw DDP WebSocket frames via Chrome DevTools Protocol, and shows whether the browser actually sends the SDP offer to the server. Below is the architecture, the four diagnostic scripts, and the RC 8.4.1 bug this tool found.
- The problem: web calls fail while mobile works
- Dead ends: what we ruled out first
- How the tool works
- PAT Login Injection
- RTCPeerConnection Hook
- DDP frame capture via CDP
- The four diagnostic scripts
- Script 01 — ICE Only
- Script 02 — Outgoing Call
- Script 03 — DDP capture (the one that matters)
- Script 04 — Incoming call
- Setup and usage
- The root cause: missing SDP offer in DDP
- Reading the output
- What this won’t tell you
- Related projects
- FAQ
- Does this tool work with Rocket.Chat versions other than 8.4?
- Can I run this on a server without a display?
- Why use system Chrome instead of Playwright’s bundled Chromium?
- Is the Personal Access Token safe to use?
- What does “Invalid state for renegotiation request” in the server log mean?
- Conclusion
The problem: web calls fail while mobile works

After enabling voice calls on a Rocket.Chat 8.4.1 instance with a properly configured coturn TURN server, the failure pattern was consistent and reproducible:
| Indicator | Value |
|---|---|
| Mobile-to-mobile calls | Work — hangupReason: normal |
| Web-to-mobile calls | Fail — hangupReason: timeout-remote-sdp |
Mongo acceptedAt | Set (callee answered) |
Mongo offer in negotiations | Absent |
Browser signalingState | have-local-offer |
Browser connectionState | new (never progressed) |
| ICE candidates gathered | 20+ (host + srflx + relay) |
The phone rang. The callee tapped Accept. The server recorded the acceptance. But no audio path came up, and after about 12 seconds the server’s signal-state timeout killed the call. MongoDB told the real story: the offer field in rocketchat_media_call_negotiations was completely absent. The browser had generated an SDP offer locally but never sent it.
Dead ends: what we ruled out first
Before building a diagnostic tool, every reasonable server-side and network hypothesis was tested and eliminated:
| Hypothesis | Result | Notes |
|---|---|---|
| TURN/STUN unreachable from browser | Eliminated | 12 ICE servers configured, 15 relay candidates gathered |
Stale e2eKeyId on DM rooms | Eliminated | Cleaned on 4 rooms — behavior unchanged |
Stale media_call_channels | Eliminated | Cleaned 13 entries — core bug remained |
| Cloudflare TURN poisoning ICE | Eliminated | Removed CF, own coturn only — same failure |
| Microphone permission gate | Eliminated | Headless Chrome auto-accepts — getUserMedia succeeds |
| Wrong voice-call button in UI | Partially true | DM header button is legacy, but modal “Call” is media-calls — still no offer sent |
With server-side causes ruled out, the next step was instrumenting the browser itself. Not watching DevTools manually, but programmatically capturing every WebRTC lifecycle event and every WebSocket frame sent during a call attempt.
How the tool works
rocketchat-voice-tester is a Node.js project on top of Playwright. It drives a system-installed Google Chrome with WebRTC instrumentation injected into every page, authenticates via a Personal Access Token, hooks every RTCPeerConnection constructor call, and optionally captures raw WebSocket frames through Chrome DevTools Protocol (CDP).
┌──────────────────────────────────────────────────────────┐
│ scripts/0X-*.js │
│ Per-script flow: click buttons, wait, capture, report │
└────────────────┬─────────────────────────────────────────┘
│ uses
┌────────────────▼─────────────────────────────────────────┐
│ lib/launcher.js │
│ - PAT-based Meteor login injection │
│ - RTCPeerConnection constructor hook │
│ - REST API helpers (DM resolution) │
│ - Final stats capture (pc.getStats) │
└────────────────┬─────────────────────────────────────────┘
│ drives
┌────────────────▼─────────────────────────────────────────┐
│ System Google Chrome via Playwright │
│ --use-fake-device-for-media-stream │
│ --use-fake-ui-for-media-stream │
│ --disable-blink-features=AutomationControlled │
└────────────────┬─────────────────────────────────────────┘
│ HTTPS / WSS
▼
Rocket.Chat server The tool deliberately uses your system Chrome rather than Playwright’s bundled Chromium, so the WebRTC stack matches what real users have. This matters because ICE gathering behavior and TURN protocol support can differ between Chromium builds.
PAT Login Injection
Instead of automating the login form (which changes between RC versions and breaks selectors), the tool injects Meteor session tokens into localStorage before any page JavaScript executes:
localStorage.setItem('Meteor.loginToken', TOKEN);
localStorage.setItem('Meteor.loginTokenExpires', '2030-01-01T00:00:00.000Z');
localStorage.setItem('Meteor.userId', USER_ID); When Meteor initializes, it finds a valid session and skips the login screen entirely. This approach is version-independent and eliminates flaky CSS selector dependencies.
RTCPeerConnection Hook
The tool wraps the global RTCPeerConnection constructor via addInitScript to intercept every WebRTC operation before RC’s frontend code runs:
const OrigPC = window.RTCPeerConnection;
window.RTCPeerConnection = function(...args) {
const pc = new OrigPC(...args);
window.__rtc_pcs.push(pc);
// listeners: icecandidate, icecandidateerror,
// connectionstatechange, signalingstatechange, track
// monkey-patches: createOffer, setLocalDescription, addIceCandidate
return pc;
};
Object.setPrototypeOf(window.RTCPeerConnection, OrigPC);
window.RTCPeerConnection.prototype = OrigPC.prototype; The prototype chain preservation on the last two lines matters. Rocket.Chat’s frontend uses instanceof RTCPeerConnection checks in several places. A naive wrapper that breaks the prototype chain causes silent failures in voice call initialization, and you’d spend hours figuring out why.
DDP frame capture via CDP
Playwright’s high-level WebSocket events don’t expose raw frame payloads. To get around that, the tool opens a Chrome DevTools Protocol session directly:
const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.enable');
cdp.on('Network.webSocketFrameSent', ({ response }) => {
// response.payloadData contains the raw frame bytes
// regex-match media-calls stream messages
// classify by "type" field: offer, signal, local-state, error
});
cdp.on('Network.webSocketFrameReceived', ({ response }) => { … }); This gives access to every DDP message the browser sends to stream-notify-user/<userId>/media-calls, which is the transport layer where the SDP offer should appear. Classifying these frames by their type field tells you exactly what happened.
The four diagnostic scripts
Each script isolates a different layer of the voice call stack. Run them in order — if an earlier layer fails, later scripts won’t produce meaningful results.
Script 01 — ICE Only
Pure TURN/STUN reachability test. Reads VoIP_TeamCollab_Ice_Servers from your RC instance, creates a local RTCPeerConnection, and gathers ICE candidates. No call is placed — this validates your relay infrastructure independently.
npm run test:ice
# Healthy output:
candidates: {"host":10,"srflx":1,"relay":15} → TURN works
# Broken TURN:
candidates: {"host":10,"srflx":1,"relay":0} → TURN unreachable
# Everything broken:
candidates: {"host":10,"srflx":0,"relay":0} → STUN unreachable too Script 02 — Outgoing Call
Opens the DM with the configured callee, clicks the “Voice call” button, clicks “Call” in the modal, then observes the RTCPeerConnection lifecycle for 30 seconds. Reports final connection state and candidate-pair statistics.
npm run test:outgoing
# Healthy call:
connection: "connected", succeededPairs: [{rtt: 0.012, bytesSent: 4820}]
# RC 8.4.1 bug signature:
signaling: "have-local-offer", succeededPairs: []
# Offer created locally but never sent to server Script 03 — DDP capture (the one that matters)
Same call flow as Script 02, but with raw WebSocket frame capture via CDP. Every frame sent to the media-calls DDP stream is intercepted and classified by type. This is the single most useful script in the toolkit — it answers the question “did the browser actually send the SDP offer?” at the protocol level.
npm run test:ddp
# RC 8.4.1 output (broken — no offer sent):
Breakdown of media-calls message types sent by browser:
error: 30
local-state: 5
# What a healthy call should show:
offer: 1
signal: 3
local-state: 5 If offer is missing from this breakdown, the browser created the SDP offer and set it as the local description — but never transmitted it to the server over DDP. That’s the RC 8.4.1 web bug, confirmed at the transport layer.
Script 04 — Incoming call
Opens the browser and waits. Have someone call you from a mobile device. Captures the incoming-call flow to verify whether inbound DDP signaling reaches the browser session.
WAIT_SECONDS=90 npm run test:incoming
# If count: 0 — incoming DDP signal didn't reach this session.
# Check for stale entries in rocketchat_media_call_channels. Setup and usage
git clone https://github.com/ilia-ae/rocketchat-voice-tester.git
cd rocketchat-voice-tester
# Skip bundled Chromium — we use system Chrome
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install
cp .env.example .env
chmod 600 .env # PAT carries full user permissions Edit .env with your Rocket.Chat credentials:
RC_URL=https://chat.example.com
RC_USER_ID=your_user_id_from_rc
RC_TOKEN=your_personal_access_token
CALLEE_USERNAME=the_user_to_call Get a Personal Access Token from My Account → Personal Access Tokens → Add in the RC web client. The token inherits the full permissions of the user that created it — treat it like a password.
On Linux, set CHROME_PATH to your Chrome binary path (e.g., /usr/bin/google-chrome). On macOS, the default path is auto-detected.
The root cause: missing SDP offer in DDP
After eliminating every server-side and network hypothesis, the DDP capture (Script 03) proved the root cause definitively. During a web-to-mobile call attempt, the browser sent 30+ WebSocket frames to stream-notify-user/<userId>/media-calls. The breakdown:
error: 30+— ICE candidate errors (code 701 from TCP TURN attempts, harmless noise)local-state: 5— call state updates (ringing)offer: 0— the browser never sent the SDP offer
The bug lives in @rocket.chat/ui-voip@20.0.0 and @rocket.chat/media-calls@0.4.0, shipped with RC 8.4.0–8.4.1. The call flow reaches signalingState: "have-local-offer" — meaning the browser created the offer and set it as the local description — and stops. ICE candidates are gathered successfully (20+ including relay candidates from a working TURN server), but without the offer reaching the server, the remote side never receives the SDP, and the server times out after ~12 seconds.
PR #40422 on the Rocket.Chat repository describes a related race condition in packages/media-signaling/src/lib/Session.ts where the library “would never request the user’s audio for that call, the negotiation would eventually timeout and the call would be dropped.” RC 8.5.0-rc.0 bumps both packages to major new versions (ui-voip@21.0.0-rc.0, media-calls@0.5.0-rc.0) — expected to include the fix.
Reading the output
| Scenario | What to do |
|---|---|
All ICE types present, offer: 1 in DDP breakdown | Voice calls should work end-to-end. If they still fail, check codec negotiation or mobile push delivery. |
ICE relay candidates missing (relay: 0) | Fix TURN server configuration before investigating further. Check coturn logs, firewall rules, and TURN credentials. |
No offer in DDP breakdown | RC 8.4.1 frontend bug confirmed. Not fixable client-side. Upgrade to RC 8.5.0+ when available. |
count: 0 (no RTCPeerConnection created) | UI click didn’t trigger call initialization. Check voice call permissions, license module, and button selectors. |
| Many 701 errors but relay candidates present | TCP TURN binding failures — harmless noise on multi-NIC or VPN hosts. UDP TURN is what matters. |
What this won’t tell you
- Real audio quality. Chrome runs with a fake media device (
--use-fake-device-for-media-stream), so ICE/DTLS/SRTP negotiate, but there’s no actual microphone signal. For codec or jitter issues, use real devices andchrome://webrtc-internals/. - Mobile clients. This is web-only. For iOS, use Xcode console; for Android,
adb logcatfiltered onVoIPandMediaCall. - Server-side bugs the client can’t see. Check
journalctl -u rocketchatand queryrocketchat_media_calls,_negotiations,_channelsin Mongo. - WebRTC stats over time. The tool snapshots
pc.getStats()once at the end. For continuous stats during a call, usechrome://webrtc-internals/in a real browser.
Related projects
If you’re managing a self-hosted Rocket.Chat instance, the Rocket.Chat Deploy Toolkit covers server deployment, updates, and backup automation. For TURN/STUN infrastructure shared with XMPP, see the Snikket XMPP Server Manager article on coturn configuration and SSLH multiplexing.
FAQ
Does this tool work with Rocket.Chat versions other than 8.4?
The PAT login injection and WebRTC hooks are version-independent. The UI selectors for clicking the voice call button may need updating for significantly different RC versions, but the diagnostic capture layer (ICE gathering, DDP frame classification) works regardless of RC version.
Can I run this on a server without a display?
Yes. The tool runs headless by default (HEADLESS=true in .env). Chrome’s --use-fake-device-for-media-stream flag provides synthetic audio/video sources, so no physical microphone or camera is needed.
Why use system Chrome instead of Playwright’s bundled Chromium?
WebRTC behavior — especially ICE candidate gathering, TURN protocol support, and codec negotiation — can differ between Chromium builds. Using the same Chrome version that real users have ensures the diagnostic results reflect actual production behavior, not artifacts of a different browser build.
Is the Personal Access Token safe to use?
The PAT carries the full permissions of the user that issued it. The .env file is gitignored, and the token is only injected into a sandboxed Playwright Chrome instance — it doesn’t touch your real browser profile. Revoke unused tokens after testing via My Account → Personal Access Tokens in the RC web UI.
What does “Invalid state for renegotiation request” in the server log mean?
The browser is repeatedly attempting to renegotiate a call that the server has already timed out. These messages are an effect of the missing-offer bug, not the cause. They stop after the client gives up. Seeing them in journalctl is a secondary confirmation that the frontend never sent the initial SDP offer.
Conclusion
When WebRTC calls fail, you check the network first. TURN reachability, firewall rules, NAT traversal. In RC 8.4.1, all of that was fine. The actual failure was a race condition in the frontend signaling code that prevented the SDP offer from ever leaving the browser. Standard DevTools can show you the RTCPeerConnection state, but they can’t classify DDP stream messages or prove that a specific message type was never sent. Hooking into WebRTC at the constructor level and capturing raw WebSocket frames via CDP was the only way to get a clear answer.
The tool is open source at github.com/ilia-ae/rocketchat-voice-tester. If you’re running RC 8.4.x with voice calls enabled, npm run test:ddp will tell you in thirty seconds whether your instance is affected. If the DDP breakdown shows offer: 1, you’re clear. If it doesn’t — the fix is in RC 8.5.0. If you need help diagnosing voice call issues on your self-hosted Rocket.Chat, get in touch.


