Skip to content

Commit 0a8f4f8

Browse files
author
igor.luckenkov
committed
Always create abortController for each execution, listen to external (passed in by client) abort signal and abort our own signal after the execution is ended. Polyfill AbortController
1 parent 56d1315 commit 0a8f4f8

File tree

3 files changed

+135
-133
lines changed

3 files changed

+135
-133
lines changed

src/execution/__tests__/executor-test.ts

Lines changed: 63 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it } from 'mocha';
44
import { expectJSON } from '../../__testUtils__/expectJSON.js';
55
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
66

7+
import { AbortController } from '../../jsutils/AbortController.js';
78
import { inspect } from '../../jsutils/inspect.js';
89

910
import { Kind } from '../../language/kinds.js';
@@ -1276,59 +1277,59 @@ describe('Execute: Handles basic execution tasks', () => {
12761277
expect(possibleTypes).to.deep.equal([fooObject]);
12771278
});
12781279

1279-
describe('Abort execution', () => {
1280-
it('stops execution and throws an error when signal is aborted', async () => {
1281-
/**
1282-
* This test has 3 resolvers nested in each other.
1283-
* Every resolve function waits 200ms before returning data.
1284-
*
1285-
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
1286-
* and then aborts the execution.
1287-
*
1288-
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
1289-
* and to get an error about aborted operation.
1290-
*/
1291-
1292-
const WAIT_MS_BEFORE_RESOLVING = 200;
1293-
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
1294-
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;
1295-
1296-
const schema = new GraphQLSchema({
1297-
query: new GraphQLObjectType({
1298-
name: 'Query',
1299-
fields: {
1300-
resolvesIn500ms: {
1301-
type: new GraphQLObjectType({
1302-
name: 'ResolvesIn500ms',
1303-
fields: {
1304-
resolvesIn400ms: {
1305-
type: new GraphQLObjectType({
1306-
name: 'ResolvesIn400ms',
1307-
fields: {
1308-
shouldNotBeResolved: {
1309-
type: GraphQLString,
1310-
resolve: () => {
1311-
throw new Error('This should not be executed!');
1312-
},
1280+
it('stops execution and throws an error when signal is aborted', async () => {
1281+
/**
1282+
* This test has 3 resolvers nested in each other.
1283+
* Every resolve function waits 200ms before returning data.
1284+
*
1285+
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
1286+
* and then aborts the execution.
1287+
*
1288+
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
1289+
* and to get an error about aborted operation.
1290+
*/
1291+
1292+
const WAIT_MS_BEFORE_RESOLVING = 200;
1293+
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
1294+
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;
1295+
1296+
const schema = new GraphQLSchema({
1297+
query: new GraphQLObjectType({
1298+
name: 'Query',
1299+
fields: {
1300+
resolvesIn500ms: {
1301+
type: new GraphQLObjectType({
1302+
name: 'ResolvesIn500ms',
1303+
fields: {
1304+
resolvesIn400ms: {
1305+
type: new GraphQLObjectType({
1306+
name: 'ResolvesIn400ms',
1307+
fields: {
1308+
shouldNotBeResolved: {
1309+
type: GraphQLString,
1310+
/* c8 ignore next 3 */
1311+
resolve: () => {
1312+
throw new Error('This should not be executed!');
13131313
},
13141314
},
1315+
},
1316+
}),
1317+
resolve: () =>
1318+
new Promise((resolve) => {
1319+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
13151320
}),
1316-
resolve: () =>
1317-
new Promise((resolve) => {
1318-
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1319-
}),
1320-
},
13211321
},
1322+
},
1323+
}),
1324+
resolve: () =>
1325+
new Promise((resolve) => {
1326+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
13221327
}),
1323-
resolve: () =>
1324-
new Promise((resolve) => {
1325-
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1326-
}),
1327-
},
13281328
},
1329-
}),
1330-
});
1331-
const document = parse(`
1329+
},
1330+
}),
1331+
});
1332+
const document = parse(`
13321333
query {
13331334
resolvesIn500ms {
13341335
resolvesIn400ms {
@@ -1338,67 +1339,22 @@ describe('Execute: Handles basic execution tasks', () => {
13381339
}
13391340
`);
13401341

1341-
const abortController = new AbortController();
1342-
const executionPromise = execute({
1343-
schema,
1344-
document,
1345-
signal: abortController.signal,
1346-
});
1347-
1348-
setTimeout(
1349-
() => abortController.abort(),
1350-
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
1351-
);
1352-
1353-
const result = await executionPromise;
1354-
expect(result.errors?.[0].message).to.eq(
1355-
'Execution aborted. Reason: AbortError: This operation was aborted',
1356-
);
1357-
expect(result.data).to.eql({
1358-
resolvesIn500ms: { resolvesIn400ms: null },
1359-
});
1360-
});
1361-
1362-
const abortMessageTestInputs = [
1363-
{ message: 'Aborted from somewhere', reason: 'Aborted from somewhere' },
1364-
{ message: undefined, reason: 'AbortError: This operation was aborted' },
1365-
];
1366-
1367-
for (const { message, reason } of abortMessageTestInputs) {
1368-
it('aborts with "Reason:" in the error message', async () => {
1369-
const schema = new GraphQLSchema({
1370-
query: new GraphQLObjectType({
1371-
name: 'Query',
1372-
fields: {
1373-
a: {
1374-
type: GraphQLString,
1375-
resolve: () =>
1376-
new Promise((resolve) => {
1377-
setTimeout(() => resolve({}), 100);
1378-
}),
1379-
},
1380-
},
1381-
}),
1382-
});
1383-
1384-
const document = parse(`
1385-
query { a }
1386-
`);
1387-
1388-
const abortController = new AbortController();
1389-
const executionPromise = execute({
1390-
schema,
1391-
document,
1392-
signal: abortController.signal,
1393-
});
1342+
const abortController = new AbortController();
1343+
const executionPromise = execute({
1344+
schema,
1345+
document,
1346+
signal: abortController.signal,
1347+
});
13941348

1395-
abortController.abort(message);
1349+
setTimeout(
1350+
() => abortController.abort(),
1351+
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
1352+
);
13961353

1397-
const { errors } = await executionPromise;
1398-
expect(errors?.[0].message).to.eq(
1399-
`Execution aborted. Reason: ${reason}`,
1400-
);
1401-
});
1402-
}
1354+
const result = await executionPromise;
1355+
expect(result.errors?.[0].message).to.eq('Execution aborted.');
1356+
expect(result.data).to.eql({
1357+
resolvesIn500ms: { resolvesIn400ms: null },
1358+
});
14031359
});
14041360
});

src/execution/execute.ts

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import type {
2+
IAbortController,
3+
IAbortSignal,
4+
} from '../jsutils/AbortController.js';
5+
import { AbortController } from '../jsutils/AbortController.js';
16
import { inspect } from '../jsutils/inspect.js';
27
import { invariant } from '../jsutils/invariant.js';
38
import { isAsyncIterable } from '../jsutils/isAsyncIterable.js';
@@ -122,9 +127,10 @@ export interface ExecutionContext {
122127
subscribeFieldResolver: GraphQLFieldResolver<any, any>;
123128
errors: Array<GraphQLError>;
124129
subsequentPayloads: Set<AsyncPayloadRecord>;
125-
signal: Maybe<{
126-
isAborted: boolean;
127-
instance: AbortSignal;
130+
abortion: Maybe<{
131+
passedInAbortSignal: IAbortSignal;
132+
executionAbortController: IAbortController;
133+
executionAbortSignal: IAbortSignal;
128134
}>;
129135
}
130136

@@ -265,7 +271,7 @@ export interface ExecutionArgs {
265271
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
266272
typeResolver?: Maybe<GraphQLTypeResolver<any, any>>;
267273
subscribeFieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
268-
signal?: AbortSignal;
274+
signal?: IAbortSignal;
269275
}
270276

271277
const UNEXPECTED_MULTIPLE_PAYLOADS =
@@ -333,28 +339,25 @@ export function experimentalExecuteIncrementally(
333339
return executeImpl(exeContext);
334340
}
335341

336-
function subscribeToAbortSignal(exeContext: ExecutionContext): {
337-
unsubscribeFromAbortSignal: () => void;
338-
} {
339-
const onAbort = () => {
340-
if ('signal' in exeContext && exeContext.signal) {
341-
exeContext.signal.isAborted = true;
342-
}
343-
};
342+
function subscribeToAbortSignal(exeContext: ExecutionContext): () => void {
343+
const { abortion } = exeContext;
344+
if (!abortion) {
345+
return () => null;
346+
}
344347

345-
exeContext.signal?.instance.addEventListener('abort', onAbort);
348+
const onAbort = () => abortion.executionAbortController.abort(abortion);
349+
abortion.passedInAbortSignal.addEventListener('abort', onAbort);
346350

347-
return {
348-
unsubscribeFromAbortSignal: () => {
349-
exeContext.signal?.instance.removeEventListener('abort', onAbort);
350-
},
351+
return () => {
352+
abortion.passedInAbortSignal.removeEventListener('abort', onAbort);
353+
abortion.executionAbortController.abort();
351354
};
352355
}
353356

354357
function executeImpl(
355358
exeContext: ExecutionContext,
356359
): PromiseOrValue<ExecutionResult | ExperimentalIncrementalExecutionResults> {
357-
const { unsubscribeFromAbortSignal } = subscribeToAbortSignal(exeContext);
360+
const unsubscribeFromAbortSignal = subscribeToAbortSignal(exeContext);
358361

359362
// Return a Promise that will eventually resolve to the data described by
360363
// The "Response" section of the GraphQL specification.
@@ -464,7 +467,7 @@ export function buildExecutionContext(
464467
fieldResolver,
465468
typeResolver,
466469
subscribeFieldResolver,
467-
signal,
470+
signal: passedInAbortSignal,
468471
} = args;
469472

470473
// If the schema used for execution is invalid, throw an error.
@@ -530,7 +533,23 @@ export function buildExecutionContext(
530533
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
531534
subsequentPayloads: new Set(),
532535
errors: [],
533-
signal: signal ? { instance: signal, isAborted: false } : null,
536+
abortion: getContextAbortionEntities(passedInAbortSignal),
537+
};
538+
}
539+
540+
function getContextAbortionEntities(
541+
passedInAbortSignal: Maybe<IAbortSignal>,
542+
): ExecutionContext['abortion'] {
543+
if (!passedInAbortSignal) {
544+
return null;
545+
}
546+
547+
const executionAbortController = new AbortController();
548+
549+
return {
550+
passedInAbortSignal,
551+
executionAbortController,
552+
executionAbortSignal: executionAbortController.signal,
534553
};
535554
}
536555

@@ -867,12 +886,8 @@ function completeValue(
867886
result: unknown,
868887
asyncPayloadRecord?: AsyncPayloadRecord,
869888
): PromiseOrValue<unknown> {
870-
if (exeContext.signal?.isAborted) {
871-
throw new GraphQLError(
872-
`Execution aborted. Reason: ${
873-
exeContext.signal.instance.reason ?? 'Unknown.'
874-
}`,
875-
);
889+
if (exeContext.abortion?.executionAbortSignal.aborted) {
890+
throw new GraphQLError('Execution aborted.');
876891
}
877892

878893
// If result is an Error, throw a located error.

src/jsutils/AbortController.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export interface IAbortSignal {
2+
aborted: boolean;
3+
addEventListener: (type: string, handler: () => void) => void;
4+
removeEventListener: (type: string, handler: () => void) => void;
5+
}
6+
7+
export interface IAbortController {
8+
signal: IAbortSignal;
9+
abort: (reason?: any) => void;
10+
}
11+
12+
/* c8 ignore start */
13+
export const AbortController: new () => IAbortController =
14+
// eslint-disable-next-line no-undef
15+
global.AbortController ||
16+
class MockAbortController implements IAbortController {
17+
private _signal: IAbortSignal = {
18+
aborted: false,
19+
addEventListener: () => null,
20+
removeEventListener: () => null,
21+
};
22+
23+
public get signal(): IAbortSignal {
24+
return this._signal;
25+
}
26+
27+
public abort(): void {
28+
this._signal.aborted = true;
29+
}
30+
};
31+
/* c8 ignore stop */

0 commit comments

Comments
 (0)