From e29f3a2e62b4c2342755b4d7f515bfcfcfbcaa4e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 02:42:39 +0800 Subject: [PATCH] fix(voice-call): share runtime stop completion --- extensions/voice-call/src/runtime.test.ts | 25 ++++++++++++++-- extensions/voice-call/src/runtime.ts | 36 ++++++++++++----------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index def3576e53fe..7d83bf99ebe2 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -282,6 +282,13 @@ describe("createVoiceCallRuntime lifecycle", () => { it("returns an idempotent stop handler", async () => { const tunnelStop = vi.fn().mockResolvedValue(undefined); + let releaseWebhookStop: (() => void) | undefined; + mocks.webhookStop.mockImplementation( + () => + new Promise((resolve) => { + releaseWebhookStop = resolve; + }), + ); mocks.startTunnel.mockResolvedValue({ publicUrl: "https://public.example/voice/webhook", provider: "ngrok", @@ -294,8 +301,22 @@ describe("createVoiceCallRuntime lifecycle", () => { agentRuntime: {} as never, }); - await runtime.stop(); - await runtime.stop(); + const firstStop = runtime.stop(); + const secondStop = runtime.stop(); + let stopped = false; + void secondStop.then(() => { + stopped = true; + }); + + expect(secondStop).toBe(firstStop); + await vi.waitFor(() => { + expect(mocks.webhookStop).toHaveBeenCalledTimes(1); + }); + expect(stopped).toBe(false); + + releaseWebhookStop?.(); + await firstStop; + expect(stopped).toBe(true); expect(tunnelStop).toHaveBeenCalledTimes(1); expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1); diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 634ed3b712bf..2be2e76a450f 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -131,7 +131,7 @@ function createRuntimeResourceLifecycle(params: { stop: (opts?: { suppressErrors?: boolean }) => Promise; } { let tunnelResult: TunnelResult | null = null; - let stopped = false; + let stopPromise: Promise | null = null; const runStep = async (step: () => Promise, suppressErrors: boolean) => { if (suppressErrors) { @@ -145,23 +145,25 @@ function createRuntimeResourceLifecycle(params: { setTunnelResult: (result) => { tunnelResult = result; }, - stop: async (opts) => { - if (stopped) { - return; + stop: (opts) => { + if (stopPromise) { + return stopPromise; } - stopped = true; const suppressErrors = opts?.suppressErrors ?? false; - await runStep(async () => { - if (tunnelResult) { - await tunnelResult.stop(); - } - }, suppressErrors); - await runStep(async () => { - await cleanupTailscaleExposure(params.config); - }, suppressErrors); - await runStep(async () => { - await params.webhookServer.stop(); - }, suppressErrors); + stopPromise = (async () => { + await runStep(async () => { + if (tunnelResult) { + await tunnelResult.stop(); + } + }, suppressErrors); + await runStep(async () => { + await cleanupTailscaleExposure(params.config); + }, suppressErrors); + await runStep(async () => { + await params.webhookServer.stop(); + }, suppressErrors); + })(); + return stopPromise; }, }; } @@ -556,7 +558,7 @@ export async function createVoiceCallRuntime(params: { await manager.initialize(provider, webhookUrl); - const stop = async () => await lifecycle.stop(); + const stop = () => lifecycle.stop(); log.info("[voice-call] Runtime initialized"); log.info(`[voice-call] Webhook URL: ${webhookUrl}`);