diff --git a/src/js/internal/net/symbols.ts b/src/js/internal/net/symbols.ts index 623f9e871c99..4fa22f342b66 100644 --- a/src/js/internal/net/symbols.ts +++ b/src/js/internal/net/symbols.ts @@ -4,4 +4,6 @@ export default { // 'secureConnect' (node parity), so internal deferrals park on this instead. kSecureConnectDone: Symbol("kSecureConnectDone"), kVerifyError: Symbol("kVerifyError"), + // net.Socket.prototype method behind the client-side `new tls.TLSSocket(socket)`. + kUpgradeClientTLS: Symbol("kUpgradeClientTLS"), }; diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 81f7b6317db9..513329ca49d6 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -42,7 +42,7 @@ import type { TLSSocket } from "node:tls"; const { kTimeout, getTimerDuration } = require("internal/timers"); const { validateFunction, validateNumber, validateAbortSignal, validatePort, validateBoolean, validateInt32, validateString } = require("internal/validators"); // prettier-ignore const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); -const { kArmHandshakeTimeout, kSecureConnectDone, kVerifyError } = require("internal/net/symbols"); +const { kArmHandshakeTimeout, kSecureConnectDone, kVerifyError, kUpgradeClientTLS } = require("internal/net/symbols"); const ArrayPrototypeIncludes = Array.prototype.includes; const ArrayPrototypeJoin = Array.prototype.join; @@ -144,6 +144,7 @@ const kConnectOptions = Symbol("connect-options"); const kAttach = Symbol("kAttach"); const kCloseRawConnection = Symbol("kCloseRawConnection"); const kupgraded = Symbol("kupgraded"); +const kStandaloneWrap = Symbol("kStandaloneWrap"); const kAdoptedTLSRaw = Symbol("kAdoptedTLSRaw"); const ksocket = Symbol("ksocket"); const khandlers = Symbol("khandlers"); @@ -299,6 +300,9 @@ function onClientHandshakeComplete(self, socket, verifyError) { self._secureEstablished = true; self[kVerifyError] = verifyError ?? null; self.alpnProtocol = socket.alpnProtocol; + // Only tls.connect() installs the onConnectSecure half; a constructor wrap gets _finishInit alone. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1810 + const connectSecure = !self[kStandaloneWrap]; // Node has no try/catch around these emits; a listener throw reaches // InternalCallbackScope as uncaughtException. reportError mirrors that // without changing Bun.connect's handshake-throw-to-error-handler contract. @@ -306,7 +310,7 @@ function onClientHandshakeComplete(self, socket, verifyError) { try { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1662-L1673 const { checkServerIdentity } = self[bunTLSConnectOptions]; - if (!verifyError && !self.isSessionReused() && typeof checkServerIdentity === "function") { + if (connectSecure && !verifyError && !self.isSessionReused() && typeof checkServerIdentity === "function") { const hostname = self.servername || self._host || "localhost"; const cert = self.getPeerCertificate(true); if (cert) { @@ -321,7 +325,8 @@ function onClientHandshakeComplete(self, socket, verifyError) { if (rejectUnauthorized ?? self._rejectUnauthorized) { self.destroy(verifyError); // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1686-L1688 - self.emit("secure", self); + // A wrap's 'secure' listeners read ssl.verifyError(), which is null once destroyed. + if (connectSecure) self.emit("secure", self); return; } } else { @@ -333,7 +338,7 @@ function onClientHandshakeComplete(self, socket, verifyError) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1697-L1698 self.secureConnecting = false; self.emit(kSecureConnectDone); - self.emit("secureConnect", verifyError); + if (connectSecure) self.emit("secureConnect", verifyError); const pendingSession = self[kpendingSession]; if (pendingSession) { self[kpendingSession] = null; @@ -1940,10 +1945,6 @@ Socket.prototype.connect = function connect(...args) { } tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; this[bunTLSConnectOptions] = tls; - let tlsSocket; - if (!connection && (tlsSocket = tls.socket)) { - connection = tlsSocket; - } } if (connection) { if ( @@ -1994,10 +1995,7 @@ Socket.prototype.connect = function connect(...args) { tls, socket: this[khandlers], }); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); + listenToUpgradedDuplex(this, connection, events); this._handle = result; } else { // upgradeTLS requires an established socket; a socket that is still @@ -2024,8 +2022,13 @@ Socket.prototype.connect = function connect(...args) { throw new Error("Invalid socket"); } } else { + // Destroying a not-yet-upgraded wrap destroys the stream under it, like Node's JSStreamSocket.doClose. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L242-L253 + const destroyConnection = () => connection.destroy(); + this.once("close", destroyConnection); // wait to be connected connection.once("connect", () => { + this.removeListener("close", destroyConnection); // The TLS socket may have been destroyed before the underlying // socket connected (e.g. tls.connect({ socket }).destroy()); don't // start a handshake on a dead socket. @@ -2045,10 +2048,7 @@ Socket.prototype.connect = function connect(...args) { tls, socket: this[khandlers], }); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); + listenToUpgradedDuplex(this, connection, events); this._handle = result; } else { this[kupgraded] = connection; @@ -2278,6 +2278,20 @@ function hasUnflushedWrites(connection) { return connection.writableLength > 0 || connection[kwriteCallback] != null; } +// The stream's 'error' becomes the TLS socket's (as in Node); unhandled, it would also swallow the 'close' below. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977 +function listenToUpgradedDuplex(self, connection, events) { + connection.on("data", events[0]); + connection.on("end", events[1]); + connection.on("drain", events[2]); + connection.on("close", events[3]); + connection.on("error", onUpgradedDuplexError.bind(self)); +} + +function onUpgradedDuplexError(err) { + if (!this.destroyed) this.destroy(err); +} + function drainOnreadTail(self, fromRead?) { if (self[kOnreadTail] === undefined) return false; if (fromRead) self[kOnreadReadRequested] = true; @@ -2360,10 +2374,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, socket: serverHandlersFor(this), isServer: true, }); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); + listenToUpgradedDuplex(this, connection, events); this[kupgraded] = connection; this._handle = result; return; @@ -2389,10 +2400,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, socket: serverHandlersFor(this), isServer: true, }); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); + listenToUpgradedDuplex(this, connection, events); this._handle = result; this.emit(kUpgradeAttached); return; @@ -2423,6 +2431,12 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, }); }; +// Client-side counterpart for `new tls.TLSSocket(socket)`: the tls.connect({ socket }) upgrade minus onConnectSecure. +Socket.prototype[kUpgradeClientTLS] = function (connection) { + this[kStandaloneWrap] = true; + Socket.prototype.connect.$call(this, { socket: connection }); +}; + Socket.prototype.read = function read(size) { if (!this.connecting && !drainOnreadTail(this, true)) { this._handle?.resume?.(); @@ -3073,11 +3087,6 @@ function internalConnect(self, options, address, port, addressType, localAddress } //TLS - let connection = self[ksocket]; - const optionsSocket = options.socket; - if (optionsSocket) { - connection = optionsSocket; - } let tls = undefined; const bunTLS = self[bunTlsSymbol]; if (typeof bunTLS === "function") { @@ -3091,10 +3100,6 @@ function internalConnect(self, options, address, port, addressType, localAddress self.servername = tls.servername; tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; self[bunTLSConnectOptions] = tls; - let tlsSocket; - if (!connection && (tlsSocket = tls.socket)) { - connection = tlsSocket; - } } self.authorized = false; self.secureConnecting = true; @@ -3221,11 +3226,6 @@ function internalConnectMultiple(context, canceled?) { } //TLS - let connection = self[ksocket]; - const contextOptionsSocket = context.options.socket; - if (contextOptionsSocket) { - connection = contextOptionsSocket; - } let tls = undefined; const bunTLS = self[bunTlsSymbol]; if (typeof bunTLS === "function") { @@ -3239,10 +3239,6 @@ function internalConnectMultiple(context, canceled?) { self.servername = tls.servername; tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; self[bunTLSConnectOptions] = tls; - let tlsSocket; - if (!connection && (tlsSocket = tls.socket)) { - connection = tlsSocket; - } } self.authorized = false; self.secureConnecting = true; diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index fcfbd5824864..ba7bac4d57fb 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -22,7 +22,7 @@ const { } = require("internal/validators"); const { Server: NetServer, Socket: NetSocket } = net; -const { kArmHandshakeTimeout, kSecureConnectDone, kVerifyError } = require("internal/net/symbols"); +const { kArmHandshakeTimeout, kSecureConnectDone, kVerifyError, kUpgradeClientTLS } = require("internal/net/symbols"); const getBundledRootCertificates = $newCppFunction("NodeTLS.cpp", "getBundledRootCertificates", 1); const getExtraCACertificates = $newCppFunction("NodeTLS.cpp", "getExtraCACertificates", 1); @@ -783,15 +783,6 @@ function TLSSocket(socket?, options?) { if (ALPNProtocols) { convertALPNProtocols(ALPNProtocols, this); } - - if (isNetSocketOrDuplex && !this.isServer) { - this._handle = socket; - // keep compatibility with http2-wrapper or other places that try to grab JSStreamSocket in node.js, with here is just the TLSSocket - this._handle._parentWrap = this; - } - // For the server wrap, _handle is assigned the upgraded TLS handle by the - // server-upgrade method below; leaving it unset until then means a synchronous - // teardown during upgradeTLS won't call close() on the bare net.Socket. } // Internal path: keep the per-digest cache (the user-facing constructors, // createSecureContext() and new tls.SecureContext(), own theirs exclusively). @@ -807,12 +798,16 @@ function TLSSocket(socket?, options?) { this[kcheckServerIdentity] = checkServerIdentityOption || checkServerIdentity; this[ksession] = options.session || null; - // `new tls.TLSSocket(socket, { isServer: true })`: drive the server-side TLS - // handshake over the provided socket via net.ts's native upgrade path (reaches - // the module-private kupgraded + the shared ServerHandlers). Client-side wraps - // go through the connect path elsewhere. - if (isNetSocketOrDuplex && this.isServer) { - this[Symbol.for("::bunUpgradeServerTLS::")](socket, this[buntls](null, null)); + // Both upgrades live in net.ts (module-private state); _handle stays unset until one hands back the TLS handle. + if (isNetSocketOrDuplex) { + if (isServer) { + this[Symbol.for("::bunUpgradeServerTLS::")](socket, this[buntls](null, null)); + } else { + this[kUpgradeClientTLS](socket); + // http2-wrapper reads `new TLSSocket(new PassThrough())._handle._parentWrap.constructor` as its JSStreamSocket. + const handle = this._handle; + if (handle) handle._parentWrap = this; + } } } $toClass(TLSSocket, "TLSSocket", NetSocket); @@ -871,8 +866,8 @@ TLSSocket.prototype._destroySSL = function _destroySSL() { }; TLSSocket.prototype._start = function _start() { - // some frameworks uses this _start internal implementation is suposed to start TLS handshake/connect - this.connect(); + // Node sends a constructor wrap's ClientHello here (the mysql driver calls it); ours went out in the constructor. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1110-L1129 }; TLSSocket.prototype._final = function _final(callback) { @@ -1117,7 +1112,6 @@ TLSSocket.prototype[buntls] = function (port, host) { servername = host && !net.isIP(host) ? host : ""; } return { - socket: this._handle, ALPNProtocols: this.ALPNProtocols, checkServerIdentity: this[kcheckServerIdentity], session: this[ksession], diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index af9472e45196..a93e338f4d9e 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -245,18 +245,18 @@ impl UpgradedDuplex { let buffer = match bun_jsc::array_buffer::BinaryType::Buffer.to_js(data, &global) { Ok(b) => b, Err(err) => { - (self.handlers.on_error)(self.handlers.ctx, global.take_exception(err)); + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); return; } }; buffer.ensure_still_alive(); if let Err(err) = write_or_end.call(&global, duplex, &[buffer]) { - (self.handlers.on_error)(self.handlers.ctx, global.take_exception(err)); + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); } } else { if let Err(err) = write_or_end.call(&global, duplex, &[JSValue::NULL]) { - (self.handlers.on_error)(self.handlers.ctx, global.take_exception(err)); + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); } } } diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index fa62c2de5ebc..4bba024d3828 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { once } from "events"; +import { readFileSync } from "fs"; import { bunEnv, bunExe, tls as COMMON_CERT_, isASAN } from "harness"; import https from "https"; import net from "net"; @@ -1426,3 +1427,258 @@ describe("throwing 'secureConnect' listener", () => { expect(exitCode).toBe(0); }); }); + +describe("new tls.TLSSocket(socket) on the client side", () => { + // The STARTTLS client shape: the caller holds a connected socket and wraps + // it itself instead of going through tls.connect({ socket }). Node wraps the + // handle in the constructor; the handshake result arrives as 'secure' and + // the verification verdict through ssl.verifyError(). 'secureConnect' and + // the hostname check belong to tls.connect()'s onConnectSecure, which a + // constructor wrap never gets. Verified against node v26.3.0. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1081-L1108 + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1810 + const fixturesDir = join(import.meta.dir, "fixtures"); + // events.once() would reject on the 'error' these sockets are expected to + // emit on their way to 'close'. + const closed = (socket: net.Socket) => new Promise(resolve => socket.once("close", () => resolve())); + + async function echoServer(options: tls.TlsOptions) { + const server = tls.createServer(options, socket => { + socket.on("data", data => socket.write(`echo:${data}`)); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return server; + } + + async function connectedRawSocket(server: tls.Server) { + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + await once(raw, "connect"); + return raw; + } + + it("a write right after the wrap goes out over TLS", async () => { + const server = await echoServer(COMMON_CERT_); + try { + const raw = await connectedRawSocket(server); + const socket = new TLSSocket(raw, { isServer: false, rejectUnauthorized: false }); + const events: string[] = []; + socket.on("secure", () => events.push("secure")); + socket.on("secureConnect", () => events.push("secureConnect")); + const writeReturned = socket.write("ping"); + const [reply] = await once(socket, "data"); + expect({ + writeReturned, + reply: String(reply), + events, + protocol: socket.getProtocol(), + authorized: socket.authorized, + verifyError: (socket as any).ssl.verifyError().code, + wrapsTheRawSocket: (socket as any)._handle !== raw, + }).toEqual({ + writeReturned: true, + reply: "echo:ping", + events: ["secure"], + protocol: "TLSv1.3", + authorized: false, + verifyError: "DEPTH_ZERO_SELF_SIGNED_CERT", + wrapsTheRawSocket: true, + }); + socket.destroy(); + await closed(socket); + } finally { + server.close(); + } + }); + + it("_start() is accepted after the wrap (how the mysql driver starts TLS)", async () => { + const server = await echoServer(COMMON_CERT_); + try { + const raw = await connectedRawSocket(server); + const socket = new TLSSocket(raw, { rejectUnauthorized: false }); + (socket as any)._start(); + await once(socket, "secure"); + socket.write("ping"); + const [reply] = await once(socket, "data"); + expect(String(reply)).toBe("echo:ping"); + socket.destroy(); + await closed(socket); + } finally { + server.close(); + } + }); + + it("rejectUnauthorized refuses an untrusted server once, without a 'secure' event", async () => { + const server = await echoServer(COMMON_CERT_); + try { + const raw = await connectedRawSocket(server); + const socket = new TLSSocket(raw, { rejectUnauthorized: true }); + const events: string[] = []; + socket.on("secure", () => events.push("secure")); + // Installed by the constructor ahead of any user listener, so a wrap + // whose owner listens to nothing is not an uncaught exception. + socket.on("_tlsError", (err: NodeJS.ErrnoException) => events.push(`_tlsError ${err.code}`)); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}`)); + await closed(socket); + expect({ events, authorizationError: socket.authorizationError }).toEqual({ + events: ["_tlsError DEPTH_ZERO_SELF_SIGNED_CERT", "error DEPTH_ZERO_SELF_SIGNED_CERT"], + authorizationError: "DEPTH_ZERO_SELF_SIGNED_CERT", + }); + } finally { + server.close(); + } + }); + + it("verifies the chain but, unlike tls.connect({ socket }), not the hostname", async () => { + // agent1's certificate names no host at all, so only tls.connect()'s + // onConnectSecure has something to object to. + const ca = readFileSync(join(fixturesDir, "ca1-cert.pem")); + const server = await echoServer({ + key: readFileSync(join(fixturesDir, "agent1-key.pem")), + cert: readFileSync(join(fixturesDir, "agent1-cert.pem")), + }); + try { + const wrapped = new TLSSocket(await connectedRawSocket(server), { ca, rejectUnauthorized: true }); + await once(wrapped, "secure"); + wrapped.write("ping"); + const [reply] = await once(wrapped, "data"); + + const connected = tls.connect({ socket: await connectedRawSocket(server), ca, rejectUnauthorized: true }); + const connectedClosed = closed(connected); + const [connectError] = (await once(connected, "error")) as [NodeJS.ErrnoException]; + await connectedClosed; + + expect({ + reply: String(reply), + authorized: wrapped.authorized, + verifyError: (wrapped as any).ssl.verifyError(), + connectError: connectError.code, + }).toEqual({ + reply: "echo:ping", + authorized: true, + verifyError: null, + connectError: "ERR_TLS_CERT_ALTNAME_INVALID", + }); + wrapped.destroy(); + await closed(wrapped); + } finally { + server.close(); + } + }); + + it("wraps a generic Duplex, talking to a server-side wrap over an in-memory pair", async () => { + const makeSide = (peer: () => Duplex) => + new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + peer().push(chunk); + callback(); + }, + final(callback) { + peer().push(null); + callback(); + }, + }); + const clientSide: Duplex = makeSide(() => serverSide); + const serverSide: Duplex = makeSide(() => clientSide); + + const secure: string[] = []; + const server = new TLSSocket(serverSide, { isServer: true, ...COMMON_CERT_ }); + server.on("secure", () => secure.push("server")); + server.on("data", data => server.write(`pong ${data}`)); + server.on("end", () => server.end()); + + const client = new TLSSocket(clientSide, { rejectUnauthorized: false }); + client.on("secure", () => { + secure.push("client"); + // Unlike the fd-adopting wrap above, this only exercises the stream + // engine once the handshake is done; its pre-handshake buffering is a + // separate matter shared with tls.connect({ socket: duplex }). + client.write("one"); + }); + const exchange: string[] = []; + client.on("data", data => { + exchange.push(String(data)); + if (exchange.length === 1) client.write("two"); + else client.end(); + }); + await once(client, "close"); + expect({ secure: secure.sort(), exchange }).toEqual({ + secure: ["client", "server"], + exchange: ["pong one", "pong two"], + }); + }); + + it("the http2-wrapper JSStreamSocket probe still resolves and exits quietly", async () => { + // http2-wrapper (and so got) runs this at import time; the wrap it leaves + // behind handshakes against its own PassThrough and must fail quietly. + const script = ` + const { TLSSocket } = require("node:tls"); + const { PassThrough } = require("node:stream"); + const JSStreamSocket = new TLSSocket(new PassThrough())._handle._parentWrap.constructor; + process.on("exit", () => console.log(typeof JSStreamSocket)); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "function\n", stderr: "", exitCode: 0 }); + }); + + it.each([ + ["an unconnected net.Socket", () => new net.Socket()], + ["a Duplex", () => new Duplex()], + ])("destroy() straight after wrapping %s destroys both and emits 'close'", async (_name, makeStream) => { + // Closing Node's wrap destroys the stream underneath it (JSStreamSocket.doClose). + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L242-L253 + const stream = makeStream(); + const socket = new TLSSocket(stream); + socket.destroy(); + await closed(socket); + expect({ socketDestroyed: socket.destroyed, streamDestroyed: stream.destroyed }).toEqual({ + socketDestroyed: true, + streamDestroyed: true, + }); + }); + + describe("an error of the wrapped stream is the TLS socket's error", () => { + // Node routes the stream's 'error' to the TLS socket (JSStreamSocket + // re-emits it, _init forwards it) and tears the wrap down on its close. + // 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 + // A Duplex without _read() errors itself as soon as the engine starts + // reading it; one with _read() but no _write() throws from inside the + // engine's own write of the ClientHello instead, so that exception has to + // come back out as a plain error value. + const unreadable = () => new Duplex(); + const unwritable = () => new Duplex({ read() {} }); + const cases: [name: string, makeStream: () => Duplex, wrap: (stream: Duplex) => TLSSocket][] = [ + ["client wrap of a stream that errors", unreadable, stream => new TLSSocket(stream)], + [ + "server wrap of a stream that errors", + unreadable, + stream => new TLSSocket(stream, { isServer: true, ...COMMON_CERT_ }), + ], + [ + "tls.connect({ socket }) over a stream that errors", + unreadable, + stream => tls.connect({ socket: stream, rejectUnauthorized: false }), + ], + ["client wrap of a stream whose write() throws", unwritable, stream => new TLSSocket(stream)], + [ + "tls.connect({ socket }) over a stream whose write() throws", + unwritable, + stream => tls.connect({ socket: stream, rejectUnauthorized: false }), + ], + ]; + it.each(cases)("%s", async (_name, makeStream, wrap) => { + const stream = makeStream(); + const socket = wrap(stream); + const errors: string[] = []; + socket.on("error", (err: NodeJS.ErrnoException) => errors.push(err.code!)); + await closed(socket); + expect({ errors, streamDestroyed: stream.destroyed }).toEqual({ + errors: ["ERR_METHOD_NOT_IMPLEMENTED"], + streamDestroyed: true, + }); + }); + }); +});