Skip to content

VAPI-4027 fix(signaling): never lose a JSON-RPC reply that beats the send callback - #23

Merged
stampercasey merged 3 commits into
mainfrom
VAPI-4027-rpc-reply-race
Sep 25, 2026
Merged

stampercasey merged 3 commits into
mainfrom
VAPI-4027-rpc-reply-race

Conversation

@stampercasey

@stampercasey stampercasey commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of VAPI-4027. The endpoint-to-endpoint monitor hangs on requestOutboundConnection about 0.3% of the time. The gateway sends the reply, but the SDK drops it.

Cause. rpc-websockets' Client.call() only registers the pending call (queue[id]) inside the socket.send completion callback. Under Node over wss://, TLS defers that callback via setImmediate, so it runs after the next I/O poll. If the event loop stalls for a few milliseconds after the send (GC, busy timers), the reply is read first:

  • it finds no pending entry and has no method, so it is dropped silently;
  • the entry is registered afterwards and never settles.

The latest rpc-websockets (10.0.1) has the same code. The browser build calls the send callback synchronously, so browsers don't hit this race.

The fix

Signaling now uses RpcClient (src/v1/rpcClient.ts), a subclass whose call():

  • registers the pending call before sending, so a fast reply always finds it;
  • applies a reply timeout by default (45s, which leaves room for the planned ~30s accept/deny wait in requestOutboundConnection);
  • fails every pending call when the socket closes. The gateway handles each connection on its own, so a reply can never arrive on a reconnected socket. This is hooked in emit rather than on("close") so it still works after Signaling calls removeAllListeners().

The subclass reads rpc-websockets fields that its typings mark private (queue, socket, ready, dataPack, generate_request_id). Signaling already does the same with reconnect_timer_id.

Calls can now reject where they used to hang, so the two calls nobody awaits (the 60s ping and setMediaPreferences on open) now handle their rejections. Without that, Node would crash on an unhandled rejection.

The sdpOffer handlers already catch errors, so a lost answerSdp reply now frees the renegotiation mutex after the timeout instead of holding it for the rest of the session.

ws and @types/ws are added as dev dependencies because the new test imports ws directly. ws was already installed as a dependency of rpc-websockets.

Follow-up from code review

  • Dead sessions are surfaced. A failed setMediaPreferences used to only log, leaving a zombie session: connect() never settled on a first connect, and no init was emitted on a reconnect. A ping with no reply looked the same. Both now reject connect(), emit fatalError, and disconnect. Ping timeouts are detected through a new RpcTimeoutError. The exception is when the socket already closed mid-call: then the close handler decides what happens, so a legitimate 1001 reconnect isn't killed. Other ping errors are still logged at debug level.
  • Stock call() arguments work again. ws_opts can be passed as the 3rd argument, and a falsy timeout (null/0) means no timeout. The one difference from stock: when ws_opts is passed 3rd, the 45s default timeout still applies.
  • Synchronous send errors clean up. A synchronous throw from send() now removes the queue entry and its timer.
  • Flaky test removed. Deleted the timing-dependent test that asserted stock rpc-websockets loses the reply.

Reviewer Notes

  • Dead ping tears down instead of reconnecting. This matches the existing close-code policy: only a 1001 close reconnects, and a dead link ends up closing with 1006 anyway. Changing it means changing the close handler's retry policy, which is out of scope here.
  • The 45s timeout was checked against pv-gateway. handleRequestOutboundConnection currently replies immediately. The planned accept/deny wait is capped at ~30s (the customer callback is capped at 25s) and defaults to deny, so 45s leaves headroom. Per-method timeouts were considered but aren't needed. The constant's comment records this dependency.
  • Signaling tests use a mock. signaling.test.ts mocks rpc-websockets, so those tests don't exercise the real RpcClient. RpcClient is covered separately in rpcClient.test.ts against a real ws server. A setMediaPreferences failure before the first connect settles isn't directly tested, because the mock always sends "ready" first.

Testing

  • src/v1/rpcClient.test.ts runs the real library against a real ws server. It holds back the send callback so the reply deterministically arrives first. It covers the timeout, stock-argument compatibility, a synchronous throw from send(), and failing calls on close (including after removeAllListeners()).
  • src/v1/signaling.test.ts covers the teardown and fatalError paths.
  • End-to-end repro against a separate-process wss:// server that replies immediately. Calls are made from a timer callback, as the monitor does, followed by a synchronous stall; 50 calls per run:
Stall after send Hung, stock client Hung, RpcClient
0 ms 0 0
2 ms 48 0
5 ms 50 0
20 ms 50 0

Plain ws:// never reproduces it, because the non-TLS path calls the send callback via process.nextTick, before any I/O.

Added

Test Assertion
RpcClient > never times out when the timeout is null/0, like the stock call() a falsy timeout leaves the call pending with no timer set
RpcClient > accepts ws options as the third argument, like the stock call() ws_opts passed 3rd reaches socket.send and the call resolves
RpcClient > rejects and forgets the call when send throws synchronously the call rejects and its queue entry is removed
Signaling > should tear down and emit fatalError when setMediaPreferences fails emits fatalError, skips init, disables auto-reconnect, and disconnects
Signaling > should leave setMediaPreferences failures on a closed socket to the close handler no fatalError and the client is kept when the socket closed mid-call
Signaling > ping > should tear down and emit fatalError when a ping gets no reply an RpcTimeoutError from ping emits fatalError and disconnects
Signaling > ping > should keep the session when a ping fails for another reason a non-timeout ping error leaves the session running

Modified

Test Assertion Why
RpcClient > rejects when no reply arrives before the timeout rejects with an RpcTimeoutError timeouts are now a distinct type so Signaling can detect a dead ping

Deleted

Test Reason
RpcClient > stock rpc-websockets loses a reply that beats the send callback depended on real event-loop timing, so it was flaky on slow CI and would break if upstream fixed the bug

Test Plan

  • npm test passes (unit tests + prettier check)
  • npx tsc --noEmit -p . is clean
  • Monitor run against a live gateway shows no hangs on requestOutboundConnection

…send callback

rpc-websockets' Client.call() registers the pending call only inside the
socket's send callback. Under Node over TLS that callback is deferred to
setImmediate, so an event-loop stall of a few milliseconds after a send lets
the gateway's reply be read first. The reply finds no pending entry and is
silently dropped, then the entry is registered and the promise never settles.
This is what hung the endpoint-to-endpoint monitor on requestOutboundConnection
about 0.3% of the time. The latest rpc-websockets release (10.0.1) has the
same code, and browsers are unaffected because their send callback is
synchronous.

Signaling now uses RpcClient, a subclass whose call():
- registers the pending call before sending,
- always applies a reply timeout (45s default), and
- fails every pending call when the socket closes, since a reply can never
  arrive on a reconnected socket.

The fire-and-forget ping and the setMediaPreferences call on open now catch
rejections, which would otherwise be unhandled now that calls can time out.
@stampercasey
stampercasey requested review from a team as code owners September 24, 2026 17:50
@bwappsec

bwappsec commented Sep 24, 2026 •

Copy link
Copy Markdown

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

…) semantics

- Tear down and emit fatalError when setMediaPreferences fails or a ping
  gets no reply, instead of leaving a silent zombie session
- Restore stock call() behavior: ws_opts as third argument, falsy timeout
  means no timeout
- Clean up the pending entry and timer when send throws synchronously
- Drop the timing-dependent test that pinned the stock rpc-websockets bug
@stampercasey
stampercasey merged commit 2cb4a58 into main Sep 25, 2026
5 checks passed
@stampercasey
stampercasey deleted the VAPI-4027-rpc-reply-race branch September 25, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants