Skip to content

Commit cf9f785

Browse files
AbortController support (#4250)
This adds support for aborting execution from the outside or resolvers, this adds a few tests and tries to make the support as easy as possible. Do we want to support having abort support on subscriptions, I guess it makes sense for server-sent events. I've chosen 2 places to place these interrupts - `executeFieldsSerially` - every time we start a new mutation we check whether the runtime has interrupted - `executeFields` - every time we start executing a new field we check whether the runtime has interrupted - inside of the catch block as well so we return a singular error, all though this doesn't really matter as the consumer would not receive anything - this here should also take care of deferred fields When comparing this to `graphql-tools/execute` I am not sure whether we want to match this behavior, this throws a DomException which would be a whole new exception that gets thrown while normally during execution we wrap everything with GraphQLErrors. Supersedes #3791 Resolves #3764 Co-authored-by: yaacovCR <yaacovCR@gmail.com>
1 parent 5eedfae commit cf9f785

6 files changed

Lines changed: 246 additions & 8 deletions

File tree

integrationTests/ts/tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
{
22
"compilerOptions": {
33
"module": "commonjs",
4-
"lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"],
4+
"lib": [
5+
"es2019",
6+
"es2020.promise",
7+
"es2020.bigint",
8+
"es2020.string",
9+
"DOM"
10+
],
511
"noEmit": true,
612
"types": [],
713
"strict": true,

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"devDependencies": {
5656
"@types/chai": "4.3.19",
5757
"@types/mocha": "10.0.7",
58-
"@types/node": "22.5.4",
58+
"@types/node": "22.7.7",
5959
"@typescript-eslint/eslint-plugin": "8.4.0",
6060
"@typescript-eslint/parser": "8.4.0",
6161
"c8": "10.1.2",
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { expectJSON } from '../../__testUtils__/expectJSON.js';
5+
6+
import { parse } from '../../language/parser.js';
7+
8+
import { buildSchema } from '../../utilities/buildASTSchema.js';
9+
10+
import { execute } from '../execute.js';
11+
12+
const schema = buildSchema(`
13+
type Todo {
14+
id: ID
15+
text: String
16+
author: User
17+
}
18+
19+
type User {
20+
id: ID
21+
name: String
22+
}
23+
24+
type Query {
25+
todo: Todo
26+
}
27+
28+
type Mutation {
29+
foo: String
30+
bar: String
31+
}
32+
`);
33+
34+
describe('Execute: Cancellation', () => {
35+
it('should stop the execution when aborted during object field completion', async () => {
36+
const abortController = new AbortController();
37+
const document = parse(`
38+
query {
39+
todo {
40+
id
41+
author {
42+
id
43+
}
44+
}
45+
}
46+
`);
47+
48+
const resultPromise = execute({
49+
document,
50+
schema,
51+
abortSignal: abortController.signal,
52+
rootValue: {
53+
todo: async () =>
54+
Promise.resolve({
55+
id: '1',
56+
text: 'Hello, World!',
57+
/* c8 ignore next */
58+
author: () => expect.fail('Should not be called'),
59+
}),
60+
},
61+
});
62+
63+
abortController.abort('Aborted');
64+
65+
const result = await resultPromise;
66+
67+
expectJSON(result).toDeepEqual({
68+
data: {
69+
todo: null,
70+
},
71+
errors: [
72+
{
73+
message: 'Aborted',
74+
path: ['todo', 'id'],
75+
locations: [{ line: 4, column: 11 }],
76+
},
77+
],
78+
});
79+
});
80+
81+
it('should stop the execution when aborted during nested object field completion', async () => {
82+
const abortController = new AbortController();
83+
const document = parse(`
84+
query {
85+
todo {
86+
id
87+
author {
88+
id
89+
}
90+
}
91+
}
92+
`);
93+
94+
const resultPromise = execute({
95+
document,
96+
schema,
97+
abortSignal: abortController.signal,
98+
rootValue: {
99+
todo: {
100+
id: '1',
101+
text: 'Hello, World!',
102+
/* c8 ignore next 3 */
103+
author: async () =>
104+
Promise.resolve(() => expect.fail('Should not be called')),
105+
},
106+
},
107+
});
108+
109+
abortController.abort('Aborted');
110+
111+
const result = await resultPromise;
112+
113+
expectJSON(result).toDeepEqual({
114+
data: {
115+
todo: {
116+
id: '1',
117+
author: null,
118+
},
119+
},
120+
errors: [
121+
{
122+
message: 'Aborted',
123+
path: ['todo', 'author', 'id'],
124+
locations: [{ line: 6, column: 13 }],
125+
},
126+
],
127+
});
128+
});
129+
130+
it('should stop the execution when aborted mid-mutation', async () => {
131+
const abortController = new AbortController();
132+
const document = parse(`
133+
mutation {
134+
foo
135+
bar
136+
}
137+
`);
138+
139+
const resultPromise = execute({
140+
document,
141+
schema,
142+
abortSignal: abortController.signal,
143+
rootValue: {
144+
foo: async () => Promise.resolve('baz'),
145+
/* c8 ignore next */
146+
bar: () => expect.fail('Should not be called'),
147+
},
148+
});
149+
150+
abortController.abort('Aborted');
151+
152+
const result = await resultPromise;
153+
154+
expectJSON(result).toDeepEqual({
155+
data: {
156+
foo: 'baz',
157+
bar: null,
158+
},
159+
errors: [
160+
{
161+
message: 'Aborted',
162+
path: ['bar'],
163+
locations: [{ line: 4, column: 9 }],
164+
},
165+
],
166+
});
167+
});
168+
169+
it('should stop the execution when aborted pre-execute', async () => {
170+
const abortController = new AbortController();
171+
const document = parse(`
172+
query {
173+
todo {
174+
id
175+
author {
176+
id
177+
}
178+
}
179+
}
180+
`);
181+
abortController.abort('Aborted');
182+
const result = await execute({
183+
document,
184+
schema,
185+
abortSignal: abortController.signal,
186+
rootValue: {
187+
/* c8 ignore next */
188+
todo: () => expect.fail('Should not be called'),
189+
},
190+
});
191+
192+
expectJSON(result).toDeepEqual({
193+
errors: [
194+
{
195+
message: 'Aborted',
196+
},
197+
],
198+
});
199+
});
200+
});

src/execution/execute.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export interface ValidatedExecutionArgs {
137137
validatedExecutionArgs: ValidatedExecutionArgs,
138138
) => PromiseOrValue<ExecutionResult>;
139139
hideSuggestions: boolean;
140+
abortSignal: AbortSignal | undefined;
140141
}
141142

142143
/**
@@ -224,6 +225,7 @@ export interface ExecutionArgs {
224225
) => PromiseOrValue<ExecutionResult>
225226
>;
226227
hideSuggestions?: Maybe<boolean>;
228+
abortSignal?: Maybe<AbortSignal>;
227229
/** Additional execution options. */
228230
options?: {
229231
/** Set the maximum number of errors allowed for coercing (defaults to 50). */
@@ -376,9 +378,14 @@ export function validateExecutionArgs(
376378
typeResolver,
377379
subscribeFieldResolver,
378380
perEventExecutor,
381+
abortSignal,
379382
options,
380383
} = args;
381384

385+
if (abortSignal?.aborted) {
386+
return [locatedError(new Error(abortSignal.reason), undefined)];
387+
}
388+
382389
// If the schema used for execution is invalid, throw an error.
383390
assertValidSchema(schema);
384391

@@ -457,6 +464,7 @@ export function validateExecutionArgs(
457464
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
458465
perEventExecutor: perEventExecutor ?? executeSubscriptionEvent,
459466
hideSuggestions,
467+
abortSignal: args.abortSignal ?? undefined,
460468
};
461469
}
462470

@@ -512,6 +520,19 @@ function executeFieldsSerially(
512520
groupedFieldSet,
513521
(results, [responseName, fieldDetailsList]) => {
514522
const fieldPath = addPath(path, responseName, parentType.name);
523+
const abortSignal = exeContext.validatedExecutionArgs.abortSignal;
524+
if (abortSignal?.aborted) {
525+
handleFieldError(
526+
new Error(abortSignal.reason),
527+
exeContext,
528+
parentType,
529+
fieldDetailsList,
530+
fieldPath,
531+
);
532+
results[responseName] = null;
533+
return results;
534+
}
535+
515536
const result = executeField(
516537
exeContext,
517538
parentType,
@@ -552,6 +573,15 @@ function executeFields(
552573
try {
553574
for (const [responseName, fieldDetailsList] of groupedFieldSet) {
554575
const fieldPath = addPath(path, responseName, parentType.name);
576+
const abortSignal = exeContext.validatedExecutionArgs.abortSignal;
577+
if (abortSignal?.aborted) {
578+
throw locatedError(
579+
new Error(abortSignal.reason),
580+
toNodes(fieldDetailsList),
581+
pathToArray(fieldPath),
582+
);
583+
}
584+
555585
const result = executeField(
556586
exeContext,
557587
parentType,
@@ -1152,8 +1182,9 @@ function completeLeafValue(
11521182
const coerced = returnType.coerceOutputValue(result);
11531183
if (coerced == null) {
11541184
throw new Error(
1155-
`Expected \`${inspect(returnType)}.coerceOutputValue(${inspect(result)})\` to ` +
1156-
`return non-nullable value, returned: ${inspect(coerced)}`,
1185+
`Expected \`${inspect(returnType)}.coerceOutputValue(${inspect(
1186+
result,
1187+
)})\` to return non-nullable value, returned: ${inspect(coerced)}`,
11571188
);
11581189
}
11591190
return coerced;

src/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export interface GraphQLArgs {
6666
operationName?: Maybe<string>;
6767
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
6868
typeResolver?: Maybe<GraphQLTypeResolver<any, any>>;
69+
abortSignal?: Maybe<AbortSignal>;
6970
}
7071

7172
export function graphql(args: GraphQLArgs): Promise<ExecutionResult> {

0 commit comments

Comments
 (0)