[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
- Place a call via
Calls.createCall()(SDK) or the equivalentTrackCallgRPC call. - Let the call reach
ANSWER(orCANCEL) status. - Inspect
trackingCallsMapinsidecreateTrackCall(mods/apiserver/src/calls/createTrackCall.ts) — the entry for that call'srefis 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:
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):
if (FINAL_STATUSES.includes(status)) {
stream.write({ ref, status });
setTimeout(() => stream.end(), 500);
trackingCallsMap.delete(ref);
} else {
stream.write({ ref, status });
}Consequences:
ANSWERis not terminal, and no later event ever is. TheDialStatusenum (mods/common/src/voice/Dial.ts) isTRYING | 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.CANCELis missing fromFINAL_STATUSES, so a cancelled call leaks the same way even though it is a terminal outcome.
Impact
- Unbounded memory growth in
trackingCallsMapproportional to answered-call volume since process start. - Client-side, an SDK consumer iterating the
statusStreamfromCalls.createCall()(mods/sdk/src/Calls.ts:151-201) never sees the generator complete for a successful call — thewhile (!done || queue.length > 0)loop instatusStreamGeneratorpolls every 50ms indefinitely.
Suggested direction (a suggestion, not a prescription)
- Add
CANCELtoFINAL_STATUSES. - Introduce a genuine terminal call-end event and treat it as final. Routr already publishes call-end events with a
hangupCauseonto a separate NATS subject (routr.call.*, subscribed inmods/apiserver/src/events/nats.tsand consumed viawatchNatsinmods/apiserver/src/index.ts:49;mods/apiserver/src/events/transformEvent.ts:53-55confirms those events carryhangupCause). A separate issue may exist proposing that these be bridged onto theTrackCallstream — if so, cross-reference it here, since that work would supply exactly the terminal event this issue needs. - Consider a defensive TTL/sweep on
trackingCallsMapso a missing terminal event cannot leak indefinitely regardless of cause.
Priority
P1
Source: fonoster/fonoster