Skip to content

Commit 2ef31ba

Browse files
committed
cancel execution despite pending resolvers
1 parent c16d429 commit 2ef31ba

File tree

3 files changed

+140
-23
lines changed

3 files changed

+140
-23
lines changed

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

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const schema = buildSchema(`
5252
5353
type Query {
5454
todo: Todo
55+
nonNullableTodo: Todo!
5556
}
5657
5758
type Mutation {
@@ -300,6 +301,97 @@ describe('Execute: Cancellation', () => {
300301
});
301302
});
302303

304+
it('should stop the execution when aborted despite a hanging resolver', async () => {
305+
const abortController = new AbortController();
306+
const document = parse(`
307+
query {
308+
todo {
309+
id
310+
author {
311+
id
312+
}
313+
}
314+
}
315+
`);
316+
317+
const resultPromise = execute({
318+
document,
319+
schema,
320+
abortSignal: abortController.signal,
321+
rootValue: {
322+
todo: () =>
323+
new Promise(() => {
324+
/* will never resolve */
325+
}),
326+
},
327+
});
328+
329+
abortController.abort();
330+
331+
const result = await resultPromise;
332+
333+
expect(result.errors?.[0].originalError?.name).to.equal('AbortError');
334+
335+
expectJSON(result).toDeepEqual({
336+
data: {
337+
todo: null,
338+
},
339+
errors: [
340+
{
341+
message: 'This operation was aborted',
342+
path: ['todo'],
343+
locations: [{ line: 3, column: 9 }],
344+
},
345+
],
346+
});
347+
});
348+
349+
it('should stop the execution when aborted with proper null bubbling', async () => {
350+
const abortController = new AbortController();
351+
const document = parse(`
352+
query {
353+
nonNullableTodo {
354+
id
355+
author {
356+
id
357+
}
358+
}
359+
}
360+
`);
361+
362+
const resultPromise = execute({
363+
document,
364+
schema,
365+
abortSignal: abortController.signal,
366+
rootValue: {
367+
nonNullableTodo: async () =>
368+
Promise.resolve({
369+
id: '1',
370+
text: 'Hello, World!',
371+
/* c8 ignore next */
372+
author: () => expect.fail('Should not be called'),
373+
}),
374+
},
375+
});
376+
377+
abortController.abort();
378+
379+
const result = await resultPromise;
380+
381+
expect(result.errors?.[0].originalError?.name).to.equal('AbortError');
382+
383+
expectJSON(result).toDeepEqual({
384+
data: null,
385+
errors: [
386+
{
387+
message: 'This operation was aborted',
388+
path: ['nonNullableTodo'],
389+
locations: [{ line: 3, column: 9 }],
390+
},
391+
],
392+
});
393+
});
394+
303395
it('should stop deferred execution when aborted', async () => {
304396
const abortController = new AbortController();
305397
const document = parse(`
@@ -353,14 +445,12 @@ describe('Execute: Cancellation', () => {
353445
const abortController = new AbortController();
354446
const document = parse(`
355447
query {
356-
todo {
357-
id
358-
... on Todo @defer {
448+
... on Query @defer {
449+
todo {
450+
id
359451
text
360452
author {
361-
... on Author @defer {
362-
id
363-
}
453+
id
364454
}
365455
}
366456
}
@@ -392,31 +482,21 @@ describe('Execute: Cancellation', () => {
392482

393483
expectJSON(result).toDeepEqual([
394484
{
395-
data: {
396-
todo: {
397-
id: '1',
398-
},
399-
},
400-
pending: [{ id: '0', path: ['todo'] }],
485+
data: {},
486+
pending: [{ id: '0', path: [] }],
401487
hasNext: true,
402488
},
403489
{
404490
incremental: [
405491
{
406492
data: {
407-
text: 'hello world',
408-
author: null,
493+
todo: null,
409494
},
410495
errors: [
411496
{
412-
locations: [
413-
{
414-
column: 13,
415-
line: 7,
416-
},
417-
],
418497
message: 'This operation was aborted',
419-
path: ['todo', 'author'],
498+
path: ['todo'],
499+
locations: [{ line: 4, column: 11 }],
420500
},
421501
],
422502
id: '0',
@@ -448,6 +528,11 @@ describe('Execute: Cancellation', () => {
448528
},
449529
});
450530

531+
await resolveOnNextTick();
532+
await resolveOnNextTick();
533+
await resolveOnNextTick();
534+
await resolveOnNextTick();
535+
451536
abortController.abort();
452537

453538
const result = await resultPromise;

src/execution/__tests__/stream-test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,11 @@ describe('Execute: stream directive', () => {
16611661
items: [{ name: 'Luke' }],
16621662
id: '1',
16631663
},
1664+
],
1665+
hasNext: true,
1666+
},
1667+
{
1668+
incremental: [
16641669
{
16651670
data: { scalarField: null },
16661671
id: '0',

src/execution/execute.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { addPath, pathToArray } from '../jsutils/Path.js';
1313
import { promiseForObject } from '../jsutils/promiseForObject.js';
1414
import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js';
1515
import { promiseReduce } from '../jsutils/promiseReduce.js';
16+
import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js';
1617

1718
import { GraphQLError } from '../error/GraphQLError.js';
1819
import { locatedError } from '../error/locatedError.js';
@@ -865,7 +866,31 @@ function executeField(
865866
const result = resolveFn(source, args, contextValue, info, abortSignal);
866867

867868
if (isPromise(result)) {
868-
return completePromisedValue(
869+
const { promise, resolve, reject } =
870+
promiseWithResolvers<GraphQLWrappedResult<unknown>>();
871+
abortSignal?.addEventListener(
872+
'abort',
873+
() => {
874+
try {
875+
resolve({
876+
rawResult: null,
877+
incrementalDataRecords: undefined,
878+
errors: [
879+
buildFieldError(
880+
abortSignal.reason,
881+
returnType,
882+
fieldDetailsList,
883+
path,
884+
),
885+
],
886+
});
887+
} catch (error) {
888+
reject(error);
889+
}
890+
},
891+
{ once: true },
892+
);
893+
completePromisedValue(
869894
exeContext,
870895
returnType,
871896
fieldDetailsList,
@@ -874,7 +899,9 @@ function executeField(
874899
result,
875900
incrementalContext,
876901
deferMap,
877-
);
902+
// eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable
903+
).then(resolve, reject);
904+
return promise;
878905
}
879906

880907
const completed = completeValue(

0 commit comments

Comments
 (0)