#882·fonoster

[Bug] TrackCall stream and trackingCallsMap entry leak forever for answered/cancelled calls

Author: psandersCreated Sep 7, 2026Updated Sep 7, 2026
Labelsbugvoice

Summary

A TrackCall stream for a call that gets answered (or cancelled) is never closed and never removed from trackingCallsMap, so both the gRPC stream and the map entry leak for the lifetime of the process.

Steps to Reproduce

  1. Place a call via Calls.createCall() (SDK) or the equivalent TrackCall gRPC call.
  2. Let the call reach ANSWER (or CANCEL) status.
  3. Inspect trackingCallsMap inside createTrackCall (mods/apiserver/src/calls/createTrackCall.ts) — the entry for that call's ref is still present, and the associated stream is still open.

Expected Behavior

Once a call reaches a terminal outcome — including a successful answer that later hangs up, or a cancellation — its TrackCall stream should be closed (stream.end()) and its entry removed from trackingCallsMap.

Actual Behavior

mods/apiserver/src/calls/createTrackCall.ts:30-34 defines:

typescript
const FINAL_STATUSES = [
  DialStatus.BUSY,
  DialStatus.FAILED,
  DialStatus.NOANSWER
];

In createTrackCall.ts:60-68, only a status in FINAL_STATUSES triggers stream.end() and trackingCallsMap.delete(ref):

typescript
if (FINAL_STATUSES.includes(status)) {
  stream.write({ ref, status });
  setTimeout(() => stream.end(), 500);
  trackingCallsMap.delete(ref);
} else {
  stream.write({ ref, status });
}

Consequences:

  1. ANSWER is not terminal, and no later event ever is. The DialStatus enum (mods/common/src/voice/Dial.ts) is TRYING | CANCEL | ANSWER | BUSY | PROGRESS | NOANSWER | FAILED — there is no hangup/call-end status at all, so once a call is answered its stream stays open and its map entry stays resident forever. Every answered call leaks one entry.
  2. CANCEL is missing from FINAL_STATUSES, so a cancelled call leaks the same way even though it is a terminal outcome.

Impact

  • Unbounded memory growth in trackingCallsMap proportional to answered-call volume since process start.
  • Client-side, an SDK consumer iterating the statusStream from Calls.createCall() (mods/sdk/src/Calls.ts:151-201) never sees the generator complete for a successful call — the while (!done || queue.length > 0) loop in statusStreamGenerator polls every 50ms indefinitely.

Suggested direction (a suggestion, not a prescription)

  • Add CANCEL to FINAL_STATUSES.
  • Introduce a genuine terminal call-end event and treat it as final. Routr already publishes call-end events with a hangupCause onto a separate NATS subject (routr.call.*, subscribed in mods/apiserver/src/events/nats.ts and consumed via watchNats in mods/apiserver/src/index.ts:49; mods/apiserver/src/events/transformEvent.ts:53-55 confirms those events carry hangupCause). A separate issue may exist proposing that these be bridged onto the TrackCall stream — if so, cross-reference it here, since that work would supply exactly the terminal event this issue needs.
  • Consider a defensive TTL/sweep on trackingCallsMap so a missing terminal event cannot leak indefinitely regardless of cause.

Priority

P1