fix(fetch): cancel ReadableStream body after request stream capability probe (#7515)

The module-level capability probe in the fetch adapter creates a
ReadableStream as a Request body to test for streaming support, but
never cancels it.  The Request constructor sets up an internal pull
pipeline on the stream; since the stream is never consumed or
cancelled, the [[pullAlgorithm]] Promise remains pending indefinitely,
causing an async resource leak detectable by Node.js async_hooks and
Vitest --detect-async-leaks.

Extract the ReadableStream to a variable and call body.cancel() after
the probe completes to properly tear down the stream's internal
pipeline.
This commit is contained in:
Old Autumn 2026-03-16 15:12:42 +08:00 committed by GitHub
parent 76794ac27a
commit 94e1543576
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 34 additions and 1 deletions

View File

@ -66,8 +66,10 @@ const factory = (env) => {
test(() => {
let duplexAccessed = false;
const body = new ReadableStream();
const hasContentType = new Request(platform.origin, {
body: new ReadableStream(),
body,
method: 'POST',
get duplex() {
duplexAccessed = true;
@ -75,6 +77,8 @@ const factory = (env) => {
},
}).headers.has('Content-Type');
body.cancel();
return duplexAccessed && !hasContentType;
});

View File

@ -9,6 +9,7 @@ import {
makeEchoStream,
} from '../../setup/server.js';
import axios from '../../../index.js';
import { getFetch } from '../../../lib/adapters/fetch.js';
import stream from 'stream';
import { AbortController } from 'abortcontroller-polyfill/dist/cjs-ponyfill.js';
import util from 'util';
@ -652,4 +653,32 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () =>
}
});
});
describe('capability probe cleanup', () => {
it('should cancel the ReadableStream created during the request stream probe', () => {
// The fetch adapter factory probes for request-stream support by creating
// a ReadableStream as a Request body. Previously the stream was never
// cancelled, leaving a dangling pull-algorithm promise (async resource leak
// visible via `--detect-async-leaks` or Node.js async_hooks).
//
// Calling getFetch with a unique env triggers a fresh factory() execution
// (including the probe). We spy on ReadableStream.prototype.cancel to
// verify it is invoked during the probe.
const cancelSpy = vi.spyOn(ReadableStream.prototype, 'cancel');
try {
// Unique fetch function ensures cache miss → factory() re-runs the probe.
const uniqueFetch = async () => new Response('ok');
getFetch({ env: { fetch: uniqueFetch } });
assert.ok(
cancelSpy.mock.calls.length > 0,
'ReadableStream.prototype.cancel should be called during the capability probe'
);
} finally {
cancelSpy.mockRestore();
}
});
});
});