Skip to content

Instantly share code, notes, and snippets.

@jbeda
Created October 13, 2026 02:21
Show Gist options
  • Select an option

  • Save jbeda/cb47933e03eb33febf937c159b780b9b to your computer and use it in GitHub Desktop.

Select an option

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
// 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