Skip to content

Commit 36a17e5

Browse files
committed
Improve mapper error stack traces
Fixes #34
1 parent 2ba3a00 commit 36a17e5

File tree

3 files changed

+178
-1
lines changed

3 files changed

+178
-1
lines changed

index.d.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ export type Options = BaseOptions & {
2121
*/
2222
readonly stopOnError?: boolean;
2323

24+
/**
25+
When `true`, preserves the caller's stack trace in mapper errors for better debugging.
26+
27+
This adds the calling stack frames to errors thrown by the mapper function, making it easier to trace where the pMap call originated. However, it has some performance overhead as it captures stack traces upfront.
28+
29+
@default false
30+
*/
31+
readonly preserveStackTrace?: boolean;
32+
2433
/**
2534
You can abort the promises using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
2635
@@ -53,6 +62,15 @@ export type IterableOptions = BaseOptions & {
5362
Default: `options.concurrency`
5463
*/
5564
readonly backpressure?: number;
65+
66+
/**
67+
When `true`, preserves the caller's stack trace in mapper errors for better debugging.
68+
69+
This adds the calling stack frames to errors thrown by the mapper function, making it easier to trace where the pMapIterable call originated. However, it has some performance overhead as it captures stack traces upfront.
70+
71+
@default false
72+
*/
73+
readonly preserveStackTrace?: boolean;
5674
};
5775

5876
type MaybePromise<T> = T | Promise<T>;

index.js

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,70 @@
1+
const preserveStackMarker = Symbol('pMapPreserveStack');
2+
const noop = () => {};
3+
4+
function createStackPreserver() {
5+
const {stack: capturedStack} = new Error('pMap stack capture');
6+
7+
if (typeof capturedStack !== 'string') {
8+
return noop;
9+
}
10+
11+
// Detect stack format across different JavaScript engines
12+
let frameSeparator;
13+
14+
// Node.js and Chrome: '\n at '
15+
if (capturedStack.includes('\n at ')) {
16+
frameSeparator = '\n at ';
17+
} else if (capturedStack.includes('@')) {
18+
// Firefox: '\n' (simpler format)
19+
frameSeparator = '\n';
20+
} else if (capturedStack.includes('\n\t')) {
21+
// Safari/JSC: varies, try common patterns
22+
frameSeparator = '\n\t';
23+
} else {
24+
// Fallback to generic newline separation
25+
frameSeparator = '\n';
26+
}
27+
28+
const firstFrameIndex = capturedStack.indexOf(frameSeparator);
29+
30+
if (firstFrameIndex === -1) {
31+
return noop;
32+
}
33+
34+
const secondFrameIndex = capturedStack.indexOf(frameSeparator, firstFrameIndex + frameSeparator.length);
35+
36+
const preservedStackSuffix = secondFrameIndex === -1
37+
? capturedStack.slice(firstFrameIndex) // If only one frame exists, preserve from first frame
38+
: capturedStack.slice(secondFrameIndex);
39+
40+
return error => {
41+
try {
42+
if (!error || typeof error !== 'object' || error[preserveStackMarker]) {
43+
return;
44+
}
45+
46+
const {stack} = error;
47+
48+
if (typeof stack !== 'string') {
49+
return;
50+
}
51+
52+
error.stack = stack + preservedStackSuffix;
53+
Object.defineProperty(error, preserveStackMarker, {value: true});
54+
} catch {
55+
// Silently ignore any errors in stack preservation
56+
}
57+
};
58+
}
59+
160
export default async function pMap(
261
iterable,
362
mapper,
463
{
564
concurrency = Number.POSITIVE_INFINITY,
665
stopOnError = true,
766
signal,
67+
preserveStackTrace = false,
868
} = {},
969
) {
1070
return new Promise((resolve_, reject_) => {
@@ -20,6 +80,8 @@ export default async function pMap(
2080
throw new TypeError(`Expected \`concurrency\` to be an integer from 1 and up or \`Infinity\`, got \`${concurrency}\` (${typeof concurrency})`);
2181
}
2282

83+
const preserveStack = preserveStackTrace ? createStackPreserver() : noop;
84+
2385
const result = [];
2486
const errors = [];
2587
const skippedIndexesMap = new Map();
@@ -46,6 +108,7 @@ export default async function pMap(
46108
const reject = reason => {
47109
isRejected = true;
48110
isResolved = true;
111+
preserveStack(reason);
49112
reject_(reason);
50113
cleanup();
51114
};
@@ -130,6 +193,8 @@ export default async function pMap(
130193
resolvingCount--;
131194
await next();
132195
} catch (error) {
196+
preserveStack(error);
197+
133198
if (stopOnError) {
134199
reject(error);
135200
} else {
@@ -143,6 +208,7 @@ export default async function pMap(
143208
try {
144209
await next();
145210
} catch (error) {
211+
preserveStack(error);
146212
reject(error);
147213
}
148214
}
@@ -180,6 +246,7 @@ export function pMapIterable(
180246
{
181247
concurrency = Number.POSITIVE_INFINITY,
182248
backpressure = concurrency,
249+
preserveStackTrace = false,
183250
} = {},
184251
) {
185252
if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) {
@@ -198,6 +265,8 @@ export function pMapIterable(
198265
throw new TypeError(`Expected \`backpressure\` to be an integer from \`concurrency\` (${concurrency}) and up or \`Infinity\`, got \`${backpressure}\` (${typeof backpressure})`);
199266
}
200267

268+
const preserveStack = preserveStackTrace ? createStackPreserver() : noop;
269+
201270
return {
202271
async * [Symbol.asyncIterator]() {
203272
const iterator = iterable[Symbol.asyncIterator] === undefined ? iterable[Symbol.iterator]() : iterable[Symbol.asyncIterator]();
@@ -242,6 +311,7 @@ export function pMapIterable(
242311

243312
return {done: false, value: returnValue};
244313
} catch (error) {
314+
preserveStack(error);
245315
isDone = true;
246316
return {error};
247317
}
@@ -253,11 +323,21 @@ export function pMapIterable(
253323
trySpawn();
254324

255325
while (promises.length > 0) {
256-
const {error, done, value} = await promises[0]; // eslint-disable-line no-await-in-loop
326+
let nextResult;
327+
328+
try {
329+
nextResult = await promises[0]; // eslint-disable-line no-await-in-loop
330+
} catch (error) {
331+
preserveStack(error);
332+
throw error;
333+
}
334+
335+
const {error, done, value} = nextResult;
257336

258337
promises.shift();
259338

260339
if (error) {
340+
preserveStack(error);
261341
throw error;
262342
}
263343

test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,3 +644,82 @@ test('pMapIterable - pMapSkip', async t => {
644644
2,
645645
], async value => value)), [1, 2]);
646646
});
647+
648+
test('mapper error preserves caller stack when opt-in enabled', async t => {
649+
async function runPromisePmap() {
650+
await pMap([
651+
async () => {
652+
throw new Error('stop');
653+
},
654+
], async mapper => mapper(), {preserveStackTrace: true});
655+
}
656+
657+
const error = await t.throwsAsync(runPromisePmap, {message: 'stop'});
658+
659+
t.true(error.stack.includes('runPromisePmap'));
660+
});
661+
662+
test('mapper error does not preserve stack by default', async t => {
663+
function uniqueFunctionName() {
664+
return pMap([
665+
() => {
666+
throw new Error('stop');
667+
},
668+
], mapper => mapper());
669+
}
670+
671+
const error = await t.throwsAsync(uniqueFunctionName, {message: 'stop'});
672+
673+
// Should not include our stack enhancement
674+
t.false(error.stack.includes('uniqueFunctionName'));
675+
});
676+
677+
test('aggregate error stacks preserve caller stack when opt-in enabled', async t => {
678+
async function runPromisePmapStopOnErrorFalse() {
679+
await pMap([
680+
async () => {
681+
throw new Error('first');
682+
},
683+
async () => {
684+
throw new Error('second');
685+
},
686+
], async mapper => mapper(), {concurrency: 2, stopOnError: false, preserveStackTrace: true});
687+
}
688+
689+
const error = await t.throwsAsync(runPromisePmapStopOnErrorFalse, {instanceOf: AggregateError});
690+
691+
t.true(error.stack.includes('runPromisePmapStopOnErrorFalse'));
692+
693+
for (const innerError of error.errors) {
694+
t.true(innerError.stack.includes('runPromisePmapStopOnErrorFalse'));
695+
}
696+
});
697+
698+
test('pMapIterable mapper error preserves caller stack when opt-in enabled', async t => {
699+
async function runPMapIterable() {
700+
await collectAsyncIterable(pMapIterable([
701+
async () => {
702+
throw new Error('stop');
703+
},
704+
], async mapper => mapper(), {preserveStackTrace: true}));
705+
}
706+
707+
const error = await t.throwsAsync(runPMapIterable, {message: 'stop'});
708+
709+
t.true(error.stack.includes('runPMapIterable'));
710+
});
711+
712+
test('pMapIterable mapper error does not preserve stack by default', async t => {
713+
function uniqueIterableFunction() {
714+
return collectAsyncIterable(pMapIterable([
715+
() => {
716+
throw new Error('stop');
717+
},
718+
], mapper => mapper()));
719+
}
720+
721+
const error = await t.throwsAsync(uniqueIterableFunction, {message: 'stop'});
722+
723+
// Should not include our stack enhancement
724+
t.false(error.stack.includes('uniqueIterableFunction'));
725+
});

0 commit comments

Comments
 (0)