Skip to content

Commit 3fe6d3f

Browse files
committed
feat(execution): track async work for rejection safety
canonize async work tracking so promises started during execution are still observed after early errors or abort paths route defaultTypeResolver through tracked async helpers to avoid dropping pending isTypeOf promise rejections
1 parent d03118a commit 3fe6d3f

File tree

11 files changed

+404
-86
lines changed

11 files changed

+404
-86
lines changed

src/execution/AsyncWorkTracker.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { isPromise } from '../jsutils/isPromise.js';
2+
import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js';
3+
4+
/** @internal */
5+
export class AsyncWorkTracker {
6+
pendingAsyncWork: Set<Promise<void>>;
7+
8+
constructor() {
9+
this.pendingAsyncWork = new Set<Promise<void>>();
10+
}
11+
12+
add(promise: Promise<unknown>): void {
13+
const pendingAsyncWork = this.pendingAsyncWork;
14+
const promiseToSettle = promise.then(
15+
() => {
16+
pendingAsyncWork.delete(promiseToSettle);
17+
},
18+
() => {
19+
pendingAsyncWork.delete(promiseToSettle);
20+
},
21+
);
22+
pendingAsyncWork.add(promiseToSettle);
23+
}
24+
25+
addValues(values: ReadonlyArray<PromiseOrValue<unknown>>): void {
26+
for (const value of values) {
27+
if (isPromise(value)) {
28+
this.add(value);
29+
}
30+
}
31+
}
32+
33+
promiseAllTrackOnReject<T>(
34+
values: ReadonlyArray<PromiseOrValue<T>>,
35+
): Promise<Array<T>> {
36+
const promise = Promise.all(values);
37+
promise.then(undefined, () => {
38+
this.addValues(values);
39+
});
40+
return promise;
41+
}
42+
43+
promiseCombinatorWithTracking<T, TResult>(
44+
values: ReadonlyArray<PromiseOrValue<T>>,
45+
combinator: (
46+
promises: ReadonlyArray<PromiseOrValue<T>>,
47+
) => Promise<TResult>,
48+
): Promise<TResult> {
49+
this.add(Promise.allSettled(values));
50+
return combinator(values);
51+
}
52+
}

src/execution/Executor.ts

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
GraphQLObjectType,
3333
GraphQLOutputType,
3434
GraphQLResolveInfo,
35+
GraphQLResolveInfoHelpers,
3536
GraphQLTypeResolver,
3637
} from '../type/definition.js';
3738
import {
@@ -231,6 +232,11 @@ export class Executor<
231232
abortResultPromise: ((reason?: unknown) => void) | undefined;
232233
resolverAbortController: AbortController | undefined;
233234
getAbortSignal: () => AbortSignal | undefined;
235+
getAsyncHelpers: () => GraphQLResolveInfoHelpers;
236+
promiseAll: <T>(
237+
values: ReadonlyArray<PromiseOrValue<T>>,
238+
) => Promise<Array<T>>;
239+
trackPromise: (promise: Promise<unknown>) => void;
234240

235241
constructor(
236242
validatedExecutionArgs: ValidatedExecutionArgs,
@@ -249,8 +255,12 @@ export class Executor<
249255
} else {
250256
this.sharedExecutionContext = sharedExecutionContext;
251257
}
252-
const { getAbortSignal } = this.sharedExecutionContext;
258+
const { getAbortSignal, getAsyncHelpers, promiseAll, trackPromise } =
259+
this.sharedExecutionContext;
253260
this.getAbortSignal = getAbortSignal;
261+
this.getAsyncHelpers = getAsyncHelpers;
262+
this.promiseAll = promiseAll;
263+
this.trackPromise = trackPromise;
254264
}
255265

256266
executeQueryOrMutationOrSubscriptionEvent(): PromiseOrValue<
@@ -261,10 +271,7 @@ export class Executor<
261271
if (externalAbortSignal) {
262272
externalAbortSignal.throwIfAborted();
263273
const onExternalAbort = () => {
264-
const aborted = this.abort(externalAbortSignal.reason);
265-
if (isPromise(aborted)) {
266-
aborted.catch(() => undefined);
267-
}
274+
this.abort(externalAbortSignal.reason);
268275
};
269276
removeExternalAbortListener = () =>
270277
externalAbortSignal.removeEventListener('abort', onExternalAbort);
@@ -326,6 +333,7 @@ export class Executor<
326333
return this.buildResponse(null);
327334
},
328335
);
336+
this.sharedExecutionContext.asyncWorkTracker.add(promise);
329337
const { promise: cancellablePromise, abort: abortResultPromise } =
330338
withCancellation(promise);
331339
this.abortResultPromise = abortResultPromise;
@@ -349,7 +357,7 @@ export class Executor<
349357
}
350358
}
351359

352-
abort(reason?: unknown): PromiseOrValue<void> {
360+
abort(reason?: unknown): void {
353361
if (this.aborted) {
354362
return;
355363
}
@@ -508,8 +516,9 @@ export class Executor<
508516
}
509517
} catch (error) {
510518
if (containsPromise) {
511-
// Ensure that any promises returned by other fields are handled, as they may also reject.
512-
promiseForObject(results).catch(() => undefined);
519+
this.sharedExecutionContext.asyncWorkTracker.addValues(
520+
Object.values(results),
521+
);
513522
}
514523
throw error;
515524
}
@@ -522,7 +531,7 @@ export class Executor<
522531
// Otherwise, results is a map from field name to the result of resolving that
523532
// field, which is possibly a promise. Return a promise that will return this
524533
// same map, but with any promises replaced with the values they resolved to.
525-
return promiseForObject(results);
534+
return promiseForObject(results, this.promiseAll);
526535
}
527536

528537
/**
@@ -559,6 +568,7 @@ export class Executor<
559568
parentType,
560569
path,
561570
this.getAbortSignal,
571+
this.getAsyncHelpers,
562572
);
563573

564574
// Get the resolve function, regardless of if its result is normal or abrupt (error).
@@ -855,24 +865,26 @@ export class Executor<
855865
index++;
856866
}
857867
} catch (error) {
858-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
859-
returnIteratorCatchingErrors(asyncIterator);
868+
this.trackPromise(returnIteratorCatchingErrors(asyncIterator));
860869
if (containsPromise) {
861-
Promise.all(completedResults).catch(() => undefined);
870+
this.sharedExecutionContext.asyncWorkTracker.addValues(
871+
completedResults,
872+
);
862873
}
863874
throw error;
864875
}
865876

866877
// Throwing on completion outside of the loop may allow engines to better optimize
867878
if (this.aborted) {
868879
if (!iteration?.done) {
869-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
870-
returnIteratorCatchingErrors(asyncIterator);
880+
this.trackPromise(returnIteratorCatchingErrors(asyncIterator));
871881
}
872882
throw new Error('Aborted!');
873883
}
874884

875-
return containsPromise ? Promise.all(completedResults) : completedResults;
885+
return containsPromise
886+
? this.promiseAll(completedResults)
887+
: completedResults;
876888
}
877889

878890
/* c8 ignore next 12 */
@@ -993,15 +1005,17 @@ export class Executor<
9931005
index++;
9941006
}
9951007
} catch (error) {
996-
const maybePromises = containsPromise ? completedResults : [];
997-
maybePromises.push(...collectIteratorPromises(iterator));
998-
if (maybePromises.length) {
999-
Promise.all(maybePromises).catch(() => undefined);
1008+
const asyncWorkTracker = this.sharedExecutionContext.asyncWorkTracker;
1009+
if (containsPromise) {
1010+
asyncWorkTracker.addValues(completedResults);
10001011
}
1012+
asyncWorkTracker.addValues(collectIteratorPromises(iterator));
10011013
throw error;
10021014
}
10031015

1004-
return containsPromise ? Promise.all(completedResults) : completedResults;
1016+
return containsPromise
1017+
? this.promiseAll(completedResults)
1018+
: completedResults;
10051019
}
10061020

10071021
completeMaybePromisedListItemValue(
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { expectEqualPromisesOrValues } from '../../__testUtils__/expectEqualPromisesOrValues.js';
5+
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
6+
7+
import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js';
8+
9+
import { AsyncWorkTracker } from '../AsyncWorkTracker.js';
10+
11+
describe('AsyncWorkTracker', () => {
12+
it('works to track promises', async () => {
13+
const tracker = new AsyncWorkTracker();
14+
const delayed = promiseWithResolvers<number>();
15+
16+
tracker.add(delayed.promise);
17+
expect(tracker.pendingAsyncWork.size).to.equal(1);
18+
delayed.resolve(1);
19+
await resolveOnNextTick();
20+
expect(tracker.pendingAsyncWork.size).to.equal(0);
21+
});
22+
});
23+
24+
describe('promiseAllTrackOnReject', () => {
25+
it('resolves like Promise.all', async () => {
26+
const tracker = new AsyncWorkTracker();
27+
28+
const values = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)];
29+
30+
await expectEqualPromisesOrValues([
31+
tracker.promiseAllTrackOnReject(values),
32+
Promise.all(values),
33+
]);
34+
});
35+
36+
it('resolves synchronous values without tracking', async () => {
37+
const tracker = new AsyncWorkTracker();
38+
39+
const result = await tracker.promiseAllTrackOnReject([1, 2, 3]);
40+
41+
expect(result).to.deep.equal([1, 2, 3]);
42+
expect(tracker.pendingAsyncWork.size).to.equal(0);
43+
});
44+
45+
it('does not add an extra microtask on fulfilled promiseAll results', async () => {
46+
const tracker = new AsyncWorkTracker();
47+
let settled = false;
48+
49+
const promise = Promise.resolve(1);
50+
const trackedPromise = tracker.promiseAllTrackOnReject([promise]);
51+
trackedPromise.then(
52+
() => {
53+
settled = true;
54+
},
55+
() => undefined,
56+
);
57+
await Promise.all([promise]);
58+
expect(settled).to.equal(true);
59+
});
60+
61+
it('tracks all promises only after rejection', async () => {
62+
const delayed = promiseWithResolvers<undefined>();
63+
const tracker = new AsyncWorkTracker();
64+
const result = tracker.promiseAllTrackOnReject([
65+
Promise.reject(new Error('bad')),
66+
delayed.promise,
67+
] as const);
68+
expect(tracker.pendingAsyncWork.size).to.equal(0);
69+
70+
await result.catch(() => undefined);
71+
expect(tracker.pendingAsyncWork.size).to.equal(1);
72+
delayed.resolve(undefined);
73+
74+
await resolveOnNextTick();
75+
expect(tracker.pendingAsyncWork.size).to.equal(0);
76+
});
77+
78+
it('tracks promises until they settle and catches later rejections', async () => {
79+
let unhandledRejection: unknown = null;
80+
const unhandledRejectionListener = (reason: unknown) => {
81+
unhandledRejection = reason;
82+
};
83+
// eslint-disable-next-line no-undef
84+
process.on('unhandledRejection', unhandledRejectionListener);
85+
86+
const tracker = new AsyncWorkTracker();
87+
const delayed = promiseWithResolvers<undefined>();
88+
const result = tracker.promiseAllTrackOnReject([
89+
Promise.reject(new Error('bad')),
90+
delayed.promise,
91+
] as const);
92+
93+
await result.catch(() => undefined);
94+
expect(tracker.pendingAsyncWork.size).to.equal(1);
95+
96+
delayed.reject(new Error('late bad'));
97+
await new Promise((resolve) => setTimeout(resolve, 20));
98+
99+
// eslint-disable-next-line no-undef
100+
process.removeListener('unhandledRejection', unhandledRejectionListener);
101+
102+
expect(tracker.pendingAsyncWork.size).to.equal(0);
103+
expect(unhandledRejection).to.equal(null);
104+
});
105+
});
106+
107+
describe('promiseCombinatorWithTracking', () => {
108+
it('tracks pending promises after Promise.any resolves', async () => {
109+
const tracker = new AsyncWorkTracker();
110+
111+
const delayed = promiseWithResolvers<number>();
112+
const result = await tracker.promiseCombinatorWithTracking(
113+
[1, delayed.promise],
114+
// eslint-disable-next-line @typescript-eslint/await-thenable
115+
(promises) => Promise.any(promises),
116+
);
117+
expect(result).to.equal(1);
118+
119+
await resolveOnNextTick();
120+
expect(tracker.pendingAsyncWork.size).to.equal(1);
121+
122+
delayed.resolve(2);
123+
await Promise.allSettled(Array.from(tracker.pendingAsyncWork));
124+
await resolveOnNextTick();
125+
expect(tracker.pendingAsyncWork.size).to.equal(0);
126+
});
127+
128+
it('tracks pending promises after Promise.race rejects', async () => {
129+
const tracker = new AsyncWorkTracker();
130+
131+
const delayed = promiseWithResolvers<number>();
132+
const result = tracker.promiseCombinatorWithTracking(
133+
[Promise.reject(new Error('bad')), delayed.promise],
134+
// eslint-disable-next-line @typescript-eslint/await-thenable
135+
(promises) => Promise.race(promises),
136+
);
137+
138+
await result.catch(() => undefined);
139+
await resolveOnNextTick();
140+
expect(tracker.pendingAsyncWork.size).to.equal(1);
141+
142+
delayed.resolve(1);
143+
await Promise.allSettled(Array.from(tracker.pendingAsyncWork));
144+
await resolveOnNextTick();
145+
expect(tracker.pendingAsyncWork.size).to.equal(0);
146+
});
147+
});

src/execution/__tests__/executor-test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,14 @@ describe('Execute: Handles basic execution tasks', () => {
258258
'operation',
259259
'variableValues',
260260
'getAbortSignal',
261+
'getAsyncHelpers',
262+
);
263+
const asyncHelpers = resolvedInfo?.getAsyncHelpers();
264+
expect(asyncHelpers).to.have.all.keys(
265+
'promiseAll',
266+
'promiseAny',
267+
'promiseRace',
268+
'trackPromise',
261269
);
262270

263271
const operation = document.definitions[0];
@@ -295,6 +303,25 @@ describe('Execute: Handles basic execution tasks', () => {
295303
expect(abortSignal).to.be.instanceOf(AbortSignal);
296304
expect(resolvedInfo?.getAbortSignal()).to.equal(abortSignal);
297305

306+
expect(resolvedInfo?.getAsyncHelpers()).to.equal(asyncHelpers);
307+
308+
const promiseAll = asyncHelpers?.promiseAll;
309+
expect(promiseAll).to.be.a('function');
310+
expect(resolvedInfo?.getAsyncHelpers().promiseAll).to.equal(promiseAll);
311+
312+
const promiseAny = asyncHelpers?.promiseAny;
313+
expect(promiseAny).to.be.a('function');
314+
expect(resolvedInfo?.getAsyncHelpers().promiseAny).to.equal(promiseAny);
315+
316+
const promiseRace = asyncHelpers?.promiseRace;
317+
expect(promiseRace).to.be.a('function');
318+
expect(resolvedInfo?.getAsyncHelpers().promiseRace).to.equal(promiseRace);
319+
320+
const trackPromise = asyncHelpers?.trackPromise;
321+
expect(trackPromise).to.be.a('function');
322+
expect(resolvedInfo?.getAsyncHelpers().trackPromise).to.equal(trackPromise);
323+
trackPromise?.(Promise.resolve());
324+
298325
resolve();
299326

300327
await result;

0 commit comments

Comments
 (0)