fix(voice-call): prevent EADDRINUSE by guarding webhook server lifecycle

Three issues caused the port to remain bound after partial failures:

1. VoiceCallWebhookServer.start() had no idempotency guard — calling it
   while the server was already listening would create a second server on
   the same port.

2. createVoiceCallRuntime() did not clean up the webhook server if a step
   after webhookServer.start() failed (e.g. manager.initialize). The
   server kept the port bound while the runtime promise rejected.

3. ensureRuntime() cached the rejected promise forever, so subsequent
   calls would re-throw the same error without ever retrying. Combined
   with (2), the port stayed orphaned until gateway restart.

Fixes #32387

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
scoootscooob
2026-03-02 17:50:06 -08:00
committed by Peter Steinberger
parent 0750fc2de1
commit e707c97ca6
4 changed files with 148 additions and 76 deletions

View File

@@ -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;
};

View File

@@ -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,
};
}

View File

@@ -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();
});
});

View File

@@ -185,11 +185,19 @@ export class VoiceCallWebhookServer {
/**
* Start the webhook server.
* Idempotent: returns immediately if the server is already listening.
*/
async start(): Promise<string> {
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) => {