From 5ed5d9c2c65054553ff97460262ff5bc032c9cfb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:39:22 +0000 Subject: [PATCH 01/11] node:http: close an upgraded socket when the client FINs before the request body completes An HTTP Upgrade request that carries a body (Content-Length or chunked) wedged the process when the client half-closed before the body completed: HttpContext::onEnd kept the connection half-open for HTTP_NODE_TUNNEL_AFTER_BODY the same way it does for a live CONNECT tunnel, so the NodeHTTPResponse body-read ref and the server's pending-request count were never released. server.close() never completed and the event loop never drained. Node's UpgradeStream wraps a raw socket whose socketOnEnd listener is still attached while the body is being parsed, so a mid-body FIN ends and closes the raw socket and the UpgradeStream destroys with it. Once the body finishes the connection becomes a proper tunnel (isConnectRequest) and stays half-open on FIN as before. --- packages/bun-uws/src/HttpContext.h | 17 +++++++--- test/js/node/http/node-http.test.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 1919787a6648..19c9038ed783 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -752,15 +752,24 @@ struct HttpContext { /* CONNECT/Upgrade tunnels allow half-open: the peer finishing its * writable side ends the JS socket's readable side ('end' event) but * the server can keep writing until it ends the socket itself, like - * Node's http server (allowHalfOpen: true). This includes an accepted - * Upgrade whose body never completed (HTTP_NODE_TUNNEL_AFTER_BODY): the - * EOF ends the upgrade socket, exactly like Node's UpgradeStream. */ - if (httpResponseData->isConnectRequest || (httpResponseData->state & HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY)) { + * Node's http server (allowHalfOpen: true). */ + if (httpResponseData->isConnectRequest) { if (httpResponseData->socketData && httpContextData->onSocketData) { httpContextData->onSocketData(httpResponseData->socketData, SSL, s, "", 0, true); } return s; } + /* An accepted Upgrade whose body never completed is not a tunnel yet: + * Node's UpgradeStream wraps a socket that still has socketOnEnd + * attached, so a mid-body FIN ends the raw socket and the + * UpgradeStream destroys with it. Staying half-open here stranded + * the response's body-read ref and the server's pending-request + * count. onClose() runs the tunnel-after-body cleanup (socketData + * last=true + inStream last=true). */ + if (httpResponseData->state & HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY) { + asyncSocket->uncorkWithoutSending(); + return asyncSocket->close(); + } if (httpContextData->onClientError && !(httpResponseData->state & HttpResponseData::HTTP_NODE_PARSING_STOPPED) && (httpResponseData->hasBufferedPartialRequestHeaders() diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index a204e6d37259..d691d3babb5e 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3467,6 +3467,57 @@ it("server.close(cb) completes after a raw upgrade once both sockets are destroy await closed; }); +it("upgrade with a body: a mid-body client FIN closes the upgraded socket and lets the process exit", async () => { + // Node's UpgradeStream wraps a socket that still has socketOnEnd attached, so + // a client FIN before the request body completes closes the connection instead + // of staying half-open. Staying half-open stranded the server's pending-request + // accounting and the body-read ref, hanging the process. + const fixture = /* js */ ` + const http = require("node:http"); + const net = require("node:net"); + const { once } = require("node:events"); + + (async () => { + const server = http.createServer(); + let upgraded; + const gotUpgrade = new Promise(r => { upgraded = r; }); + const events = []; + server.on("upgrade", (req, socket, head) => { + events.push("upgrade"); + req.on("data", () => {}); + socket.on("end", () => events.push("end")); + socket.on("close", () => events.push("close")); + upgraded(socket); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const c = net.connect(server.address().port, "127.0.0.1"); + await once(c, "connect"); + c.write("GET / HTTP/1.1\\r\\nHost: x\\r\\nUpgrade: foo\\r\\nConnection: Upgrade\\r\\nContent-Length: 100\\r\\n\\r\\npartial"); + const upgradeSocket = await gotUpgrade; + // Half-close the client; the body (100 bytes) is never completed. + c.end(); + await once(upgradeSocket, "close"); + + await new Promise((resolve, reject) => { + server.close(err => err ? reject(err) : resolve()); + }); + console.log(JSON.stringify(events)); + })().catch(err => { console.error(err); process.exit(1); }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stderr: "pipe", + timeout: 10_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe(JSON.stringify(["upgrade", "end", "close"])); + expect(exitCode).toBe(0); +}, 15_000); + it("req.upgrade is true inside the 'connect' listener", async () => { let upgradeValue: unknown = "unset"; const { promise: sawConnect, resolve: onConnect } = Promise.withResolvers(); From c5b6e988ce10f4805d754dbf7bb8f019b9597911 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:43:36 +0000 Subject: [PATCH 02/11] fail a tunnel socket write() once the native socket is gone socket.write() inside the upgraded socket's 'end' listener ran with the JS Duplex still writable but the native handle already closed; handle.write() returned 0, the callback was stashed in #pendingCallback, and nothing ever flushed it. Node fails the write with ERR_STREAM_WRITE_AFTER_END. Also flush #pendingCallback from #onClose so a backpressured write whose drain never arrives still reports failure. Test comment trimmed and the assertion switched to a combined object per review. --- src/js/node/_http_server.ts | 12 ++++++++++++ test/js/node/http/node-http.test.ts | 30 +++++++++++++++++------------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 9ddc4ad74676..2158536b4da5 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1673,6 +1673,11 @@ const NodeHTTPServerSocket = class Socket extends NetSocket { clearTimeout(timer); this[kSocketTimeoutTimer] = undefined; } + const pendingCallback = this.#pendingCallback; + if (pendingCallback) { + this.#pendingCallback = null; + pendingCallback($ERR_STREAM_WRITE_AFTER_END()); + } // Node.js's `socketOnClose` → `abortIncoming()` only destroys requests // that are still in `state.incoming` — i.e. requests whose response has @@ -2002,6 +2007,13 @@ const NodeHTTPServerSocket = class Socket extends NetSocket { if (handle) { const flushed = handle.write(_chunk, _encoding); if (!flushed && handle.ondrain) { + // The native socket is already gone (the tunnel's 'end' fires before + // the JS Duplex is destroyed): there will be no drain to release the + // callback, so fail now like Node's socket.write() does. + if (handle.closed) { + _callback($ERR_STREAM_WRITE_AFTER_END()); + return false; + } // Streaming mode (CONNECT tunnels): wait for the native drain // callback before completing the write. this.#pendingCallback = _callback; diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index d691d3babb5e..4ee4f6bf9ecf 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3468,10 +3468,8 @@ it("server.close(cb) completes after a raw upgrade once both sockets are destroy }); it("upgrade with a body: a mid-body client FIN closes the upgraded socket and lets the process exit", async () => { - // Node's UpgradeStream wraps a socket that still has socketOnEnd attached, so - // a client FIN before the request body completes closes the connection instead - // of staying half-open. Staying half-open stranded the server's pending-request - // accounting and the body-read ref, hanging the process. + // Like Node's UpgradeStream: a FIN before the request body completes closes + // the upgraded socket instead of leaving the connection half-open. const fixture = /* js */ ` const http = require("node:http"); const net = require("node:net"); @@ -3481,13 +3479,19 @@ it("upgrade with a body: a mid-body client FIN closes the upgraded socket and le const server = http.createServer(); let upgraded; const gotUpgrade = new Promise(r => { upgraded = r; }); + let socketClosed; + const gotSocketClose = new Promise(r => { socketClosed = r; }); const events = []; server.on("upgrade", (req, socket, head) => { events.push("upgrade"); req.on("data", () => {}); - socket.on("end", () => events.push("end")); - socket.on("close", () => events.push("close")); - upgraded(socket); + socket.on("end", () => { + events.push("end"); + socket.write("late", err => events.push("write-cb:" + (err ? err.code : "ok"))); + }); + socket.on("error", err => events.push("error:" + err.code)); + socket.on("close", () => { events.push("close"); socketClosed(); }); + upgraded(); }); server.listen(0, "127.0.0.1"); await once(server, "listening"); @@ -3495,10 +3499,10 @@ it("upgrade with a body: a mid-body client FIN closes the upgraded socket and le const c = net.connect(server.address().port, "127.0.0.1"); await once(c, "connect"); c.write("GET / HTTP/1.1\\r\\nHost: x\\r\\nUpgrade: foo\\r\\nConnection: Upgrade\\r\\nContent-Length: 100\\r\\n\\r\\npartial"); - const upgradeSocket = await gotUpgrade; + await gotUpgrade; // Half-close the client; the body (100 bytes) is never completed. c.end(); - await once(upgradeSocket, "close"); + await gotSocketClose; await new Promise((resolve, reject) => { server.close(err => err ? reject(err) : resolve()); @@ -3513,9 +3517,11 @@ it("upgrade with a body: a mid-body client FIN closes the upgraded socket and le timeout: 10_000, }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout.trim()).toBe(JSON.stringify(["upgrade", "end", "close"])); - expect(exitCode).toBe(0); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify(["upgrade", "end", "write-cb:ERR_STREAM_WRITE_AFTER_END", "error:ERR_STREAM_WRITE_AFTER_END", "close"]), + stderr: expect.not.stringContaining("error"), + exitCode: 0, + }); }, 15_000); it("req.upgrade is true inside the 'connect' listener", async () => { From b9825e43b8e3f78fa497ea1bec16a42c94e56097 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:45:34 +0000 Subject: [PATCH 03/11] [autofix.ci] apply automated fixes --- test/js/node/http/node-http.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 4ee4f6bf9ecf..c894c84dfaf7 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3518,7 +3518,13 @@ it("upgrade with a body: a mid-body client FIN closes the upgraded socket and le }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ - stdout: JSON.stringify(["upgrade", "end", "write-cb:ERR_STREAM_WRITE_AFTER_END", "error:ERR_STREAM_WRITE_AFTER_END", "close"]), + stdout: JSON.stringify([ + "upgrade", + "end", + "write-cb:ERR_STREAM_WRITE_AFTER_END", + "error:ERR_STREAM_WRITE_AFTER_END", + "close", + ]), stderr: expect.not.stringContaining("error"), exitCode: 0, }); From 35a1f1666a90ffff1b9abb689a4015641fd9921a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:04:23 +0000 Subject: [PATCH 04/11] move the upgrade-body-FIN test to its own file node-http.test.ts has an unrelated pre-existing failure ('request via http proxy, issue#4295' ECONNREFUSED) in the gate environment. --- .../http/node-http-upgrade-body-fin.test.ts | 65 +++++++++++++++++++ test/js/node/http/node-http.test.ts | 63 ------------------ 2 files changed, 65 insertions(+), 63 deletions(-) create mode 100644 test/js/node/http/node-http-upgrade-body-fin.test.ts diff --git a/test/js/node/http/node-http-upgrade-body-fin.test.ts b/test/js/node/http/node-http-upgrade-body-fin.test.ts new file mode 100644 index 000000000000..e25f9c18ac55 --- /dev/null +++ b/test/js/node/http/node-http-upgrade-body-fin.test.ts @@ -0,0 +1,65 @@ +import { test, expect } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +test("upgrade with a body: a mid-body client FIN closes the upgraded socket and lets the process exit", async () => { + // Like Node's UpgradeStream: a FIN before the request body completes closes + // the upgraded socket instead of leaving the connection half-open. + const fixture = /* js */ ` + const http = require("node:http"); + const net = require("node:net"); + const { once } = require("node:events"); + + (async () => { + const server = http.createServer(); + let upgraded; + const gotUpgrade = new Promise(r => { upgraded = r; }); + let socketClosed; + const gotSocketClose = new Promise(r => { socketClosed = r; }); + const events = []; + server.on("upgrade", (req, socket, head) => { + events.push("upgrade"); + req.on("data", () => {}); + socket.on("end", () => { + events.push("end"); + socket.write("late", err => events.push("write-cb:" + (err ? err.code : "ok"))); + }); + socket.on("error", err => events.push("error:" + err.code)); + socket.on("close", () => { events.push("close"); socketClosed(); }); + upgraded(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const c = net.connect(server.address().port, "127.0.0.1"); + await once(c, "connect"); + c.write("GET / HTTP/1.1\\r\\nHost: x\\r\\nUpgrade: foo\\r\\nConnection: Upgrade\\r\\nContent-Length: 100\\r\\n\\r\\npartial"); + await gotUpgrade; + // Half-close the client; the body (100 bytes) is never completed. + c.end(); + await gotSocketClose; + + await new Promise((resolve, reject) => { + server.close(err => err ? reject(err) : resolve()); + }); + console.log(JSON.stringify(events)); + })().catch(err => { console.error(err); process.exit(1); }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stderr: "pipe", + timeout: 10_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify([ + "upgrade", + "end", + "write-cb:ERR_STREAM_WRITE_AFTER_END", + "error:ERR_STREAM_WRITE_AFTER_END", + "close", + ]), + stderr: expect.not.stringContaining("error"), + exitCode: 0, + }); +}, 15_000); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index c894c84dfaf7..a204e6d37259 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -3467,69 +3467,6 @@ it("server.close(cb) completes after a raw upgrade once both sockets are destroy await closed; }); -it("upgrade with a body: a mid-body client FIN closes the upgraded socket and lets the process exit", async () => { - // Like Node's UpgradeStream: a FIN before the request body completes closes - // the upgraded socket instead of leaving the connection half-open. - const fixture = /* js */ ` - const http = require("node:http"); - const net = require("node:net"); - const { once } = require("node:events"); - - (async () => { - const server = http.createServer(); - let upgraded; - const gotUpgrade = new Promise(r => { upgraded = r; }); - let socketClosed; - const gotSocketClose = new Promise(r => { socketClosed = r; }); - const events = []; - server.on("upgrade", (req, socket, head) => { - events.push("upgrade"); - req.on("data", () => {}); - socket.on("end", () => { - events.push("end"); - socket.write("late", err => events.push("write-cb:" + (err ? err.code : "ok"))); - }); - socket.on("error", err => events.push("error:" + err.code)); - socket.on("close", () => { events.push("close"); socketClosed(); }); - upgraded(); - }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - - const c = net.connect(server.address().port, "127.0.0.1"); - await once(c, "connect"); - c.write("GET / HTTP/1.1\\r\\nHost: x\\r\\nUpgrade: foo\\r\\nConnection: Upgrade\\r\\nContent-Length: 100\\r\\n\\r\\npartial"); - await gotUpgrade; - // Half-close the client; the body (100 bytes) is never completed. - c.end(); - await gotSocketClose; - - await new Promise((resolve, reject) => { - server.close(err => err ? reject(err) : resolve()); - }); - console.log(JSON.stringify(events)); - })().catch(err => { console.error(err); process.exit(1); }); - `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", fixture], - env: bunEnv, - stderr: "pipe", - timeout: 10_000, - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ - stdout: JSON.stringify([ - "upgrade", - "end", - "write-cb:ERR_STREAM_WRITE_AFTER_END", - "error:ERR_STREAM_WRITE_AFTER_END", - "close", - ]), - stderr: expect.not.stringContaining("error"), - exitCode: 0, - }); -}, 15_000); - it("req.upgrade is true inside the 'connect' listener", async () => { let upgradeValue: unknown = "unset"; const { promise: sawConnect, resolve: onConnect } = Promise.withResolvers(); From 3fe03ad16e2b018039d9de1f811718034d41176a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:06:32 +0000 Subject: [PATCH 05/11] [autofix.ci] apply automated fixes --- test/js/node/http/node-http-upgrade-body-fin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/http/node-http-upgrade-body-fin.test.ts b/test/js/node/http/node-http-upgrade-body-fin.test.ts index e25f9c18ac55..4d1886c4d671 100644 --- a/test/js/node/http/node-http-upgrade-body-fin.test.ts +++ b/test/js/node/http/node-http-upgrade-body-fin.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test"; +import { expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; test("upgrade with a body: a mid-body client FIN closes the upgraded socket and lets the process exit", async () => { From adb4ed394de1321719214aee3323c82a59555682 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:20:57 +0000 Subject: [PATCH 06/11] report ERR_STREAM_DESTROYED for a backpressured write flushed at close #pendingCallback is only ever stashed against a live handle; the socket closing before drain is ERR_STREAM_DESTROYED('write'), not write-after-end. --- src/js/node/_http_server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 2158536b4da5..3c22f1b6f01b 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1676,7 +1676,7 @@ const NodeHTTPServerSocket = class Socket extends NetSocket { const pendingCallback = this.#pendingCallback; if (pendingCallback) { this.#pendingCallback = null; - pendingCallback($ERR_STREAM_WRITE_AFTER_END()); + pendingCallback($ERR_STREAM_DESTROYED("write")); } // Node.js's `socketOnClose` → `abortIncoming()` only destroys requests From ffd3d4b552736ca989bc6d8c8e261a3d0767a4e4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:03:55 +0000 Subject: [PATCH 07/11] reword the tunnel-after-body onEnd comment as an invariant --- packages/bun-uws/src/HttpContext.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 19c9038ed783..3e977a704f1a 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -762,10 +762,10 @@ struct HttpContext { /* An accepted Upgrade whose body never completed is not a tunnel yet: * Node's UpgradeStream wraps a socket that still has socketOnEnd * attached, so a mid-body FIN ends the raw socket and the - * UpgradeStream destroys with it. Staying half-open here stranded - * the response's body-read ref and the server's pending-request - * count. onClose() runs the tunnel-after-body cleanup (socketData - * last=true + inStream last=true). */ + * UpgradeStream destroys with it. Staying half-open here would + * strand the response's body-read ref and the server's + * pending-request count. onClose() runs the tunnel-after-body + * cleanup (socketData last=true + inStream last=true). */ if (httpResponseData->state & HttpResponseData::HTTP_NODE_TUNNEL_AFTER_BODY) { asyncSocket->uncorkWithoutSending(); return asyncSocket->close(); From b3f3f2f5d71733e10dd57a31c16d1cc2d1f4d955 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:11:51 +0000 Subject: [PATCH 08/11] destroy the client socket in the upgrade-body-FIN fixture The client net.Socket is readOnly (writable ended, readable waiting for the server FIN) when the server-side assertions have already completed; under ASAN load the event loop can stay alive past the spawn timeout waiting for that FIN. The server-side refs are proven released by server.close() having fired. --- test/js/node/http/node-http-upgrade-body-fin.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/js/node/http/node-http-upgrade-body-fin.test.ts b/test/js/node/http/node-http-upgrade-body-fin.test.ts index 4d1886c4d671..a8bc8fb48121 100644 --- a/test/js/node/http/node-http-upgrade-body-fin.test.ts +++ b/test/js/node/http/node-http-upgrade-body-fin.test.ts @@ -31,12 +31,14 @@ test("upgrade with a body: a mid-body client FIN closes the upgraded socket and await once(server, "listening"); const c = net.connect(server.address().port, "127.0.0.1"); + c.on("error", () => {}); await once(c, "connect"); c.write("GET / HTTP/1.1\\r\\nHost: x\\r\\nUpgrade: foo\\r\\nConnection: Upgrade\\r\\nContent-Length: 100\\r\\n\\r\\npartial"); await gotUpgrade; // Half-close the client; the body (100 bytes) is never completed. c.end(); await gotSocketClose; + c.destroy(); await new Promise((resolve, reject) => { server.close(err => err ? reject(err) : resolve()); From f7f1eb639fdac43dad23d6b0db6943e6561992fe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:09:14 +0000 Subject: [PATCH 09/11] exit the upgrade-body-FIN fixture explicitly once server.close() completes server.close() resolving proves pending_requests reached zero (the ref the pre-fix half-open path stranded); the fixture then exits explicitly so ASAN CI lanes are not at the mercy of natural-exit timing. Without the fix the fixture blocks at await gotSocketClose and the spawn timeout kills it. --- test/js/node/http/node-http-upgrade-body-fin.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/js/node/http/node-http-upgrade-body-fin.test.ts b/test/js/node/http/node-http-upgrade-body-fin.test.ts index a8bc8fb48121..ef8c311598e1 100644 --- a/test/js/node/http/node-http-upgrade-body-fin.test.ts +++ b/test/js/node/http/node-http-upgrade-body-fin.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; -test("upgrade with a body: a mid-body client FIN closes the upgraded socket and lets the process exit", async () => { +test("upgrade with a body: a mid-body client FIN closes the upgraded socket and lets server.close() complete", async () => { // Like Node's UpgradeStream: a FIN before the request body completes closes // the upgraded socket instead of leaving the connection half-open. const fixture = /* js */ ` @@ -40,17 +40,20 @@ test("upgrade with a body: a mid-body client FIN closes the upgraded socket and await gotSocketClose; c.destroy(); + // server.close() only resolves once pending_requests has reached zero, + // which is the ref the pre-fix half-open path stranded. await new Promise((resolve, reject) => { server.close(err => err ? reject(err) : resolve()); }); console.log(JSON.stringify(events)); + process.exit(0); })().catch(err => { console.error(err); process.exit(1); }); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "pipe", - timeout: 10_000, + timeout: 20_000, }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ @@ -64,4 +67,4 @@ test("upgrade with a body: a mid-body client FIN closes the upgraded socket and stderr: expect.not.stringContaining("error"), exitCode: 0, }); -}, 15_000); +}, 30_000); From 723445ad86efbc10c3f7a7d0a9e87fcf7433a221 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:30:28 +0000 Subject: [PATCH 10/11] drop the bug-history clause from the server.close() comment --- test/js/node/http/node-http-upgrade-body-fin.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/js/node/http/node-http-upgrade-body-fin.test.ts b/test/js/node/http/node-http-upgrade-body-fin.test.ts index ef8c311598e1..6c5e96788d3b 100644 --- a/test/js/node/http/node-http-upgrade-body-fin.test.ts +++ b/test/js/node/http/node-http-upgrade-body-fin.test.ts @@ -40,8 +40,7 @@ test("upgrade with a body: a mid-body client FIN closes the upgraded socket and await gotSocketClose; c.destroy(); - // server.close() only resolves once pending_requests has reached zero, - // which is the ref the pre-fix half-open path stranded. + // server.close() only resolves once pending_requests has reached zero. await new Promise((resolve, reject) => { server.close(err => err ? reject(err) : resolve()); }); From bb9d44c5806194bfa86a8103e20b912eab913da7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:12:32 +0000 Subject: [PATCH 11/11] drop the #onClose pending-write flush A CONNECT tunnel's backpressured write can already have reached the kernel by the time the socket closes; erroring its callback with ERR_STREAM_DESTROYED surfaced as an unexpected error log entry in test-http-set-global-proxy-from-env-https-request.mjs and test-https-proxy-request.mjs. The _write handle.closed guard covers the write-after-native-close case this PR actually needs. --- src/js/node/_http_server.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 3c22f1b6f01b..8b4cf6742fbc 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1673,11 +1673,6 @@ const NodeHTTPServerSocket = class Socket extends NetSocket { clearTimeout(timer); this[kSocketTimeoutTimer] = undefined; } - const pendingCallback = this.#pendingCallback; - if (pendingCallback) { - this.#pendingCallback = null; - pendingCallback($ERR_STREAM_DESTROYED("write")); - } // Node.js's `socketOnClose` → `abortIncoming()` only destroys requests // that are still in `state.incoming` — i.e. requests whose response has