Skip to content

Race condition in handleCallDialEvents causes crash during dialSip/dialPhone #1328

@screwyforcepush

Description

@screwyforcepush

SignalWire SDK Bug Report: Race Condition in handleCallDialEvents

Summary

Race condition in @signalwire/realtime-api SDK causes TypeError: Cannot read properties of undefined (reading 'setPayload') when calling.call.dial (answered) event arrives before calling.call.state event during voiceClient.dialSip() or voiceClient.dialPhone().

Affected Versions

Bug Location

File: dist/index.node.mjs (and dist/index.node.js)

Function: handleCallDialEvents (~line 3055)

Root Cause

The voiceCallDialWorker forks two watchers in parallel:

yield sagaEffects11.fork(callDialWatcher);   // Handles calling.call.dial
yield sagaEffects11.fork(callStateWatcher);  // Handles calling.call.state

handleCallStateEvents creates the Call instance if it doesn't exist (defensive):

let callInstance = get(payload.call_id);
if (!callInstance) {
  callInstance = new Call({ voice, payload, listeners });  // Creates it
}
set(payload.call_id, callInstance);

handleCallDialEvents does NOT create the Call instance (not defensive):

case "answered": {
  const callInstance = get(payload.call.call_id);  // Returns undefined if state event hasn't arrived
  callInstance.setPayload(payload.call);           // CRASH
  voice.emit("dial.answered", callInstance);
  return true;
}

When SignalWire's backend sends calling.call.dial with dial_state: "answered" before calling.call.state, the instanceMap doesn't have the Call yet, causing the crash.

Error Output

TypeError: Cannot read properties of undefined (reading 'setPayload')
    at handleCallDialEvents (file:///app/node_modules/@signalwire/realtime-api/dist/index.node.mjs:3065:20)
    at callDialWatcher (file:///app/node_modules/@signalwire/realtime-api/dist/index.node.mjs:3495:26)
    at callDialWatcher.next (<anonymous>)
    at next (/app/node_modules/@redux-saga/core/dist/redux-saga-core.prod.cjs.js:1101:27)
    ...

The above error occurred in task callDialWatcher
created by voiceCallDialWorker
Tasks cancelled due to error:
callStateWatcher

Impact

  1. Promise hangs forever - The dialSip()/dialPhone() promise never resolves or rejects
  2. No cleanup - Error occurs inside saga, not propagated to user's catch block
  3. State corruption - If same customer calls again, old saga may resume with new call's events, causing bizarre behavior (duplicate handlers, wrong conference routing)

Reproduction

The bug is intermittent and timing-dependent. More likely to occur under:

  • High server load
  • Cloud environments (Cloud Run, AWS Lambda) with variable latency
  • Rapid successive calls

Suggested Fix

Apply the same defensive pattern used in handleCallStateEvents:

// src/voice/workers/handlers/callDialEventsHandler.ts
function handleCallDialEvents(options) {
  const { payload, instanceMap, voice } = options;
  const { get, set } = instanceMap;  // Add 'set'
  switch (payload.dial_state) {
    case "failed": {
      voice.emit("dial.failed", payload);
      return true;
    }
    case "answered": {
      let callInstance = get(payload.call.call_id);
      if (!callInstance) {
        // Defensive: create Call instance if state event hasn't arrived yet
        callInstance = new Call({ voice, payload: payload.call });
        set(payload.call.call_id, callInstance);
      }
      callInstance.setPayload(payload.call);
      voice.emit("dial.answered", callInstance);
      return true;
    }
    default:
      return false;
  }
}

Workaround

We are using patch-package to apply the fix locally:

patches/@signalwire+realtime-api+4.1.2.patch

Environment

  • Node.js 20
  • Google Cloud Run
  • SignalWire Relay v4 with SIP and PSTN dialing

Note: This bug affects production call handling applications. A saga crash during call setup can leave calls in inconsistent states with no way to recover gracefully.

Sub-issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions