diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 27fa8b16ffd0..904facfe16b6 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -874,8 +874,10 @@ function SocketEmitEndNT(self, _err?) { } // A write that was waiting on the native drain can never complete once the // socket is gone - fail it so 'finish'/destroy are not stuck behind it. + // kclosed, not _err: a native close can carry no error. A half-open 'end' + // also lands here, and its write stays parked because it can still drain. const pendingWrite = self[kwriteCallback]; - if (pendingWrite && (self.destroyed || _err)) { + if (pendingWrite && (self[kclosed] || self.destroyed)) { self[kwriteCallback] = null; pendingWrite(_err ?? $ERR_SOCKET_CLOSED()); } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 707474890f20..a672381930ae 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2685,3 +2685,85 @@ describe("pauseOnConnect", () => { } }); }); + +it("an accepted socket emits 'close' when a write is the first to see the peer's reset", async () => { + // A send() that fails with the reset consumes the socket error, so the loop then sees a + // plain hangup and the native close carries no error. With the rest of the write still + // waiting for a drain, the socket emitted 'end' and nothing else: no write callback, no + // 'error', no 'close', and the server counted it forever. + // + // The server is a child process so that it can stop polling: it reports the accepted + // socket, then blocks on stdin until this process has reset the connection. + // + // A socket that never closes gives no event to wait for, so a second connection asks. + // Its handshake takes several turns of the server's loop, and the reset socket closes in + // the first of them or not at all. The server reports when the second connection arrives. + const serverScript = ` + const tls = require("node:tls"); + const fs = require("node:fs"); + const events = []; + let accepted; + const server = tls.createServer(${JSON.stringify(COMMON_CERT)}, socket => { + if (accepted) { + server.getConnections((err, connections) => { + console.log(JSON.stringify({ events, connections })); + // Also the first socket, so that this process exits when it never closed. + accepted.destroy(); + socket.destroy(); + server.close(); + }); + return; + } + accepted = socket; + socket.on("error", () => events.push("error")); + socket.on("close", hadError => events.push("close:" + hadError)); + socket.resume(); + fs.writeSync(1, "accepted\\n"); + fs.readSync(0, Buffer.alloc(1)); + // More than the TLS layer takes once the wire rejects a record, so the rest is parked. + socket.write(Buffer.alloc(1 << 20)); + fs.writeSync(1, "wrote\\n"); + }); + server.listen(0, "127.0.0.1", () => fs.writeSync(1, "port=" + server.address().port + "\\n")); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", serverScript], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + // Drain stderr while stdout is scanned, so a child that logs a lot cannot block on it. + const stderrText = proc.stderr.text(); + let stdout = ""; + let raw: net.Socket | undefined; + let probe: net.Socket | undefined; + let reset = false; + for await (const chunk of proc.stdout) { + stdout += Buffer.from(chunk).toString(); + const port = /port=(\d+)/.exec(stdout); + if (port && !raw) { + raw = net.connect(Number(port[1]), "127.0.0.1"); + raw.on("error", () => {}); + connect({ socket: raw, rejectUnauthorized: false }).on("error", () => {}); + } + if (raw && !reset && stdout.includes("accepted\n")) { + reset = true; + raw.resetAndDestroy(); + proc.stdin.write("x"); + proc.stdin.flush(); + } + if (port && !probe && stdout.includes("wrote\n")) { + probe = connect({ port: Number(port[1]), host: "127.0.0.1", rejectUnauthorized: false }); + probe.on("error", () => {}); + } + } + const [stderr, exitCode] = await Promise.all([stderrText, proc.exited]); + probe?.destroy(); + expect(stderr).toBe(""); + const lines = stdout.trim().split("\n"); + // The one connection left is the second one. + expect(JSON.parse(lines[lines.length - 1])).toEqual({ events: ["error", "close:true"], connections: 1 }); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); +});