Skip to content

http2: closing and then destroying a client stream whose body is still queued sends no RST_STREAM and loops on empty DATA frames until OOM #67363

Open

Description

@elyndefigma

Version

v22.23.4

Platform

Linux x64

Subsystem

http2

What steps will reproduce the bug?

Save as repro.js and run node repro.js (no dependencies). The client runs in a forked child, and the parent kills it at 1 GiB.

'use strict'
const http2 = require('node:http2')
const fs = require('node:fs')
const { fork, execFileSync } = require('node:child_process')

// The client runs in a forked child: once the bug triggers, its event loop never runs
// again, so the parent watches the child's RSS and kills it at 1 GiB.
if (process.argv[2] === 'child') {
  const session = http2.connect(`http://127.0.0.1:${process.argv[3]}`)
  const req = session.request({ ':method': 'POST', ':path': '/' })
  req.on('error', () => {})
  req.end(Buffer.alloc(2 * 1024 * 1024))
  // Close (NO_ERROR) and destroy while most of the body is still queued behind flow control.
  setTimeout(() => { req.close(); req.destroy() }, 100)
  setInterval(() => {}, 1000)
  return
}

// Parent: a server that doesn't read the 2 MiB body for 1s, so the upload stalls at the
// 64 KiB stream window and the abort lands while most of the body is queued.
function rssMiB (pid) {
  try {
    const m = /VmRSS:\s+(\d+)/.exec(fs.readFileSync(`/proc/${pid}/status`, 'utf8'))
    if (m) return Number(m[1]) / 1024
  } catch {}
  return Number(execFileSync('ps', ['-o', 'rss=', '-p', String(pid)]).toString().trim()) / 1024
}
const server = http2.createServer()
server.on('session', (s) => s.on('error', () => {}))
server.on('stream', (stream) => {
  stream.on('error', () => {})
  stream.on('close', () => console.log(`server: stream closed, rstCode=${stream.rstCode}`))
  setTimeout(() => stream.resume(), 1000)
})
server.listen(0, '127.0.0.1', () => {
  const child = fork(__filename, ['child', String(server.address().port)])
  let peak = 0
  const done = (code, msg) => { clearInterval(t); child.kill('SIGKILL'); server.close(); console.log(msg); process.exit(code) }
  const t = setInterval(() => {
    peak = Math.max(peak, rssMiB(child.pid))
    if (peak > 1024) done(1, `RUNAWAY: client RSS passed ${Math.round(peak)} MiB after one aborted upload`)
  }, 100)
  setTimeout(() => done(0, `ok: client RSS peaked at ${Math.round(peak)} MiB`), 5000)
})

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

Every time. The required condition: when the client closes and destroys the stream, part of the request body must still be queued behind flow control, because the server hasn't read it yet. A body that fits in the window, or a server that reads promptly, doesn't trigger it.

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

close() followed by destroy() should leave the stream reset on the wire (RST_STREAM), and the session should go on working with flat memory. Destroying a stream shouldn't leave nghttp2 holding a stream that Node no longer knows about.

What do you see instead?

RUNAWAY: client RSS passed 1104 MiB after one aborted upload

No RST_STREAM is sent. Once the server reads the body and sends WINDOW_UPDATE, the client's main thread spins in native code and RSS grows at about 0.5–1 GiB/s until the process is killed. In production OOM reports, the faulting frame is node::http2::Http2Session::CopyDataIntoOutgoing.

Additional information

The sequence as we understand it:

  1. req.close() (NO_ERROR) while the writable side is still ending: closeStream in lib/internal/http2/core.js defers submitting the RST_STREAM until 'finish'. That never fires, because the body can't flush without window.
  2. req.destroy(): _destroy doesn't submit an RST because the stream is already closed, and Http2Stream::Destroy removes the stream from the session. nghttp2 still has the stream open with its data provider.
  3. On WINDOW_UPDATE, nghttp2 calls Http2Session::OnRead (src/node_http2.cc). The stream lookup fails and it returns 0 without NGHTTP2_DATA_FLAG_EOF. nghttp2 sends a 0-length DATA frame and calls OnRead again. Those frames consume no window, so the nghttp2_session_mem_send / CopyDataIntoOutgoing loop in SendPendingData never ends.

Possible fixes. We haven't built Node to test either one:

  • In _destroy, submit the RST if close() deferred it to 'finish' and it hasn't gone out yet.
  • As a backstop, have OnRead return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE when the stream is gone, so nghttp2 resets it instead of looping.

The same thing can be reached without calling close() directly. undici's HTTP/2 client calls close() and then destroy() when a request is aborted (UNDICI_ISSUE_LINK). That's how we hit it in production, and nodejs/undici#6181 looks like the same crash.

Related: #66306 (an 'aborted' listener destroyed the stream before the RST was submitted). Its fix, #66314, only reorders the 'aborted' event and doesn't cover this sequence. #66742 (canceled upload leaves session memory over budget) is a separate issue.

This report was prepared with an AI coding assistant and reviewed by me.

Activity

  1. inoway46 commented on Nov 10, 2026

    @inoway46
    Contributor

    Thanks for the detailed report. I can reproduce this on main, v22.23.4, v24.22.2 and v26.13.0. On main, the repro needs a larger body (e.g. 64 MiB) because the default stream window is now 4 MiB (#64623). Server responses are affected in the same way when the server calls close() and then destroy().

    I opened #67372 with the first fix you suggested, plus the OnRead backstop.

  2. added
    confirmed-bugIssues and PRs for confirmed bugs.
    http2Issues and PRs related to the http2 subsystem.
    on Nov 10, 2026
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

    confirmed-bugIssues and PRs for confirmed bugs.http2Issues and PRs related to the http2 subsystem.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions