mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 17:21:52 +00:00
fix(voice-call): harden webhook lifecycle cleanup and retries (#32395) (thanks @scoootscooob)
This commit is contained in:
@@ -125,6 +125,7 @@ export async function createVoiceCallRuntime(params: {
|
||||
const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig);
|
||||
|
||||
const localUrl = await webhookServer.start();
|
||||
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
|
||||
@@ -133,7 +134,6 @@ export async function createVoiceCallRuntime(params: {
|
||||
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 {
|
||||
@@ -217,8 +217,13 @@ export async function createVoiceCallRuntime(params: {
|
||||
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.
|
||||
// If any step after the server started fails, clean up every provisioned
|
||||
// resource (tunnel, tailscale exposure, and webhook server) so retries
|
||||
// don't leak processes or keep the port bound.
|
||||
if (tunnelResult) {
|
||||
await tunnelResult.stop().catch(() => {});
|
||||
}
|
||||
await cleanupTailscaleExposure(config).catch(() => {});
|
||||
await webhookServer.stop().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -285,9 +285,11 @@ describe("VoiceCallWebhookServer start idempotency", () => {
|
||||
// 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)
|
||||
// Dynamic port allocations should resolve to a real listening port.
|
||||
expect(firstUrl).toContain("/voice/webhook");
|
||||
expect(firstUrl).not.toContain(":0/");
|
||||
// Idempotent re-start should return the same already-bound URL.
|
||||
expect(secondUrl).toBe(firstUrl);
|
||||
expect(secondUrl).toContain("/voice/webhook");
|
||||
} finally {
|
||||
await server.stop();
|
||||
|
||||
@@ -30,6 +30,7 @@ type WebhookResponsePayload = {
|
||||
*/
|
||||
export class VoiceCallWebhookServer {
|
||||
private server: http.Server | null = null;
|
||||
private listeningUrl: string | null = null;
|
||||
private config: VoiceCallConfig;
|
||||
private manager: CallManager;
|
||||
private provider: VoiceCallProvider;
|
||||
@@ -195,7 +196,7 @@ export class VoiceCallWebhookServer {
|
||||
// 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 this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -223,10 +224,16 @@ export class VoiceCallWebhookServer {
|
||||
this.server.on("error", reject);
|
||||
|
||||
this.server.listen(port, bind, () => {
|
||||
const url = `http://${bind}:${port}${webhookPath}`;
|
||||
const url = this.resolveListeningUrl(bind, webhookPath);
|
||||
this.listeningUrl = url;
|
||||
console.log(`[voice-call] Webhook server listening on ${url}`);
|
||||
if (this.mediaStreamHandler) {
|
||||
console.log(`[voice-call] Media stream WebSocket on ws://${bind}:${port}${streamPath}`);
|
||||
const address = this.server?.address();
|
||||
const actualPort =
|
||||
address && typeof address === "object" ? address.port : this.config.serve.port;
|
||||
console.log(
|
||||
`[voice-call] Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`,
|
||||
);
|
||||
}
|
||||
resolve(url);
|
||||
|
||||
@@ -251,14 +258,26 @@ export class VoiceCallWebhookServer {
|
||||
if (this.server) {
|
||||
this.server.close(() => {
|
||||
this.server = null;
|
||||
this.listeningUrl = null;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
this.listeningUrl = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveListeningUrl(bind: string, webhookPath: string): string {
|
||||
const address = this.server?.address();
|
||||
if (address && typeof address === "object") {
|
||||
const host = address.address && address.address.length > 0 ? address.address : bind;
|
||||
const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
return `http://${normalizedHost}:${address.port}${webhookPath}`;
|
||||
}
|
||||
return `http://${bind}:${this.config.serve.port}${webhookPath}`;
|
||||
}
|
||||
|
||||
private getUpgradePathname(request: http.IncomingMessage): string | null {
|
||||
try {
|
||||
const host = request.headers.host || "localhost";
|
||||
|
||||
Reference in New Issue
Block a user