diff --git a/src/infra/abort-pattern.test.ts b/src/infra/abort-pattern.test.ts new file mode 100644 index 000000000..dd83580ab --- /dev/null +++ b/src/infra/abort-pattern.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +/** + * Regression test for #7174: Memory leak from closure-wrapped controller.abort(). + * + * Using `() => controller.abort()` creates a closure that captures the + * surrounding lexical scope (controller, timer, locals). In long-running + * processes these closures accumulate and prevent GC. + * + * The fix is `controller.abort.bind(controller)` which creates a minimal + * bound function with no scope capture. + * + * This test verifies the behavioral equivalence of .bind() for both the + * setTimeout and addEventListener use-cases. + */ +describe("abort pattern: .bind() vs arrow closure (#7174)", () => { + it("controller.abort.bind(controller) aborts the signal", () => { + const controller = new AbortController(); + const boundAbort = controller.abort.bind(controller); + expect(controller.signal.aborted).toBe(false); + boundAbort(); + expect(controller.signal.aborted).toBe(true); + }); + + it("bound abort works with setTimeout", async () => { + const controller = new AbortController(); + const timer = setTimeout(controller.abort.bind(controller), 10); + expect(controller.signal.aborted).toBe(false); + await new Promise((r) => setTimeout(r, 50)); + expect(controller.signal.aborted).toBe(true); + clearTimeout(timer); + }); + + it("bound abort works as addEventListener callback and can be removed", () => { + const parent = new AbortController(); + const child = new AbortController(); + const onAbort = child.abort.bind(child); + + parent.signal.addEventListener("abort", onAbort, { once: true }); + expect(child.signal.aborted).toBe(false); + + parent.abort(); + expect(child.signal.aborted).toBe(true); + }); + + it("removeEventListener works with saved .bind() reference", () => { + const parent = new AbortController(); + const child = new AbortController(); + const onAbort = child.abort.bind(child); + + parent.signal.addEventListener("abort", onAbort); + // Remove before parent aborts — child should NOT be aborted + parent.signal.removeEventListener("abort", onAbort); + parent.abort(); + expect(child.signal.aborted).toBe(false); + }); + + it("bound abort forwards abort through combined signals", () => { + // Simulates the combineAbortSignals pattern from pi-tools.abort.ts + const signalA = new AbortController(); + const signalB = new AbortController(); + const combined = new AbortController(); + + const onAbort = combined.abort.bind(combined); + signalA.signal.addEventListener("abort", onAbort, { once: true }); + signalB.signal.addEventListener("abort", onAbort, { once: true }); + + expect(combined.signal.aborted).toBe(false); + signalA.abort(); + expect(combined.signal.aborted).toBe(true); + }); +});