From f7caf83da49c4214980faca0fe00527646c7395b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 03:27:03 +0100 Subject: [PATCH] fix(voice-call): coalesce webhook server starts --- CHANGELOG.md | 1 + extensions/voice-call/src/webhook.test.ts | 15 +++++++++++++++ extensions/voice-call/src/webhook.ts | 18 ++++++++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87eadff6c0e..e18ecae0199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler. - Plugins/Voice Call: reuse the webhook runtime across in-process plugin contexts, avoiding `EADDRINUSE` when agent tools or CLI commands run while the Gateway already owns the voice webhook port. Fixes #58115. Thanks @sfbrian. - Plugins/Voice Call: answer accepted Telnyx inbound Call Control legs on `call.initiated`, so webhooks that reach OpenClaw no longer leave the caller ringing until hangup. Fixes #58231 and #40131. Thanks @KonsultDigital. +- Plugins/Voice Call: coalesce concurrent webhook server starts on the same runtime instance, avoiding a second `listen()` bind when overlapping startup paths race. Thanks @education-01. - Plugins/Voice Call: pin voice response sessions to `responseModel` before embedded agent runs, avoiding live-session model switch failures when the global default model differs. Fixes #60118. Thanks @xinbenlv. - Media tools: honor the configured web-fetch SSRF policy for media understanding, image/music/video generation references, and PDF inputs, so explicit RFC2544 opt-ins cover WebChat OSS uploads without weakening defaults. Fixes #71300. (#71321) Thanks @neeravmakwana. - Agents/TTS: suppress successful spoken transcripts from verbose chat tool output when structured voice media is already queued, while preserving text output for non-builtin tool-name collisions. Fixes #71282. Thanks @neeravmakwana. diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 1c3ee15df7f..f0a13b802b3 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -1002,6 +1002,21 @@ describe("VoiceCallWebhookServer start idempotency", () => { } }); + it("supports concurrent start() calls without double-binding the port", async () => { + const { manager } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, provider); + + try { + const [firstUrl, secondUrl] = await Promise.all([server.start(), server.start()]); + + expectWebhookUrl(firstUrl, "/voice/webhook"); + expect(secondUrl).toBe(firstUrl); + } finally { + await server.stop(); + } + }); + it("can start again after stop()", async () => { const { manager } = createManager([]); const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 8b35dc93589..24b3a908878 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -150,6 +150,7 @@ function buildRealtimeRejectedTwiML(): WebhookResponsePayload { export class VoiceCallWebhookServer { private server: http.Server | null = null; private listeningUrl: string | null = null; + private startPromise: Promise | null = null; private config: VoiceCallConfig; private manager: CallManager; private provider: VoiceCallProvider; @@ -443,7 +444,11 @@ export class VoiceCallWebhookServer { await this.initializeMediaStreaming(); } - return new Promise((resolve, reject) => { + if (this.startPromise) { + return this.startPromise; + } + + this.startPromise = new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res, webhookPath).catch((err) => { console.error("[voice-call] Webhook error:", err); @@ -468,11 +473,17 @@ export class VoiceCallWebhookServer { }); } - this.server.on("error", reject); + this.server.on("error", (err) => { + this.server = null; + this.listeningUrl = null; + this.startPromise = null; + reject(err); + }); this.server.listen(port, bind, () => { const url = this.resolveListeningUrl(bind, webhookPath); this.listeningUrl = url; + this.startPromise = null; console.log(`[voice-call] Webhook server listening on ${url}`); if (this.mediaStreamHandler) { const address = this.server?.address(); @@ -491,6 +502,8 @@ export class VoiceCallWebhookServer { }); }); }); + + return this.startPromise; } /** @@ -502,6 +515,7 @@ export class VoiceCallWebhookServer { } this.pendingDisconnectHangups.clear(); this.webhookInFlightLimiter.clear(); + this.startPromise = null; if (this.stopStaleCallReaper) { this.stopStaleCallReaper();