Skip to content

Commit 3cc40b5

Browse files
authoredNov 16, 2022
Fix abort event listeners not always being cleaned up (#2162)
1 parent 5f278d7 commit 3cc40b5

File tree

2 files changed

+65
-18
lines changed

2 files changed

+65
-18
lines changed
 

‎source/core/index.ts

+18-8
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
176176
private _triggerRead: boolean;
177177
declare private _jobs: Array<() => void>;
178178
private _cancelTimeouts: () => void;
179+
private readonly _removeListeners: () => void;
179180
private _nativeResponse?: IncomingMessageWithTimings;
180181
private _flushed: boolean;
181182
private _aborted: boolean;
@@ -199,6 +200,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
199200
this._unproxyEvents = noop;
200201
this._triggerRead = false;
201202
this._cancelTimeouts = noop;
203+
this._removeListeners = noop;
202204
this._jobs = [];
203205
this._flushed = false;
204206
this._requestInitialized = false;
@@ -247,14 +249,6 @@ export default class Request extends Duplex implements RequestEvents<Request> {
247249
return;
248250
}
249251

250-
if (this.options.signal?.aborted) {
251-
this.destroy(new AbortError(this));
252-
}
253-
254-
this.options.signal?.addEventListener('abort', () => {
255-
this.destroy(new AbortError(this));
256-
});
257-
258252
// Important! If you replace `body` in a handler with another stream, make sure it's readable first.
259253
// The below is run only once.
260254
const {body} = this.options;
@@ -271,6 +265,21 @@ export default class Request extends Duplex implements RequestEvents<Request> {
271265
}
272266
});
273267
}
268+
269+
if (this.options.signal) {
270+
const abort = () => {
271+
this.destroy(new AbortError(this));
272+
};
273+
274+
if (this.options.signal.aborted) {
275+
abort();
276+
} else {
277+
this.options.signal.addEventListener('abort', abort);
278+
this._removeListeners = () => {
279+
this.options.signal.removeEventListener('abort', abort);
280+
};
281+
}
282+
}
274283
}
275284

276285
async flush() {
@@ -508,6 +517,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
508517
// Prevent further retries
509518
this._stopRetry();
510519
this._cancelTimeouts();
520+
this._removeListeners();
511521

512522
if (this.options) {
513523
const {body} = this.options;

‎test/abort.ts

+47-10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import test from 'ava';
55
import delay from 'delay';
66
import {pEvent} from 'p-event';
77
import type {Handler} from 'express';
8+
import {createSandbox} from 'sinon';
89
import got from '../source/index.js';
910
import slowDataStream from './helpers/slow-data-stream.js';
1011
import type {GlobalClock} from './helpers/types.js';
@@ -64,9 +65,25 @@ if (globalThis.AbortController !== undefined) {
6465
);
6566
};
6667

68+
const sandbox = createSandbox();
69+
70+
const createAbortController = (): {controller: AbortController; signalHandlersRemoved: () => boolean} => {
71+
const controller = new AbortController();
72+
sandbox.spy(controller.signal);
73+
// @ts-expect-error AbortSignal type definition issue: https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/57805
74+
const signalHandlersRemoved = () => controller.signal.addEventListener.callCount === controller.signal.removeEventListener.callCount;
75+
return {
76+
controller, signalHandlersRemoved,
77+
};
78+
};
79+
80+
test.afterEach(() => {
81+
sandbox.restore();
82+
});
83+
6784
test.serial('does not retry after abort', withServerAndFakeTimers, async (t, server, got, clock) => {
6885
const {emitter, promise} = prepareServer(server, clock);
69-
const controller = new AbortController();
86+
const {controller, signalHandlersRemoved} = createAbortController();
7087

7188
const gotPromise = got('redirect', {
7289
signal: controller.signal,
@@ -88,12 +105,14 @@ if (globalThis.AbortController !== undefined) {
88105
});
89106

90107
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
108+
109+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
91110
});
92111

93112
test.serial('abort request timeouts', withServer, async (t, server, got) => {
94113
server.get('/', () => {});
95114

96-
const controller = new AbortController();
115+
const {controller, signalHandlersRemoved} = createAbortController();
97116

98117
const gotPromise = got({
99118
signal: controller.signal,
@@ -121,14 +140,16 @@ if (globalThis.AbortController !== undefined) {
121140
message: 'This operation was aborted.',
122141
});
123142

143+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
144+
124145
// Wait for unhandled errors
125146
await delay(40);
126147
});
127148

128149
test.serial('aborts in-progress request', withServerAndFakeTimers, async (t, server, got, clock) => {
129150
const {emitter, promise} = prepareServer(server, clock);
130151

131-
const controller = new AbortController();
152+
const {controller, signalHandlersRemoved} = createAbortController();
132153

133154
const body = new ReadableStream({
134155
read() {},
@@ -148,12 +169,14 @@ if (globalThis.AbortController !== undefined) {
148169
message: 'This operation was aborted.',
149170
});
150171
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
172+
173+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
151174
});
152175

153176
test.serial('aborts in-progress request with timeout', withServerAndFakeTimers, async (t, server, got, clock) => {
154177
const {emitter, promise} = prepareServer(server, clock);
155178

156-
const controller = new AbortController();
179+
const {controller, signalHandlersRemoved} = createAbortController();
157180

158181
const body = new ReadableStream({
159182
read() {},
@@ -173,10 +196,12 @@ if (globalThis.AbortController !== undefined) {
173196
message: 'This operation was aborted.',
174197
});
175198
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
199+
200+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
176201
});
177202

178203
test.serial('abort immediately', withServerAndFakeTimers, async (t, server, got, clock) => {
179-
const controller = new AbortController();
204+
const {controller, signalHandlersRemoved} = createAbortController();
180205

181206
const promise = new Promise<void>((resolve, reject) => {
182207
// We won't get an abort or even a connection
@@ -198,11 +223,13 @@ if (globalThis.AbortController !== undefined) {
198223
message: 'This operation was aborted.',
199224
});
200225
await t.notThrowsAsync(promise, 'Request finished instead of aborting.');
226+
227+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
201228
});
202229

203230
test('recover from abort using abortable promise attribute', async t => {
204231
// Abort before connection started
205-
const controller = new AbortController();
232+
const {controller, signalHandlersRemoved} = createAbortController();
206233

207234
const p = got('http://example.com', {signal: controller.signal});
208235
const recover = p.catch((error: Error) => {
@@ -216,10 +243,12 @@ if (globalThis.AbortController !== undefined) {
216243
controller.abort();
217244

218245
await t.notThrowsAsync(recover);
246+
247+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
219248
});
220249

221250
test('recover from abort using error instance', async t => {
222-
const controller = new AbortController();
251+
const {controller, signalHandlersRemoved} = createAbortController();
223252

224253
const p = got('http://example.com', {signal: controller.signal});
225254
const recover = p.catch((error: Error) => {
@@ -233,13 +262,15 @@ if (globalThis.AbortController !== undefined) {
233262
controller.abort();
234263

235264
await t.notThrowsAsync(recover);
265+
266+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
236267
});
237268

238269
// TODO: Use `fakeTimers` here
239270
test.serial('throws on incomplete (aborted) response', withServer, async (t, server, got) => {
240271
server.get('/', downloadHandler());
241272

242-
const controller = new AbortController();
273+
const {controller, signalHandlersRemoved} = createAbortController();
243274

244275
const promise = got('', {signal: controller.signal});
245276

@@ -251,6 +282,8 @@ if (globalThis.AbortController !== undefined) {
251282
code: 'ERR_ABORTED',
252283
message: 'This operation was aborted.',
253284
});
285+
286+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
254287
});
255288

256289
test('throws when aborting cached request', withServer, async (t, server, got) => {
@@ -263,18 +296,20 @@ if (globalThis.AbortController !== undefined) {
263296

264297
await got({cache});
265298

266-
const controller = new AbortController();
299+
const {controller, signalHandlersRemoved} = createAbortController();
267300
const promise = got({cache, signal: controller.signal});
268301
controller.abort();
269302

270303
await t.throwsAsync(promise, {
271304
code: 'ERR_ABORTED',
272305
message: 'This operation was aborted.',
273306
});
307+
308+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
274309
});
275310

276311
test('support setting the signal as a default option', async t => {
277-
const controller = new AbortController();
312+
const {controller, signalHandlersRemoved} = createAbortController();
278313

279314
const got2 = got.extend({signal: controller.signal});
280315
const p = got2('http://example.com', {signal: controller.signal});
@@ -284,6 +319,8 @@ if (globalThis.AbortController !== undefined) {
284319
code: 'ERR_ABORTED',
285320
message: 'This operation was aborted.',
286321
});
322+
323+
t.true(signalHandlersRemoved(), 'Abort signal event handlers not removed');
287324
});
288325
} else {
289326
test('x', t => {

0 commit comments

Comments
 (0)
Please sign in to comment.