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,
});
}
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,6 +126,11 @@ export async function createVoiceCallRuntime(params: {
const localUrl = await webhookServer.start();
// 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;
@@ -211,4 +216,10 @@ export async function createVoiceCallRuntime(params: {
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;
}
}

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) => {