Skip to content

Commit e449cae

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 f8b148f commit e449cae

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';
@@ -1314,59 +1315,59 @@ describe('Execute: Handles basic execution tasks', () => {
13141315
expect(possibleTypes).to.deep.equal([fooObject]);
13151316
});
13161317

1317-
describe('Abort execution', () => {
1318-
it('stops execution and throws an error when signal is aborted', async () => {
1319-
/**
1320-
* This test has 3 resolvers nested in each other.
1321-
* Every resolve function waits 200ms before returning data.
1322-
*
1323-
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
1324-
* and then aborts the execution.
1325-
*
1326-
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
1327-
* and to get an error about aborted operation.
1328-
*/
1329-
1330-
const WAIT_MS_BEFORE_RESOLVING = 200;
1331-
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
1332-
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;
1333-
1334-
const schema = new GraphQLSchema({
1335-
query: new GraphQLObjectType({
1336-
name: 'Query',
1337-
fields: {
1338-
resolvesIn500ms: {
1339-
type: new GraphQLObjectType({
1340-
name: 'ResolvesIn500ms',
1341-
fields: {
1342-
resolvesIn400ms: {
1343-
type: new GraphQLObjectType({
1344-
name: 'ResolvesIn400ms',
1345-
fields: {
1346-
shouldNotBeResolved: {
1347-
type: GraphQLString,
1348-
resolve: () => {
1349-
throw new Error('This should not be executed!');
1350-
},
1318+
it('stops execution and throws an error when signal is aborted', async () => {
1319+
/**
1320+
* This test has 3 resolvers nested in each other.
1321+
* Every resolve function waits 200ms before returning data.
1322+
*
1323+
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
1324+
* and then aborts the execution.
1325+
*
1326+
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
1327+
* and to get an error about aborted operation.
1328+
*/
1329+
1330+
const WAIT_MS_BEFORE_RESOLVING = 200;
1331+
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
1332+
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;
1333+
1334+
const schema = new GraphQLSchema({
1335+
query: new GraphQLObjectType({
1336+
name: 'Query',
1337+
fields: {
1338+
resolvesIn500ms: {
1339+
type: new GraphQLObjectType({
1340+
name: 'ResolvesIn500ms',
1341+
fields: {
1342+
resolvesIn400ms: {
1343+
type: new GraphQLObjectType({
1344+
name: 'ResolvesIn400ms',
1345+
fields: {
1346+
shouldNotBeResolved: {
1347+
type: GraphQLString,
1348+
/* c8 ignore next 3 */
1349+
resolve: () => {
1350+
throw new Error('This should not be executed!');
13511351
},
13521352
},
1353+
},
1354+
}),
1355+
resolve: () =>
1356+
new Promise((resolve) => {
1357+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
13531358
}),
1354-
resolve: () =>
1355-
new Promise((resolve) => {
1356-
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1357-
}),
1358-
},
13591359
},
1360+
},
1361+
}),
1362+
resolve: () =>
1363+
new Promise((resolve) => {
1364+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
13601365
}),
1361-
resolve: () =>
1362-
new Promise((resolve) => {
1363-
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1364-
}),
1365-
},
13661366
},
1367-
}),
1368-
});
1369-
const document = parse(`
1367+
},
1368+
}),
1369+
});
1370+
const document = parse(`
13701371
query {
13711372
resolvesIn500ms {
13721373
resolvesIn400ms {
@@ -1376,67 +1377,22 @@ describe('Execute: Handles basic execution tasks', () => {
13761377
}
13771378
`);
13781379

1379-
const abortController = new AbortController();
1380-
const executionPromise = execute({
1381-
schema,
1382-
document,
1383-
signal: abortController.signal,
1384-
});
1385-
1386-
setTimeout(
1387-
() => abortController.abort(),
1388-
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
1389-
);
1390-
1391-
const result = await executionPromise;
1392-
expect(result.errors?.[0].message).to.eq(
1393-
'Execution aborted. Reason: AbortError: This operation was aborted',
1394-
);
1395-
expect(result.data).to.eql({
1396-
resolvesIn500ms: { resolvesIn400ms: null },
1397-
});
1398-
});
1399-
1400-
const abortMessageTestInputs = [
1401-
{ message: 'Aborted from somewhere', reason: 'Aborted from somewhere' },
1402-
{ message: undefined, reason: 'AbortError: This operation was aborted' },
1403-
];
1404-
1405-
for (const { message, reason } of abortMessageTestInputs) {
1406-
it('aborts with "Reason:" in the error message', async () => {
1407-
const schema = new GraphQLSchema({
1408-
query: new GraphQLObjectType({
1409-
name: 'Query',
1410-
fields: {
1411-
a: {
1412-
type: GraphQLString,
1413-
resolve: () =>
1414-
new Promise((resolve) => {
1415-
setTimeout(() => resolve({}), 100);
1416-
}),
1417-
},
1418-
},
1419-
}),
1420-
});
1421-
1422-
const document = parse(`
1423-
query { a }
1424-
`);
1425-
1426-
const abortController = new AbortController();
1427-
const executionPromise = execute({
1428-
schema,
1429-
document,
1430-
signal: abortController.signal,
1431-
});
1380+
const abortController = new AbortController();
1381+
const executionPromise = execute({
1382+
schema,
1383+
document,
1384+
signal: abortController.signal,
1385+
});
14321386

1433-
abortController.abort(message);
1387+
setTimeout(
1388+
() => abortController.abort(),
1389+
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
1390+
);
14341391

1435-
const { errors } = await executionPromise;
1436-
expect(errors?.[0].message).to.eq(
1437-
`Execution aborted. Reason: ${reason}`,
1438-
);
1439-
});
1440-
}
1392+
const result = await executionPromise;
1393+
expect(result.errors?.[0].message).to.eq('Execution aborted.');
1394+
expect(result.data).to.eql({
1395+
resolvesIn500ms: { resolvesIn400ms: null },
1396+
});
14411397
});
14421398
});

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_EXPERIMENTAL_DIRECTIVES =
@@ -342,28 +348,25 @@ export function experimentalExecuteIncrementally(
342348
return executeImpl(exeContext);
343349
}
344350

345-
function subscribeToAbortSignal(exeContext: ExecutionContext): {
346-
unsubscribeFromAbortSignal: () => void;
347-
} {
348-
const onAbort = () => {
349-
if ('signal' in exeContext && exeContext.signal) {
350-
exeContext.signal.isAborted = true;
351-
}
352-
};
351+
function subscribeToAbortSignal(exeContext: ExecutionContext): () => void {
352+
const { abortion } = exeContext;
353+
if (!abortion) {
354+
return () => null;
355+
}
353356

354-
exeContext.signal?.instance.addEventListener('abort', onAbort);
357+
const onAbort = () => abortion.executionAbortController.abort(abortion);
358+
abortion.passedInAbortSignal.addEventListener('abort', onAbort);
355359

356-
return {
357-
unsubscribeFromAbortSignal: () => {
358-
exeContext.signal?.instance.removeEventListener('abort', onAbort);
359-
},
360+
return () => {
361+
abortion.passedInAbortSignal.removeEventListener('abort', onAbort);
362+
abortion.executionAbortController.abort();
360363
};
361364
}
362365

363366
function executeImpl(
364367
exeContext: ExecutionContext,
365368
): PromiseOrValue<ExecutionResult | ExperimentalIncrementalExecutionResults> {
366-
const { unsubscribeFromAbortSignal } = subscribeToAbortSignal(exeContext);
369+
const unsubscribeFromAbortSignal = subscribeToAbortSignal(exeContext);
367370

368371
// Return a Promise that will eventually resolve to the data described by
369372
// The "Response" section of the GraphQL specification.
@@ -473,7 +476,7 @@ export function buildExecutionContext(
473476
fieldResolver,
474477
typeResolver,
475478
subscribeFieldResolver,
476-
signal,
479+
signal: passedInAbortSignal,
477480
} = args;
478481

479482
// If the schema used for execution is invalid, throw an error.
@@ -539,7 +542,23 @@ export function buildExecutionContext(
539542
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
540543
subsequentPayloads: new Set(),
541544
errors: [],
542-
signal: signal ? { instance: signal, isAborted: false } : null,
545+
abortion: getContextAbortionEntities(passedInAbortSignal),
546+
};
547+
}
548+
549+
function getContextAbortionEntities(
550+
passedInAbortSignal: Maybe<IAbortSignal>,
551+
): ExecutionContext['abortion'] {
552+
if (!passedInAbortSignal) {
553+
return null;
554+
}
555+
556+
const executionAbortController = new AbortController();
557+
558+
return {
559+
passedInAbortSignal,
560+
executionAbortController,
561+
executionAbortSignal: executionAbortController.signal,
543562
};
544563
}
545564

@@ -873,12 +892,8 @@ function completeValue(
873892
result: unknown,
874893
asyncPayloadRecord?: AsyncPayloadRecord,
875894
): PromiseOrValue<unknown> {
876-
if (exeContext.signal?.isAborted) {
877-
throw new GraphQLError(
878-
`Execution aborted. Reason: ${
879-
exeContext.signal.instance.reason ?? 'Unknown.'
880-
}`,
881-
);
895+
if (exeContext.abortion?.executionAbortSignal.aborted) {
896+
throw new GraphQLError('Execution aborted.');
882897
}
883898

884899
// 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)