fix(voice-call): coalesce webhook server starts

This commit is contained in:
Peter Steinberger
2026-04-25 03:27:03 +01:00
parent 53618cca0d
commit f7caf83da4
3 changed files with 32 additions and 2 deletions

View File

@@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
- Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler.
- Plugins/Voice Call: reuse the webhook runtime across in-process plugin contexts, avoiding `EADDRINUSE` when agent tools or CLI commands run while the Gateway already owns the voice webhook port. Fixes #58115. Thanks @sfbrian.
- Plugins/Voice Call: answer accepted Telnyx inbound Call Control legs on `call.initiated`, so webhooks that reach OpenClaw no longer leave the caller ringing until hangup. Fixes #58231 and #40131. Thanks @KonsultDigital.
- Plugins/Voice Call: coalesce concurrent webhook server starts on the same runtime instance, avoiding a second `listen()` bind when overlapping startup paths race. Thanks @education-01.
- Plugins/Voice Call: pin voice response sessions to `responseModel` before embedded agent runs, avoiding live-session model switch failures when the global default model differs. Fixes #60118. Thanks @xinbenlv.
- Media tools: honor the configured web-fetch SSRF policy for media understanding, image/music/video generation references, and PDF inputs, so explicit RFC2544 opt-ins cover WebChat OSS uploads without weakening defaults. Fixes #71300. (#71321) Thanks @neeravmakwana.
- Agents/TTS: suppress successful spoken transcripts from verbose chat tool output when structured voice media is already queued, while preserving text output for non-builtin tool-name collisions. Fixes #71282. Thanks @neeravmakwana.

View File

@@ -1002,6 +1002,21 @@ describe("VoiceCallWebhookServer start idempotency", () => {
}
});
it("supports concurrent start() calls without double-binding the port", 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, secondUrl] = await Promise.all([server.start(), server.start()]);
expectWebhookUrl(firstUrl, "/voice/webhook");
expect(secondUrl).toBe(firstUrl);
} 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" } });

View File

@@ -150,6 +150,7 @@ function buildRealtimeRejectedTwiML(): WebhookResponsePayload {
export class VoiceCallWebhookServer {
private server: http.Server | null = null;
private listeningUrl: string | null = null;
private startPromise: Promise<string> | null = null;
private config: VoiceCallConfig;
private manager: CallManager;
private provider: VoiceCallProvider;
@@ -443,7 +444,11 @@ export class VoiceCallWebhookServer {
await this.initializeMediaStreaming();
}
return new Promise((resolve, reject) => {
if (this.startPromise) {
return this.startPromise;
}
this.startPromise = new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res, webhookPath).catch((err) => {
console.error("[voice-call] Webhook error:", err);
@@ -468,11 +473,17 @@ export class VoiceCallWebhookServer {
});
}
this.server.on("error", reject);
this.server.on("error", (err) => {
this.server = null;
this.listeningUrl = null;
this.startPromise = null;
reject(err);
});
this.server.listen(port, bind, () => {
const url = this.resolveListeningUrl(bind, webhookPath);
this.listeningUrl = url;
this.startPromise = null;
console.log(`[voice-call] Webhook server listening on ${url}`);
if (this.mediaStreamHandler) {
const address = this.server?.address();
@@ -491,6 +502,8 @@ export class VoiceCallWebhookServer {
});
});
});
return this.startPromise;
}
/**
@@ -502,6 +515,7 @@ export class VoiceCallWebhookServer {
}
this.pendingDisconnectHangups.clear();
this.webhookInFlightLimiter.clear();
this.startPromise = null;
if (this.stopStaleCallReaper) {
this.stopStaleCallReaper();