From 94e1543576aec5381607dde6f3356a9073dc97bf Mon Sep 17 00:00:00 2001 From: Old Autumn Date: Mon, 16 Mar 2026 15:12:42 +0800 Subject: [PATCH] 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. --- lib/adapters/fetch.js | 6 +++++- tests/unit/adapters/fetch.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/adapters/fetch.js b/lib/adapters/fetch.js index 99dedbcc..6588ddf0 100644 --- a/lib/adapters/fetch.js +++ b/lib/adapters/fetch.js @@ -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; }); diff --git a/tests/unit/adapters/fetch.test.js b/tests/unit/adapters/fetch.test.js index d6de2ff3..8b1bc3fd 100644 --- a/tests/unit/adapters/fetch.test.js +++ b/tests/unit/adapters/fetch.test.js @@ -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(); + } + }); + }); });