Skip to content

Commit 9a89a35

Browse files
Fix hang on stream request cleanup in Node.js (#741)
Co-authored-by: Seth Holladay <[email protected]>
1 parent 60958f9 commit 9a89a35

File tree

3 files changed

+39
-10
lines changed

3 files changed

+39
-10
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
"delay": "^6.0.0",
6868
"expect-type": "^0.19.0",
6969
"express": "^4.18.2",
70-
"jest-leak-detector": "^29.7.0",
70+
"jest-leak-detector": "^30.1.0",
7171
"pify": "^6.1.0",
7272
"playwright": "^1.45.3",
7373
"raw-body": "^2.5.2",

source/core/Ky.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,18 @@ export class Ky {
8686
const isRetriableMethod = ky._options.retry.methods.includes(ky.request.method.toLowerCase());
8787
const result = (isRetriableMethod ? ky._retry(function_) : function_())
8888
.finally(async () => {
89-
// Now that we know a retry is not needed, close the ReadableStream of the cloned request.
89+
const originalRequest = ky._originalRequest;
90+
const cleanupPromises = [];
91+
92+
if (originalRequest && !originalRequest.bodyUsed) {
93+
cleanupPromises.push(originalRequest.body?.cancel());
94+
}
95+
9096
if (!ky.request.bodyUsed) {
91-
await ky.request.body?.cancel();
97+
cleanupPromises.push(ky.request.body?.cancel());
9298
}
99+
100+
await Promise.all(cleanupPromises);
93101
}) as ResponsePromise;
94102

95103
for (const [type, mimeType] of Object.entries(responseTypes) as ObjectEntries<typeof responseTypes>) {
@@ -148,6 +156,7 @@ export class Ky {
148156
protected _retryCount = 0;
149157
protected _input: Input;
150158
protected _options: InternalOptions;
159+
protected _originalRequest?: Request;
151160

152161
// eslint-disable-next-line complexity
153162
constructor(input: Input, options: Options = {}) {
@@ -341,13 +350,13 @@ export class Ky {
341350
const nonRequestOptions = findUnknownOptions(this.request, this._options);
342351

343352
// Cloning is done here to prepare in advance for retries
344-
const mainRequest = this.request;
345-
this.request = mainRequest.clone();
353+
this._originalRequest = this.request;
354+
this.request = this._originalRequest.clone();
346355

347356
if (this._options.timeout === false) {
348-
return this._options.fetch(mainRequest, nonRequestOptions);
357+
return this._options.fetch(this._originalRequest, nonRequestOptions);
349358
}
350359

351-
return timeout(mainRequest, nonRequestOptions, this.abortController, this._options as TimeoutOptions);
360+
return timeout(this._originalRequest, nonRequestOptions, this.abortController, this._options as TimeoutOptions);
352361
}
353362
}

test/memory-leak.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
import {ReadableStream} from 'node:stream/web';
12
import test from 'ava';
2-
import _LeakDetector from 'jest-leak-detector';
3+
import LeakDetector from 'jest-leak-detector';
34
import ky, {type KyInstance} from '../source/index.js';
45
import {createHttpTestServer} from './helpers/create-http-test-server.js';
56

6-
const LeakDetector = _LeakDetector.default as typeof _LeakDetector;
7-
87
test('shared abort signal must not cause memory leak of input', async t => {
98
const server = await createHttpTestServer();
109
server.get('/', (_request, response) => {
@@ -34,3 +33,24 @@ test('shared abort signal must not cause memory leak of input', async t => {
3433
await server.close();
3534
}
3635
});
36+
37+
test('failed stream request must not cause memory leak', async t => {
38+
async function isStreamLeaking() {
39+
const stream: ReadableStream | undefined = ReadableStream.from([new TextEncoder().encode('Bell is Ringing.')]);
40+
const detector = new LeakDetector(stream);
41+
42+
await t.throwsAsync(
43+
ky.post('invalid:', {
44+
body: stream,
45+
}),
46+
{
47+
instanceOf: TypeError,
48+
message: 'fetch failed',
49+
},
50+
);
51+
52+
return detector.isLeaking();
53+
}
54+
55+
t.false(await isStreamLeaking());
56+
});

0 commit comments

Comments
 (0)