Skip to content

Commit 21b4165

Browse files
committed
add abort signal support to our async iterables
1 parent 670c26c commit 21b4165

File tree

4 files changed

+457
-46
lines changed

4 files changed

+457
-46
lines changed

src/execution/PromiseCanceller.ts

Lines changed: 40 additions & 4 deletions
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,14 +28,21 @@ export class PromiseCanceller {
2828
this.abortSignal.removeEventListener('abort', this.abort);
2929
}
3030

31-
withCancellation<T>(originalPromise: Promise<T>): Promise<T> {
31+
cancellablePromise<T>(
32+
originalPromise: Promise<T>,
33+
onCancel?: (() => unknown) | undefined,
34+
): Promise<T> {
3235
if (this.abortSignal.aborted) {
36+
onCancel?.();
3337
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
3438
return Promise.reject(this.abortSignal.reason);
3539
}
3640

3741
const { promise, resolve, reject } = promiseWithResolvers<T>();
38-
const abort = () => reject(this.abortSignal.reason);
42+
const abort = () => {
43+
onCancel?.();
44+
reject(this.abortSignal.reason);
45+
};
3946
this._aborts.add(abort);
4047
originalPromise.then(
4148
(resolved) => {
@@ -50,4 +57,33 @@ export class PromiseCanceller {
5057

5158
return promise;
5259
}
60+
61+
cancellableIterable<T>(iterable: AsyncIterable<T>): AsyncIterable<T> {
62+
const iterator = iterable[Symbol.asyncIterator]();
63+
64+
if (iterator.return) {
65+
const _return = iterator.return.bind(iterator);
66+
const _returnIgnoringErrors = async (): Promise<IteratorResult<T>> => {
67+
_return().catch(() => {
68+
/* c8 ignore next */
69+
// ignore
70+
});
71+
return Promise.resolve({ value: undefined, done: true });
72+
};
73+
74+
return {
75+
[Symbol.asyncIterator]: () => ({
76+
next: () =>
77+
this.cancellablePromise(iterator.next(), _returnIgnoringErrors),
78+
return: () => this.cancellablePromise(_return()),
79+
}),
80+
};
81+
}
82+
83+
return {
84+
[Symbol.asyncIterator]: () => ({
85+
next: () => this.cancellablePromise(iterator.next()),
86+
}),
87+
};
88+
}
5389
}
Lines changed: 213 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,242 @@
1+
import { expect } from 'chai';
12
import { describe, it } from 'mocha';
23

34
import { expectPromise } from '../../__testUtils__/expectPromise.js';
45

56
import { PromiseCanceller } from '../PromiseCanceller.js';
67

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

12-
const promiseCanceller = new PromiseCanceller(abortSignal);
14+
const promiseCanceller = new PromiseCanceller(abortSignal);
1315

14-
const promise = Promise.resolve(1);
16+
const promise = Promise.resolve(1);
1517

16-
const withCancellation = promiseCanceller.withCancellation(promise);
18+
const withCancellation = promiseCanceller.cancellablePromise(promise);
1719

18-
abortController.abort(new Error('Cancelled!'));
20+
abortController.abort(new Error('Cancelled!'));
1921

20-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
21-
});
22+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
23+
});
24+
25+
it('works to cancel an already resolved promise after abort signal triggered', async () => {
26+
const abortController = new AbortController();
27+
const abortSignal = abortController.signal;
28+
29+
abortController.abort(new Error('Cancelled!'));
30+
31+
const promiseCanceller = new PromiseCanceller(abortSignal);
32+
33+
const promise = Promise.resolve(1);
34+
35+
const withCancellation = promiseCanceller.cancellablePromise(promise);
36+
37+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
38+
});
39+
40+
it('works to cancel a hanging promise', async () => {
41+
const abortController = new AbortController();
42+
const abortSignal = abortController.signal;
43+
44+
const promiseCanceller = new PromiseCanceller(abortSignal);
45+
46+
const promise = new Promise(() => {
47+
/* never resolves */
48+
});
49+
50+
const withCancellation = promiseCanceller.cancellablePromise(promise);
51+
52+
abortController.abort(new Error('Cancelled!'));
53+
54+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
55+
});
2256

23-
it('works to cancel a hanging promise', async () => {
24-
const abortController = new AbortController();
25-
const abortSignal = abortController.signal;
57+
it('works to cancel a hanging promise created after abort signal triggered', async () => {
58+
const abortController = new AbortController();
59+
const abortSignal = abortController.signal;
2660

27-
const promiseCanceller = new PromiseCanceller(abortSignal);
61+
abortController.abort(new Error('Cancelled!'));
2862

29-
const promise = new Promise(() => {
30-
/* never resolves */
63+
const promiseCanceller = new PromiseCanceller(abortSignal);
64+
65+
const promise = new Promise(() => {
66+
/* never resolves */
67+
});
68+
69+
const withCancellation = promiseCanceller.cancellablePromise(promise);
70+
71+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
72+
});
73+
74+
it('works to trigger onCancel when cancelling a hanging promise', async () => {
75+
const abortController = new AbortController();
76+
const abortSignal = abortController.signal;
77+
78+
const promiseCanceller = new PromiseCanceller(abortSignal);
79+
80+
const promise = new Promise(() => {
81+
/* never resolves */
82+
});
83+
84+
let onCancelCalled = false;
85+
const onCancel = () => {
86+
onCancelCalled = true;
87+
};
88+
89+
const withCancellation = promiseCanceller.cancellablePromise(
90+
promise,
91+
onCancel,
92+
);
93+
94+
expect(onCancelCalled).to.equal(false);
95+
96+
abortController.abort(new Error('Cancelled!'));
97+
98+
expect(onCancelCalled).to.equal(true);
99+
100+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
31101
});
32102

33-
const withCancellation = promiseCanceller.withCancellation(promise);
103+
it('works to trigger onCancel when cancelling a hanging promise created after abort signal triggered', async () => {
104+
const abortController = new AbortController();
105+
const abortSignal = abortController.signal;
106+
107+
abortController.abort(new Error('Cancelled!'));
108+
109+
const promiseCanceller = new PromiseCanceller(abortSignal);
110+
111+
const promise = new Promise(() => {
112+
/* never resolves */
113+
});
34114

35-
abortController.abort(new Error('Cancelled!'));
115+
let onCancelCalled = false;
116+
const onCancel = () => {
117+
onCancelCalled = true;
118+
};
36119

37-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
120+
const withCancellation = promiseCanceller.cancellablePromise(
121+
promise,
122+
onCancel,
123+
);
124+
125+
expect(onCancelCalled).to.equal(true);
126+
127+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
128+
});
38129
});
39130

40-
it('works to cancel a hanging promise created after abort signal triggered', async () => {
41-
const abortController = new AbortController();
42-
const abortSignal = abortController.signal;
131+
describe('cancellableAsyncIterable', () => {
132+
it('works to abort a next call', async () => {
133+
const abortController = new AbortController();
134+
const abortSignal = abortController.signal;
135+
136+
const promiseCanceller = new PromiseCanceller(abortSignal);
137+
138+
const asyncIterable = {
139+
[Symbol.asyncIterator]: () => ({
140+
next: () => Promise.resolve({ value: 1, done: false }),
141+
}),
142+
};
143+
144+
const cancellableAsyncIterable =
145+
promiseCanceller.cancellableIterable(asyncIterable);
146+
147+
const nextPromise =
148+
cancellableAsyncIterable[Symbol.asyncIterator]().next();
149+
150+
abortController.abort(new Error('Cancelled!'));
151+
152+
await expectPromise(nextPromise).toRejectWith('Cancelled!');
153+
});
154+
155+
it('works to abort a next call when already aborted', async () => {
156+
const abortController = new AbortController();
157+
const abortSignal = abortController.signal;
158+
159+
abortController.abort(new Error('Cancelled!'));
160+
161+
const promiseCanceller = new PromiseCanceller(abortSignal);
162+
163+
const asyncIterable = {
164+
[Symbol.asyncIterator]: () => ({
165+
next: () => Promise.resolve({ value: 1, done: false }),
166+
}),
167+
};
168+
169+
const cancellableAsyncIterable =
170+
promiseCanceller.cancellableIterable(asyncIterable);
171+
172+
const nextPromise =
173+
cancellableAsyncIterable[Symbol.asyncIterator]().next();
43174

44-
abortController.abort(new Error('Cancelled!'));
175+
await expectPromise(nextPromise).toRejectWith('Cancelled!');
176+
});
177+
178+
it('works to call return', async () => {
179+
const abortController = new AbortController();
180+
const abortSignal = abortController.signal;
181+
182+
const promiseCanceller = new PromiseCanceller(abortSignal);
183+
184+
let returned = false;
185+
const asyncIterable = {
186+
[Symbol.asyncIterator]: () => ({
187+
next: () => Promise.resolve({ value: 1, done: false }),
188+
return: () => {
189+
returned = true;
190+
return Promise.resolve({ value: undefined, done: true });
191+
},
192+
}),
193+
};
194+
195+
const cancellableAsyncIterable =
196+
promiseCanceller.cancellableIterable(asyncIterable);
197+
198+
abortController.abort(new Error('Cancelled!'));
199+
200+
expect(returned).to.equal(false);
201+
202+
const nextPromise =
203+
cancellableAsyncIterable[Symbol.asyncIterator]().next();
45204

46-
const promiseCanceller = new PromiseCanceller(abortSignal);
205+
expect(returned).to.equal(true);
47206

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

52-
const withCancellation = promiseCanceller.withCancellation(promise);
210+
it('works to call return when already aborted', async () => {
211+
const abortController = new AbortController();
212+
const abortSignal = abortController.signal;
53213

54-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
214+
abortController.abort(new Error('Cancelled!'));
215+
216+
const promiseCanceller = new PromiseCanceller(abortSignal);
217+
218+
let returned = false;
219+
const asyncIterable = {
220+
[Symbol.asyncIterator]: () => ({
221+
next: () => Promise.resolve({ value: 1, done: false }),
222+
return: () => {
223+
returned = true;
224+
return Promise.resolve({ value: undefined, done: true });
225+
},
226+
}),
227+
};
228+
229+
const cancellableAsyncIterable =
230+
promiseCanceller.cancellableIterable(asyncIterable);
231+
232+
expect(returned).to.equal(false);
233+
234+
const nextPromise =
235+
cancellableAsyncIterable[Symbol.asyncIterator]().next();
236+
237+
expect(returned).to.equal(true);
238+
239+
await expectPromise(nextPromise).toRejectWith('Cancelled!');
240+
});
55241
});
56242
});

0 commit comments

Comments
 (0)