diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a16aee935..eb71555c356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob. - Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu. - Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx. +- Voice-call/runtime lifecycle: prevent `EADDRINUSE` loops by resetting failed runtime promises, making webhook `start()` idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob. - Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun. - Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3. - Feishu/DM pairing reply target: send pairing challenge replies to `chat:` instead of `user:` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky. diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 79cd28066d3..27e42fc780c 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -125,6 +125,7 @@ export async function createVoiceCallRuntime(params: { const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig); const localUrl = await webhookServer.start(); + let tunnelResult: TunnelResult | null = null; // Wrap remaining initialization in try/catch so the webhook server is // properly stopped if any subsequent step fails. Without this, the server @@ -133,7 +134,6 @@ export async function createVoiceCallRuntime(params: { try { // Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale let publicUrl: string | null = config.publicUrl ?? null; - let tunnelResult: TunnelResult | null = null; if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") { try { @@ -217,8 +217,13 @@ export async function createVoiceCallRuntime(params: { stop, }; } catch (err) { - // If any step after the server started fails, close the server to - // release the port so the next attempt doesn't hit EADDRINUSE. + // If any step after the server started fails, clean up every provisioned + // resource (tunnel, tailscale exposure, and webhook server) so retries + // don't leak processes or keep the port bound. + if (tunnelResult) { + await tunnelResult.stop().catch(() => {}); + } + await cleanupTailscaleExposure(config).catch(() => {}); await webhookServer.stop().catch(() => {}); throw err; } diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 8d0ad3d068a..6e3ecc6aafa 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -285,9 +285,11 @@ describe("VoiceCallWebhookServer start idempotency", () => { // Second call should return immediately without EADDRINUSE const secondUrl = await server.start(); - // Both calls should return a valid URL (port may differ from config - // since we use port 0 for dynamic allocation, but paths must match) + // Dynamic port allocations should resolve to a real listening port. expect(firstUrl).toContain("/voice/webhook"); + expect(firstUrl).not.toContain(":0/"); + // Idempotent re-start should return the same already-bound URL. + expect(secondUrl).toBe(firstUrl); expect(secondUrl).toContain("/voice/webhook"); } finally { await server.stop(); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index e1ad0197e30..6dda99edd88 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -30,6 +30,7 @@ type WebhookResponsePayload = { */ export class VoiceCallWebhookServer { private server: http.Server | null = null; + private listeningUrl: string | null = null; private config: VoiceCallConfig; private manager: CallManager; private provider: VoiceCallProvider; @@ -195,7 +196,7 @@ export class VoiceCallWebhookServer { // This prevents EADDRINUSE when start() is called more than once on the // same instance (e.g. during config hot-reload or concurrent ensureRuntime). if (this.server?.listening) { - return `http://${bind}:${port}${webhookPath}`; + return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath); } return new Promise((resolve, reject) => { @@ -223,10 +224,16 @@ export class VoiceCallWebhookServer { this.server.on("error", reject); this.server.listen(port, bind, () => { - const url = `http://${bind}:${port}${webhookPath}`; + const url = this.resolveListeningUrl(bind, webhookPath); + this.listeningUrl = url; console.log(`[voice-call] Webhook server listening on ${url}`); if (this.mediaStreamHandler) { - console.log(`[voice-call] Media stream WebSocket on ws://${bind}:${port}${streamPath}`); + const address = this.server?.address(); + const actualPort = + address && typeof address === "object" ? address.port : this.config.serve.port; + console.log( + `[voice-call] Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`, + ); } resolve(url); @@ -251,14 +258,26 @@ export class VoiceCallWebhookServer { if (this.server) { this.server.close(() => { this.server = null; + this.listeningUrl = null; resolve(); }); } else { + this.listeningUrl = null; resolve(); } }); } + private resolveListeningUrl(bind: string, webhookPath: string): string { + const address = this.server?.address(); + if (address && typeof address === "object") { + const host = address.address && address.address.length > 0 ? address.address : bind; + const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `http://${normalizedHost}:${address.port}${webhookPath}`; + } + return `http://${bind}:${this.config.serve.port}${webhookPath}`; + } + private getUpgradePathname(request: http.IncomingMessage): string | null { try { const host = request.headers.host || "localhost";