Skip to content

Commit 61c6351

Browse files
authored
Merge branch 'main' into refactor/tree-shakable-kind-2
2 parents 05b06d4 + 831c121 commit 61c6351

15 files changed

+1777
-378
lines changed

src/execution/AbortSignalListener.ts

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js';
2+
3+
/**
4+
* A AbortSignalListener object can be used to trigger multiple responses
5+
* in response to a single AbortSignal.
6+
*
7+
* @internal
8+
*/
9+
export class AbortSignalListener {
10+
abortSignal: AbortSignal;
11+
abort: () => void;
12+
13+
private _onAborts: Set<() => void>;
14+
15+
constructor(abortSignal: AbortSignal) {
16+
this.abortSignal = abortSignal;
17+
this._onAborts = new Set<() => void>();
18+
this.abort = () => {
19+
for (const abort of this._onAborts) {
20+
abort();
21+
}
22+
};
23+
24+
abortSignal.addEventListener('abort', this.abort);
25+
}
26+
27+
add(onAbort: () => void): void {
28+
this._onAborts.add(onAbort);
29+
}
30+
31+
delete(onAbort: () => void): void {
32+
this._onAborts.delete(onAbort);
33+
}
34+
35+
disconnect(): void {
36+
this.abortSignal.removeEventListener('abort', this.abort);
37+
}
38+
}
39+
40+
export function cancellablePromise<T>(
41+
originalPromise: Promise<T>,
42+
abortSignalListener: AbortSignalListener,
43+
): Promise<T> {
44+
const abortSignal = abortSignalListener.abortSignal;
45+
if (abortSignal.aborted) {
46+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
47+
return Promise.reject(abortSignal.reason);
48+
}
49+
50+
const { promise, resolve, reject } = promiseWithResolvers<T>();
51+
const onAbort = () => reject(abortSignal.reason);
52+
abortSignalListener.add(onAbort);
53+
originalPromise.then(
54+
(resolved) => {
55+
abortSignalListener.delete(onAbort);
56+
resolve(resolved);
57+
},
58+
(error: unknown) => {
59+
abortSignalListener.delete(onAbort);
60+
reject(error);
61+
},
62+
);
63+
64+
return promise;
65+
}
66+
67+
export function cancellableIterable<T>(
68+
iterable: AsyncIterable<T>,
69+
abortSignalListener: AbortSignalListener,
70+
): AsyncIterable<T> {
71+
const iterator = iterable[Symbol.asyncIterator]();
72+
73+
const _next = iterator.next.bind(iterator);
74+
75+
if (iterator.return) {
76+
const _return = iterator.return.bind(iterator);
77+
78+
return {
79+
[Symbol.asyncIterator]: () => ({
80+
next: () => cancellablePromise(_next(), abortSignalListener),
81+
return: () => cancellablePromise(_return(), abortSignalListener),
82+
}),
83+
};
84+
}
85+
86+
return {
87+
[Symbol.asyncIterator]: () => ({
88+
next: () => cancellablePromise(_next(), abortSignalListener),
89+
}),
90+
};
91+
}

src/execution/IncrementalPublisher.ts

+6
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 { AbortSignalListener } from './AbortSignalListener.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+
abortSignalListener: AbortSignalListener | undefined;
4648
cancellableStreams: Set<CancellableStreamRecord> | undefined;
4749
}
4850

@@ -125,6 +127,7 @@ class IncrementalPublisher {
125127
IteratorResult<SubsequentIncrementalExecutionResult, void>
126128
> => {
127129
if (isDone) {
130+
this._context.abortSignalListener?.disconnect();
128131
await this._returnAsyncIteratorsIgnoringErrors();
129132
return { value: undefined, done: true };
130133
}
@@ -171,6 +174,9 @@ class IncrementalPublisher {
171174
batch = await this._incrementalGraph.nextCompletedBatch();
172175
} while (batch !== undefined);
173176

177+
// TODO: add test for this case
178+
/* c8 ignore next */
179+
this._context.abortSignalListener?.disconnect();
174180
await this._returnAsyncIteratorsIgnoringErrors();
175181
return { value: undefined, done: true };
176182
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { expectPromise } from '../../__testUtils__/expectPromise.js';
5+
6+
import {
7+
AbortSignalListener,
8+
cancellableIterable,
9+
cancellablePromise,
10+
} from '../AbortSignalListener.js';
11+
12+
describe('AbortSignalListener', () => {
13+
it('works to add a listener', () => {
14+
const abortController = new AbortController();
15+
16+
const abortSignalListener = new AbortSignalListener(abortController.signal);
17+
18+
let called = false;
19+
const onAbort = () => {
20+
called = true;
21+
};
22+
abortSignalListener.add(onAbort);
23+
24+
abortController.abort();
25+
26+
expect(called).to.equal(true);
27+
});
28+
29+
it('works to delete a listener', () => {
30+
const abortController = new AbortController();
31+
32+
const abortSignalListener = new AbortSignalListener(abortController.signal);
33+
34+
let called = false;
35+
/* c8 ignore next 3 */
36+
const onAbort = () => {
37+
called = true;
38+
};
39+
abortSignalListener.add(onAbort);
40+
abortSignalListener.delete(onAbort);
41+
42+
abortController.abort();
43+
44+
expect(called).to.equal(false);
45+
});
46+
47+
it('works to disconnect a listener from the abortSignal', () => {
48+
const abortController = new AbortController();
49+
50+
const abortSignalListener = new AbortSignalListener(abortController.signal);
51+
52+
let called = false;
53+
/* c8 ignore next 3 */
54+
const onAbort = () => {
55+
called = true;
56+
};
57+
abortSignalListener.add(onAbort);
58+
59+
abortSignalListener.disconnect();
60+
61+
abortController.abort();
62+
63+
expect(called).to.equal(false);
64+
});
65+
});
66+
67+
describe('cancellablePromise', () => {
68+
it('works to cancel an already resolved promise', async () => {
69+
const abortController = new AbortController();
70+
71+
const abortSignalListener = new AbortSignalListener(abortController.signal);
72+
73+
const promise = Promise.resolve(1);
74+
75+
const withCancellation = cancellablePromise(promise, abortSignalListener);
76+
77+
abortController.abort(new Error('Cancelled!'));
78+
79+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
80+
});
81+
82+
it('works to cancel an already resolved promise after abort signal triggered', async () => {
83+
const abortController = new AbortController();
84+
const abortSignalListener = new AbortSignalListener(abortController.signal);
85+
86+
abortController.abort(new Error('Cancelled!'));
87+
88+
const promise = Promise.resolve(1);
89+
90+
const withCancellation = cancellablePromise(promise, abortSignalListener);
91+
92+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
93+
});
94+
95+
it('works to cancel a hanging promise', async () => {
96+
const abortController = new AbortController();
97+
const abortSignalListener = new AbortSignalListener(abortController.signal);
98+
99+
const promise = new Promise(() => {
100+
/* never resolves */
101+
});
102+
103+
const withCancellation = cancellablePromise(promise, abortSignalListener);
104+
105+
abortController.abort(new Error('Cancelled!'));
106+
107+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
108+
});
109+
110+
it('works to cancel a hanging promise created after abort signal triggered', async () => {
111+
const abortController = new AbortController();
112+
const abortSignalListener = new AbortSignalListener(abortController.signal);
113+
114+
abortController.abort(new Error('Cancelled!'));
115+
116+
const promise = new Promise(() => {
117+
/* never resolves */
118+
});
119+
120+
const withCancellation = cancellablePromise(promise, abortSignalListener);
121+
122+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
123+
});
124+
});
125+
126+
describe('cancellableAsyncIterable', () => {
127+
it('works to abort a next call', async () => {
128+
const abortController = new AbortController();
129+
const abortSignalListener = new AbortSignalListener(abortController.signal);
130+
131+
const asyncIterable = {
132+
[Symbol.asyncIterator]: () => ({
133+
next: () => Promise.resolve({ value: 1, done: false }),
134+
}),
135+
};
136+
137+
const withCancellation = cancellableIterable(
138+
asyncIterable,
139+
abortSignalListener,
140+
);
141+
142+
const nextPromise = withCancellation[Symbol.asyncIterator]().next();
143+
144+
abortController.abort(new Error('Cancelled!'));
145+
146+
await expectPromise(nextPromise).toRejectWith('Cancelled!');
147+
});
148+
149+
it('works to abort a next call when already aborted', async () => {
150+
const abortController = new AbortController();
151+
const abortSignalListener = new AbortSignalListener(abortController.signal);
152+
153+
abortController.abort(new Error('Cancelled!'));
154+
155+
const asyncIterable = {
156+
[Symbol.asyncIterator]: () => ({
157+
next: () => Promise.resolve({ value: 1, done: false }),
158+
}),
159+
};
160+
161+
const withCancellation = cancellableIterable(
162+
asyncIterable,
163+
abortSignalListener,
164+
);
165+
166+
const nextPromise = withCancellation[Symbol.asyncIterator]().next();
167+
168+
await expectPromise(nextPromise).toRejectWith('Cancelled!');
169+
});
170+
});

0 commit comments

Comments
 (0)