Skip to content

Commit 1d98a6a

Browse files
authored
add cancellation support to async iterable iteration (#4274)
When the abort signal is triggered, any pending `.next()` calls should return immediately
1 parent cca3f98 commit 1d98a6a

File tree

6 files changed

+437
-55
lines changed

6 files changed

+437
-55
lines changed

src/execution/PromiseCanceller.ts

+26-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js';
22

33
/**
4-
* A PromiseCanceller object can be used to cancel multiple promises
5-
* using a single AbortSignal.
4+
* A PromiseCanceller object can be used to trigger multiple responses
5+
* in response to a single AbortSignal.
66
*
77
* @internal
88
*/
@@ -28,7 +28,7 @@ export class PromiseCanceller {
2828
this.abortSignal.removeEventListener('abort', this.abort);
2929
}
3030

31-
withCancellation<T>(originalPromise: Promise<T>): Promise<T> {
31+
cancellablePromise<T>(originalPromise: Promise<T>): Promise<T> {
3232
if (this.abortSignal.aborted) {
3333
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
3434
return Promise.reject(this.abortSignal.reason);
@@ -50,4 +50,27 @@ export class PromiseCanceller {
5050

5151
return promise;
5252
}
53+
54+
cancellableIterable<T>(iterable: AsyncIterable<T>): AsyncIterable<T> {
55+
const iterator = iterable[Symbol.asyncIterator]();
56+
57+
const _next = iterator.next.bind(iterator);
58+
59+
if (iterator.return) {
60+
const _return = iterator.return.bind(iterator);
61+
62+
return {
63+
[Symbol.asyncIterator]: () => ({
64+
next: () => this.cancellablePromise(_next()),
65+
return: () => this.cancellablePromise(_return()),
66+
}),
67+
};
68+
}
69+
70+
return {
71+
[Symbol.asyncIterator]: () => ({
72+
next: () => this.cancellablePromise(_next()),
73+
}),
74+
};
75+
}
5376
}

src/execution/__tests__/PromiseCanceller-test.ts

+92-27
Original file line numberDiff line numberDiff line change
@@ -5,52 +5,117 @@ import { expectPromise } from '../../__testUtils__/expectPromise.js';
55
import { PromiseCanceller } from '../PromiseCanceller.js';
66

77
describe('PromiseCanceller', () => {
8-
it('works to cancel an already resolved promise', async () => {
9-
const abortController = new AbortController();
10-
const abortSignal = abortController.signal;
8+
describe('cancellablePromise', () => {
9+
it('works to cancel an already resolved promise', async () => {
10+
const abortController = new AbortController();
11+
const abortSignal = abortController.signal;
1112

12-
const promiseCanceller = new PromiseCanceller(abortSignal);
13+
const promiseCanceller = new PromiseCanceller(abortSignal);
1314

14-
const promise = Promise.resolve(1);
15+
const promise = Promise.resolve(1);
1516

16-
const withCancellation = promiseCanceller.withCancellation(promise);
17+
const withCancellation = promiseCanceller.cancellablePromise(promise);
1718

18-
abortController.abort(new Error('Cancelled!'));
19+
abortController.abort(new Error('Cancelled!'));
1920

20-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
21-
});
21+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
22+
});
23+
24+
it('works to cancel an already resolved promise after abort signal triggered', async () => {
25+
const abortController = new AbortController();
26+
const abortSignal = abortController.signal;
27+
28+
abortController.abort(new Error('Cancelled!'));
2229

23-
it('works to cancel a hanging promise', async () => {
24-
const abortController = new AbortController();
25-
const abortSignal = abortController.signal;
30+
const promiseCanceller = new PromiseCanceller(abortSignal);
2631

27-
const promiseCanceller = new PromiseCanceller(abortSignal);
32+
const promise = Promise.resolve(1);
2833

29-
const promise = new Promise(() => {
30-
/* never resolves */
34+
const withCancellation = promiseCanceller.cancellablePromise(promise);
35+
36+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
3137
});
3238

33-
const withCancellation = promiseCanceller.withCancellation(promise);
39+
it('works to cancel a hanging promise', async () => {
40+
const abortController = new AbortController();
41+
const abortSignal = abortController.signal;
42+
43+
const promiseCanceller = new PromiseCanceller(abortSignal);
44+
45+
const promise = new Promise(() => {
46+
/* never resolves */
47+
});
48+
49+
const withCancellation = promiseCanceller.cancellablePromise(promise);
50+
51+
abortController.abort(new Error('Cancelled!'));
52+
53+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
54+
});
55+
56+
it('works to cancel a hanging promise created after abort signal triggered', async () => {
57+
const abortController = new AbortController();
58+
const abortSignal = abortController.signal;
59+
60+
abortController.abort(new Error('Cancelled!'));
3461

35-
abortController.abort(new Error('Cancelled!'));
62+
const promiseCanceller = new PromiseCanceller(abortSignal);
3663

37-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
64+
const promise = new Promise(() => {
65+
/* never resolves */
66+
});
67+
68+
const withCancellation = promiseCanceller.cancellablePromise(promise);
69+
70+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
71+
});
3872
});
3973

40-
it('works to cancel a hanging promise created after abort signal triggered', async () => {
41-
const abortController = new AbortController();
42-
const abortSignal = abortController.signal;
74+
describe('cancellableAsyncIterable', () => {
75+
it('works to abort a next call', async () => {
76+
const abortController = new AbortController();
77+
const abortSignal = abortController.signal;
78+
79+
const promiseCanceller = new PromiseCanceller(abortSignal);
80+
81+
const asyncIterable = {
82+
[Symbol.asyncIterator]: () => ({
83+
next: () => Promise.resolve({ value: 1, done: false }),
84+
}),
85+
};
86+
87+
const cancellableAsyncIterable =
88+
promiseCanceller.cancellableIterable(asyncIterable);
4389

44-
abortController.abort(new Error('Cancelled!'));
90+
const nextPromise =
91+
cancellableAsyncIterable[Symbol.asyncIterator]().next();
4592

46-
const promiseCanceller = new PromiseCanceller(abortSignal);
93+
abortController.abort(new Error('Cancelled!'));
4794

48-
const promise = new Promise(() => {
49-
/* never resolves */
95+
await expectPromise(nextPromise).toRejectWith('Cancelled!');
5096
});
5197

52-
const withCancellation = promiseCanceller.withCancellation(promise);
98+
it('works to abort a next call when already aborted', async () => {
99+
const abortController = new AbortController();
100+
const abortSignal = abortController.signal;
53101

54-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
102+
abortController.abort(new Error('Cancelled!'));
103+
104+
const promiseCanceller = new PromiseCanceller(abortSignal);
105+
106+
const asyncIterable = {
107+
[Symbol.asyncIterator]: () => ({
108+
next: () => Promise.resolve({ value: 1, done: false }),
109+
}),
110+
};
111+
112+
const cancellableAsyncIterable =
113+
promiseCanceller.cancellableIterable(asyncIterable);
114+
115+
const nextPromise =
116+
cancellableAsyncIterable[Symbol.asyncIterator]().next();
117+
118+
await expectPromise(nextPromise).toRejectWith('Cancelled!');
119+
});
55120
});
56121
});

0 commit comments

Comments
 (0)