Skip to content

Commit 8bdda03

Browse files
committed
cancel execution despite pending resolvers
1 parent d59c725 commit 8bdda03

File tree

4 files changed

+234
-48
lines changed

4 files changed

+234
-48
lines changed

src/execution/Canceller.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js';
2+
3+
/**
4+
* A Canceller object that can be used to cancel multiple promises
5+
* using a single AbortSignal.
6+
*
7+
* @internal
8+
*/
9+
export class Canceller {
10+
abortSignal: AbortSignal | undefined;
11+
abort: () => void;
12+
13+
private _aborts: Set<() => void>;
14+
15+
constructor(abortSignal?: AbortSignal) {
16+
this.abortSignal = abortSignal;
17+
this._aborts = new Set<() => void>();
18+
this.abort = () => {
19+
for (const abort of this._aborts) {
20+
abort();
21+
}
22+
};
23+
24+
abortSignal?.addEventListener('abort', this.abort);
25+
}
26+
27+
unsubscribe(): void {
28+
this.abortSignal?.removeEventListener('abort', this.abort);
29+
}
30+
31+
withCancellation<T>(originalPromise: Promise<T>): Promise<T> {
32+
if (this.abortSignal === undefined) {
33+
return originalPromise;
34+
}
35+
36+
const { promise, resolve, reject } = promiseWithResolvers<T>();
37+
const abort = () => reject(this.abortSignal?.reason);
38+
this._aborts.add(abort);
39+
originalPromise.then(
40+
(resolved) => {
41+
this._aborts.delete(abort);
42+
resolve(resolved);
43+
},
44+
(error: unknown) => {
45+
this._aborts.delete(abort);
46+
reject(error);
47+
},
48+
);
49+
50+
return promise;
51+
}
52+
}

src/execution/IncrementalPublisher.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { pathToArray } from '../jsutils/Path.js';
44

55
import type { GraphQLError } from '../error/GraphQLError.js';
66

7+
import type { Canceller } from './Canceller.js';
78
import { IncrementalGraph } from './IncrementalGraph.js';
89
import type {
910
CancellableStreamRecord,
@@ -43,6 +44,7 @@ export function buildIncrementalResponse(
4344
}
4445

4546
interface IncrementalPublisherContext {
47+
canceller: Canceller;
4648
cancellableStreams: Set<CancellableStreamRecord> | undefined;
4749
}
4850

@@ -171,6 +173,7 @@ class IncrementalPublisher {
171173
batch = await this._incrementalGraph.nextCompletedBatch();
172174
} while (batch !== undefined);
173175

176+
this._context.canceller.unsubscribe();
174177
await this._returnAsyncIteratorsIgnoringErrors();
175178
return { value: undefined, done: true };
176179
};

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

Lines changed: 149 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { parse } from '../../language/parser.js';
99

1010
import { buildSchema } from '../../utilities/buildASTSchema.js';
1111

12-
import { execute, experimentalExecuteIncrementally } from '../execute.js';
12+
import {
13+
execute,
14+
experimentalExecuteIncrementally,
15+
subscribe,
16+
} from '../execute.js';
1317
import type {
1418
InitialIncrementalExecutionResult,
1519
SubsequentIncrementalExecutionResult,
@@ -52,12 +56,17 @@ const schema = buildSchema(`
5256
5357
type Query {
5458
todo: Todo
59+
nonNullableTodo: Todo!
5560
}
5661
5762
type Mutation {
5863
foo: String
5964
bar: String
6065
}
66+
67+
type Subscription {
68+
foo: String
69+
}
6170
`);
6271

6372
describe('Execute: Cancellation', () => {
@@ -300,6 +309,97 @@ describe('Execute: Cancellation', () => {
300309
});
301310
});
302311

312+
it('should stop the execution when aborted despite a hanging resolver', async () => {
313+
const abortController = new AbortController();
314+
const document = parse(`
315+
query {
316+
todo {
317+
id
318+
author {
319+
id
320+
}
321+
}
322+
}
323+
`);
324+
325+
const resultPromise = execute({
326+
document,
327+
schema,
328+
abortSignal: abortController.signal,
329+
rootValue: {
330+
todo: () =>
331+
new Promise(() => {
332+
/* will never resolve */
333+
}),
334+
},
335+
});
336+
337+
abortController.abort();
338+
339+
const result = await resultPromise;
340+
341+
expect(result.errors?.[0].originalError?.name).to.equal('AbortError');
342+
343+
expectJSON(result).toDeepEqual({
344+
data: {
345+
todo: null,
346+
},
347+
errors: [
348+
{
349+
message: 'This operation was aborted',
350+
path: ['todo'],
351+
locations: [{ line: 3, column: 9 }],
352+
},
353+
],
354+
});
355+
});
356+
357+
it('should stop the execution when aborted with proper null bubbling', async () => {
358+
const abortController = new AbortController();
359+
const document = parse(`
360+
query {
361+
nonNullableTodo {
362+
id
363+
author {
364+
id
365+
}
366+
}
367+
}
368+
`);
369+
370+
const resultPromise = execute({
371+
document,
372+
schema,
373+
abortSignal: abortController.signal,
374+
rootValue: {
375+
nonNullableTodo: async () =>
376+
Promise.resolve({
377+
id: '1',
378+
text: 'Hello, World!',
379+
/* c8 ignore next */
380+
author: () => expect.fail('Should not be called'),
381+
}),
382+
},
383+
});
384+
385+
abortController.abort();
386+
387+
const result = await resultPromise;
388+
389+
expect(result.errors?.[0].originalError?.name).to.equal('AbortError');
390+
391+
expectJSON(result).toDeepEqual({
392+
data: null,
393+
errors: [
394+
{
395+
message: 'This operation was aborted',
396+
path: ['nonNullableTodo'],
397+
locations: [{ line: 3, column: 9 }],
398+
},
399+
],
400+
});
401+
});
402+
303403
it('should stop deferred execution when aborted', async () => {
304404
const abortController = new AbortController();
305405
const document = parse(`
@@ -353,14 +453,12 @@ describe('Execute: Cancellation', () => {
353453
const abortController = new AbortController();
354454
const document = parse(`
355455
query {
356-
todo {
357-
id
358-
... on Todo @defer {
456+
... on Query @defer {
457+
todo {
458+
id
359459
text
360460
author {
361-
... on Author @defer {
362-
id
363-
}
461+
id
364462
}
365463
}
366464
}
@@ -382,41 +480,27 @@ describe('Execute: Cancellation', () => {
382480
abortController.signal,
383481
);
384482

385-
await resolveOnNextTick();
386-
await resolveOnNextTick();
387-
await resolveOnNextTick();
388-
389483
abortController.abort();
390484

391485
const result = await resultPromise;
392486

393487
expectJSON(result).toDeepEqual([
394488
{
395-
data: {
396-
todo: {
397-
id: '1',
398-
},
399-
},
400-
pending: [{ id: '0', path: ['todo'] }],
489+
data: {},
490+
pending: [{ id: '0', path: [] }],
401491
hasNext: true,
402492
},
403493
{
404494
incremental: [
405495
{
406496
data: {
407-
text: 'hello world',
408-
author: null,
497+
todo: null,
409498
},
410499
errors: [
411500
{
412-
locations: [
413-
{
414-
column: 13,
415-
line: 7,
416-
},
417-
],
418501
message: 'This operation was aborted',
419-
path: ['todo', 'author'],
502+
path: ['todo'],
503+
locations: [{ line: 4, column: 11 }],
420504
},
421505
],
422506
id: '0',
@@ -448,6 +532,10 @@ describe('Execute: Cancellation', () => {
448532
},
449533
});
450534

535+
await resolveOnNextTick();
536+
await resolveOnNextTick();
537+
await resolveOnNextTick();
538+
451539
abortController.abort();
452540

453541
const result = await resultPromise;
@@ -498,4 +586,39 @@ describe('Execute: Cancellation', () => {
498586
],
499587
});
500588
});
589+
590+
it('should stop the execution when aborted during subscription', async () => {
591+
const abortController = new AbortController();
592+
const document = parse(`
593+
subscription {
594+
foo
595+
}
596+
`);
597+
598+
const resultPromise = subscribe({
599+
document,
600+
schema,
601+
abortSignal: abortController.signal,
602+
rootValue: {
603+
foo: async () =>
604+
new Promise(() => {
605+
/* will never resolve */
606+
}),
607+
},
608+
});
609+
610+
abortController.abort();
611+
612+
const result = await resultPromise;
613+
614+
expectJSON(result).toDeepEqual({
615+
errors: [
616+
{
617+
message: 'This operation was aborted',
618+
path: ['foo'],
619+
locations: [{ line: 3, column: 9 }],
620+
},
621+
],
622+
});
623+
});
501624
});

0 commit comments

Comments
 (0)