diff --git a/src/auto-reply/inbound-debounce.ts b/src/auto-reply/inbound-debounce.ts index 194dee6f0c2..078e9567e3e 100644 --- a/src/auto-reply/inbound-debounce.ts +++ b/src/auto-reply/inbound-debounce.ts @@ -86,11 +86,12 @@ export function createInboundDebouncer(params: InboundDebounceCreateParams const next = previous.catch(() => undefined).then(task); const settled = next.catch(() => undefined); keyChains.set(key, settled); - void settled.finally(() => { + const cleanup = () => { if (keyChains.get(key) === settled) { keyChains.delete(key); } - }); + }; + settled.then(cleanup, cleanup); return next; }; diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index 6a47db79161..d8bd1d76be9 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -521,6 +521,29 @@ describe("createInboundDebouncer", () => { expect(calls).toEqual(["1", "2"]); }); + it("does not leak unhandled rejections when a keyed flush failure is awaited", async () => { + const debouncer = createInboundDebouncer<{ key: string; id: string }>({ + debounceMs: 0, + buildKey: (item) => item.key, + onFlush: async () => { + throw new Error("flush failed"); + }, + }); + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + + try { + await expect(debouncer.enqueue({ key: "a", id: "1" })).resolves.toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + it("bypasses debouncing for new keys once the tracked-key cap is reached", async () => { vi.useFakeTimers(); const calls: Array = [];