Repository navigation
Description
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:
req.close()(NO_ERROR) while the writable side is still ending:closeStreaminlib/internal/http2/core.jsdefers submitting the RST_STREAM until'finish'. That never fires, because the body can't flush without window.req.destroy():_destroydoesn't submit an RST because the stream is already closed, andHttp2Stream::Destroyremoves the stream from the session. nghttp2 still has the stream open with its data provider.- On WINDOW_UPDATE, nghttp2 calls
Http2Session::OnRead(src/node_http2.cc). The stream lookup fails and it returns 0 withoutNGHTTP2_DATA_FLAG_EOF. nghttp2 sends a 0-length DATA frame and calls OnRead again. Those frames consume no window, so thenghttp2_session_mem_send/CopyDataIntoOutgoingloop inSendPendingDatanever ends.
Possible fixes. We haven't built Node to test either one:
- In
_destroy, submit the RST ifclose()deferred it to'finish'and it hasn't gone out yet. - As a backstop, have
OnReadreturnNGHTTP2_ERR_TEMPORAL_CALLBACK_FAILUREwhen 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
http2: send deferred RST_STREAM on stream destroy
inoway46 commentedon Nov 10, 2026
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 thendestroy().I opened #67372 with the first fix you suggested, plus the
OnReadbackstop.