Skip to content

Commit

Permalink
cancel execution despite pending resolvers
Browse files Browse the repository at this point in the history
  • Loading branch information
yaacovCR committed Oct 29, 2024
1 parent c16d429 commit 2ef31ba
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 23 deletions.
127 changes: 106 additions & 21 deletions src/execution/__tests__/abort-signal-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const schema = buildSchema(`
type Query {
todo: Todo
nonNullableTodo: Todo!
}
type Mutation {
Expand Down Expand Up @@ -300,6 +301,97 @@ describe('Execute: Cancellation', () => {
});
});

it('should stop the execution when aborted despite a hanging resolver', async () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
id
author {
id
}
}
}
`);

const resultPromise = execute({
document,
schema,
abortSignal: abortController.signal,
rootValue: {
todo: () =>
new Promise(() => {
/* will never resolve */
}),
},
});

abortController.abort();

const result = await resultPromise;

expect(result.errors?.[0].originalError?.name).to.equal('AbortError');

expectJSON(result).toDeepEqual({
data: {
todo: null,
},
errors: [
{
message: 'This operation was aborted',
path: ['todo'],
locations: [{ line: 3, column: 9 }],
},
],
});
});

it('should stop the execution when aborted with proper null bubbling', async () => {
const abortController = new AbortController();
const document = parse(`
query {
nonNullableTodo {
id
author {
id
}
}
}
`);

const resultPromise = execute({
document,
schema,
abortSignal: abortController.signal,
rootValue: {
nonNullableTodo: async () =>
Promise.resolve({
id: '1',
text: 'Hello, World!',
/* c8 ignore next */
author: () => expect.fail('Should not be called'),
}),
},
});

abortController.abort();

const result = await resultPromise;

expect(result.errors?.[0].originalError?.name).to.equal('AbortError');

expectJSON(result).toDeepEqual({
data: null,
errors: [
{
message: 'This operation was aborted',
path: ['nonNullableTodo'],
locations: [{ line: 3, column: 9 }],
},
],
});
});

it('should stop deferred execution when aborted', async () => {
const abortController = new AbortController();
const document = parse(`
Expand Down Expand Up @@ -353,14 +445,12 @@ describe('Execute: Cancellation', () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
id
... on Todo @defer {
... on Query @defer {
todo {
id
text
author {
... on Author @defer {
id
}
id
}
}
}
Expand Down Expand Up @@ -392,31 +482,21 @@ describe('Execute: Cancellation', () => {

expectJSON(result).toDeepEqual([
{
data: {
todo: {
id: '1',
},
},
pending: [{ id: '0', path: ['todo'] }],
data: {},
pending: [{ id: '0', path: [] }],
hasNext: true,
},
{
incremental: [
{
data: {
text: 'hello world',
author: null,
todo: null,
},
errors: [
{
locations: [
{
column: 13,
line: 7,
},
],
message: 'This operation was aborted',
path: ['todo', 'author'],
path: ['todo'],
locations: [{ line: 4, column: 11 }],
},
],
id: '0',
Expand Down Expand Up @@ -448,6 +528,11 @@ describe('Execute: Cancellation', () => {
},
});

await resolveOnNextTick();
await resolveOnNextTick();
await resolveOnNextTick();
await resolveOnNextTick();

abortController.abort();

const result = await resultPromise;
Expand Down
5 changes: 5 additions & 0 deletions src/execution/__tests__/stream-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,11 @@ describe('Execute: stream directive', () => {
items: [{ name: 'Luke' }],
id: '1',
},
],
hasNext: true,
},
{
incremental: [
{
data: { scalarField: null },
id: '0',
Expand Down
31 changes: 29 additions & 2 deletions src/execution/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { addPath, pathToArray } from '../jsutils/Path.js';
import { promiseForObject } from '../jsutils/promiseForObject.js';
import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js';
import { promiseReduce } from '../jsutils/promiseReduce.js';
import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js';

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

if (isPromise(result)) {
return completePromisedValue(
const { promise, resolve, reject } =
promiseWithResolvers<GraphQLWrappedResult<unknown>>();
abortSignal?.addEventListener(
'abort',
() => {
try {
resolve({
rawResult: null,
incrementalDataRecords: undefined,
errors: [
buildFieldError(
abortSignal.reason,
returnType,
fieldDetailsList,
path,
),
],
});
} catch (error) {
reject(error);
}
},
{ once: true },
);
completePromisedValue(
exeContext,
returnType,
fieldDetailsList,
Expand All @@ -874,7 +899,9 @@ function executeField(
result,
incrementalContext,
deferMap,
);
// eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable
).then(resolve, reject);
return promise;
}

const completed = completeValue(
Expand Down

0 comments on commit 2ef31ba

Please sign in to comment.