Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/safe-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
},
{ 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];
Expand Down
13 changes: 12 additions & 1 deletion lib/safe-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,22 @@ export async function withBoundedParallel<T>(
const results: SafeOperationResult<any>[] = [];
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] = {
Expand Down