From 1a5d71ee6bb7c98b673de5ff9e45008cf42391c2 Mon Sep 17 00:00:00 2001 From: WhatsSkiLL Date: Mon, 27 Jul 2026 18:43:08 +0200 Subject: [PATCH] fix-meeting-audio-pull-timeout-cleanup (#114526) Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> --- src/meeting-bot/node-audio-pull-waiters.ts | 11 +++- src/meeting-bot/node-host.test.ts | 67 ++++++++++++++++++---- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/meeting-bot/node-audio-pull-waiters.ts b/src/meeting-bot/node-audio-pull-waiters.ts index 9b8185fb2bcc..be85a1a5dc99 100644 --- a/src/meeting-bot/node-audio-pull-waiters.ts +++ b/src/meeting-bot/node-audio-pull-waiters.ts @@ -1,5 +1,3 @@ -import { setTimeout as sleep } from "node:timers/promises"; - /** Internal pull-wait ownership used by the node-host long poll. */ export class MeetingNodeAudioPullWaiters { readonly #waiters = new Set<() => void>(); @@ -14,12 +12,19 @@ export class MeetingNodeAudioPullWaiters { wake = resolve; this.#waiters.add(wake); }); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }); try { - await Promise.race([sleep(timeoutMs), ready]); + await Promise.race([timeout, ready]); } finally { // A stalled bridge can be polled indefinitely. Timeout must release its // resolver instead of retaining one waiter per empty pull. this.#waiters.delete(wake); + if (timer !== undefined) { + clearTimeout(timer); + } } } diff --git a/src/meeting-bot/node-host.test.ts b/src/meeting-bot/node-host.test.ts index 617135cb4ebb..9b8ef0d4972f 100644 --- a/src/meeting-bot/node-host.test.ts +++ b/src/meeting-bot/node-host.test.ts @@ -1,20 +1,65 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MeetingNodeAudioPullWaiters } from "./node-audio-pull-waiters.js"; describe("MeetingNodeAudioPullWaiters", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("removes a pull waiter when its timeout wins", async () => { vi.useFakeTimers(); - try { - const waiters = new MeetingNodeAudioPullWaiters(); - const waiting = waiters.wait(250); + const waiters = new MeetingNodeAudioPullWaiters(); + const waiting = waiters.wait(250); - expect(waiters.size).toBe(1); - await vi.advanceTimersByTimeAsync(250); - await waiting; + expect(waiters.size).toBe(1); + await vi.advanceTimersByTimeAsync(250); + await waiting; - expect(waiters.size).toBe(0); - } finally { - vi.useRealTimers(); - } + expect(waiters.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels a pull timeout when audio wakes the waiter early", async () => { + vi.useFakeTimers(); + const waiters = new MeetingNodeAudioPullWaiters(); + const waiting = waiters.wait(2_000); + + expect(waiters.size).toBe(1); + expect(vi.getTimerCount()).toBe(1); + waiters.wake(); + await waiting; + + expect(waiters.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("releases its real timeout resource when audio wakes the waiter early", async () => { + const activeTimeouts = () => + process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length; + const baselineTimeouts = activeTimeouts(); + const waiters = new MeetingNodeAudioPullWaiters(); + const waiting = waiters.wait(2_000); + + expect(waiters.size).toBe(1); + expect(activeTimeouts()).toBe(baselineTimeouts + 1); + waiters.wake(); + await waiting; + + expect(waiters.size).toBe(0); + expect(activeTimeouts()).toBe(baselineTimeouts); + }); + + it("cancels every pull timeout when a wake releases concurrent waiters", async () => { + vi.useFakeTimers(); + const waiters = new MeetingNodeAudioPullWaiters(); + const waiting = [waiters.wait(2_000), waiters.wait(2_000)]; + + expect(waiters.size).toBe(2); + expect(vi.getTimerCount()).toBe(2); + waiters.wake(); + await Promise.all(waiting); + + expect(waiters.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); }); });