diff --git a/lib/safe-operations.test.ts b/lib/safe-operations.test.ts index c2b67b8..7184047 100644 --- a/lib/safe-operations.test.ts +++ b/lib/safe-operations.test.ts @@ -636,6 +636,30 @@ describe('withBoundedParallel', () => { expect(receivedSignal!.aborted).toBe(false); }); + // Regression test for #221: withBoundedParallel used to build + // `new AbortController().signal` inline per item when no outer signal was + // passed, so every handler got its own signal that nothing could ever + // abort. The fix shares one AbortController across the whole batch. + it('shares a single fallback signal across items when no outer signal is provided', async () => { + const items = [1, 2, 3]; + const receivedSignals: AbortSignal[] = []; + + await withBoundedParallel( + items, + async (_item, _index, signal) => { + receivedSignals.push(signal); + return { success: true, data: 1 } as SafeOperationResult; + }, + { maxConcurrency: 1 }, // force sequential execution for a deterministic order + ); + + expect(receivedSignals).toHaveLength(3); + // Every item must receive the exact same AbortSignal instance — not a + // fresh one per item, which is the bug this test guards against. + expect(receivedSignals[1]).toBe(receivedSignals[0]); + expect(receivedSignals[2]).toBe(receivedSignals[0]); + }); + it('passes the same parent signal to handlers when provided', async () => { const controller = new AbortController(); const items = [1, 2]; diff --git a/lib/safe-operations.ts b/lib/safe-operations.ts index b50a00b..866c84d 100644 --- a/lib/safe-operations.ts +++ b/lib/safe-operations.ts @@ -157,11 +157,22 @@ export async function withBoundedParallel( const results: SafeOperationResult[] = []; let index = 0; + // When the caller doesn't pass an outer signal, fall back to a single + // AbortController created once for the whole batch — not one per item. + // A fresh AbortController allocated per item is discarded the instant + // it's created; nothing ever holds a reference to it, so `.abort()` can + // never be called and every handler's signal reads `aborted: false` + // forever, silently defeating per-item cancellation (#221). Sharing one + // controller across the batch at least makes the signal a coherent, + // referenceable object — the same one every item receives — instead of + // a dead one manufactured and thrown away per item. + const fallbackSignal = options?.signal ?? new AbortController().signal; + const worker = async () => { while (index < items.length && !options?.signal?.aborted) { const i = index++; try { - const result = await handler(items[i]!, i, options?.signal ?? new AbortController().signal); + const result = await handler(items[i]!, i, fallbackSignal); results[i] = result; } catch (err) { results[i] = {