VAPI-4027 fix(signaling): never lose a JSON-RPC reply that beats the send callback - #23
Merged
Merged
Conversation
…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.
✅ Snyk checks have passed. No issues have been found so far.
💻 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
smoghe-bw
approved these changes
Sep 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Part of VAPI-4027. The endpoint-to-endpoint monitor hangs on
requestOutboundConnectionabout 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 thesocket.sendcompletion callback. Under Node overwss://, TLS defers that callback viasetImmediate, 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:method, so it is dropped silently;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
Signalingnow usesRpcClient(src/v1/rpcClient.ts), a subclass whosecall():requestOutboundConnection);emitrather thanon("close")so it still works afterSignalingcallsremoveAllListeners().The subclass reads rpc-websockets fields that its typings mark private (
queue,socket,ready,dataPack,generate_request_id).Signalingalready does the same withreconnect_timer_id.Calls can now reject where they used to hang, so the two calls nobody awaits (the 60s
pingandsetMediaPreferenceson open) now handle their rejections. Without that, Node would crash on an unhandled rejection.The sdpOffer handlers already catch errors, so a lost
answerSdpreply now frees the renegotiation mutex after the timeout instead of holding it for the rest of the session.wsand@types/wsare added as dev dependencies because the new test importswsdirectly.wswas already installed as a dependency of rpc-websockets.Follow-up from code review
setMediaPreferencesused to only log, leaving a zombie session:connect()never settled on a first connect, and noinitwas emitted on a reconnect. A ping with no reply looked the same. Both now rejectconnect(), emitfatalError, and disconnect. Ping timeouts are detected through a newRpcTimeoutError. 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.call()arguments work again.ws_optscan be passed as the 3rd argument, and a falsy timeout (null/0) means no timeout. The one difference from stock: whenws_optsis passed 3rd, the 45s default timeout still applies.send()now removes the queue entry and its timer.Reviewer Notes
handleRequestOutboundConnectioncurrently 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.Signalingtests use a mock.signaling.test.tsmocks rpc-websockets, so those tests don't exercise the realRpcClient.RpcClientis covered separately inrpcClient.test.tsagainst a realwsserver. AsetMediaPreferencesfailure before the first connect settles isn't directly tested, because the mock always sends "ready" first.Testing
src/v1/rpcClient.test.tsruns the real library against a realwsserver. It holds back the send callback so the reply deterministically arrives first. It covers the timeout, stock-argument compatibility, a synchronous throw fromsend(), and failing calls on close (including afterremoveAllListeners()).src/v1/signaling.test.tscovers the teardown andfatalErrorpaths.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:RpcClientPlain
ws://never reproduces it, because the non-TLS path calls the send callback viaprocess.nextTick, before any I/O.Added
RpcClient > never times out when the timeout is null/0, like the stock call()RpcClient > accepts ws options as the third argument, like the stock call()ws_optspassed 3rd reachessocket.sendand the call resolvesRpcClient > rejects and forgets the call when send throws synchronouslySignaling > should tear down and emit fatalError when setMediaPreferences failsfatalError, skipsinit, disables auto-reconnect, and disconnectsSignaling > should leave setMediaPreferences failures on a closed socket to the close handlerfatalErrorand the client is kept when the socket closed mid-callSignaling > ping > should tear down and emit fatalError when a ping gets no replyRpcTimeoutErrorfrompingemitsfatalErrorand disconnectsSignaling > ping > should keep the session when a ping fails for another reasonModified
RpcClient > rejects when no reply arrives before the timeoutRpcTimeoutErrorSignalingcan detect a dead pingDeleted
RpcClient > stock rpc-websockets loses a reply that beats the send callbackTest Plan
npm testpasses (unit tests + prettier check)npx tsc --noEmit -p .is cleanrequestOutboundConnection