From 20210563f9d314c5e5eb59056bafd8966ff43869 Mon Sep 17 00:00:00 2001 From: xquanluu Date: Wed, 26 Aug 2026 17:14:16 +0700 Subject: [PATCH] fix: end the call when dial finishes with no actionHook, or a failed one - a websocket application was parked in _awaitCommandsOrHangup after a dial it was never asked about: no actionHook at all, or one whose request failed. An http application already ends the call here simply by running out of verbs; a websocket one sat in dead air until timeLimit - TaskDial._endSessionUnlessHandedOff -> CallSession.expectNoFurtherVerbs suppresses the await for that one task iteration, so the loop falls out and the call tears down normally - verbs still queued are unaffected: the flag is read behind the tasks.length === 0 test, so the next verb always runs - skipped for a nested dial, and when the application already took the call elsewhere (KillReason.Replaced / ReferComplete) Co-Authored-By: Claude Opus 5 (1M context) --- lib/session/call-session.js | 17 ++++++++++++++++- lib/tasks/dial.js | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/session/call-session.js b/lib/session/call-session.js index bca9c5b0..4a63f0ff 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -1348,6 +1348,7 @@ class CallSession extends Emitter { } while (this.tasks.length && !this.callGone) { + this._noFurtherVerbsExpected = false; // scoped to the task about to run const taskNum = ++this.taskIdx; const stackNum = this.stackIdx; const task = this.tasks.shift(); @@ -1403,7 +1404,8 @@ class CallSession extends Emitter { this.appIsUsingWebsockets && !this.requestor.closedGracefully && !this.callGone && - !this.isConfirmCallSession + !this.isConfirmCallSession && + !this._noFurtherVerbsExpected ) { try { await this._awaitCommandsOrHangup(); @@ -3189,6 +3191,19 @@ Duration=${duration} ` } } + /** + * A finished task telling us the application has no follow-on verbs coming, so + * don't park a websocket app waiting for them. Cleared each task iteration. + */ + expectNoFurtherVerbs(reason) { + /* http apps end the call here anyway, so that path is log noise */ + if (this.appIsUsingWebsockets) { + this.logger.info(`CallSession:expectNoFurtherVerbs - ${reason}; ending call rather than awaiting commands`); + } + else this.logger.debug(`CallSession:expectNoFurtherVerbs - ${reason}`); + this._noFurtherVerbsExpected = true; + } + _awaitCommandsOrHangup() { assert(!this.wakeupResolver); return new Promise((resolve, reject) => { diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js index e4ff994c..d4f2e133 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -209,6 +209,10 @@ class TaskDial extends Task { 'Dial:exec - exitMediaPath is set so features such as transcribe and record will not work on this call'); } + /* the catch covers all of exec, and plenty of paths throw before the hook + is sent; don't blame the hook for those */ + let hookAttempted = false; + try { if (this.listenTask) { const {span, ctx} = this.startChildSpan(`nested:${this.listenTask.summary}`); @@ -246,16 +250,31 @@ class TaskDial extends Task { if (!this.killed) await this._attemptCalls(cs); await this.awaitTaskDone(); this.logger.debug({callSid: this.cs.callSid}, 'Dial:exec task is done, sending actionHook if any'); + if (this.actionHook) hookAttempted = true; + else this._endSessionUnlessHandedOff(cs, 'dial completed with no actionHook'); await this.performAction(this.results, this.killReason !== KillReason.Replaced); this._removeDtmfDetection(cs.dlg); this._removeDtmfDetection(this.dlg); this._removeSipIndialogRequestListener(this.dlg); } catch (err) { this.logger.error({err}, 'TaskDial:exec terminating with error'); + this._endSessionUnlessHandedOff(cs, + hookAttempted ? 'dial actionHook failed' : 'dial failed before any actionHook was sent'); this.kill(cs); } } + /** + * Dial is over with nothing to do next, so don't leave a websocket app parked. + */ + _endSessionUnlessHandedOff(cs, reason) { + /* a nested dial reports to its parent verb - only the session's own task may end it */ + if (cs.currentTask !== this) return; + /* the app already took the call elsewhere */ + if ([KillReason.Replaced, KillReason.ReferComplete].includes(this.killReason)) return; + cs.expectNoFurtherVerbs(reason); + } + async kill(cs, reason) { super.kill(cs); try {