mirror of https://github.com/openclaw/openclaw.git
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:
parent
0750fc2de1
commit
e707c97ca6
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue