-
-
Notifications
You must be signed in to change notification settings - Fork 743
fix(h2): TypeError: Cannot read properties of null (reading 'servername') in _resume when H2 stream completes #4846
Description
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
- Use undici with
allowH2: trueagainst an HTTP/2 server - Send requests over the H2 connection
- When a stream's
endevent fires (viaendReadableNT/processTicksAndRejectionsdeferral), the handler nulls the queue slot and callskResume()— ifkPendingIdxstill 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) {