diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 00bed8c949a..0aadec4e18b 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -181,7 +181,15 @@ const voiceCallPlugin = { logger: api.logger, }); } - runtime = await runtimePromise; + try { + runtime = await runtimePromise; + } catch (err) { + // Reset so the next call can retry instead of caching the + // rejected promise forever (which also leaves the port orphaned + // if the server started before the failure). See: #32387 + runtimePromise = null; + throw err; + } return runtime; }; diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 057a7a30fe4..79cd28066d3 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -126,89 +126,100 @@ export async function createVoiceCallRuntime(params: { const localUrl = await webhookServer.start(); - // Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale - let publicUrl: string | null = config.publicUrl ?? null; - 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 + // keeps the port bound while the runtime promise rejects, causing + // EADDRINUSE on the next attempt. See: #32387 + 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 { - tunnelResult = await startTunnel({ - provider: config.tunnel.provider, - port: config.serve.port, - path: config.serve.path, - ngrokAuthToken: config.tunnel.ngrokAuthToken, - ngrokDomain: config.tunnel.ngrokDomain, - }); - publicUrl = tunnelResult?.publicUrl ?? null; - } catch (err) { - log.error( - `[voice-call] Tunnel setup failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - if (!publicUrl && config.tailscale?.mode !== "off") { - publicUrl = await setupTailscaleExposure(config); - } - - const webhookUrl = publicUrl ?? localUrl; - - if (publicUrl && provider.name === "twilio") { - (provider as TwilioProvider).setPublicUrl(publicUrl); - } - - if (provider.name === "twilio" && config.streaming?.enabled) { - const twilioProvider = provider as TwilioProvider; - if (ttsRuntime?.textToSpeechTelephony) { + if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") { try { - const ttsProvider = createTelephonyTtsProvider({ - coreConfig, - ttsOverride: config.tts, - runtime: ttsRuntime, + tunnelResult = await startTunnel({ + provider: config.tunnel.provider, + port: config.serve.port, + path: config.serve.path, + ngrokAuthToken: config.tunnel.ngrokAuthToken, + ngrokDomain: config.tunnel.ngrokDomain, }); - twilioProvider.setTTSProvider(ttsProvider); - log.info("[voice-call] Telephony TTS provider configured"); + publicUrl = tunnelResult?.publicUrl ?? null; } catch (err) { - log.warn( - `[voice-call] Failed to initialize telephony TTS: ${ - err instanceof Error ? err.message : String(err) - }`, + log.error( + `[voice-call] Tunnel setup failed: ${err instanceof Error ? err.message : String(err)}`, ); } - } else { - log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled"); } - const mediaHandler = webhookServer.getMediaStreamHandler(); - if (mediaHandler) { - twilioProvider.setMediaStreamHandler(mediaHandler); - log.info("[voice-call] Media stream handler wired to provider"); + if (!publicUrl && config.tailscale?.mode !== "off") { + publicUrl = await setupTailscaleExposure(config); } + + const webhookUrl = publicUrl ?? localUrl; + + if (publicUrl && provider.name === "twilio") { + (provider as TwilioProvider).setPublicUrl(publicUrl); + } + + if (provider.name === "twilio" && config.streaming?.enabled) { + const twilioProvider = provider as TwilioProvider; + if (ttsRuntime?.textToSpeechTelephony) { + try { + const ttsProvider = createTelephonyTtsProvider({ + coreConfig, + ttsOverride: config.tts, + runtime: ttsRuntime, + }); + twilioProvider.setTTSProvider(ttsProvider); + log.info("[voice-call] Telephony TTS provider configured"); + } catch (err) { + log.warn( + `[voice-call] Failed to initialize telephony TTS: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } else { + log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled"); + } + + const mediaHandler = webhookServer.getMediaStreamHandler(); + if (mediaHandler) { + twilioProvider.setMediaStreamHandler(mediaHandler); + log.info("[voice-call] Media stream handler wired to provider"); + } + } + + await manager.initialize(provider, webhookUrl); + + const stop = async () => { + if (tunnelResult) { + await tunnelResult.stop(); + } + await cleanupTailscaleExposure(config); + await webhookServer.stop(); + }; + + log.info("[voice-call] Runtime initialized"); + log.info(`[voice-call] Webhook URL: ${webhookUrl}`); + if (publicUrl) { + log.info(`[voice-call] Public URL: ${publicUrl}`); + } + + return { + config, + provider, + manager, + webhookServer, + webhookUrl, + publicUrl, + 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. + await webhookServer.stop().catch(() => {}); + throw err; } - - await manager.initialize(provider, webhookUrl); - - const stop = async () => { - if (tunnelResult) { - await tunnelResult.stop(); - } - await cleanupTailscaleExposure(config); - await webhookServer.stop(); - }; - - log.info("[voice-call] Runtime initialized"); - log.info(`[voice-call] Webhook URL: ${webhookUrl}`); - if (publicUrl) { - log.info(`[voice-call] Public URL: ${publicUrl}`); - } - - return { - config, - provider, - manager, - webhookServer, - webhookUrl, - publicUrl, - stop, - }; } diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index c6b63719cc5..8d0ad3d068a 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -273,3 +273,48 @@ describe("VoiceCallWebhookServer replay handling", () => { } }); }); + +describe("VoiceCallWebhookServer start idempotency", () => { + it("returns existing URL when start() is called twice without stop()", 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 = await server.start(); + // 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) + expect(firstUrl).toContain("/voice/webhook"); + expect(secondUrl).toContain("/voice/webhook"); + } 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" } }); + const server = new VoiceCallWebhookServer(config, manager, provider); + + const firstUrl = await server.start(); + expect(firstUrl).toContain("/voice/webhook"); + await server.stop(); + + // After stopping, a new start should succeed + const secondUrl = await server.start(); + expect(secondUrl).toContain("/voice/webhook"); + await server.stop(); + }); + + it("stop() is safe to call when server was never started", async () => { + const { manager } = createManager([]); + const config = createConfig(); + const server = new VoiceCallWebhookServer(config, manager, provider); + + // Should not throw + await server.stop(); + }); +}); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index ec1969c25fc..e1ad0197e30 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -185,11 +185,19 @@ export class VoiceCallWebhookServer { /** * Start the webhook server. + * Idempotent: returns immediately if the server is already listening. */ async start(): Promise { const { port, bind, path: webhookPath } = this.config.serve; const streamPath = this.config.streaming?.streamPath || "/voice/stream"; + // Guard: if a server is already listening, return the existing URL. + // 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 new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res, webhookPath).catch((err) => {