mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:56:45 +00:00
fix(voice-call): harden webhook lifecycle cleanup and retries (#32395) (thanks @scoootscooob)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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