Created
October 13, 2026 02:21
-
-
Save jbeda/cb47933e03eb33febf937c159b780b9b to your computer and use it in GitHub Desktop.
Node.js HTTP/2: complete buffered response fails async iteration after NO_ERROR reset with request side open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Node.js HTTP/2: complete buffered response + NO_ERROR reset, request side open. | |
| // Run: node repro.mjs (expected clean EOF; throws ERR_STREAM_PREMATURE_CLOSE). | |
| // Controls: node repro.mjs no-reset; node repro.mjs drained (both pass). | |
| // Verified 20/20 failing cases and 20/20 passing controls on v24.18.0/v26.11.1. | |
| // Uses only Node core and localhost. Observed in the wild with gRPC, but general | |
| // HTTP/2 behavior; no gRPC library or application framework is required. | |
| // Upstream: https://github.com/nodejs/node/issues/66693 | |
| import assert from 'node:assert/strict'; | |
| import { once } from 'node:events'; | |
| import http2 from 'node:http2'; | |
| import net from 'node:net'; | |
| // A tiny raw peer makes DATA -> END_STREAM trailers -> RST_STREAM(NO_ERROR) | |
| // explicit. A Node HTTP/2 server may change that ordering during its own cleanup. | |
| const mode = process.argv[2] ?? 'reset'; | |
| assert.ok(['reset', 'no-reset', 'drained'].includes(mode)); | |
| const initial = Buffer.from('run-start'); | |
| const terminal = Buffer.from('complete response'); | |
| const preface = Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'); | |
| const frame = (type, flags, id, payload = Buffer.alloc(0)) => { | |
| const header = Buffer.alloc(9); | |
| header.writeUIntBE(payload.length, 0, 3); | |
| header[3] = type; | |
| header[4] = flags; | |
| header.writeUInt32BE(id, 5); | |
| return Buffer.concat([header, payload]); | |
| }; | |
| // HPACK: static-table :status=200; literal, without indexing, grpc-status=0. | |
| const responseHeaders = Buffer.from([0x88]); | |
| const responseTrailers = Buffer.concat([ | |
| Buffer.from([0x00, 11]), Buffer.from('grpc-status'), | |
| Buffer.from([1]), Buffer.from('0'), | |
| ]); | |
| const server = net.createServer(); | |
| let socket; | |
| let streamId; | |
| server.on('connection', (peer) => { | |
| socket = peer; | |
| peer.on('error', () => {}); // Cleanup may reset the localhost connection. | |
| peer.write(frame(4, 0, 0)); // SETTINGS | |
| let input = Buffer.alloc(0); | |
| let receivedPreface = false; | |
| peer.on('data', (chunk) => { | |
| input = Buffer.concat([input, chunk]); | |
| if (!receivedPreface) { | |
| if (input.length < preface.length) return; | |
| assert.deepEqual(input.subarray(0, preface.length), preface); | |
| input = input.subarray(preface.length); | |
| receivedPreface = true; | |
| } | |
| while (input.length >= 9) { | |
| const length = input.readUIntBE(0, 3); | |
| if (input.length < length + 9) return; | |
| const type = input[3]; | |
| const flags = input[4]; | |
| const id = input.readUInt32BE(5) & 0x7fffffff; | |
| input = input.subarray(length + 9); | |
| if (type === 4 && !(flags & 1)) peer.write(frame(4, 1, 0)); | |
| if (type === 1 && streamId === undefined) { | |
| streamId = id; | |
| peer.write(Buffer.concat([ | |
| frame(1, 4, id, responseHeaders), // HEADERS, END_HEADERS | |
| frame(0, 0, id, initial), | |
| ])); | |
| } | |
| } | |
| }); | |
| }); | |
| server.listen(0, '127.0.0.1'); | |
| await once(server, 'listening'); | |
| const session = http2.connect(`http://127.0.0.1:${server.address().port}`); | |
| const timeout = setTimeout(() => { | |
| throw new Error('reproducer timed out'); | |
| }, 5000); | |
| try { | |
| await once(session, 'connect'); | |
| const request = session.request({ ':method': 'POST', ':path': '/' }); | |
| request.write('request remains open'); // Deliberately no request.end(). | |
| const [headers] = await once(request, 'response'); | |
| assert.equal(headers[':status'], 200); | |
| const iterator = request[Symbol.asyncIterator](); | |
| const first = await iterator.next(); | |
| assert.deepEqual(first.value, initial); | |
| assert.equal(request.writableEnded, false); | |
| const trailers = once(request, 'trailers'); | |
| if (mode === 'drained') { | |
| socket.write(frame(0, 0, streamId, terminal)); | |
| assert.deepEqual((await iterator.next()).value, terminal); | |
| } | |
| const aborted = mode === 'no-reset' ? undefined : once(request, 'aborted'); | |
| socket.write(Buffer.concat([ | |
| ...(mode === 'drained' ? [] : [frame(0, 0, streamId, terminal)]), | |
| frame(1, 5, streamId, responseTrailers), // END_HEADERS | END_STREAM | |
| ...(mode === 'no-reset' ? [] : [frame(3, 0, streamId, Buffer.alloc(4))]), | |
| ])); | |
| const [receivedTrailers] = await trailers; | |
| assert.equal(receivedTrailers['grpc-status'], '0'); | |
| if (aborted) await aborted; | |
| assert.equal(request.rstCode, 0); | |
| if (mode === 'reset') assert.equal(request.readableLength, terminal.length); | |
| console.log(JSON.stringify({ | |
| node: process.version, mode, status: receivedTrailers['grpc-status'], | |
| rstCode: request.rstCode, readableLength: request.readableLength, | |
| writableEnded: request.writableEnded, | |
| })); | |
| if (mode !== 'drained') { | |
| const last = await iterator.next(); | |
| assert.equal(last.done, false); | |
| assert.deepEqual(last.value, terminal); | |
| console.log('Complete response bytes received.'); | |
| } | |
| // Expected: clean EOF. On Node v24.18.0, reset mode throws here even though | |
| // the complete response and successful END_STREAM trailers were received. | |
| assert.equal((await iterator.next()).done, true); | |
| console.log('PASS: clean EOF'); | |
| } finally { | |
| clearTimeout(timeout); | |
| session.destroy(); | |
| socket?.destroy(); | |
| await new Promise((resolve) => server.close(resolve)); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment