Skip to content

fix(h2): TypeError: Cannot read properties of null (reading 'servername') in _resume when H2 stream completes #4846

@hxinhan

Description

@hxinhan

Bug Description

TypeError: Cannot read properties of null (reading 'servername') crash in _resume when using HTTP/2. The _resume function in lib/dispatcher/client.js fetches client[kQueue][client[kPendingIdx]] and immediately accesses .servername on it without a null guard. Several code paths in client-h2.js null out a queue slot (client[kQueue][client[kRunningIdx]++] = null) and then call client[kResume]() — if kPendingIdx points at that nulled slot, the process crashes.

Dispatcher config

new Agent({
  keepAliveTimeout: 20_000,
  keepAliveMaxTimeout: 60_000,
  bodyTimeout: 10_000,
  headersTimeout: 10_000,
  connections: 500,
  pipelining: 100,
  allowH2: true,
  maxConcurrentStreams: 100,
}).compose(interceptors.responseError())

Reproducible By

  1. Use undici with allowH2: true against an HTTP/2 server
  2. Send requests over the H2 connection
  3. When a stream's end event fires (via endReadableNT / processTicksAndRejections deferral), the handler nulls the queue slot and calls kResume() — if kPendingIdx still points at that slot, the crash occurs

Minimal reproduction:

'use strict'

// Regression test for:
// TypeError: Cannot read properties of null (reading 'servername')
//   at _resume (lib/dispatcher/client.js)
//
// Race condition in H2: when a stream's 'end' event fires, client-h2.js does:
//   client[kQueue][client[kRunningIdx]++] = null   <- nulls the slot
//   client[kResume]()                              <- _resume reads null slot
//
// If kPendingIdx was reset to kRunningIdx (e.g. by onHttp2SocketClose) between
// writeH2 dispatching the stream and the 'end' event firing, kPendingIdx now
// points at the null slot. _resume fetches kQueue[kPendingIdx] = null and
// crashes on null.servername.
//
// Fix: null guard in _resume after fetching the request from the queue.

const { test } = require('node:test')
const assert = require('node:assert')
const { Client } = require('..')
const {
  kQueue,
  kRunningIdx,
  kPendingIdx,
  kResume
} = require('../lib/core/symbols')

test('_resume should not crash when kQueue[kPendingIdx] is null', (t) => {
  // Create a client against a non-existent server — we never connect,
  // we only need the properly-initialized internal state.
  const client = new Client('https://localhost:1', {
    connect: { rejectUnauthorized: false },
    allowH2: true
  })

  // Reproduce the exact queue state that triggers the bug:
  //
  //   kQueue = [null]   (slot was nulled by: kQueue[kRunningIdx++] = null)
  //   kRunningIdx = 0   (points at the null slot)
  //   kPendingIdx = 0   (reset to kRunningIdx by onHttp2SocketClose)
  //
  // kPending = kQueue.length - kPendingIdx = 1 - 0 = 1  (non-zero, passes the guard)
  // kRunning = kPendingIdx - kRunningIdx   = 0 - 0 = 0  (below pipelining limit)
  // kQueue[kPendingIdx] = null                           (the crash point)
  client[kQueue].push(null)
  client[kRunningIdx] = 0
  client[kPendingIdx] = 0

  // Calling kResume() now replicates what client-h2.js does after nulling the slot.
  // Without the fix: TypeError: Cannot read properties of null (reading 'servername')
  // With the fix:    returns early safely.
  assert.doesNotThrow(
    () => client[kResume](),
    'Expected _resume to handle null queue slot without throwing'
  )

  // Restore a valid queue state before destroying so the client
  // doesn't trip over the null slot we injected during cleanup.
  client[kQueue].length = 0
  client[kRunningIdx] = 0
  client[kPendingIdx] = 0

  client.destroy().catch(() => {})
})

Expected Behavior

_resume should handle a null queue slot gracefully and return early, the same way it already handles kPending === 0.

Logs & Screenshots

TypeError: Cannot read properties of null (reading 'servername')
    at _resume (/opt/sye/node_modules/undici/lib/dispatcher/client.js:610:79)
    at resume (/opt/sye/node_modules/undici/lib/dispatcher/client.js:561:3)
    at Client.<computed> (/opt/sye/node_modules/undici/lib/dispatcher/client.js:285:31)
    at ClientHttp2Stream.<anonymous> (/opt/sye/node_modules/undici/lib/dispatcher/client-h2.js:720:22)
    at Object.onceWrapper (node:events:638:28)
    at ClientHttp2Stream.emit (node:events:536:35)
    at endReadableNT (node:internal/streams/readable:1698:12)
    at processTicksAndRejections (node:internal/process/task_queues:82:21)

Environment

  • OS Linux
  • Node.js v20.19.0
  • undici v7.21.0
  • allowH2: true

Additional context

The null slot is produced by multiple paths in client-h2.js:

Line 719: normal stream completion (responseReceived path)
Line 725: stream ended without a response
Line 337: onHttp2SessionGoAway
Lines 540/573: WebSocket/CONNECT upgrade paths
All of them do client[kQueue][client[kRunningIdx]++] = null then call clientkResume. The potential fix could be a one-line null guard in _resume in client.js:

const request = client[kQueue][client[kPendingIdx]]

if (request == null) {  // add this
  return
}

if (client[kUrl].protocol === 'https:' && client[kServerName] !== request.servername) {

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions