Skip to content

Commit c866968

Browse files
committed
fix(executor): do not use leaking registerAbortSignalListener, and handle listeners inside the execution context
1 parent 6b6ba04 commit c866968

File tree

6 files changed

+110
-48
lines changed

6 files changed

+110
-48
lines changed

.changeset/swift-geese-behave.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@graphql-tools/executor': patch
3+
'@graphql-tools/utils': patch
4+
---
5+
6+
In executor, do not use leaking `registerAbortSignalListener`, and handle listeners inside the
7+
execution context

packages/executor/src/execution/__tests__/abort-signal.test.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('Abort Signal', () => {
3434
subscribe() {
3535
return new Repeater(async (push, stop) => {
3636
let i = 0;
37-
stop.then(() => {
37+
stop.finally(() => {
3838
stopped = true;
3939
});
4040

@@ -150,7 +150,7 @@ describe('Abort Signal', () => {
150150
didInvokeFirstFn = true;
151151
return true;
152152
},
153-
async second() {
153+
second() {
154154
didInvokeSecondFn = true;
155155
controller.abort();
156156
return true;
@@ -162,18 +162,21 @@ describe('Abort Signal', () => {
162162
},
163163
},
164164
});
165-
const result$ = normalizedExecutor({
166-
schema,
167-
document: parse(/* GraphQL */ `
168-
mutation {
169-
first
170-
second
171-
third
172-
}
173-
`),
174-
signal: controller.signal,
175-
});
176-
expect(result$).rejects.toBeInstanceOf(DOMException);
165+
await expect(
166+
Promise.resolve().then(() =>
167+
normalizedExecutor({
168+
schema,
169+
document: parse(/* GraphQL */ `
170+
mutation {
171+
first
172+
second
173+
third
174+
}
175+
`),
176+
signal: controller.signal,
177+
}),
178+
),
179+
).rejects.toBeInstanceOf(DOMException);
177180
expect(didInvokeFirstFn).toBe(true);
178181
expect(didInvokeSecondFn).toBe(true);
179182
expect(didInvokeThirdFn).toBe(false);

packages/executor/src/execution/__tests__/stream-test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,9 @@ describe('Execute: stream directive', () => {
576576
path: ['friendList', 2],
577577
},
578578
],
579-
hasNext: false,
579+
hasNext: true,
580580
},
581+
{ hasNext: false },
581582
]);
582583
});
583584

@@ -645,10 +646,10 @@ describe('Execute: stream directive', () => {
645646
path: ['friendList', 2],
646647
},
647648
],
648-
hasNext: false,
649+
hasNext: true,
649650
},
650651
},
651-
{ done: true, value: undefined },
652+
{ done: false, value: { hasNext: false } },
652653
{ done: true, value: undefined },
653654
]);
654655
});

packages/executor/src/execution/execute.ts

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import {
3434
collectFields,
3535
createGraphQLError,
3636
fakePromise,
37-
getAbortPromise,
3837
getArgumentValues,
3938
getDefinedRootType,
4039
GraphQLResolveInfo,
@@ -52,11 +51,10 @@ import {
5251
Path,
5352
pathToArray,
5453
promiseReduce,
55-
registerAbortSignalListener,
5654
} from '@graphql-tools/utils';
5755
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
5856
import { DisposableSymbols } from '@whatwg-node/disposablestack';
59-
import { handleMaybePromise } from '@whatwg-node/promise-helpers';
57+
import { createDeferredPromise, handleMaybePromise } from '@whatwg-node/promise-helpers';
6058
import { coerceError } from './coerceError.js';
6159
import { flattenAsyncIterable } from './flattenAsyncIterable.js';
6260
import { invariant } from './invariant.js';
@@ -127,6 +125,8 @@ export interface ExecutionContext<TVariables = any, TContext = any> {
127125
errors: Array<GraphQLError>;
128126
subsequentPayloads: Set<AsyncPayloadRecord>;
129127
signal?: AbortSignal;
128+
onSignalAbort?(handler: () => void): void;
129+
signalPromise?: Promise<never>;
130130
}
131131

132132
export interface FormattedExecutionResult<
@@ -421,6 +421,8 @@ export function buildExecutionContext<TData = any, TVariables = any, TContext =
421421
signal,
422422
} = args;
423423

424+
signal?.throwIfAborted();
425+
424426
// If the schema used for execution is invalid, throw an error.
425427
assertValidSchema(schema);
426428

@@ -489,6 +491,31 @@ export function buildExecutionContext<TData = any, TVariables = any, TContext =
489491
return coercedVariableValues.errors;
490492
}
491493

494+
signal?.throwIfAborted();
495+
496+
let onSignalAbort: ExecutionContext['onSignalAbort'];
497+
let signalPromise: ExecutionContext['signalPromise'];
498+
499+
if (signal) {
500+
const listeners = new Set<() => void>();
501+
const signalDeferred = createDeferredPromise<never>();
502+
signalPromise = signalDeferred.promise;
503+
const sharedListener = () => {
504+
signalDeferred.reject(signal.reason);
505+
signal.removeEventListener('abort', sharedListener);
506+
};
507+
signal.addEventListener('abort', sharedListener, { once: true });
508+
signalPromise.catch(() => {
509+
for (const listener of listeners) {
510+
listener();
511+
}
512+
listeners.clear();
513+
});
514+
onSignalAbort = handler => {
515+
listeners.add(handler);
516+
};
517+
}
518+
492519
return {
493520
schema,
494521
fragments,
@@ -502,6 +529,8 @@ export function buildExecutionContext<TData = any, TVariables = any, TContext =
502529
subsequentPayloads: new Set(),
503530
errors: [],
504531
signal,
532+
onSignalAbort,
533+
signalPromise,
505534
};
506535
}
507536

@@ -626,9 +655,9 @@ function executeFields(
626655
}
627656
}
628657
} catch (error) {
629-
if (containsPromise) {
658+
if (error !== exeContext.signal?.reason && containsPromise) {
630659
// Ensure that any promises returned by other fields are handled, as they may also reject.
631-
return promiseForObject(results, exeContext.signal).finally(() => {
660+
return promiseForObject(results, exeContext.signal, exeContext.signalPromise).finally(() => {
632661
throw error;
633662
});
634663
}
@@ -643,7 +672,7 @@ function executeFields(
643672
// Otherwise, results is a map from field name to the result of resolving that
644673
// field, which is possibly a promise. Return a promise that will return this
645674
// same map, but with any promises replaced with the values they resolved to.
646-
return promiseForObject(results, exeContext.signal);
675+
return promiseForObject(results, exeContext.signal, exeContext.signalPromise);
647676
}
648677

649678
/**
@@ -673,6 +702,7 @@ function executeField(
673702

674703
// Get the resolve function, regardless of if its result is normal or abrupt (error).
675704
try {
705+
exeContext.signal?.throwIfAborted();
676706
// Build a JS object of arguments from the field.arguments AST, using the
677707
// variables scope to fulfill any variable references.
678708
// TODO: find a way to memoize, in case this field is within a List type.
@@ -967,8 +997,9 @@ async function completeAsyncIteratorValue(
967997
iterator: AsyncIterator<unknown>,
968998
asyncPayloadRecord?: AsyncPayloadRecord,
969999
): Promise<ReadonlyArray<unknown>> {
970-
if (exeContext.signal && iterator.return) {
971-
registerAbortSignalListener(exeContext.signal, () => {
1000+
exeContext.signal?.throwIfAborted();
1001+
if (iterator.return) {
1002+
exeContext.onSignalAbort?.(() => {
9721003
iterator.return?.();
9731004
});
9741005
}
@@ -1746,18 +1777,25 @@ function executeSubscription(exeContext: ExecutionContext): MaybePromise<AsyncIt
17461777
const result = resolveFn(rootValue, args, contextValue, info);
17471778

17481779
if (isPromise(result)) {
1749-
return result.then(assertEventStream).then(undefined, error => {
1750-
throw locatedError(error, fieldNodes, pathToArray(path));
1751-
});
1780+
return result
1781+
.then(result => assertEventStream(result, exeContext.signal, exeContext.onSignalAbort))
1782+
.then(undefined, error => {
1783+
throw locatedError(error, fieldNodes, pathToArray(path));
1784+
});
17521785
}
17531786

1754-
return assertEventStream(result, exeContext.signal);
1787+
return assertEventStream(result, exeContext.signal, exeContext.onSignalAbort);
17551788
} catch (error) {
17561789
throw locatedError(error, fieldNodes, pathToArray(path));
17571790
}
17581791
}
17591792

1760-
function assertEventStream(result: unknown, signal?: AbortSignal): AsyncIterable<unknown> {
1793+
function assertEventStream(
1794+
result: unknown,
1795+
signal?: AbortSignal,
1796+
onSignalAbort?: (handler: () => void) => void,
1797+
): AsyncIterable<unknown> {
1798+
signal?.throwIfAborted();
17611799
if (result instanceof Error) {
17621800
throw result;
17631801
}
@@ -1768,13 +1806,13 @@ function assertEventStream(result: unknown, signal?: AbortSignal): AsyncIterable
17681806
'Subscription field must return Async Iterable. ' + `Received: ${inspect(result)}.`,
17691807
);
17701808
}
1771-
if (signal) {
1809+
if (onSignalAbort) {
17721810
return {
17731811
[Symbol.asyncIterator]() {
17741812
const asyncIterator = result[Symbol.asyncIterator]();
17751813

17761814
if (asyncIterator.return) {
1777-
registerAbortSignalListener(signal, () => {
1815+
onSignalAbort?.(() => {
17781816
asyncIterator.return?.();
17791817
});
17801818
}
@@ -2101,8 +2139,6 @@ function yieldSubsequentPayloads(
21012139
): AsyncGenerator<SubsequentIncrementalExecutionResult, void, void> {
21022140
let isDone = false;
21032141

2104-
const abortPromise = exeContext.signal ? getAbortPromise(exeContext.signal) : undefined;
2105-
21062142
async function next(): Promise<IteratorResult<SubsequentIncrementalExecutionResult, void>> {
21072143
if (isDone) {
21082144
return { value: undefined, done: true };
@@ -2112,8 +2148,8 @@ function yieldSubsequentPayloads(
21122148
record => record.promise,
21132149
);
21142150

2115-
if (abortPromise) {
2116-
await Promise.race([abortPromise, ...subSequentPayloadPromises]);
2151+
if (exeContext.signalPromise) {
2152+
await Promise.race([exeContext.signalPromise, ...subSequentPayloadPromises]);
21172153
} else {
21182154
await Promise.race(subSequentPayloadPromises);
21192155
}
Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getAbortPromise } from '@graphql-tools/utils';
1+
import { isPromise } from '@graphql-tools/utils';
22

33
type ResolvedObject<TData> = {
44
[TKey in keyof TData]: TData[TKey] extends Promise<infer TValue> ? TValue : TData[TKey];
@@ -11,19 +11,30 @@ type ResolvedObject<TData> = {
1111
* This is akin to bluebird's `Promise.props`, but implemented only using
1212
* `Promise.all` so it will work with any implementation of ES6 promises.
1313
*/
14-
export async function promiseForObject<TData>(
14+
export function promiseForObject<TData>(
1515
object: TData,
1616
signal?: AbortSignal,
17+
signalPromise?: Promise<never>,
1718
): Promise<ResolvedObject<TData>> {
19+
signal?.throwIfAborted();
1820
const resolvedObject = Object.create(null);
19-
const promises = Promise.all(
20-
Object.entries(object as any).map(async ([key, value]) => {
21-
resolvedObject[key] = await value;
22-
}),
23-
);
24-
if (signal) {
25-
const abortPromise = getAbortPromise(signal);
26-
return Promise.race([abortPromise, promises]).then(() => resolvedObject);
21+
const promises: Promise<void>[] = [];
22+
for (const key in object) {
23+
const value = object[key];
24+
if (isPromise(value)) {
25+
promises.push(
26+
value.then(value => {
27+
signal?.throwIfAborted();
28+
resolvedObject[key] = value;
29+
}),
30+
);
31+
} else {
32+
resolvedObject[key] = value;
33+
}
2734
}
28-
return promises.then(() => resolvedObject);
35+
const promiseAll = Promise.all(promises);
36+
if (signalPromise) {
37+
return Promise.race([signalPromise, promiseAll]).then(() => resolvedObject);
38+
}
39+
return promiseAll.then(() => resolvedObject);
2940
}

packages/utils/src/registerAbortSignalListener.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fakeRejectPromise } from '@whatwg-node/promise-helpers';
12
import { memoize1 } from './memoize.js';
23

34
// AbortSignal handler cache to avoid the "possible EventEmitter memory leak detected"
@@ -32,8 +33,11 @@ export function registerAbortSignalListener(signal: AbortSignal, listener: () =>
3233
}
3334

3435
export const getAbortPromise = memoize1(function getAbortPromise(signal: AbortSignal) {
36+
// If the signal is already aborted, return a rejected promise
37+
if (signal.aborted) {
38+
return fakeRejectPromise(signal.reason);
39+
}
3540
return new Promise<void>((_resolve, reject) => {
36-
// If the signal is already aborted, return a rejected promise
3741
if (signal.aborted) {
3842
reject(signal.reason);
3943
return;

0 commit comments

Comments
 (0)