Skip to content

Commit 9dab56e

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 c2cee1b commit 9dab56e

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);
@@ -324,6 +331,7 @@ export class Executor<
324331
return this.buildResponse(null);
325332
},
326333
);
334+
this.sharedExecutionContext.asyncWorkTracker.add(promise);
327335
const { promise: cancellablePromise, abort: abortResultPromise } =
328336
withCancellation(promise);
329337
this.abortResultPromise = abortResultPromise;
@@ -347,7 +355,7 @@ export class Executor<
347355
}
348356
}
349357

350-
abort(reason?: unknown): PromiseOrValue<void> {
358+
abort(reason?: unknown): void {
351359
if (this.aborted) {
352360
return;
353361
}
@@ -506,8 +514,9 @@ export class Executor<
506514
}
507515
} catch (error) {
508516
if (containsPromise) {
509-
// Ensure that any promises returned by other fields are handled, as they may also reject.
510-
promiseForObject(results).catch(() => undefined);
517+
this.sharedExecutionContext.asyncWorkTracker.addValues(
518+
Object.values(results),
519+
);
511520
}
512521
throw error;
513522
}
@@ -520,7 +529,7 @@ export class Executor<
520529
// Otherwise, results is a map from field name to the result of resolving that
521530
// field, which is possibly a promise. Return a promise that will return this
522531
// same map, but with any promises replaced with the values they resolved to.
523-
return promiseForObject(results);
532+
return promiseForObject(results, this.promiseAll);
524533
}
525534

526535
/**
@@ -557,6 +566,7 @@ export class Executor<
557566
parentType,
558567
path,
559568
this.getAbortSignal,
569+
this.getAsyncHelpers,
560570
);
561571

562572
// Get the resolve function, regardless of if its result is normal or abrupt (error).
@@ -853,24 +863,26 @@ export class Executor<
853863
index++;
854864
}
855865
} catch (error) {
856-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
857-
returnIteratorCatchingErrors(asyncIterator);
866+
this.trackPromise(returnIteratorCatchingErrors(asyncIterator));
858867
if (containsPromise) {
859-
Promise.all(completedResults).catch(() => undefined);
868+
this.sharedExecutionContext.asyncWorkTracker.addValues(
869+
completedResults,
870+
);
860871
}
861872
throw error;
862873
}
863874

864875
// Throwing on completion outside of the loop may allow engines to better optimize
865876
if (this.aborted) {
866877
if (!iteration?.done) {
867-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
868-
returnIteratorCatchingErrors(asyncIterator);
878+
this.trackPromise(returnIteratorCatchingErrors(asyncIterator));
869879
}
870880
throw new Error('Aborted!');
871881
}
872882

873-
return containsPromise ? Promise.all(completedResults) : completedResults;
883+
return containsPromise
884+
? this.promiseAll(completedResults)
885+
: completedResults;
874886
}
875887

876888
/* c8 ignore next 12 */
@@ -991,15 +1003,17 @@ export class Executor<
9911003
index++;
9921004
}
9931005
} catch (error) {
994-
const maybePromises = containsPromise ? completedResults : [];
995-
maybePromises.push(...collectIteratorPromises(iterator));
996-
if (maybePromises.length) {
997-
Promise.all(maybePromises).catch(() => undefined);
1006+
const asyncWorkTracker = this.sharedExecutionContext.asyncWorkTracker;
1007+
if (containsPromise) {
1008+
asyncWorkTracker.addValues(completedResults);
9981009
}
1010+
asyncWorkTracker.addValues(collectIteratorPromises(iterator));
9991011
throw error;
10001012
}
10011013

1002-
return containsPromise ? Promise.all(completedResults) : completedResults;
1014+
return containsPromise
1015+
? this.promiseAll(completedResults)
1016+
: completedResults;
10031017
}
10041018

10051019
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)