fix(voice-call): harden webhook lifecycle cleanup and retries (#32395) (thanks @scoootscooob)

This commit is contained in:
Peter Steinberger
2026-03-03 02:39:28 +00:00
parent e707c97ca6
commit 9f691099db
4 changed files with 35 additions and 8 deletions

View File

@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
- Voice-call/runtime lifecycle: prevent `EADDRINUSE` loops by resetting failed runtime promises, making webhook `start()` idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
- Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.

View File

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

View File

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

View File

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