Skip to content

http2: server sends RST_STREAM(NO_ERROR) without END_STREAM when the connection window is exhausted, truncating complete responses #66525

Description

@iNaD

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);
});

The wantTrailers listener only makes the timing deterministic. In real traffic the same state comes about by itself whenever large responses use up the connection window while smaller responses on the same session finish.

How often does it reproduce? Is there a required condition?

Every time with the script above (5/5 on each version listed).

Required condition: a compat-API response (Http2ServerResponse, which always responds with waitForTrailers: true) whose request had no body. Its body has been sent, but the connection flow-control window is 0 when its empty END_STREAM DATA frame is submitted.

What is the expected behavior? Why is that the expected behavior?

node v26.10.0 resume=false: /small frames: HEADERS(headers), DATA(1000B), DATA(0B, END_STREAM)
=> OK: response completed with END_STREAM

The END_STREAM frame should wait for the window to open, then be sent. It does with RESUME=1 (see below).

RFC 9113 §8.1 only allows a server to send RST_STREAM with NO_ERROR after a complete response, "i.e., a frame with the END_STREAM flag set". Its purpose is to stop a request body the server doesn't need. Here the request already ended with its HEADERS frame, so there is nothing to abort.

What do you see instead?

node v26.10.0 resume=false: /small frames: HEADERS(headers), DATA(1000B), RST_STREAM(code=0)
=> BUG: full body received, then RST_STREAM without END_STREAM

The client gets the whole body, but the stream ends with a reset instead of END_STREAM, so it can't tell the response is complete. Firefox reports these as NS_ERROR_NET_PARTIAL_TRANSFER and discards the response. With RESUME=1 the same script passes:

node v26.10.0 resume=true: /small frames: HEADERS(headers), DATA(1000B), DATA(0B, END_STREAM)
=> OK: response completed with END_STREAM

Additional information

Mechanism (lib/internal/http2/core.js, src/node_http2.cc):

  1. The compat API responds with waitForTrailers: true. The body's last DATA frame goes out without END_STREAM, and wantTrailers → sendTrailers({}) → setImmediate(finishSendTrailers) submits an empty DATA frame with END_STREAM (Http2Stream::SubmitTrailers).
  2. finishSendTrailers calls stream[kMaybeDestroy](). The stream is writableFinished, has no trailers flag any more, !didRead, and readableFlowing === null. So it takes the "gracefully close" branch and calls setImmediate(callStreamClose) → stream.close() → closeStream() → RST_STREAM(NO_ERROR) straight away, because writableFinished is true.
  3. If the connection window is 0 at that moment, nghttp2 defers the empty END_STREAM DATA frame by flow control, even though it carries 0 bytes. RST_STREAM isn't flow-controlled, so it goes out first, and the deferred END_STREAM frame is dropped with the stream.

Native trace from a real server (NODE_DEBUG_NATIVE=HTTP2SESSION,HTTP2STREAM, Vite dev server, Firefox). This is stream 207 of a session that was also sending 5 MB, 3 MB and 2.7 MB responses:

HttpStream 207 ... submitting response
Http2Session server ... sending 3478 bytes for data frame on stream 207
HttpStream 207 ... writable side shutdown
Http2Session server ... no more data for stream 207
HttpStream 207 ... let javascript know we are ready for trailers
HttpStream 207 ... sending 0 trailers
   (~2300 log lines: ~50 more streams submit trailers, one WINDOW_UPDATE arrives, all of it goes to the multi-MB streams)
Http2Session server ... write finished with status 0
HttpStream 207 ... sending rst_stream with code 0
Http2Session server ... stream 207 closed with code: 0

In that capture, 88 of 335 streams had this sequence: "no more data" logged once instead of twice, END_STREAM never sent. All 88 matched the 88 NS_ERROR_NET_PARTIAL_TRANSFER failures Firefox reported across 3 page loads.

Workaround: mark bodyless request streams as read, so the graceful-close branch is skipped and the stream closes normally after END_STREAM:

server.on('stream', (stream) => { if (stream.endAfterHeaders) stream.resume(); });

Possible fixes (not tested): skip the graceful close when the request side has already ended (endAfterHeaders / readable ended), since there is no request body to abort. Or defer the RST until the END_STREAM frame has actually been sent.

Impact: Vite's dev server uses http2.createSecureServer with the compat API. Since Vite 7.2 it also does so when a proxy is configured, so Angular CLI dev servers are affected too. Large apps randomly fail to load modules in Firefox: vitejs/vite#21569, vitejs/vite#18182, vitejs/vite discussion #21574.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions