http2: server sends RST_STREAM(NO_ERROR) without END_STREAM when the connection window is exhausted, truncating complete responses#66525
Version
v26.10.0 (nghttp2 1.70.0); also reproduced on v26.5.1 and v24.18.0 (nghttp2 1.69.0). The code path is unchanged on main.
Platform
Darwin 25.6.0 arm64
Subsystem
http2
What steps will reproduce the bug?
Disclaimer: The following research and reproduction were done together with Claude Opus 5.5
Save as repro.mjs and run node repro.mjs. There are no dependencies and no TLS. A raw h2c client never sends WINDOW_UPDATE, so the 65535-byte connection window stays exhausted until the client reopens it after 300 ms.
import http2 from 'node:http2';
import net from 'node:net';
const server = http2.createServer();
const pending = {};
server.on('request', (req, res) => {
pending[req.url] = res;
if (!pending['/small'] || !pending['/big']) return;
// Once /small's body is on the wire, /big drains the 65535-byte connection window
// before /small's END_STREAM frame is submitted.
pending['/small'].stream.once('wantTrailers', () => pending['/big'].end('y'.repeat(1_000_000)));
pending['/small'].end('x'.repeat(1000));
});
if (process.env.RESUME) server.on('stream', (s) => s.resume());
// Raw h2c client that never sends WINDOW_UPDATE, so the connection window stays at 0 once drained.
const frame = (type, flags, id, payload = Buffer.alloc(0)) => {
const h = Buffer.alloc(9);
h.writeUIntBE(payload.length, 0, 3);
h[3] = type; h[4] = flags; h.writeUInt32BE(id, 5);
return Buffer.concat([h, payload]);
};
const lit = (idx, v) => Buffer.concat([Buffer.from([idx, v.length]), Buffer.from(v)]); // literal, indexed name, no huffman
const headers = (path) => Buffer.concat([Buffer.from([0x82, 0x86]), lit(0x04, path), lit(0x01, 'localhost')]); // GET, http
const TYPES = { 0: 'DATA', 1: 'HEADERS', 3: 'RST_STREAM', 4: 'SETTINGS', 6: 'PING', 7: 'GOAWAY', 8: 'WINDOW_UPDATE' };
server.listen(0, () => {
const sock = net.connect(server.address().port, 'localhost', () => {
sock.write(Buffer.concat([
Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'),
frame(4, 0, 0),
frame(1, 0x5, 1, headers('/big')), // END_STREAM | END_HEADERS
frame(1, 0x5, 3, headers('/small')),
]));
});
let buf = Buffer.alloc(0), small = [], done = false;
const finish = (verdict) => {
if (done) return;
done = true;
console.log(`node ${process.version} resume=${!!process.env.RESUME}: /small frames: ${small.join(', ')}\n=> ${verdict}`);
process.exitCode = verdict.startsWith('OK') ? 0 : 1;
sock.destroy(); server.close();
};
sock.on('data', (d) => {
buf = Buffer.concat([buf, d]);
while (buf.length >= 9 && buf.length >= 9 + buf.readUIntBE(0, 3)) {
const len = buf.readUIntBE(0, 3), type = buf[3], flags = buf[4], id = buf.readUInt32BE(5) & 0x7fffffff;
const payload = buf.subarray(9, 9 + len);
buf = buf.subarray(9 + len);
if (type === 4 && !(flags & 1)) sock.write(frame(4, 1, 0)); // SETTINGS ack
if (id !== 3) continue;
const endStream = (type === 0 || type === 1) && flags & 1;
small.push(`${TYPES[type]}(${type === 0 ? `${len}B` : type === 3 ? `code=${payload.readUInt32BE(0)}` : 'headers'}${endStream ? ', END_STREAM' : ''})`);
if (endStream) finish('OK: response completed with END_STREAM');
if (type === 3) finish('BUG: full body received, then RST_STREAM without END_STREAM');
}
});
// Reopen the connection window later; a correct server sends /small's END_STREAM then.
const win = Buffer.alloc(4); win.writeUInt32BE(10_000_000);
setTimeout(() => sock.write(Buffer.concat([frame(8, 0, 0, win), frame(8, 0, 1, win)])), 300);
setTimeout(() => finish('TIMEOUT: /small never finished'), 2000);
});