From e92595f8576d95ceadce5c47bcc78ba70700db67 Mon Sep 17 00:00:00 2001 From: hxinhan Date: Sun, 1 Mar 2026 16:20:05 +0100 Subject: [PATCH] fix(h2): TypeError: Cannot read properties of null (reading 'servername') in _resume when H2 stream completes --- lib/dispatcher/client.js | 4 ++ test/http2-resume-null-request.js | 64 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 test/http2-resume-null-request.js diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index 101acb60123..6b5ba886a1c 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -607,6 +607,10 @@ function _resume (client, sync) { const request = client[kQueue][client[kPendingIdx]] + if (request === null) { + return + } + if (client[kUrl].protocol === 'https:' && client[kServerName] !== request.servername) { if (client[kRunning] > 0) { return diff --git a/test/http2-resume-null-request.js b/test/http2-resume-null-request.js new file mode 100644 index 00000000000..445a687beb6 --- /dev/null +++ b/test/http2-resume-null-request.js @@ -0,0 +1,64 @@ +'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', () => { + // 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(() => {}) +})