Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3ffb451
node:tls: propagate a wrapped Duplex transport's teardown to the TLS …
robobun Sep 10, 2026
a1997bd
test(tls): keep the duplex-teardown cases in one file and assert the …
robobun Sep 10, 2026
e198698
socket: trim the comments on the duplex close path to what is load-be…
robobun Sep 10, 2026
f18e764
socket: shorten the duplex close comments
robobun Sep 10, 2026
ee2a954
node:tls: report a Duplex transport's error on the TLS socket
Sep 10, 2026
dc42eb4
node:tls: keep unread plaintext when the transport closes after a cle…
robobun Sep 10, 2026
d5e1d4b
Merge branch 'robobun/73a87144/tls-duplex-close-propagates' into robo…
cirospaciari Sep 11, 2026
0a8dc8c
node:tls: move the transport error fixture to a file, run it with bunRun
Sep 11, 2026
8e9fb07
node:tls: cut the 'error' forward comment to one line plus the node l…
Sep 11, 2026
9ac0d74
Merge branch 'main' into robobun/73a87144/tls-duplex-close-propagates
robobun Sep 11, 2026
d3c6c78
node:tls: keep unread plaintext and report the pending-handshake clos…
robobun Sep 11, 2026
72670be
node:tls: re-check the wrapped connection when the deferred destroy runs
robobun Sep 11, 2026
617f6a2
Merge remote-tracking branch 'origin/robobun/73a87144/tls-duplex-clos…
Sep 11, 2026
8e4abb5
test(tls): cover a server wrap and https.request in the transport err…
robobun Sep 11, 2026
2aeaa45
Merge remote-tracking branch 'origin/main' into robobun/fe1c7d40/tls-…
Sep 22, 2026
9098c05
test(tls): pass the key and cert to the transport error fixture throu…
Sep 22, 2026
92679b8
Merge remote-tracking branch 'origin/main' into robobun/fe1c7d40/tls-…
Sep 25, 2026
f11e55b
test(tls): run the transport error cases in one subprocess
Sep 25, 2026
e04a07a
ci: retrigger
Sep 25, 2026
880b8aa
test(tls): cover a Duplex that a tls.Server wraps, release the wraps …
Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ function onUpgradedClose(self, connection) {
function destroyWhenUpgradedCloses(self, connection) {
connection.once("close", (self[kOnUpgradedClose] = onUpgradedClose.bind(null, self, connection)));
}
// Node's wrap 'error' -> _emitTLSError. Not for a net.Socket: a listener there makes its close synthesize ECONNRESET.
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L65
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977
Comment thread
robobun marked this conversation as resolved.
Resolved
function forwardUpgradedError(self, connection) {
if (!(connection instanceof Socket)) connection.on("error", err => self._emitTLSError(err));
Comment thread
robobun marked this conversation as resolved.
Resolved
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// The wrapped socket reports nothing and closes with the TLS socket: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L676-L688
function closeWithTLSSocket(self, raw) {
const tlsSocket = raw[kAdoptedTLSRaw];
Expand Down Expand Up @@ -2216,6 +2222,7 @@ Socket.prototype.connect = function connect(...args) {
connection.on("end", events[1]);
connection.on("drain", events[2]);
connection.on("close", events[3]);
forwardUpgradedError(this, connection);
this._handle = result;
} else {
// upgradeTLS requires an established socket; a socket that is still
Expand Down Expand Up @@ -2269,6 +2276,7 @@ Socket.prototype.connect = function connect(...args) {
connection.on("end", events[1]);
connection.on("drain", events[2]);
connection.on("close", events[3]);
forwardUpgradedError(this, connection);
this._handle = result;
} else {
this[kupgraded] = connection;
Expand Down Expand Up @@ -2578,6 +2586,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection,
connection.on("end", events[1]);
connection.on("drain", events[2]);
connection.on("close", events[3]);
forwardUpgradedError(this, connection);
this[kupgraded] = connection;
this._handle = result;
return;
Expand Down Expand Up @@ -2608,6 +2617,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection,
connection.on("end", events[1]);
connection.on("drain", events[2]);
connection.on("close", events[3]);
forwardUpgradedError(this, connection);
this._handle = result;
this.emit(kUpgradeAttached);
return;
Expand Down
88 changes: 87 additions & 1 deletion test/js/node/tls/node-tls-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { heapStats } from "bun:jsc";
import { describe, expect, it } from "bun:test";
import { once } from "events";
import { writeFileSync } from "fs";
import { bunEnv, bunExe, tls as COMMON_CERT_, isASAN, nodeExe, tempDir } from "harness";
import { bunEnv, bunExe, bunRun, tls as COMMON_CERT_, isASAN, nodeExe, tempDir } from "harness";
import https from "https";
import net from "net";
import { join } from "path";
Expand Down Expand Up @@ -1592,6 +1592,92 @@ describe("a TLS socket over a Duplex transport follows that transport's teardown
});
});

describe("a TLS socket over a Duplex transport reports that transport's error", () => {
// Node re-emits the transport's 'error' on its JSStreamSocket wrap, and
// TLSSocket._init routes the wrap's error through _emitTLSError:
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L65
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977
// Without a listener on the transport, node:stream throws the error.

it.each(["client", "server", "tls.Server"])("a %s wrap listens for the transport's 'error'", side => {
const transport = new Duplex({
read() {},
write(_chunk, _encoding, callback) {
callback();
},
});
let socket: TLSSocket | undefined;
try {
if (side === "client") {
socket = tls.connect({ socket: transport, rejectUnauthorized: false });
} else if (side === "server") {
socket = new TLSSocket(transport, { isServer: true, secureContext: tls.createSecureContext(COMMON_CERT_) });
} else {
// The server makes the wrap, and the transport's close ends it.
tls.createServer(COMMON_CERT_).emit("connection", transport);
}
// Exactly one, like node's wrap: the forward to the TLS socket.
expect(transport.listenerCount("error")).toBe(1);
} finally {
socket?.destroy();
transport.destroy();
}
});

it("a transport error reaches the TLS socket and leaves the process alive", async () => {
// Out of process: with nothing listening on the transport the error is
// thrown, which takes the process down. Each line is what node v26.3.0 prints.
const fixture = join(import.meta.dir, "node-tls-duplex-transport-error-fixture.ts");
const { key, cert } = COMMON_CERT_;
const result = await bunRun(fixture, { KEY: key, CERT: cert });
expect(result).toEqual({
stdout: [
"client early: _tlsError:transport failed|error:transport failed|close:false",
"client late: _tlsError:transport failed|error:transport failed|close:false",
// A server wrap still owns its socket, so there is no 'error'.
"server early: _tlsError:transport failed|close:false",
"server late: _tlsError:transport failed|close:false",
// A tls.Server reports the '_tlsError' of its wrap as 'tlsClientError'.
"tls.Server early: tlsClientError:transport failed:TLSSocket|close:false",
"tls.Server late: tlsClientError:transport failed:TLSSocket|close:false",
].join("\n"),
stderr: "",
exitCode: 0,
signalCode: null,
});
});

it("a transport error fails an https.request over the wrap", async () => {
// The http layer listens on the TLS socket only.
let started = false;
const transport = new Duplex({
read() {},
write(_chunk, _encoding, callback) {
callback();
if (started) return;
started = true;
// The engine runs: this write is its ClientHello.
process.nextTick(() => transport.destroy(new Error("transport failed")));
},
});
const req = https.request({
host: "localhost",
path: "/",
createConnection: () => tls.connect({ socket: transport, rejectUnauthorized: false }),
});
const events: string[] = [];
const closed = Promise.withResolvers<void>();
req.on("error", err => events.push(`error:${err.message}`));
req.on("close", () => {
events.push(`close destroyed=${req.destroyed}`);
closed.resolve();
});
req.end();
await closed.promise;
expect(events).toEqual(["error:transport failed", "close destroyed=true"]);
});
});

it("delivers 'session' even when the data handler destroys the socket immediately", async () => {
// The TLS1.3 NewSessionTickets ride in the same read pass as the response
// bytes. If the parked session were only flushed after the data dispatch,
Expand Down
68 changes: 68 additions & 0 deletions test/js/node/tls/node-tls-duplex-transport-error-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading