Skip to content

Commit 9190a9f

Browse files
authored
refactor(cancellablePromise): extract withCancellation (#4649)
1 parent 31ef71a commit 9190a9f

File tree

2 files changed

+134
-81
lines changed

2 files changed

+134
-81
lines changed

src/execution/__tests__/cancellablePromise-test.ts

Lines changed: 84 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,119 +5,135 @@ import { expectPromise } from '../../__testUtils__/expectPromise.js';
55

66
import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js';
77

8-
import { cancellablePromise } from '../cancellablePromise.js';
8+
import { cancellablePromise, withCancellation } from '../cancellablePromise.js';
99

10-
describe('cancellablePromise', () => {
10+
describe('withCancellation', () => {
1111
it('works to wrap a resolved promise', async () => {
12-
const abortController = new AbortController();
13-
1412
const promise = Promise.resolve(1);
15-
16-
const withCancellation = cancellablePromise(
17-
promise,
18-
abortController.signal,
19-
);
20-
21-
expect(await withCancellation).to.equal(1);
13+
const withAbort = withCancellation(promise);
14+
expect(await withAbort.promise).to.equal(1);
2215
});
2316

2417
it('works to wrap a rejected promise', async () => {
25-
const abortController = new AbortController();
26-
2718
const promise = Promise.reject(new Error('Rejected!'));
19+
const withAbort = withCancellation(promise);
20+
await expectPromise(withAbort.promise).toRejectWith('Rejected!');
21+
});
2822

29-
const withCancellation = cancellablePromise(
30-
promise,
31-
abortController.signal,
32-
);
23+
it('works to abort an already resolved promise', async () => {
24+
const promise = Promise.resolve(1);
25+
const withAbort = withCancellation(promise);
3326

34-
await expectPromise(withCancellation).toRejectWith('Rejected!');
27+
withAbort.abort(new Error('Cancelled!'));
28+
await expectPromise(withAbort.promise).toRejectWith('Cancelled!');
3529
});
3630

37-
it('works to cancel an already resolved promise', async () => {
38-
const abortController = new AbortController();
31+
it('works to abort a hanging promise', async () => {
32+
const promise = new Promise(() => {
33+
/* never resolves */
34+
});
35+
const withAbort = withCancellation(promise);
36+
37+
withAbort.abort(new Error('Cancelled!'));
38+
await expectPromise(withAbort.promise).toRejectWith('Cancelled!');
39+
});
3940

41+
it('does nothing when aborting an already settled promise', async () => {
4042
const promise = Promise.resolve(1);
43+
const withAbort = withCancellation(promise);
4144

42-
const withCancellation = cancellablePromise(
43-
promise,
44-
abortController.signal,
45-
);
45+
expect(await withAbort.promise).to.equal(1);
46+
withAbort.abort(new Error('Cancelled!'));
47+
expect(await withAbort.promise).to.equal(1);
48+
});
4649

47-
abortController.abort(new Error('Cancelled!'));
50+
it('handles later original rejections when already aborted', async () => {
51+
const deferred = promiseWithResolvers<undefined>();
4852

49-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
50-
});
53+
let unhandledRejection: unknown = null;
54+
const unhandledRejectionListener = (reason: unknown) => {
55+
unhandledRejection = reason;
56+
};
57+
// eslint-disable-next-line no-undef
58+
process.on('unhandledRejection', unhandledRejectionListener);
5159

52-
it('works to cancel an already resolved promise after abort signal triggered', async () => {
53-
const abortController = new AbortController();
60+
try {
61+
const withAbort = withCancellation(deferred.promise);
62+
withAbort.abort(new Error('Cancelled!'));
63+
await expectPromise(withAbort.promise).toRejectWith('Cancelled!');
5464

55-
abortController.abort(new Error('Cancelled!'));
65+
deferred.reject(new Error('Rejected later'));
66+
await new Promise((resolve) => setTimeout(resolve, 20));
67+
} finally {
68+
// eslint-disable-next-line no-undef
69+
process.removeListener('unhandledRejection', unhandledRejectionListener);
70+
}
5671

57-
const promise = Promise.resolve(1);
72+
expect(unhandledRejection).to.equal(null);
73+
});
74+
});
5875

59-
const withCancellation = cancellablePromise(
60-
promise,
76+
describe('cancellablePromise', () => {
77+
it('works to wrap a resolved promise', async () => {
78+
const abortController = new AbortController();
79+
const cancelledPromise = cancellablePromise(
80+
Promise.resolve(1),
6181
abortController.signal,
6282
);
63-
64-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
83+
expect(await cancelledPromise).to.equal(1);
6584
});
6685

67-
it('works to cancel an already rejected promise after abort signal triggered', async () => {
86+
it('works to wrap a rejected promise', async () => {
6887
const abortController = new AbortController();
88+
const cancelledPromise = cancellablePromise(
89+
Promise.reject(new Error('Rejected!')),
90+
abortController.signal,
91+
);
92+
await expectPromise(cancelledPromise).toRejectWith('Rejected!');
93+
});
6994

95+
it('rejects immediately when signal is already aborted', async () => {
96+
const abortController = new AbortController();
7097
abortController.abort(new Error('Cancelled!'));
7198

72-
const promise = Promise.reject(new Error('Rejected!'));
73-
74-
const withCancellation = cancellablePromise(
75-
promise,
99+
const cancelledPromise = cancellablePromise(
100+
new Promise(() => {
101+
/* never resolves */
102+
}),
76103
abortController.signal,
77104
);
78105

79-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
106+
await expectPromise(cancelledPromise).toRejectWith('Cancelled!');
80107
});
81108

82-
it('works to cancel a hanging promise', async () => {
109+
it('works to abort a hanging promise', async () => {
83110
const abortController = new AbortController();
84-
85-
const promise = new Promise(() => {
86-
/* never resolves */
87-
});
88-
89-
const withCancellation = cancellablePromise(
90-
promise,
111+
const cancelledPromise = cancellablePromise(
112+
new Promise(() => {
113+
/* never resolves */
114+
}),
91115
abortController.signal,
92116
);
93117

94118
abortController.abort(new Error('Cancelled!'));
95-
96-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
119+
await expectPromise(cancelledPromise).toRejectWith('Cancelled!');
97120
});
98121

99-
it('works to cancel a hanging promise created after abort signal triggered', async () => {
122+
it('does nothing when aborting an already settled promise', async () => {
100123
const abortController = new AbortController();
101-
102-
abortController.abort(new Error('Cancelled!'));
103-
104-
const promise = new Promise(() => {
105-
/* never resolves */
106-
});
107-
108-
const withCancellation = cancellablePromise(
109-
promise,
124+
const cancelledPromise = cancellablePromise(
125+
Promise.resolve(1),
110126
abortController.signal,
111127
);
112128

113-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
129+
expect(await cancelledPromise).to.equal(1);
130+
abortController.abort(new Error('Cancelled!'));
131+
expect(await cancelledPromise).to.equal(1);
114132
});
115133

116134
it('handles later original rejections when already aborted', async () => {
117-
const abortController = new AbortController();
118-
abortController.abort(new Error('Cancelled!'));
119-
120135
const deferred = promiseWithResolvers<undefined>();
136+
const abortController = new AbortController();
121137

122138
let unhandledRejection: unknown = null;
123139
const unhandledRejectionListener = (reason: unknown) => {
@@ -127,11 +143,12 @@ describe('cancellablePromise', () => {
127143
process.on('unhandledRejection', unhandledRejectionListener);
128144

129145
try {
130-
const withCancellation = cancellablePromise(
146+
const cancelledPromise = cancellablePromise(
131147
deferred.promise,
132148
abortController.signal,
133149
);
134-
await expectPromise(withCancellation).toRejectWith('Cancelled!');
150+
abortController.abort(new Error('Cancelled!'));
151+
await expectPromise(cancelledPromise).toRejectWith('Cancelled!');
135152

136153
deferred.reject(new Error('Rejected later'));
137154
await new Promise((resolve) => setTimeout(resolve, 20));
Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,66 @@
11
import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js';
22

3-
export function cancellablePromise<T>(
3+
export interface CancellablePromise<T> {
4+
promise: Promise<T>;
5+
abort: (reason?: unknown) => void;
6+
}
7+
8+
export function withCancellation<T>(
49
originalPromise: Promise<T>,
10+
): CancellablePromise<T> {
11+
const { promise, resolve, reject } = promiseWithResolvers<T>();
12+
let settled = false;
13+
14+
const settleResolve = (value: T): void => {
15+
if (settled) {
16+
return;
17+
}
18+
settled = true;
19+
resolve(value);
20+
};
21+
const settleReject = (error: unknown): void => {
22+
if (settled) {
23+
return;
24+
}
25+
settled = true;
26+
reject(error);
27+
};
28+
29+
originalPromise.then(settleResolve, settleReject);
30+
31+
return {
32+
promise,
33+
abort(reason?: unknown): void {
34+
settleReject(reason);
35+
},
36+
};
37+
}
38+
39+
export function cancellablePromise<T>(
40+
promise: Promise<T>,
541
abortSignal: AbortSignal,
642
): Promise<T> {
43+
const withAbort = withCancellation(promise);
44+
745
if (abortSignal.aborted) {
8-
// If cancellation has already happened, still drain the original promise to
9-
// avoid unhandled rejections from work that settles later.
10-
originalPromise.catch(() => undefined);
11-
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
12-
return Promise.reject(abortSignal.reason);
46+
withAbort.abort(abortSignal.reason);
47+
return withAbort.promise;
1348
}
1449

15-
const { promise, resolve, reject } = promiseWithResolvers<T>();
16-
const onAbort = () => reject(abortSignal.reason);
50+
const onAbort = () => {
51+
abortSignal.removeEventListener('abort', onAbort);
52+
withAbort.abort(abortSignal.reason);
53+
};
1754
abortSignal.addEventListener('abort', onAbort);
18-
originalPromise.then(
19-
(resolved) => {
55+
56+
withAbort.promise.then(
57+
() => {
2058
abortSignal.removeEventListener('abort', onAbort);
21-
resolve(resolved);
2259
},
23-
(error: unknown) => {
60+
() => {
2461
abortSignal.removeEventListener('abort', onAbort);
25-
reject(error);
2662
},
2763
);
2864

29-
return promise;
65+
return withAbort.promise;
3066
}

0 commit comments

Comments
 (0)