Skip to content

HTTP/2: client session outgoing queue grows without bound (Http2Session::CopyDataIntoOutgoing), OOM-killing the process #6181

Open

Description

@gabedemesa

Bug Description

A long-running Node.js 26 process that makes outbound requests with global fetch was OOM-killed by the kernel. The bundled undici negotiates HTTP/2 by default. The process grew from ~0.8 GB to over 3 GB in under a minute, inside node::http2::Http2Session::CopyDataIntoOutgoing: one HTTP/2 client session's outgoing write queue grew without bound. The session's maxSessionMemory (10 MB default) did not stop it.

This looks related to #6149 (HTTP/2 session-memory handling in undici 8), but the failure is worse: the process crashed instead of requests failing. I'm filing it separately so the evidence is in one place. Happy to fold it into #6149 if you prefer.

Reproduction

Production failure: no reproduction of the OOM itself. It happened once, in production, after ~6 months of running Node 26 with HTTP/2 negotiated by default on this traffic. The evidence under "Actual Behavior" is from the kernel OOM report and the exact node binary.

Standalone reproduction of the same failure mode (client HTTP/2 queue growing against a peer that stops draining). It does not reproduce the kernel OOM or a crash inside Http2Session::CopyDataIntoOutgoing. Tested on node-v26.12.1-linux-x64 (the binary in this report) and node-v26.13.0-linux-x64 (latest v26 release, 2026-10-28). process.versions.undici is 8.12.2 on both. Neither build contains the session-memory fixes merged to Node main on 2026-11-04 (nodejs/node#67113, nodejs/node#67114). Node 22, Node 24 and v27 alpha were not tested.

An HTTP/2 peer accepts the handshake, then pauses the socket. The client uses http2.connect with maxSessionMemory: 1 and submits 30,000 GETs:

import http2 from 'node:http2'

const server = http2.createServer()
server.on('connection', (socket) => setTimeout(() => socket.pause(), 30))
server.on('stream', (stream) => stream.on('error', () => {}))

server.listen(0, '127.0.0.1', () => {
  const session = http2.connect(`http://127.0.0.1:${server.address().port}`, {
    maxSessionMemory: 1 // documented default is 10 MB; 1 MB fails the same way
  })
  session.on('connect', () => {
    let firstError = null
    for (let i = 0; i < 30_000; i++) {
      const stream = session.request({ ':path': `/${i}`, ':method': 'GET' })
      stream.on('error', (err) => {
        firstError ??= `${err.code}: ${err.message}`
      })
      stream.end()
    }
    console.log(JSON.stringify({
      maxSessionMemoryMB: 1,
      submitted: 30_000,
      outboundQueueSize: session.state.outboundQueueSize,
      rssMB: Math.round(process.memoryUsage().rss / 1048576),
      firstError
    }))
    process.exit(0)
  })
})

Output (Linux x64, the process exits right after the log line):

v26.12.1: {"maxSessionMemoryMB":1,"submitted":30000,"outboundQueueSize":30001,"rssMB":245,"firstError":null}
v26.13.0: {"maxSessionMemoryMB":1,"submitted":30000,"outboundQueueSize":30001,"rssMB":244,"firstError":null}

A second run in a Docker container (node:26.12.1-slim and node:26.13.0-slim) gave the same outboundQueueSize and firstError with rssMB 284 and 285, so the RSS values vary by host.

Observations from a longer harness (pause the server socket, submit GETs, yield every 25,000, then resume):

  • The default 10 MB cap behaves the same while the peer is not reading. At 100,000 GETs on v26.13.0, session.socket.bytesWritten had stopped at 462,517, session.state.outboundQueueSize was 75,000, RSS was 545 MB, and no stream had errored. v26.12.1 matched (RSS 541 MB, same frozen bytesWritten, outboundQueueSize 75,000, zero errors). The pause was not perfect (the server still read some bytes, because HTTP/2 calls read-start directly), but bytesWritten froze, which is the "socket stopped draining" observation.
  • maxSessionMemory fires only after the peer resumes. Resuming the peer fails the queued streams and closes the session. On both binaries the first error was ERR_HTTP2_STREAM_ERROR: Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM. In that run outboundQueueSize was 30,000 and RSS was 249 MB (v26.13.0) / 248 MB (v26.12.1) before the resume, and 298 MB after the session closed. During the stall, firstError stayed null.
  • One request body can queue past the cap on a single stream. With maxSessionMemory: 1 and the peer not reading, 5,000,000 writes of one 256-byte chunk on v26.13.0 left bytesWritten at 65,647, writableLength at 1,279,999,744, RSS at 434 MB (heap 354 MB) and zero errors. outboundQueueSize stayed 0 and remoteWindowSize stayed 33,024: the bytes stayed on the stream, waiting for flow control. The test reused one Buffer.alloc(256) and ignored write() returning false, so writableLength is the sum of chunk lengths, not 1.28 GB of copied payload (RSS did not grow by that much because the chunks alias one buffer). The queue of write records did grow.

What this does not show:

  • Global fetch with the default dispatcher. Against a local TLS server that negotiated h2 and then paused, 1,000 parallel fetch requests opened 10 sessions. Each http2.connect from undici carried peerMaxConcurrentStreams: 100 and did not pass maxSessionMemory. RSS stayed about 112 MB and the fetches were still pending after 2 seconds, so undici's default dispatcher does not submit the unbounded frame pile this reproduction creates.
  • The production crash. No run died inside node::http2::Http2Session::CopyDataIntoOutgoing; the test process was stopped at a few hundred MB, and no native stack was captured. Growth in this reproduction tracks how many client frames were submitted, so a few GETs per minute on one long-lived fetch session is still unexplained.

From reading Node v26.13.0 (not from a runtime trace): maxSessionMemory is checked when the peer opens a stream (Http2Session::CanAddStream, called from OnBeginHeadersCallback). ClientHttp2Session.request() goes through requestOnConnect to Http2Session::SubmitRequest, which calls nghttp2_submit_request and Http2Stream::New without CanAddStream. Http2Stream::DoWrite calls IncrementAvailableOutboundLength and does not check has_available_session_memory. SendPendingData copies every chunk that nghttp2_session_mem_send returns into outgoing_buffers_ through CopyDataIntoOutgoing, with no cap inside that loop. current_session_memory() counts outgoing_storage_.size() and does not count the outgoing_buffers_ descriptor vector (each NgHttp2StreamWrite is 32 bytes).

Production traffic shape:

  • Global fetch, default dispatcher. All requests to one HTTPS origin (behind a CDN that negotiates h2 over ALPN) are multiplexed on one long-lived session.
  • Mostly small GETs, several per minute per process. Process uptime ~5 days.

Expected Behavior

A client HTTP/2 session whose socket stops draining should either error or close, or queue up to maxSessionMemory and fail requests. It should not grow until the process is killed.

Actual Behavior

  1. Kernel memcg OOM report (allocating thread node-MainThread):
    • cgroup usage 3145728kB = limit.
    • Memory stats: anon 3189157888, file 0, shmem 0, sock 12288, kernel 29347840.
    • Killed process: anon-rss:3114056kB.
    • The fault was in do_anonymous_page, writing into freshly allocated memory.
  2. Faulting instruction. RIP resolves, against the exact node binary's symbol table, to node::http2::Http2Session::CopyDataIntoOutgoing(unsigned char const*, unsigned long).
    • The faulting bytes are a loop that copies 32-byte elements into a new buffer (movdqu xmm0,[r15+0x10] … movups [r14+0x10],xmm0, cmp r13,r15).
    • RDX=0x0000000080001002, and the source and destination regions are ~1–2 GiB apart.
    • My reading (inference): outgoing_buffers_ was being reallocated from ~1 GiB to ~2 GiB, i.e. tens of millions of queued outgoing writes.
  3. Not the JS heap. heap_size_limit in this cgroup is 1632 MB, and no V8 "heap out of memory" fatal occurred (exit 137, SIGKILL from the kernel). The growth was native.
  4. Queued, not sent. Container working set was ~795 MB at the last metrics scrape, 57 s before the kill, and flat at ~750–850 MB for the preceding days. Network receive and transmit stayed at baseline through the kill (~5–8 MB/min in, 2–4 MB/min out).
  5. Event loop. In the ~10 s before the kill it slowed: a normally ~100 ms request took ~960 ms. Nothing more was logged until the kill.

Not established: which frames were queued (HEADERS / DATA / RST_STREAM / WINDOW_UPDATE / PING), or why the socket stopped draining. No heap snapshot or NODE_DEBUG=http2 output exists from the incident.

Logs & Screenshots

node-MainThread invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=903
RIP: 0033:<node>+0x70fbe9   -> node::http2::Http2Session::CopyDataIntoOutgoing
Code: ... f3 41 0f 6f 47 10 49 83 c7 20 49 8d 46 20 <41> 0f 11 46 10 4d 39 fd 74 1d ...
RDX: 0000000080001002
memory: usage 3145728kB, limit 3145728kB, failcnt 5091
anon 3189157888 / file 0 / sock 12288 / shmem 0
Memory cgroup out of memory: Killed process (node-MainThread) total-vm:14508236kB, anon-rss:3114056kB, file-rss:48924kB, shmem-rss:0kB

Environment

  • OS: Linux x86_64, kernel 6.12 (Amazon Linux 2023 host), Ubuntu 24.04-based container, cgroup v2, 3 GiB memory limit
  • Node.js version: v26.12.1
  • undici version: 8.12.2 (bundled with Node, used through global fetch; connector default allowH2 = true)

Additional context

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