fix(auto-reply): avoid leaking inbound debounce cleanup

This commit is contained in:
Vincent Koc
2026-04-13 16:36:03 +01:00
parent 7c91d0dbc9
commit df27091f5f
2 changed files with 26 additions and 2 deletions

View File

@@ -86,11 +86,12 @@ export function createInboundDebouncer<T>(params: InboundDebounceCreateParams<T>
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;
};

View File

@@ -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<string[]> = [];