mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 13:48:37 +00:00
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:
committed by
Peter Steinberger
parent
0750fc2de1
commit
e707c97ca6
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user