From 21ef4ad440cff20db24aa30be23fedb0ab99c326 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:43:24 +0000 Subject: [PATCH] node:http2: return undefined from session.socket after destroy() The Http2Session socket getter cached the Proxy wrapper in #socket_proxy and returned it unconditionally, even after destroy() had cleared the underlying bunHTTP2Socket. With the backing socket gone, every trap on that proxy (including getPrototypeOf, which instanceof calls) throws ERR_HTTP2_SOCKET_UNBOUND. So the ordinary post-teardown guard if (session.socket && !session.socket.destroyed) ... threw from inside cleanup code, and typeof session.socket reported 'object' where Node returns 'undefined'. Check bunHTTP2Socket first in both session classes and return undefined once it has been cleared, matching Node's documented behavior. A proxy captured before destroy still throws ERR_HTTP2_SOCKET_UNBOUND on access, which is also what Node does (test-http2-unbound-socket-proxy.js). --- src/js/node/http2.ts | 12 +++++--- test/js/node/http2/node-http2.test.js | 41 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 14302e78b572..54e118ff90be 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3891,9 +3891,11 @@ class ServerHttp2Session extends Http2Session { } get socket() { + // Once the underlying socket is detached (destroy/close), Node returns + // undefined instead of the cached proxy so `instanceof` and truthiness + // guards in cleanup handlers stay inert. + if (!this[bunHTTP2Socket]) return undefined; if (this.#socket_proxy) return this.#socket_proxy; - const socket = this[bunHTTP2Socket]; - if (!socket) return null; this.#socket_proxy = new Proxy(this, proxySocketHandler); return this.#socket_proxy; } @@ -4646,9 +4648,11 @@ class ClientHttp2Session extends Http2Session { return this.#parser?.setLocalWindowSize?.(windowSize); } get socket() { + // Once the underlying socket is detached (destroy/close), Node returns + // undefined instead of the cached proxy so `instanceof` and truthiness + // guards in cleanup handlers stay inert. + if (!this[bunHTTP2Socket]) return undefined; if (this.#socket_proxy) return this.#socket_proxy; - const socket = this[bunHTTP2Socket]; - if (!socket) return null; this.#socket_proxy = new Proxy(this, proxySocketHandler); return this.#socket_proxy; } diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 8f658be0d0f6..c4c2c78f7a86 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -1617,6 +1617,47 @@ it("sensitive headers should work", async () => { } }); +it("session.socket is undefined after the session is destroyed", async () => { + const server = http2.createServer(); + try { + server.on("session", ss => ss.on("error", () => {})); + server.on("stream", stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + const listening = Promise.withResolvers(); + server.on("error", listening.reject); + server.listen(0, "127.0.0.1", listening.resolve); + await listening.promise; + + const client = http2.connect(`http://127.0.0.1:${server.address().port}`); + const connected = Promise.withResolvers(); + client.on("error", connected.reject); + client.on("connect", connected.resolve); + await connected.promise; + + // Cache the proxy before destroy so we exercise the stale-proxy path; + // operations on the captured reference must still throw SOCKET_UNBOUND. + const socketBefore = client.socket; + expect(socketBefore).toBeDefined(); + + const closed = Promise.withResolvers(); + client.on("close", closed.resolve); + client.destroy(); + await closed.promise; + + expect(client.socket).toBeUndefined(); + expect(() => client.socket instanceof net.Socket).not.toThrow(); + expect(client.socket instanceof net.Socket).toBe(false); + expect(() => socketBefore instanceof net.Socket).toThrow( + expect.objectContaining({ code: "ERR_HTTP2_SOCKET_UNBOUND" }), + ); + } finally { + server.close(); + } +}); + it("http2 session.goaway() validates input types", async done => { const { mustCall } = createCallCheckCtx(done); const server = http2.createServer((req, res) => {