Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 8 additions & 30 deletions packages/node/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ describe('toWebReadableStream', () => {
/**
* Below the 256 KiB flood chunk, so the consumer cancels on its first read
* while the request is still streaming — the condition that crashes a bare
* `Readable.toWeb`.
* `Readable.toWeb` before Node 26.10.
*/
const LIMIT = 64 * 1024

Expand Down Expand Up @@ -437,21 +437,19 @@ describe('toWebReadableStream', () => {
}

/**
* Boots a server that spools each request body via `wrap`, fires `iterations`
* aborted uploads at it, and reports how many completed plus any crashes.
* `stopOnCrash` ends the flood at the first crash.
* Boots a server that spools each request body via `toWebReadableStream`,
* fires `iterations` aborted uploads at it, and reports how many completed
* plus any crashes.
*/
async function runUploadServer(
kind: 'http1' | 'http2',
wrap: (req: Readable) => ReadableStream<Uint8Array>,
iterations: number,
stopOnCrash = false,
): Promise<{ handled: number, crashes: Error[] }> {
const tmpDir = await mkdtemp(path.join(tmpdir(), `toweb-${kind}-`))
let handled = 0

const listener = async (req: any, res: any): Promise<void> => {
await spoolUntilRejected(wrap(req), tmpDir)
await spoolUntilRejected(toWebReadableStream(req), tmpDir)
handled++
try {
if (!res.headersSent) {
Expand All @@ -467,13 +465,10 @@ describe('toWebReadableStream', () => {
const server = kind === 'http1' ? createServer(listener) : createHttp2Server(listener)
const flood = kind === 'http1' ? floodAndAbortHttp1 : floodAndAbortHttp2

const crashes = await recordUncaught(async (crashes) => {
const crashes = await recordUncaught(async () => {
await new Promise<void>(resolve => server.listen(0, resolve))
const { port } = server.address() as AddressInfo
for (let i = 0; i < iterations; i++) {
if (stopOnCrash && crashes.length) {
break
}
await flood(port)
await new Promise(resolve => setTimeout(resolve, 5))
}
Expand All @@ -484,9 +479,6 @@ describe('toWebReadableStream', () => {
return { handled, crashes }
}

const bare = (req: Readable): ReadableStream<Uint8Array> => Readable.toWeb(req) as ReadableStream<Uint8Array>
const wrapped = (req: Readable): ReadableStream<Uint8Array> => toWebReadableStream(req)

it('converts a raw buffer stream and preserves its bytes as copies', async () => {
const chunks = [Buffer.from('hello '), Buffer.from('world'), Buffer.alloc(1024, 7)]
const source = Readable.from(chunks)
Expand Down Expand Up @@ -524,29 +516,15 @@ describe('toWebReadableStream', () => {
expect(source.destroyed).toBe(true) // cancellation still tears the source down
})

it('lets a bare Readable.toWeb crash an aborted HTTP/1 upload (documents the bug)', async () => {
const { crashes } = await runUploadServer('http1', bare, 25, true)

expect(crashes.length).toBeGreaterThan(0)
expect(crashes[0]).toMatchObject({ code: 'ERR_INVALID_STATE' })
}, 30_000)

it('keeps an aborted HTTP/1 upload from crashing the process', async () => {
const { handled, crashes } = await runUploadServer('http1', wrapped, 25)
const { handled, crashes } = await runUploadServer('http1', 25)

expect(crashes).toEqual([])
expect(handled).toBe(25)
}, 30_000)

it('lets a bare Readable.toWeb crash an aborted HTTP/2 upload (documents the bug)', async () => {
const { crashes } = await runUploadServer('http2', bare, 25, true)

expect(crashes.length).toBeGreaterThan(0)
expect(crashes[0]).toMatchObject({ code: 'ERR_INVALID_STATE' })
}, 30_000)

it('keeps an aborted HTTP/2 upload from crashing the process', async () => {
const { handled, crashes } = await runUploadServer('http2', wrapped, 25)
const { handled, crashes } = await runUploadServer('http2', 25)

expect(crashes).toEqual([])
expect(handled).toBe(25)
Expand Down
3 changes: 3 additions & 0 deletions packages/node/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { IncomingMessage } from 'node:http'
* after cancel. The per-chunk copy detaches chunks from Node's pooled `Buffer`
* memory.
*
* Fixed upstream in Node 26.10 (nodejs/node#62773); switch back to
* `Readable.toWeb` once every supported Node release has the fix.
*
* Cancel destroys the source, except http1 server requests: they share their
* socket with the response, so destroying them would kill an in-flight
* response. They are abandoned instead — stalled by backpressure and reclaimed
Expand Down
Loading