diff --git a/src/execution/ResolveInfo.ts b/src/execution/ResolveInfo.ts index 65de696834..7a2a2fe1d0 100644 --- a/src/execution/ResolveInfo.ts +++ b/src/execution/ResolveInfo.ts @@ -26,6 +26,12 @@ export class ResolveInfo implements GraphQLResolveInfo { private _fieldDetailsList: FieldDetailsList; private _parentType: GraphQLObjectType; private _path: Path; + private _abortSignal: AbortSignal | undefined; + private _registerAbortSignal: () => { + abortSignal: AbortSignal | undefined; + unregister?: () => void; + }; + private _unregisterAbortSignal: (() => void) | undefined; private _fieldName: string | undefined; private _fieldNodes: ReadonlyArray | undefined; @@ -37,18 +43,24 @@ export class ResolveInfo implements GraphQLResolveInfo { private _operation: OperationDefinitionNode | undefined; private _variableValues: VariableValues | undefined; + // eslint-disable-next-line max-params constructor( validatedExecutionArgs: ValidatedExecutionArgs, fieldDef: GraphQLField, fieldDetailsList: FieldDetailsList, parentType: GraphQLObjectType, path: Path, + registerAbortSignal: () => { + abortSignal: AbortSignal | undefined; + unregister?: () => void; + }, ) { this._validatedExecutionArgs = validatedExecutionArgs; this._fieldDef = fieldDef; this._fieldDetailsList = fieldDetailsList; this._parentType = parentType; this._path = path; + this._registerAbortSignal = registerAbortSignal; } get fieldName(): string { @@ -103,4 +115,17 @@ export class ResolveInfo implements GraphQLResolveInfo { this._variableValues ??= this._validatedExecutionArgs.variableValues; return this._variableValues; } + + get abortSignal(): AbortSignal | undefined { + if (this._abortSignal !== undefined) { + return this._abortSignal; + } + const { abortSignal, unregister } = this._registerAbortSignal(); + this._abortSignal = abortSignal; + this._unregisterAbortSignal = unregister; + return this._abortSignal; + } + unregisterAbortSignal(): void { + this._unregisterAbortSignal?.(); + } } diff --git a/src/execution/__tests__/ResolveInfo-test.ts b/src/execution/__tests__/ResolveInfo-test.ts index 4caa2e2200..746b987a36 100644 --- a/src/execution/__tests__/ResolveInfo-test.ts +++ b/src/execution/__tests__/ResolveInfo-test.ts @@ -44,12 +44,22 @@ describe('ResolveInfo', () => { assert(fieldDetailsList != null); const path = { key: 'test', prev: undefined, typename: 'Query' }; + + const abortController = new AbortController(); + const abortSignal = abortController.signal; + let unregisterCalled = false; const resolveInfo = new ResolveInfo( validatedExecutionArgs, query.getFields().test, fieldDetailsList, query, path, + () => ({ + abortSignal, + unregister: () => { + unregisterCalled = true; + }, + }), ); it('exposes fieldName', () => { @@ -99,4 +109,15 @@ describe('ResolveInfo', () => { validatedExecutionArgs.variableValues, ); }); + + it('exposes abortSignal', () => { + const retrievedAbortSignal = resolveInfo.abortSignal; + expect(retrievedAbortSignal).to.equal(abortSignal); + expect(retrievedAbortSignal).to.equal(resolveInfo.abortSignal); // ensure same reference + }); + + it('calls unregisterAbortSignal', () => { + resolveInfo.unregisterAbortSignal(); + expect(unregisterCalled).to.equal(true); + }); }); diff --git a/src/execution/__tests__/cancellablePromise-test.ts b/src/execution/__tests__/cancellablePromise-test.ts new file mode 100644 index 0000000000..2c152404f3 --- /dev/null +++ b/src/execution/__tests__/cancellablePromise-test.ts @@ -0,0 +1,113 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectPromise } from '../../__testUtils__/expectPromise.js'; + +import { cancellablePromise } from '../cancellablePromise.js'; + +describe('cancellablePromise', () => { + it('works to wrap a resolved promise', async () => { + const abortController = new AbortController(); + + const promise = Promise.resolve(1); + + const withCancellation = cancellablePromise( + promise, + abortController.signal, + ); + + expect(await withCancellation).to.equal(1); + }); + + it('works to wrap a rejected promise', async () => { + const abortController = new AbortController(); + + const promise = Promise.reject(new Error('Rejected!')); + + const withCancellation = cancellablePromise( + promise, + abortController.signal, + ); + + await expectPromise(withCancellation).toRejectWith('Rejected!'); + }); + + it('works to cancel an already resolved promise', async () => { + const abortController = new AbortController(); + + const promise = Promise.resolve(1); + + const withCancellation = cancellablePromise( + promise, + abortController.signal, + ); + + abortController.abort(new Error('Cancelled!')); + + await expectPromise(withCancellation).toRejectWith('Cancelled!'); + }); + + it('works to cancel an already resolved promise after abort signal triggered', async () => { + const abortController = new AbortController(); + + abortController.abort(new Error('Cancelled!')); + + const promise = Promise.resolve(1); + + const withCancellation = cancellablePromise( + promise, + abortController.signal, + ); + + await expectPromise(withCancellation).toRejectWith('Cancelled!'); + }); + + it('works to cancel an already rejected promise after abort signal triggered', async () => { + const abortController = new AbortController(); + + abortController.abort(new Error('Cancelled!')); + + const promise = Promise.reject(new Error('Rejected!')); + + const withCancellation = cancellablePromise( + promise, + abortController.signal, + ); + + await expectPromise(withCancellation).toRejectWith('Cancelled!'); + }); + + it('works to cancel a hanging promise', async () => { + const abortController = new AbortController(); + + const promise = new Promise(() => { + /* never resolves */ + }); + + const withCancellation = cancellablePromise( + promise, + abortController.signal, + ); + + abortController.abort(new Error('Cancelled!')); + + await expectPromise(withCancellation).toRejectWith('Cancelled!'); + }); + + it('works to cancel a hanging promise created after abort signal triggered', async () => { + const abortController = new AbortController(); + + abortController.abort(new Error('Cancelled!')); + + const promise = new Promise(() => { + /* never resolves */ + }); + + const withCancellation = cancellablePromise( + promise, + abortController.signal, + ); + + await expectPromise(withCancellation).toRejectWith('Cancelled!'); + }); +}); diff --git a/src/execution/__tests__/cancellation-test.ts b/src/execution/__tests__/cancellation-test.ts new file mode 100644 index 0000000000..0ce195defd --- /dev/null +++ b/src/execution/__tests__/cancellation-test.ts @@ -0,0 +1,835 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { expectPromise } from '../../__testUtils__/expectPromise.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; +import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js'; + +import { parse } from '../../language/parser.js'; + +import { + GraphQLInterfaceType, + GraphQLNonNull, + GraphQLObjectType, +} from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { execute, subscribe } from '../entrypoints.js'; + +const schema = buildSchema(` + type Todo { + id: ID + items: [String] + author: User + } + + type User { + id: ID + name: String + } + + type Query { + todo: Todo + nonNullableTodo: Todo! + blocker: String + } + + type Mutation { + foo: String + bar: String + } + + type Subscription { + foo: String + } +`); + +describe('Execute: Cancellation', () => { + it('should stop the execution when aborted during object field completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + abortController.abort(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should provide access to the abort signal within resolvers', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + } + } + `); + + let aborted = false; + const cancellableAsyncFn = async (abortSignal: AbortSignal) => { + if (abortSignal.aborted) { + aborted = true; + } else { + abortSignal.addEventListener('abort', () => { + aborted = true; + }); + } + await resolveOnNextTick(); + throw Error('some random other error that does not show up in response'); + }; + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: { + id: (_args: any, _context: any, info: { abortSignal: AbortSignal }) => + cancellableAsyncFn(info.abortSignal), + }, + }, + }); + + abortController.abort(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + expect(aborted).to.equal(true); + }); + + it('should stop the execution when aborted during object field completion with a custom error', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + abortController.abort(new Error('Custom abort error')); + + await expectPromise(resultPromise).toRejectWith('Custom abort error'); + }); + + it('should stop the execution when aborted during nested object field completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: { + id: '1', + /* c8 ignore next 3 */ + author: async () => + Promise.resolve(() => expect.fail('Should not be called')), + }, + }, + }); + + abortController.abort(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + 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(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should stop the execution when aborted despite a hanging item', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + items + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: () => ({ + id: '1', + items: [ + new Promise(() => { + /* will never resolve */ + }), + ], + }), + }, + }); + + abortController.abort(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should stop the execution when aborted during promised list item completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + items + } + } + `); + const { promise: itemPromise, resolve: resolveItem } = + promiseWithResolvers(); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: () => ({ + items: [itemPromise], + }), + }, + }); + + abortController.abort(); + resolveItem('value'); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should stop the execution when aborted despite a hanging async item', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + items + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: () => ({ + id: '1', + async *items() { + yield await new Promise(() => { + /* will never resolve */ + }); /* c8 ignore start */ + } /* c8 ignore stop */, + }), + }, + }); + + abortController.abort(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should stop resolving abstract types after aborting', async () => { + const abortController = new AbortController(); + const { promise: resolveTypePromise, resolve: resolveType } = + promiseWithResolvers(); + const { promise: resolveTypeStarted, resolve: resolveTypeStartedResolve } = + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + + const nodeInterface = new GraphQLInterfaceType({ + name: 'Node', + fields: { + id: { type: GraphQLString }, + }, + resolveType() { + resolveTypeStartedResolve(); + return resolveTypePromise; + }, + }); + + const userType = new GraphQLObjectType({ + name: 'User', + interfaces: [nodeInterface], + fields: { + id: { type: GraphQLString }, + }, + }); + + const interfaceSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + node: { + type: nodeInterface, + resolve: () => ({ id: '1' }), + }, + }, + }), + types: [userType], + }); + + const document = parse('{ node { id } }'); + + const resultPromise = execute({ + schema: interfaceSchema, + document, + abortSignal: abortController.signal, + }); + + await resolveTypeStarted; + abortController.abort(); + resolveType('User'); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should stop resolving isTypeOf after aborting', async () => { + const abortController = new AbortController(); + const { promise: isTypeOfPromise, resolve: resolveIsTypeOf } = + promiseWithResolvers(); + const { promise: isTypeOfStarted, resolve: resolveIsTypeOfStarted } = + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + + const todoType = new GraphQLObjectType({ + name: 'Todo', + fields: { + id: { type: GraphQLString }, + }, + isTypeOf() { + resolveIsTypeOfStarted(); + return isTypeOfPromise; + }, + }); + + const isTypeOfSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + todo: { + type: todoType, + resolve: () => ({ id: '1' }), + }, + }, + }), + }); + + const document = parse('{ todo { id } }'); + + const resultPromise = execute({ + schema: isTypeOfSchema, + document, + abortSignal: abortController.signal, + }); + + await isTypeOfStarted; + abortController.abort(); + resolveIsTypeOf(true); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + 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', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + abortController.abort(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('suppresses sibling errors after a non-null error bubbles', async () => { + const { promise: boomPromise, reject: rejectBoom } = + promiseWithResolvers(); + const { promise: sidePromise, reject: rejectSide } = + promiseWithResolvers(); + + const parentType = new GraphQLObjectType({ + name: 'Parent', + fields: { + boom: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => boomPromise, + }, + side: { + type: GraphQLString, + resolve: () => sidePromise, + }, + }, + }); + + const bubbleSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + parent: { + type: parentType, + resolve: () => ({}), + }, + other: { + type: GraphQLString, + resolve: () => 'ok', + }, + }, + }), + }); + + const document = parse('{ parent { boom side } other }'); + const resultPromise = execute({ schema: bubbleSchema, document }); + + rejectBoom(new Error('boom')); + // wait for boom to bubble up + await resolveOnNextTick(); + await resolveOnNextTick(); + await resolveOnNextTick(); + rejectSide(new Error('side')); + + const result = await resultPromise; + expectJSON(result).toDeepEqual({ + data: { + parent: null, + other: 'ok', + }, + errors: [ + { + message: 'boom', + locations: [{ line: 1, column: 12 }], + path: ['parent', 'boom'], + }, + ], + }); + }); + + it('should stop the execution when aborted mid-mutation', async () => { + const abortController = new AbortController(); + const document = parse(` + mutation { + foo + bar + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + foo: async () => Promise.resolve('baz'), + /* c8 ignore next */ + bar: () => expect.fail('Should not be called'), + }, + }); + + await resolveOnNextTick(); + await resolveOnNextTick(); + await resolveOnNextTick(); + + abortController.abort(); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should stop the execution when aborted pre-execute', () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + abortController.abort(); + + expect(() => + execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + /* c8 ignore next */ + todo: () => expect.fail('Should not be called'), + }, + }), + ).to.throw('This operation was aborted'); + }); + + it('should stop the execution when aborted prior to return of a subscription resolver', async () => { + const abortController = new AbortController(); + const document = parse(` + subscription { + foo + } + `); + + const subscriptionPromise = subscribe({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + foo: async () => + new Promise(() => { + /* will never resolve */ + }), + }, + }); + + abortController.abort(); + + const result = await subscriptionPromise; + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'This operation was aborted', + path: ['foo'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('should successfully wrap the subscription', async () => { + const abortController = new AbortController(); + const document = parse(` + subscription { + foo + } + `); + + async function* foo() { + yield await Promise.resolve({ foo: 'foo' }); + } + + const subscription = await subscribe({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + foo: Promise.resolve(foo()), + }, + }); + + assert(isAsyncIterable(subscription)); + + expectJSON(await subscription.next()).toDeepEqual({ + value: { + data: { + foo: 'foo', + }, + }, + done: false, + }); + + expectJSON(await subscription.next()).toDeepEqual({ + value: undefined, + done: true, + }); + }); + + it('should stop the execution when aborted during subscription', async () => { + const abortController = new AbortController(); + const document = parse(` + subscription { + foo + } + `); + + async function* foo() { + yield await Promise.resolve({ foo: 'foo' }); + } + + const subscription = subscribe({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + foo: foo(), + }, + }); + + assert(isAsyncIterable(subscription)); + + expectJSON(await subscription.next()).toDeepEqual({ + value: { + data: { + foo: 'foo', + }, + }, + done: false, + }); + + abortController.abort(); + + await expectPromise(subscription.next()).toRejectWith( + 'This operation was aborted', + ); + }); + + it('should stop the execution when aborted during subscription returned asynchronously', async () => { + const abortController = new AbortController(); + const document = parse(` + subscription { + foo + } + `); + + async function* foo() { + yield await Promise.resolve({ foo: 'foo' }); + } + + const subscription = await subscribe({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + foo: Promise.resolve(foo()), + }, + }); + + assert(isAsyncIterable(subscription)); + + expectJSON(await subscription.next()).toDeepEqual({ + value: { + data: { + foo: 'foo', + }, + }, + done: false, + }); + + abortController.abort(); + + await expectPromise(subscription.next()).toRejectWith( + 'This operation was aborted', + ); + }); + + it('ignores async iterator return errors after aborting list completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + items + } + } + `); + const { promise: nextReturned, resolve: resolveNextReturned } = + promiseWithResolvers>(); + const { promise: nextStarted, resolve: resolveNextStarted } = + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + let returnCalled = false; + const asyncIterator = { + [Symbol.asyncIterator]() { + return this; + }, + next() { + resolveNextStarted(); + return nextReturned; + }, + return() { + returnCalled = true; + throw new Error('Return failed'); + }, + }; + + const resultPromise = execute({ + schema, + document, + rootValue: { + todo: { + items: asyncIterator, + }, + }, + abortSignal: abortController.signal, + }); + await nextStarted; + abortController.abort(); + resolveNextReturned({ value: 'value', done: false }); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + expect(returnCalled).to.equal(true); + }); + + it('ignores async iterator return promise rejections after aborting list completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + items + } + } + `); + const { promise: nextReturned, resolve: resolveNextReturned } = + promiseWithResolvers>(); + const { promise: nextStarted, resolve: resolveNextStarted } = + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + let returnCalled = false; + const asyncIterator = { + [Symbol.asyncIterator]() { + return this; + }, + next() { + resolveNextStarted(); + return nextReturned; + }, + return() { + returnCalled = true; + return Promise.reject(new Error('Return failed')); + }, + }; + + const resultPromise = execute({ + schema, + document, + rootValue: { + todo: { + items: asyncIterator, + }, + }, + abortSignal: abortController.signal, + }); + await nextStarted; + abortController.abort(); + resolveNextReturned({ value: 'value', done: false }); + + await expectPromise(resultPromise).toRejectWith( + 'This operation was aborted', + ); + expect(returnCalled).to.equal(true); + }); +}); diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 4aceb431c2..900d7ee2ba 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -9,6 +9,7 @@ import { inspect } from '../../jsutils/inspect.js'; import { Kind } from '../../language/kinds.js'; import { parse } from '../../language/parser.js'; +import type { GraphQLResolveInfo } from '../../type/definition.js'; import { GraphQLInputObjectType, GraphQLInterfaceType, @@ -188,7 +189,7 @@ describe('Execute: Handles basic execution tasks', () => { }); it('provides info about current execution state', () => { - let resolvedInfo; + let resolvedInfo: GraphQLResolveInfo | undefined; const testType = new GraphQLObjectType({ name: 'Test', fields: { @@ -213,6 +214,8 @@ describe('Execute: Handles basic execution tasks', () => { const field = operation.selectionSet.selections[0]; + assert(resolvedInfo != null); + expect(resolvedInfo).to.deep.include({ fieldName: 'test', fieldNodes: [field], @@ -237,6 +240,8 @@ describe('Execute: Handles basic execution tasks', () => { coerced: { var: 'abc' }, }, }); + + expect(resolvedInfo.abortSignal).to.be.instanceOf(AbortSignal); }); it('populates path correctly with complex types', () => { diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts index ace4df2655..5635639a64 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -76,6 +76,35 @@ describe('Execute: Accepts any iterable as list value', () => { ], }); }); + + it('Ignores iterator return errors when iteration throws', () => { + let returnCalled = false; + const listField = { + [Symbol.iterator]() { + return { + next() { + throw new Error('bad'); + }, + return() { + returnCalled = true; + throw new Error('return bad'); + }, + }; + }, + }; + + expectJSON(complete({ listField })).toDeepEqual({ + data: { listField: null }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 3 }], + path: ['listField'], + }, + ], + }); + expect(returnCalled).to.equal(true); + }); }); describe('Execute: Handles abrupt completion in synchronous iterables', () => { diff --git a/src/execution/cancellablePromise.ts b/src/execution/cancellablePromise.ts new file mode 100644 index 0000000000..29350c0b0d --- /dev/null +++ b/src/execution/cancellablePromise.ts @@ -0,0 +1,27 @@ +import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; + +export function cancellablePromise( + originalPromise: Promise, + abortSignal: AbortSignal, +): Promise { + if (abortSignal.aborted) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(abortSignal.reason); + } + + const { promise, resolve, reject } = promiseWithResolvers(); + const onAbort = () => reject(abortSignal.reason); + abortSignal.addEventListener('abort', onAbort); + originalPromise.then( + (resolved) => { + abortSignal.removeEventListener('abort', onAbort); + resolve(resolved); + }, + (error: unknown) => { + abortSignal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + + return promise; +} diff --git a/src/execution/entrypoints.ts b/src/execution/entrypoints.ts index e1613d465b..4de7e7e8bd 100644 --- a/src/execution/entrypoints.ts +++ b/src/execution/entrypoints.ts @@ -181,6 +181,7 @@ export interface ExecutionArgs { ) => PromiseOrValue >; hideSuggestions?: Maybe; + abortSignal?: Maybe; /** Additional execution options. */ options?: { /** Set the maximum number of errors allowed for coercing (defaults to 50). */ @@ -211,6 +212,7 @@ export function validateExecutionArgs( typeResolver, subscribeFieldResolver, perEventExecutor, + abortSignal: externalAbortSignal, options, } = args; @@ -299,6 +301,7 @@ export function validateExecutionArgs( perEventExecutor: perEventExecutor ?? executeSubscriptionEvent, hideSuggestions, errorPropagation, + externalAbortSignal: externalAbortSignal ?? undefined, }; } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 7bcb22cdcc..1910cdd9f4 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -41,6 +41,7 @@ import { } from '../type/definition.js'; import type { GraphQLSchema } from '../type/schema.js'; +import { cancellablePromise } from './cancellablePromise.js'; import type { FieldDetailsList, FragmentDetails, @@ -128,11 +129,54 @@ export interface ValidatedExecutionArgs { ) => PromiseOrValue; hideSuggestions: boolean; errorPropagation: boolean; + externalAbortSignal: AbortSignal | undefined; +} + +/** + * @internal + */ +class CollectedErrors { + private _errorPositions: Set; + private _errors: Array; + constructor() { + this._errorPositions = new Set(); + this._errors = []; + } + + get errors(): ReadonlyArray { + return this._errors; + } + + add(error: GraphQLError, path: Path | undefined) { + // Do not modify errors list if the execution position for this error or + // any of its ancestors has already been nulled via error propagation. + // This check should be unnecessary for implementations able to implement + // actual cancellation. + if (this._hasNulledPosition(path)) { + return; + } + this._errorPositions.add(path); + this._errors.push(error); + } + + private _hasNulledPosition(startPath: Path | undefined): boolean { + let path = startPath; + while (path !== undefined) { + if (this._errorPositions.has(path)) { + return true; + } + path = path.prev; + } + return this._errorPositions.has(undefined); + } } export interface ExecutionContext { validatedExecutionArgs: ValidatedExecutionArgs; - errors: Array; + onExternalAbort: (() => void) | undefined; + finished: boolean; + abortControllers: Set; + collectedErrors: CollectedErrors; } /** @@ -163,10 +207,28 @@ export interface FormattedExecutionResult< export function executeQueryOrMutationOrSubscriptionEvent( validatedExecutionArgs: ValidatedExecutionArgs, ): PromiseOrValue { + const externalAbortSignal = validatedExecutionArgs.externalAbortSignal; + + if (externalAbortSignal?.aborted) { + throw externalAbortSignal.reason; + } + const exeContext: ExecutionContext = { validatedExecutionArgs, - errors: [], + onExternalAbort: undefined, + finished: false, + abortControllers: new Set(), + collectedErrors: new CollectedErrors(), }; + + if (externalAbortSignal) { + const onExternalAbort = () => { + finish(exeContext, externalAbortSignal.reason); + }; + externalAbortSignal.addEventListener('abort', onExternalAbort); + exeContext.onExternalAbort = onExternalAbort; + } + try { const { schema, @@ -205,25 +267,55 @@ export function executeQueryOrMutationOrSubscriptionEvent( ); if (isPromise(result)) { - return result.then( - (resolved) => buildDataResponse(exeContext, resolved), - (error: unknown) => ({ - data: null, - errors: [...exeContext.errors, error as GraphQLError], - }), + const promise = result.then( + (data) => { + finish(exeContext); + return buildResponse(exeContext, data); + }, + (error: unknown) => { + finish(exeContext); + exeContext.collectedErrors.add(error as GraphQLError, undefined); + return buildResponse(exeContext, null); + }, ); + return externalAbortSignal + ? cancellablePromise(promise, externalAbortSignal) + : promise; } - return buildDataResponse(exeContext, result); + return buildResponse(exeContext, result); } catch (error) { - return { data: null, errors: [...exeContext.errors, error] }; + exeContext.collectedErrors.add(error as GraphQLError, undefined); + return buildResponse(exeContext, null); + } +} + +function finish(exeContext: ExecutionContext, reason?: unknown): void { + if (exeContext.finished) { + return; + } + exeContext.finished = true; + const { abortControllers, onExternalAbort } = exeContext; + const finishReason = reason ?? new Error('Execution has already completed.'); + for (const abortController of abortControllers) { + abortController.abort(finishReason); + } + if (onExternalAbort) { + exeContext.validatedExecutionArgs.externalAbortSignal?.removeEventListener( + 'abort', + onExternalAbort, + ); } } -function buildDataResponse( +/** + * Given a completed execution context and data, build the `{ errors, data }` + * response defined by the "Response" section of the GraphQL specification. + */ +function buildResponse( exeContext: ExecutionContext, - data: ObjMap, + data: ObjMap | null, ): ExecutionResult { - const errors = exeContext.errors; + const errors = exeContext.collectedErrors.errors; return errors.length ? { errors, data } : { data }; } @@ -278,6 +370,9 @@ function executeFieldsSerially( return promiseReduce( groupedFieldSet, (results, [responseName, fieldDetailsList]) => { + if (exeContext.finished) { + throw new Error('Execution has already completed.'); + } const fieldPath = addPath(path, responseName, parentType.name); const result = executeField( exeContext, @@ -392,6 +487,20 @@ function executeField( fieldDetailsList, parentType, path, + () => { + /* c8 ignore next 3 */ + if (exeContext.finished) { + throw new Error('Execution has already completed.'); + } + const abortController = new AbortController(); + exeContext.abortControllers.add(abortController); + return { + abortSignal: abortController.signal, + unregister: () => { + exeContext.abortControllers.delete(abortController); + }, + }; + }, ); // Get the resolve function, regardless of if its result is normal or abrupt (error). @@ -420,6 +529,7 @@ function executeField( info, path, result, + true, ); } @@ -435,19 +545,28 @@ function executeField( if (isPromise(completed)) { // Note: we don't rely on a `catch` method, but we do expect "thenable" // to take a second callback for the error case. - return completed.then(undefined, (rawError: unknown) => { - handleFieldError( - rawError, - exeContext, - returnType, - fieldDetailsList, - path, - ); - return null; - }); + return completed.then( + (resolved) => { + info.unregisterAbortSignal(); + return resolved; + }, + (rawError: unknown) => { + info.unregisterAbortSignal(); + handleFieldError( + rawError, + exeContext, + returnType, + fieldDetailsList, + path, + ); + return null; + }, + ); } + info.unregisterAbortSignal(); return completed; } catch (rawError) { + info.unregisterAbortSignal(); handleFieldError(rawError, exeContext, returnType, fieldDetailsList, path); return null; } @@ -460,6 +579,10 @@ function handleFieldError( fieldDetailsList: FieldDetailsList, path: Path, ): void { + if (exeContext.finished) { + throw new Error('Execution has already completed.'); + } + const error = locatedError( rawError, toNodes(fieldDetailsList), @@ -477,7 +600,7 @@ function handleFieldError( // Otherwise, error protection is applied, logging the error and resolving // a null value for this field if one is encountered. - exeContext.errors.push(error); + exeContext.collectedErrors.add(error, path); } /** @@ -594,12 +717,16 @@ async function completePromisedValue( exeContext: ExecutionContext, returnType: GraphQLOutputType, fieldDetailsList: FieldDetailsList, - info: GraphQLResolveInfo, + info: ResolveInfo, path: Path, result: Promise, + isFieldValue?: boolean, ): Promise { try { const resolved = await result; + if (exeContext.finished) { + throw new Error('Execution has already completed.'); + } let completed = completeValue( exeContext, returnType, @@ -612,9 +739,14 @@ async function completePromisedValue( if (isPromise(completed)) { completed = await completed; } - + if (isFieldValue) { + info.unregisterAbortSignal(); + } return completed; } catch (rawError) { + if (isFieldValue) { + info.unregisterAbortSignal(); + } handleFieldError(rawError, exeContext, returnType, fieldDetailsList, path); return null; } @@ -636,10 +768,10 @@ async function completeAsyncIterable( const completedResults: Array = []; const asyncIterator = items[Symbol.asyncIterator](); let index = 0; + let iteration; try { while (true) { const itemPath = addPath(path, index, undefined); - let iteration; try { // eslint-disable-next-line no-await-in-loop iteration = await asyncIterator.next(); @@ -650,10 +782,7 @@ async function completeAsyncIterable( pathToArray(path), ); } - - // TODO: add test case for stream returning done before initialCount - /* c8 ignore next 3 */ - if (iteration.done) { + if (exeContext.finished || iteration.done) { break; } @@ -692,13 +821,19 @@ async function completeAsyncIterable( index++; } } catch (error) { - returnIteratorIgnoringErrors(asyncIterator); + returnIteratorCatchingErrors(asyncIterator); throw error; } - return containsPromise - ? /* c8 ignore start */ Promise.all(completedResults) - : /* c8 ignore stop */ completedResults; + // Throwing on completion outside of the loop may allow engines to better optimize + if (exeContext.finished) { + if (!iteration?.done) { + returnIteratorCatchingErrors(asyncIterator); + } + throw new Error('Execution has already completed.'); + } + + return containsPromise ? Promise.all(completedResults) : completedResults; } /** @@ -792,10 +927,11 @@ function completeIterableValue( ) { containsPromise = true; } + index++; } } catch (error) { - returnIteratorIgnoringErrors(iterator); + returnIteratorCatchingErrors(iterator); throw error; } @@ -868,6 +1004,9 @@ async function completePromisedListItemValue( ): Promise { try { const resolved = await item; + if (exeContext.finished) { + throw new Error('Execution has already completed.'); + } let completed = completeValue( exeContext, itemType, @@ -929,8 +1068,11 @@ function completeAbstractValue( const runtimeType = resolveTypeFn(result, contextValue, info, returnType); if (isPromise(runtimeType)) { - return runtimeType.then((resolvedRuntimeType) => - completeObjectValue( + return runtimeType.then((resolvedRuntimeType) => { + if (exeContext.finished) { + throw new Error('Execution has already completed.'); + } + return completeObjectValue( exeContext, ensureValidRuntimeType( resolvedRuntimeType, @@ -944,8 +1086,8 @@ function completeAbstractValue( info, path, result, - ), - ); + ); + }); } return completeObjectValue( @@ -1037,6 +1179,9 @@ function completeObjectValue( if (isPromise(isTypeOf)) { return isTypeOf.then((resolvedIsTypeOf) => { + if (exeContext.finished) { + throw new Error('Execution has already completed.'); + } if (!resolvedIsTypeOf) { throw invalidReturnTypeError(returnType, result, fieldDetailsList); } @@ -1115,6 +1260,14 @@ export function mapSourceToResponse( return validatedExecutionArgs.perEventExecutor(perEventExecutionArgs); } + const externalAbortSignal = validatedExecutionArgs.externalAbortSignal; + if (externalAbortSignal) { + const generator = mapAsyncIterable(resultOrStream, mapFn); + return { + ...generator, + next: () => cancellablePromise(generator.next(), externalAbortSignal), + }; + } return mapAsyncIterable(resultOrStream, mapFn); } @@ -1146,6 +1299,7 @@ function executeSubscription( operation, variableValues, hideSuggestions, + externalAbortSignal, } = validatedExecutionArgs; const rootType = schema.getSubscriptionType(); @@ -1189,6 +1343,7 @@ function executeSubscription( fieldDetailsList, rootType, path, + () => ({ abortSignal: externalAbortSignal }), ); try { @@ -1216,7 +1371,10 @@ function executeSubscription( const result = resolveFn(rootValue, args, contextValue, info); if (isPromise(result)) { - return result + const promise = externalAbortSignal + ? cancellablePromise(result, externalAbortSignal) + : result; + return promise .then(assertEventStream) .then(undefined, (error: unknown) => { throw locatedError( @@ -1226,7 +1384,6 @@ function executeSubscription( ); }); } - return assertEventStream(result); } catch (error) { throw locatedError(error, toNodes(fieldDetailsList), pathToArray(path)); @@ -1249,9 +1406,9 @@ function assertEventStream(result: unknown): AsyncIterable { return result; } -function returnIteratorIgnoringErrors( +function returnIteratorCatchingErrors( iterator: Iterator | AsyncIterator, -) { +): void { try { const result = iterator.return?.(); if (isPromise(result)) { diff --git a/src/graphql.ts b/src/graphql.ts index 0598c4d7a4..769927f5fd 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -66,6 +66,7 @@ export interface GraphQLArgs { operationName?: Maybe; fieldResolver?: Maybe>; typeResolver?: Maybe>; + abortSignal?: Maybe; } export function graphql(args: GraphQLArgs): Promise { @@ -101,6 +102,7 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { fieldResolver, typeResolver, hideSuggestions, + abortSignal, } = args; // Validate Schema @@ -136,5 +138,6 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { fieldResolver, typeResolver, hideSuggestions, + abortSignal, }); } diff --git a/src/type/definition.ts b/src/type/definition.ts index 0ebb5d5b7c..14d077d87b 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -1042,6 +1042,7 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: VariableValues; + readonly abortSignal: AbortSignal | undefined; } /**