From f7c434e0cb7eed9e98db10fc772eceec820d3dc5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:52:03 +0000 Subject: [PATCH 01/11] node:net: count bytesRead on the native socket handle net.Socket.bytesRead was a plain JS property that only the JS data handlers added to. When a native consumer owns the socket (a node:http2 session), on_data hands the bytes to that consumer and returns before the JS handler runs, so the count stayed at 0. The onread data handler never added to it either. Add a bytes_read counter to the native socket next to bytes_written, increase it in on_data before any consumer sees the data, and expose it as a bytesRead getter on TCPSocket and TLSSocket. net.Socket.bytesRead is now a prototype getter that reads the handle, like node, and keeps the last value once the handle is detached. The raw half of a TLS upgrade inherits the count of the wrapper it replaces. An onread socket leaves out the bytes the handle read that its callback has not received yet, so the count advances one slice per callback like node, whose handle reads at most one onread buffer per callback. Fixes #42302 --- packages/bun-types/bun.d.ts | 6 ++ src/js/node/net.ts | 33 +++++++++-- src/runtime/node/node_net_binding.rs | 1 + src/runtime/socket/Listener.rs | 5 ++ src/runtime/socket/socket_body.rs | 18 ++++++ src/runtime/socket/sockets.classes.ts | 3 + test/js/node/http2/node-http2.test.js | 48 ++++++++++++++++ test/js/node/net/node-net.test.ts | 81 +++++++++++++++++++++++++++ 8 files changed, 189 insertions(+), 6 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index a8633057bc40..90296818c215 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6914,6 +6914,12 @@ declare module "bun" { */ readonly bytesWritten: number; + /** + * The total number of bytes received on the socket since it was established. + * On a TLS socket this counts the decrypted bytes. + */ + readonly bytesRead: number; + /** * Alias for `socket.end()`. Allows the socket to be used with `using` declarations * for automatic resource management. diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 3fe850d638e4..85d73f03cc87 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -138,6 +138,7 @@ const { owner_symbol } = require("internal/async_hooks").symbols; // native Listener reachable via accepted socket handles (see a93d2fa48e). const kServerSocket = Symbol("kServerSocket"); const kBytesWritten = Symbol("kBytesWritten"); +const kBytesRead = Symbol("kBytesRead"); const bunTLSConnectOptions = Symbol.for("::buntlsconnectoptions::"); // tls.Server exposes its native SecureContext constructor through this key so // the SNI dispatch (below) can recognize a raw native context the way Node's @@ -188,6 +189,10 @@ function onUpgradeWriteClose(callback) { } const kUpgradeAttached = Symbol("kUpgradeAttached"); const kOnreadTail = Symbol("kOnreadTail"); +// Bytes the handle read that the onread callback has not received yet: the +// tail plus the rest of the buffer deliver() is slicing. bytesRead leaves them +// out, because node's handle reads at most one onread buffer per callback. +const kOnreadUndelivered = Symbol("kOnreadUndelivered"); const kOnreadDraining = Symbol("kOnreadDraining"); const kOnreadBuffer = Symbol("kOnreadBuffer"); const kOnreadPendingEnd = Symbol("kOnreadPendingEnd"); @@ -262,8 +267,13 @@ function closeAdoptedTLSRawNowNT(handle, self, isException) { handle.close(onSocketHandleClosed); setImmediate(emitCloseNT, self, isException); } +function handleBytesRead(self, handle) { + return handle.bytesRead - (self[kOnreadUndelivered] || 0); +} function detachSocket(self) { if (!self) self = this; + const handle = self._handle; + if (handle) self[kBytesRead] = handleBytesRead(self, handle); self._handle = null; } function destroyNT(self, err) { @@ -479,7 +489,6 @@ const SocketHandlers: SocketHandler = { if (!self) return; self._unrefTimer(); - self.bytesRead += buffer.length; pushDataToSocket(self, socket, buffer); }, drain(socket) { @@ -799,7 +808,6 @@ const ServerHandlers: SocketHandler = { if (!self) return; self._unrefTimer(); - self.bytesRead += buffer.length; pushDataToSocket(self, socket, buffer); }, keylog(socket, line) { @@ -1313,7 +1321,6 @@ const SocketHandlers2: SocketHandler server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index b8b47517551c..49c0e7cd9ed4 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -645,6 +645,7 @@ impl Listener { server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -691,6 +692,7 @@ impl Listener { server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -1257,6 +1259,7 @@ impl Listener { ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -1344,6 +1347,7 @@ impl Listener { ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -1581,6 +1585,7 @@ fn connect_finish( ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(crate::socket::NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 8239988b3ae6..4ac4537161b4 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -326,6 +326,9 @@ pub struct NewSocket { pub(crate) server_name: JsCell>>, pub(crate) buffered_data_for_node_net: JsCell>, pub(crate) bytes_written: Cell, + /// Payload bytes this wrapper delivered through `on_data` (plaintext on a + /// TLS view). Node's `socket.bytesRead` reads the handle's counter. + pub(crate) bytes_read: Cell, pub(crate) native_callback: JsCell, /// `upgradeTLS` produces two `TLSSocket` wrappers over one @@ -2203,6 +2206,10 @@ impl NewSocket { if this.socket.get().is_detached() { return Ok(()); } + // Counted before any consumer sees the bytes, so `bytesRead` is + // right whether a native consumer (an h2 session) or the JS `data` + // handler takes them. + this.bytes_read.set(this.bytes_read.get() + data.len() as u64); if this.native_callback.get().on_data(data)? { return Ok(()); } @@ -3342,6 +3349,11 @@ impl NewSocket { ) } + #[bun_jsc::host_fn(getter)] + pub(crate) fn get_bytes_read(this: &Self, _global: &JSGlobalObject) -> JSValue { + JSValue::js_number(this.bytes_read.get() as f64) + } + /// In-place TCP→TLS upgrade. The underlying `us_socket_t` is /// `adoptTLS`'d into the per-VM TLS group with a fresh (or /// SecureContext-shared) `SSL_CTX*`. Returns `[raw, tls]` — two @@ -3550,6 +3562,7 @@ impl NewSocket { ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: JsCell::new(Vec::new()), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -3654,6 +3667,10 @@ impl NewSocket { ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: JsCell::new(Vec::new()), bytes_written: Cell::new(0), + // Same fd as the retired TCP wrapper: the bytes read before the + // upgrade stay with the raw view, like node's TCP handle under a + // TLSWrap. + bytes_read: Cell::new(this.bytes_read.get()), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), @@ -4688,6 +4705,7 @@ pub fn js_upgrade_duplex_to_tls( ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: JsCell::new(Vec::new()), bytes_written: Cell::new(0), + bytes_read: Cell::new(0), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), verify_error: JsCell::new(None), diff --git a/src/runtime/socket/sockets.classes.ts b/src/runtime/socket/sockets.classes.ts index 854e00ffebed..9257005ecae1 100644 --- a/src/runtime/socket/sockets.classes.ts +++ b/src/runtime/socket/sockets.classes.ts @@ -107,6 +107,9 @@ function generate(ssl) { bytesWritten: { getter: "getBytesWritten", }, + bytesRead: { + getter: "getBytesRead", + }, fd: { getter: "getFD", }, diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 7cf5c3e7ab3a..40759f2edb59 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -6329,3 +6329,51 @@ describe("frames issued from inside a user-supplied Duplex transport's _write", }, ); }); + +// The h2 session consumes the socket natively, so the JS 'data' handler of +// net.Socket never runs. bytesRead has to come from the handle, like node. +describe.each(["h2c", "h2"])("%s session.socket.bytesRead", transport => { + it("counts the bytes the peer wrote, on the client and on the server", async () => { + const secure = transport === "h2"; + const server = secure ? http2.createSecureServer({ ...TLS_CERT, allowHTTP1: false }) : http2.createServer(); + let serverSocket; + // The client's SETTINGS ack is its last frame. The server has read every byte the client + // wrote once it acked the server's settings ('localSettings'). + const serverAcked = new Promise(resolve => { + server.on("session", session => { + serverSocket = session.socket; + session.once("localSettings", resolve); + }); + }); + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("hello world from h2"); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const url = `${secure ? "https" : "http"}://127.0.0.1:${server.address().port}`; + const client = http2.connect(url, secure ? TLS_OPTIONS : undefined); + try { + const req = client.request({ ":path": "/" }); + req.resume(); + await new Promise((resolve, reject) => { + req.on("end", resolve); + req.on("error", reject); + }); + await serverAcked; + // The response reached 'end', so the client read every byte the server wrote. Under TLS + // both counts are of decrypted bytes, which is what node's TLSWrap reports too. + expect({ + clientRead: client.socket.bytesRead, + serverRead: serverSocket.bytesRead, + }).toEqual({ + clientRead: serverSocket.bytesWritten, + serverRead: client.socket.bytesWritten, + }); + expect(client.socket.bytesRead).toBeGreaterThan(0); + expect(serverSocket.bytesRead).toBeGreaterThan(0); + } finally { + client.close(); + server.close(); + } + }); +}); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index acf6472644af..a97d30d90fdd 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3100,3 +3100,84 @@ describe.concurrent("uncaughtException from socket listeners", () => { expect(exitCode).toBe(1); }); }); + +// bytesRead comes from the native handle, like node. The handle counts what it +// reads even when a JS 'data' handler never runs (the onread path, an h2 +// session), and the count is kept once the handle is gone. +describe("net.Socket bytesRead", () => { + async function listen(onConnection: (c: Socket) => void) { + const server = createServer(onConnection); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { server, port: (server.address() as import("node:net").AddressInfo).port }; + } + + it("equals the bytes the peer wrote and survives 'close'", async () => { + const { server, port } = await listen(c => c.end(Buffer.alloc(100_000, "x"))); + try { + const client = createConnection({ port, host: "127.0.0.1" }); + const duringData: number[] = []; + let total = 0; + client.on("data", chunk => { + total += chunk.length; + duringData.push(client.bytesRead); + }); + const [hadError] = await once(client, "close"); + // Each 'data' sees the count of every byte read so far, including the chunk it got. + expect({ hadError, total, lastDuringData: duringData.at(-1), afterClose: client.bytesRead }).toEqual({ + hadError: false, + total: 100_000, + lastDuringData: 100_000, + afterClose: 100_000, + }); + expect(client._handle).toBeNull(); + } finally { + server.close(); + } + }); + + it("onread: counts one slice per callback, and stops at a false return until resume()", async () => { + const peer = Promise.withResolvers(); + const { server, port } = await listen(c => { + c.write(Buffer.alloc(1024, "a")); + peer.resolve(c); + }); + let client: Socket | undefined; + try { + const seen: number[] = []; + const paused = Promise.withResolvers(); + const done = Promise.withResolvers(); + client = createConnection({ + port, + host: "127.0.0.1", + onread: { + buffer: Buffer.alloc(256), + callback(n: number) { + seen.push(client!.bytesRead); + if (seen.length === 1) { + paused.resolve(); + return false; + } + if (seen.length === 5) done.resolve(); + return true; + }, + }, + }); + client.on("error", done.reject); + await paused.promise; + // The peer's second write lands in the handle while the callback is stopped. + const serverSocket = await peer.promise; + await new Promise(resolve => serverSocket.end(Buffer.alloc(16, "b"), () => resolve())); + for (let i = 0; i < 4; i++) await new Promise(resolve => setImmediate(resolve)); + // node v26.3.0: the handle reads at most one onread buffer per callback, so the + // count is the bytes the callback has received, not the bytes in the kernel. + expect({ seen, whilePaused: client.bytesRead }).toEqual({ seen: [256], whilePaused: 256 }); + client.resume(); + await done.promise; + await once(client, "close"); + expect({ seen, afterClose: client.bytesRead }).toEqual({ seen: [256, 512, 768, 1024, 1040], afterClose: 1040 }); + } finally { + client?.destroy(); + server.close(); + } + }); +}); From cb207fd52fbb13bcbb243b27a3f8e93c85ad043e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:03:00 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- src/runtime/socket/socket_body.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 4ac4537161b4..2af24a2aab7e 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -2209,7 +2209,8 @@ impl NewSocket { // Counted before any consumer sees the bytes, so `bytesRead` is // right whether a native consumer (an h2 session) or the JS `data` // handler takes them. - this.bytes_read.set(this.bytes_read.get() + data.len() as u64); + this.bytes_read + .set(this.bytes_read.get() + data.len() as u64); if this.native_callback.get().on_data(data)? { return Ok(()); } From 4c1a605eeef6a477c8b3c48ca474524187e8ce4d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:32:40 +0000 Subject: [PATCH 03/11] node:net: derive the undelivered onread bytes from the tail A running counter kept stale state across a reconnect. Compute the bytes the callback has not received from the tail and the chunk deliver() is slicing instead, so a fresh handle starts from the state it has. Shorten the comments. --- src/js/node/net.ts | 31 ++++++++++++++++++------------- src/runtime/socket/socket_body.rs | 10 ++-------- test/js/node/net/node-net.test.ts | 5 ++++- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 85d73f03cc87..42ce1622b854 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -189,10 +189,8 @@ function onUpgradeWriteClose(callback) { } const kUpgradeAttached = Symbol("kUpgradeAttached"); const kOnreadTail = Symbol("kOnreadTail"); -// Bytes the handle read that the onread callback has not received yet: the -// tail plus the rest of the buffer deliver() is slicing. bytesRead leaves them -// out, because node's handle reads at most one onread buffer per callback. -const kOnreadUndelivered = Symbol("kOnreadUndelivered"); +// Bytes of the chunk deliver() is slicing that the onread callback has not received yet. +const kOnreadSlicing = Symbol("kOnreadSlicing"); const kOnreadDraining = Symbol("kOnreadDraining"); const kOnreadBuffer = Symbol("kOnreadBuffer"); const kOnreadPendingEnd = Symbol("kOnreadPendingEnd"); @@ -267,8 +265,10 @@ function closeAdoptedTLSRawNowNT(handle, self, isException) { handle.close(onSocketHandleClosed); setImmediate(emitCloseNT, self, isException); } +// An onread socket leaves out what the handle read but the callback has not received: node's +// handle reads at most one onread buffer per callback. function handleBytesRead(self, handle) { - return handle.bytesRead - (self[kOnreadUndelivered] || 0); + return handle.bytesRead - (self[kOnreadTail]?.length ?? 0) - (self[kOnreadSlicing] ?? 0); } function detachSocket(self) { if (!self) self = this; @@ -1703,7 +1703,7 @@ function Socket(options?) { const self = this; this[kOnreadTail] = undefined; this[kOnreadDraining] = false; - this[kOnreadUndelivered] = 0; + this[kOnreadSlicing] = 0; // Node calls the factory once at initSocketHandle time, then once after // every callback (stream_base_commons onStreamRead): the first delivery // already has a real buffer, and a non-Uint8Array result leaves the prior @@ -1721,12 +1721,20 @@ function Socket(options?) { // native read in this loop, so the catch is per-slice. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L179 this[kOnreadDeliver] = function deliver(buffer) { + self[kOnreadSlicing] = buffer.length; + try { + deliverSlices(buffer); + } finally { + self[kOnreadSlicing] = 0; + } + }; + function deliverSlices(buffer) { let offset = 0; const total = buffer.length; while (offset < total) { const dest = self[kOnreadBuffer]; if (dest === true) { - self[kOnreadUndelivered] -= total - offset; + self[kOnreadSlicing] = 0; let ret; try { ret = onreadCallback(total - offset, true); @@ -1758,7 +1766,7 @@ function Socket(options?) { const n = MathMin(dest.length, total - offset); dest.set(buffer.subarray(offset, offset + n)); offset += n; - self[kOnreadUndelivered] -= n; + self[kOnreadSlicing] = total - offset; let ret; try { ret = onreadCallback(n, dest); @@ -1779,7 +1787,7 @@ function Socket(options?) { return; } } - }; + } // when the onread option is specified we use a different handlers object this[khandlers] = { ...SocketHandlers2, @@ -1787,7 +1795,6 @@ function Socket(options?) { const { self } = socket.data; if (!self) return; self._unrefTimer(); - self[kOnreadUndelivered] += buffer.length; const tail = self[kOnreadTail]; if (tail !== undefined) { self[kOnreadTail] = Buffer.concat([tail, buffer]); @@ -1857,9 +1864,7 @@ Object.defineProperty(Socket.prototype, "_bytesDispatched", { }, }); -// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L896-L900: the handle -// counts every byte it reads, so a native consumer of the socket (an h2 -// session) is counted too. The count moves to kBytesRead when the handle goes. +// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L896-L900 Object.defineProperty(Socket.prototype, "bytesRead", { get: function () { const handle = this._handle; diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 2af24a2aab7e..e9de9f7af1e0 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -326,8 +326,7 @@ pub struct NewSocket { pub(crate) server_name: JsCell>>, pub(crate) buffered_data_for_node_net: JsCell>, pub(crate) bytes_written: Cell, - /// Payload bytes this wrapper delivered through `on_data` (plaintext on a - /// TLS view). Node's `socket.bytesRead` reads the handle's counter. + /// Bytes delivered through `on_data` (plaintext on a TLS view). pub(crate) bytes_read: Cell, pub(crate) native_callback: JsCell, @@ -2206,9 +2205,6 @@ impl NewSocket { if this.socket.get().is_detached() { return Ok(()); } - // Counted before any consumer sees the bytes, so `bytesRead` is - // right whether a native consumer (an h2 session) or the JS `data` - // handler takes them. this.bytes_read .set(this.bytes_read.get() + data.len() as u64); if this.native_callback.get().on_data(data)? { @@ -3668,9 +3664,7 @@ impl NewSocket { ref_pollref_on_connect: Cell::new(true), buffered_data_for_node_net: JsCell::new(Vec::new()), bytes_written: Cell::new(0), - // Same fd as the retired TCP wrapper: the bytes read before the - // upgrade stay with the raw view, like node's TCP handle under a - // TLSWrap. + // Same fd as the retired TCP wrapper: its count moves to the raw view. bytes_read: Cell::new(this.bytes_read.get()), native_callback: JsCell::new(NativeCallbacks::None), twin: JsCell::new(None), diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index a97d30d90fdd..c4eee2f42f27 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3162,7 +3162,10 @@ describe("net.Socket bytesRead", () => { }, }, }); - client.on("error", done.reject); + client.on("error", err => { + paused.reject(err); + done.reject(err); + }); await paused.promise; // The peer's second write lands in the handle while the callback is stopped. const serverSocket = await peer.promise; From 7b47d1a2dde54ce8b377bb259331db8497ed7bd5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:43:48 +0000 Subject: [PATCH 04/11] node:net: drop the declined onread tail when the socket connects again The tail belongs to the handle that read it. With a new handle the count would subtract bytes the new handle never read. Add reconnect tests for a plain socket and for an onread socket with a declined tail. Fixes #42303 --- src/js/node/net.ts | 13 ++++++- test/js/node/net/node-net.test.ts | 62 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 42ce1622b854..444c3c10632a 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -265,8 +265,7 @@ function closeAdoptedTLSRawNowNT(handle, self, isException) { handle.close(onSocketHandleClosed); setImmediate(emitCloseNT, self, isException); } -// An onread socket leaves out what the handle read but the callback has not received: node's -// handle reads at most one onread buffer per callback. +// Bytes the onread callback has not received do not count: node reads one onread buffer per callback. function handleBytesRead(self, handle) { return handle.bytesRead - (self[kOnreadTail]?.length ?? 0) - (self[kOnreadSlicing] ?? 0); } @@ -660,6 +659,15 @@ function finishSocketEnd(self) { } } +// A new connection replaces the handle, and the bytes the old handle read but +// the onread callback declined go with it, as in node. +function dropOnreadTail(self) { + if (self[kOnreadBuffer] === undefined) return; + self[kOnreadTail] = undefined; + self[kOnreadPendingEnd] = false; + self[kOnreadReadRequested] = false; +} + function deferEndForOnreadTail(self) { if (self[kOnreadTail] === undefined || self.destroyed) return false; self[kOnreadPendingEnd] = true; @@ -2175,6 +2183,7 @@ Socket.prototype.connect = function connect(...args) { } this.connecting = true; + dropOnreadTail(this); const { path } = options; const pipe = !!path; diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index c4eee2f42f27..b23966bab13b 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3135,6 +3135,68 @@ describe("net.Socket bytesRead", () => { } }); + it("covers one connection of a socket that connects again, like node", async () => { + const { server, port } = await listen(c => c.end("banner")); + const s = new Socket(); + s.resume(); + try { + const seen: number[] = []; + for (let i = 0; i < 3; i++) { + s.connect(port, "127.0.0.1"); + await once(s, "close"); + seen.push(s.bytesRead); + } + // node v26.3.0: a new connection has a new handle, which starts at 0. + expect(seen).toEqual([6, 6, 6]); + } finally { + s.destroy(); + server.close(); + } + }); + + it("onread: a declined tail does not carry over to the next connection", async () => { + let connections = 0; + const { server, port } = await listen(c => c.end(++connections === 1 ? "AAAABBBBCCCC" : "xyz")); + const calls: string[] = []; + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + let s: Socket | undefined; + try { + s = createConnection({ + port, + host: "127.0.0.1", + onread: { + buffer: Buffer.alloc(4), + callback(n: number, buf: Buffer) { + calls.push(buf.toString("latin1", 0, n)); + if (calls.length === 1) { + first.resolve(); + return false; + } + second.resolve(); + }, + }, + }); + s.on("error", err => { + first.reject(err); + second.reject(err); + }); + await first.promise; + // 8 bytes sit in the tail: the handle read 12, the callback took 4. + expect(s.bytesRead).toBe(4); + s.destroy(); + await once(s, "close"); + expect(s.bytesRead).toBe(4); + s.connect(port, "127.0.0.1"); + await second.promise; + await once(s, "close"); + expect({ calls, bytesRead: s.bytesRead }).toEqual({ calls: ["AAAA", "xyz"], bytesRead: 3 }); + } finally { + s?.destroy(); + server.close(); + } + }); + it("onread: counts one slice per callback, and stops at a false return until resume()", async () => { const peer = Promise.withResolvers(); const { server, port } = await listen(c => { From adf3551a8515d46f3930937fe171f822e39536c7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:44:19 +0000 Subject: [PATCH 05/11] node:net: shorten the dropOnreadTail comment --- src/js/node/net.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 444c3c10632a..edcd26a7f093 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -659,8 +659,7 @@ function finishSocketEnd(self) { } } -// A new connection replaces the handle, and the bytes the old handle read but -// the onread callback declined go with it, as in node. +// The declined bytes belong to the handle that read them: a new connection starts without them. function dropOnreadTail(self) { if (self[kOnreadBuffer] === undefined) return; self[kOnreadTail] = undefined; From f012d70be57e5aa9813a5219382c9f336c13be7f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:58:48 +0000 Subject: [PATCH 06/11] node:net: start bytesRead at 0 when connect() replaces a live connection detach_for_reconnect reuses the wrapper for the new native socket, so its count has to start over. An onread callback that calls connect() also stops the rest of the old chunk, and the slicing state goes with the tail. --- src/js/node/net.ts | 6 ++- src/runtime/socket/socket_body.rs | 1 + test/js/node/net/node-net.test.ts | 65 +++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index edcd26a7f093..732bd176c8c0 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -663,6 +663,7 @@ function finishSocketEnd(self) { function dropOnreadTail(self) { if (self[kOnreadBuffer] === undefined) return; self[kOnreadTail] = undefined; + self[kOnreadSlicing] = 0; self[kOnreadPendingEnd] = false; self[kOnreadReadRequested] = false; } @@ -1755,7 +1756,7 @@ function Socket(options?) { // synchronously the way node's bare call does. reportError(e); } - if (self.destroyed) return; + if (self.destroyed || self.connecting) return; if (ret === false || self.isPaused()) { self[kOnreadTail] = kOnreadEmptyTail; readStop(self, self._handle); @@ -1786,7 +1787,8 @@ function Socket(options?) { // delivered, matching node's per-onStreamRead behavior. reportError(e); } - if (self.destroyed) return; + // A callback that called connect() started a new connection: the rest of this chunk is the old one's. + if (self.destroyed || self.connecting) return; if (ret === false || self.isPaused()) { const rest = buffer.subarray(offset); self[kOnreadTail] = rest.length !== 0 ? rest : kOnreadEmptyTail; diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index e9de9f7af1e0..cb03ebce6a69 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1356,6 +1356,7 @@ impl NewSocket { self.socket.set(SocketHandler::::DETACHED); self.buffered_data_for_node_net .with_mut(|b| b.clear_and_free()); + self.bytes_read.set(0); self.detach_native_callback(); old.close(uws::CloseCode::Failure); self.poll_ref.with_mut(|p| p.unref(js_loop_ctx())); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index b23966bab13b..91d2a02a4c40 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3154,6 +3154,71 @@ describe("net.Socket bytesRead", () => { } }); + it("starts at 0 when connect() replaces a live connection", async () => { + let connections = 0; + const { server, port } = await listen(c => { + c.on("error", () => {}); + c.end(++connections === 1 ? "first-banner" : "xyz"); + }); + const s = new Socket(); + s.resume(); + try { + s.connect(port, "127.0.0.1"); + const [firstChunk] = await once(s, "data"); + const firstRead = s.bytesRead; + // The wrapper is reused for the new connection, the count is not. + s.connect(port, "127.0.0.1"); + await once(s, "close"); + expect({ firstChunk: String(firstChunk), firstRead, secondRead: s.bytesRead }).toEqual({ + firstChunk: "first-banner", + firstRead: 12, + secondRead: 3, + }); + } finally { + s.destroy(); + server.close(); + } + }); + + it("onread: connect() from the callback stops the rest of the old chunk", async () => { + let connections = 0; + const { server, port } = await listen(c => { + c.on("error", () => {}); + c.end(++connections === 1 ? "AAAABBBBCCCC" : "xyz"); + }); + const calls: [string, number][] = []; + const done = Promise.withResolvers(); + let s: Socket | undefined; + try { + s = createConnection({ + port, + host: "127.0.0.1", + onread: { + buffer: Buffer.alloc(4), + callback(n: number, buf: Buffer) { + calls.push([buf.toString("latin1", 0, n), s!.bytesRead]); + if (calls.length === 1) s!.connect(port, "127.0.0.1"); + else done.resolve(); + }, + }, + }); + s.on("error", done.reject); + await done.promise; + await once(s, "close"); + // BBBB and CCCC belong to the first connection and are not delivered after connect(). + expect({ calls, bytesRead: s.bytesRead }).toEqual({ + calls: [ + ["AAAA", 4], + ["xyz", 3], + ], + bytesRead: 3, + }); + } finally { + s?.destroy(); + server.close(); + } + }); + it("onread: a declined tail does not carry over to the next connection", async () => { let connections = 0; const { server, port } = await listen(c => c.end(++connections === 1 ? "AAAABBBBCCCC" : "xyz")); From ce6dd59421d976dda78d5488777ae26042199094 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:01:53 +0000 Subject: [PATCH 07/11] node:net: test onread bytesRead over TLS and with the true sentinel --- test/js/node/net/node-net.test.ts | 69 +++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 6d2e4e565b36..b83ef616ac56 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -30,7 +30,7 @@ import { Stream, } from "node:net"; import { join } from "node:path"; -import { TLSSocket } from "node:tls"; +import { createServer as createTLSServer, connect as tlsConnect, TLSSocket } from "node:tls"; const socket_domain = tmpdirSync(); @@ -3135,8 +3135,8 @@ describe.concurrent("uncaughtException from socket listeners", () => { // reads even when a JS 'data' handler never runs (the onread path, an h2 // session), and the count is kept once the handle is gone. describe("net.Socket bytesRead", () => { - async function listen(onConnection: (c: Socket) => void) { - const server = createServer(onConnection); + async function listen(onConnection: (c: Socket) => void, kind: "net" | "tls" = "net") { + const server = kind === "tls" ? createTLSServer(tlsCert, onConnection) : createServer(onConnection); await once(server.listen(0, "127.0.0.1"), "listening"); return { server, port: (server.address() as import("node:net").AddressInfo).port }; } @@ -3340,4 +3340,67 @@ describe("net.Socket bytesRead", () => { server.close(); } }); + + // Expected values observed under node v26.3.0. A TLS count is of decrypted bytes. + it.each(["net", "tls"] as const)("onread over %s: counts each slice as it reaches the callback", async kind => { + const { server, port } = await listen(c => { + c.on("error", () => {}); + c.end("abcdefghij"); + }, kind); + let client: Socket | undefined; + try { + const seen: number[][] = []; + let delivered = 0; + const options = { + port, + host: "127.0.0.1", + onread: { + buffer: Buffer.alloc(4), + callback(n: number) { + delivered += n; + seen.push([delivered, client!.bytesRead]); + }, + }, + }; + client = + kind === "tls" ? tlsConnect({ ...options, ca: tlsCert.cert, servername: "localhost" }) : connect(options); + await once(client, "close"); + // The last slice is shorter than the buffer. + expect({ seen, afterClose: client.bytesRead }).toEqual({ + seen: [ + [4, 4], + [8, 8], + [10, 10], + ], + afterClose: 10, + }); + } finally { + client?.destroy(); + server.close(); + } + }); + + it("onread: counts a read that reaches the callback as the `true` sentinel", async () => { + const { server, port } = await listen(c => c.end("hello")); + let client: Socket | undefined; + try { + const seen: unknown[][] = []; + client = connect({ + port, + host: "127.0.0.1", + onread: { + // A factory that never returns a Uint8Array leaves no buffer to copy into. + buffer: () => null as any, + callback(n: number, buf: unknown) { + seen.push([n, buf, client!.bytesRead]); + }, + }, + }); + await once(client, "close"); + expect({ seen, afterClose: client.bytesRead }).toEqual({ seen: [[5, true, 5]], afterClose: 5 }); + } finally { + client?.destroy(); + server.close(); + } + }); }); From b80485561a909061a627a790c5f1174f766086b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:14:33 +0000 Subject: [PATCH 08/11] test: describe.each for the onread bytesRead transport cases --- test/js/node/net/node-net.test.ts | 72 ++++++++++++++++--------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index b83ef616ac56..635ca1916d64 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3342,42 +3342,44 @@ describe("net.Socket bytesRead", () => { }); // Expected values observed under node v26.3.0. A TLS count is of decrypted bytes. - it.each(["net", "tls"] as const)("onread over %s: counts each slice as it reaches the callback", async kind => { - const { server, port } = await listen(c => { - c.on("error", () => {}); - c.end("abcdefghij"); - }, kind); - let client: Socket | undefined; - try { - const seen: number[][] = []; - let delivered = 0; - const options = { - port, - host: "127.0.0.1", - onread: { - buffer: Buffer.alloc(4), - callback(n: number) { - delivered += n; - seen.push([delivered, client!.bytesRead]); + describe.each(["net", "tls"] as const)("onread over %s", kind => { + it("counts each slice as it reaches the callback", async () => { + const { server, port } = await listen(c => { + c.on("error", () => {}); + c.end("abcdefghij"); + }, kind); + let client: Socket | undefined; + try { + const seen: number[][] = []; + let delivered = 0; + const options = { + port, + host: "127.0.0.1", + onread: { + buffer: Buffer.alloc(4), + callback(n: number) { + delivered += n; + seen.push([delivered, client!.bytesRead]); + }, }, - }, - }; - client = - kind === "tls" ? tlsConnect({ ...options, ca: tlsCert.cert, servername: "localhost" }) : connect(options); - await once(client, "close"); - // The last slice is shorter than the buffer. - expect({ seen, afterClose: client.bytesRead }).toEqual({ - seen: [ - [4, 4], - [8, 8], - [10, 10], - ], - afterClose: 10, - }); - } finally { - client?.destroy(); - server.close(); - } + }; + client = + kind === "tls" ? tlsConnect({ ...options, ca: tlsCert.cert, servername: "localhost" }) : connect(options); + await once(client, "close"); + // The last slice is shorter than the buffer. + expect({ seen, afterClose: client.bytesRead }).toEqual({ + seen: [ + [4, 4], + [8, 8], + [10, 10], + ], + afterClose: 10, + }); + } finally { + client?.destroy(); + server.close(); + } + }); }); it("onread: counts a read that reaches the callback as the `true` sentinel", async () => { From b293a5455277c0bcbc677501a0a8c7687a3076d4 Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 11 Sep 2026 13:29:28 -0700 Subject: [PATCH 09/11] node:tls: test bytesRead on the sockets that a TLS upgrade wraps (#42373) Follow-up to #42304 (closed in favor of #42313). Stacked on #42313. Tests only. ### Problem - #42313 moves `bytesRead` to a counter on the native socket. Its TLS upgrade path has no test: the `raw` half of the `[raw, tls]` pair inherits the count of the handle it replaces. - On main one case of that path is wrong. A `net.Socket` with an `onread` buffer that `tls.connect({ socket })` wraps reports `bytesRead === 0`. Node v26.3.0 reports 3055, the TLS records. ### Fix - 4 tests in `test/js/node/tls/node-tls-upgrade.test.ts`. Three rows (`no reader`, `readable: false`, `an onread buffer`) check that the wrapped socket counts the TLS records: `tlsSocket.bytesRead` is 6, `raw.bytesRead` is greater. - One STARTTLS test checks both wrapped sockets. The count right after the upgrade equals the count before it (2 on the client, 8 on the server). Then it grows. - Verified on a debug build, expected values from node v26.3.0. With #42313's `src/` all 9 tests of the file pass. With main's `src/` (4b5862fd88) the `an onread buffer` row fails with `Expected: > 6, Received: 0`. ### Background - `tls.connect({ socket })` and `new tls.TLSSocket(socket, { isServer: true })` run TLS over an existing `net.Socket`. Bun's native `upgradeTLS` turns the TCP handle into a `[raw, tls]` pair over one fd. The wrapped socket keeps `raw`, which sees the ciphertext. - In node the upgrade keeps the handle, and the handle counts each read before `TLSWrap` consumes it. So `socket.bytesRead` goes on from its value before the upgrade. - `onread: { buffer, callback }` makes a socket read into `buffer` and call `callback(nread, buffer)`.
Notes **Why a separate PR.** #42313 had a CI run in progress and no human review yet. A push there restarts the run. If the tests are wanted inside #42313, cherry-pick a6cbad99a1. **After #42313 merges** I rebase this branch on main, so that it holds the one test commit only. **Why the `onread` row is 0 on main.** The `data` handler that the `onread` option installs never added to `bytesRead` (#42304 was the JS fix for that). #42265 then added an early return for the wrapped socket to that handler, before the deliver loop. #42313 counts on the handle before any JS handler runs, so the row passes there. **Probe numbers** (`tls.connect({ socket: raw })`, the server sends `"banner"`): | | node v26.3.0 | main (4b5862fd88) | #42313 (d2dd882b47) | |---|---|---|---| | `raw.bytesRead`, no `onread` | 3055 | 2813 | 2813 | | `raw.bytesRead`, `raw` has `onread` | 3055 | 0 | 2813 | | calls of the `onread` callback | 0 | 0 | 0 | STARTTLS probe (plaintext `greeting\n`, `go`, `ok\n`, then TLS in both directions). Client `raw.bytesRead`: 12 before, 12 right after the upgrade, at the end 3027 in node and 2821 in bun. Server: 2, 2, then 1720 in node and 1588 in bun. Bun 1.4.3, main and #42313 print the same numbers for this probe. Node and bun differ in the size of the handshake, so the tests compare against the plaintext count and not against a fixed number.
--- test/js/node/tls/node-tls-upgrade.test.ts | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/js/node/tls/node-tls-upgrade.test.ts b/test/js/node/tls/node-tls-upgrade.test.ts index 409028ddbea5..811d84744eb1 100644 --- a/test/js/node/tls/node-tls-upgrade.test.ts +++ b/test/js/node/tls/node-tls-upgrade.test.ts @@ -148,3 +148,75 @@ test("a STARTTLS exchange hands no TLS bytes to the 'data' listeners of the wrap server.close(); } }); + +// node's handle counts a read before TLSWrap consumes it: https://github.com/nodejs/node/blob/v26.3.0/src/stream_base-inl.h#L75-L80 +test.each([ + ["no reader", {}], + ["readable: false", { readable: false }], + ["an onread buffer", { onread: { buffer: Buffer.alloc(64), callback() {} } }], +])( + "a net.Socket with %s that tls.connect({ socket }) wraps counts the TLS records in bytesRead", + async (_, options) => { + const server = tls.createServer(certs, socket => { + socket.on("error", () => {}); + socket.end("banner"); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const raw = net.connect({ port: (server.address() as net.AddressInfo).port, host: "127.0.0.1", ...options }); + await once(raw, "connect"); + const tlsSocket = tls.connect({ socket: raw, ca: certs.cert, servername: "localhost" }); + let got = ""; + tlsSocket.on("data", data => (got += data)); + await once(tlsSocket, "close"); + expect(got).toBe("banner"); + // The TLS socket counts plaintext. The wrapped socket counts the handshake and the records. + expect(tlsSocket.bytesRead).toBe(6); + expect(raw.bytesRead).toBeGreaterThan(tlsSocket.bytesRead); + } finally { + server.close(); + } + }, +); + +// Values observed under node v26.3.0: the upgrade keeps the handle, so the count goes on. +test("bytesRead of the wrapped sockets keeps the bytes read before a STARTTLS upgrade", async () => { + const serverSide = Promise.withResolvers<{ before: number; after: number; raw: number; tls: number }>(); + const server = net.createServer(socket => { + socket.on("error", serverSide.reject); + socket.once("data", () => { + socket.write("GO", () => { + const before = socket.bytesRead; + const tlsSocket = new tls.TLSSocket(socket, { isServer: true, secureContext: tls.createSecureContext(certs) }); + const after = socket.bytesRead; + tlsSocket.on("error", serverSide.reject); + tlsSocket.on("data", data => tlsSocket.end("echo:" + data)); + tlsSocket.on("close", () => + serverSide.resolve({ before, after, raw: socket.bytesRead, tls: tlsSocket.bytesRead }), + ); + }); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const raw = net.connect({ port: (server.address() as net.AddressInfo).port, host: "127.0.0.1" }); + raw.write("STARTTLS"); + expect(String((await once(raw, "data"))[0])).toBe("GO"); + const before = raw.bytesRead; + const tlsSocket = tls.connect({ socket: raw, ca: certs.cert, servername: "localhost" }); + const after = raw.bytesRead; + tlsSocket.on("secureConnect", () => tlsSocket.write("hi")); + let got = ""; + tlsSocket.on("data", data => (got += data)); + await once(tlsSocket, "close"); + expect(got).toBe("echo:hi"); + expect({ before, after, tls: tlsSocket.bytesRead }).toEqual({ before: 2, after: 2, tls: 7 }); + expect(raw.bytesRead).toBeGreaterThan(before + tlsSocket.bytesRead); + + const peer = await serverSide.promise; + expect({ before: peer.before, after: peer.after, tls: peer.tls }).toEqual({ before: 8, after: 8, tls: 2 }); + expect(peer.raw).toBeGreaterThan(peer.before + peer.tls); + } finally { + server.close(); + } +}); From 78d96f996529acb685a0d8799830f70af747865d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:09:04 +0000 Subject: [PATCH 10/11] node:net: deliver whole onread chunks while a connect() is pending deliver() stopped after each callback when `socket.connecting` was set. That flag is also set while a connect() on a live socket waits for its lookup, and it stays set when connect() throws on a bad port. The old connection still delivers data in both cases, and every chunk lost all but its first slice. Stop only when connect() ran under the chunk that deliver() is slicing: dropOnreadTail() bumps an epoch, and deliver() compares it. kConnectDispatch() drops a tail that was declined after connect(), so a new native socket never starts with one. Also make the `bytesRead` getter enumerable, as node's protoGetter does (for...in listed it before), and point its comment at the right lines. --- src/js/node/net.ts | 17 +++++-- test/js/node/net/node-net.test.ts | 81 ++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index dfb652d411e3..e9eef77e0a58 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -191,6 +191,9 @@ const kUpgradeAttached = Symbol("kUpgradeAttached"); const kOnreadTail = Symbol("kOnreadTail"); // Bytes of the chunk deliver() is slicing that the onread callback has not received yet. const kOnreadSlicing = Symbol("kOnreadSlicing"); +// Bumped by dropOnreadTail(): deliver() stops a chunk whose connection was replaced under it. `connecting` does +// not say that: the old connection keeps delivering while a connect() waits for its lookup. +const kOnreadEpoch = Symbol("kOnreadEpoch"); const kOnreadDraining = Symbol("kOnreadDraining"); const kOnreadBuffer = Symbol("kOnreadBuffer"); const kOnreadPendingEnd = Symbol("kOnreadPendingEnd"); @@ -666,6 +669,7 @@ function dropOnreadTail(self) { if (self[kOnreadBuffer] === undefined) return; self[kOnreadTail] = undefined; self[kOnreadSlicing] = 0; + self[kOnreadEpoch]++; self[kOnreadPendingEnd] = false; self[kOnreadReadRequested] = false; } @@ -1552,6 +1556,8 @@ function kConnectPipe(self, req, address) { } function kConnectDispatch(self, req, opts) { + // doConnect swaps the native socket under a live handle: bytes declined since connect() stay with the old one. + dropOnreadTail(self); // Node's TCPWrap returns errno for sync uv_*_connect failure and defers // oncomplete; doConnect instead fires connectError inside this call. Bracket // it so connectError hands the errno back here instead of re-entering. @@ -1714,6 +1720,7 @@ function Socket(options?) { this[kOnreadTail] = undefined; this[kOnreadDraining] = false; this[kOnreadSlicing] = 0; + this[kOnreadEpoch] = 0; // Node calls the factory once at initSocketHandle time, then once after // every callback (stream_base_commons onStreamRead): the first delivery // already has a real buffer, and a non-Uint8Array result leaves the prior @@ -1741,6 +1748,7 @@ function Socket(options?) { function deliverSlices(buffer) { let offset = 0; const total = buffer.length; + const epoch = self[kOnreadEpoch]; while (offset < total) { const dest = self[kOnreadBuffer]; if (dest === true) { @@ -1758,7 +1766,7 @@ function Socket(options?) { // synchronously the way node's bare call does. reportError(e); } - if (self.destroyed || self.connecting) return; + if (self.destroyed || self[kOnreadEpoch] !== epoch) return; if (ret === false || self.isPaused()) { self[kOnreadTail] = kOnreadEmptyTail; readStop(self, self._handle); @@ -1789,8 +1797,8 @@ function Socket(options?) { // delivered, matching node's per-onStreamRead behavior. reportError(e); } - // A callback that called connect() started a new connection: the rest of this chunk is the old one's. - if (self.destroyed || self.connecting) return; + // A callback that called connect() replaced the connection: the rest of this chunk is the old one's. + if (self.destroyed || self[kOnreadEpoch] !== epoch) return; if (ret === false || self.isPaused()) { const rest = buffer.subarray(offset); self[kOnreadTail] = rest.length !== 0 ? rest : kOnreadEmptyTail; @@ -1876,8 +1884,9 @@ Object.defineProperty(Socket.prototype, "_bytesDispatched", { }, }); -// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L896-L900 +// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L939-L950 Object.defineProperty(Socket.prototype, "bytesRead", { + enumerable: true, get: function () { const handle = this._handle; return (handle ? handleBytesRead(this, handle) : this[kBytesRead]) || 0; diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 635ca1916d64..2fa09373ae70 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3141,6 +3141,25 @@ describe("net.Socket bytesRead", () => { return { server, port: (server.address() as import("node:net").AddressInfo).port }; } + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L939-L950 + it("is an enumerable getter on the prototype with no setter, like node", () => { + const s = new Socket(); + const forIn: string[] = []; + for (const key in s) forIn.push(key); + const { get, set, enumerable, configurable } = Object.getOwnPropertyDescriptor(Socket.prototype, "bytesRead")!; + expect({ + forIn: forIn.includes("bytesRead"), + own: Object.hasOwn(s, "bytesRead"), + descriptor: { get: typeof get, set: typeof set, enumerable, configurable }, + value: s.bytesRead, + }).toEqual({ + forIn: true, + own: false, + descriptor: { get: "function", set: "undefined", enumerable: true, configurable: false }, + value: 0, + }); + }); + it("equals the bytes the peer wrote and survives 'close'", async () => { const { server, port } = await listen(c => c.end(Buffer.alloc(100_000, "x"))); try { @@ -3249,9 +3268,69 @@ describe("net.Socket bytesRead", () => { } }); + it("onread: a chunk that arrives while connect() waits for its lookup is delivered whole", async () => { + const peer = Promise.withResolvers(); + const { server, port } = await listen(c => { + c.on("error", () => {}); + c.write("AAAABBBBCCCC"); + peer.resolve(c); + }); + const calls: [string, number][] = []; + const firstChunk = Promise.withResolvers(); + const done = Promise.withResolvers(); + let s: Socket | undefined; + let serverSocket: Socket | undefined; + try { + s = createConnection({ + port, + host: "127.0.0.1", + onread: { + buffer: Buffer.alloc(4), + callback(n: number, buf: Buffer) { + const slice = buf.toString("latin1", 0, n); + calls.push([slice, s!.bytesRead]); + if (slice === "CCCC") firstChunk.resolve(); + // A separate write, after the slicing of the second chunk is over. + if (slice === "DDDD") setImmediate(() => serverSocket!.write("Z")); + if (slice === "Z") done.resolve(); + }, + }, + }); + s.on("error", err => { + firstChunk.reject(err); + done.reject(err); + }); + serverSocket = await peer.promise; + await firstChunk.promise; + // The lookup never answers: the socket stays in `connecting` and the first connection stays open. + s.connect({ port, host: "never-resolved.test", lookup() {} }); + expect(s.connecting).toBe(true); + serverSocket.write("DDDDEEEEFFFF"); + await done.promise; + // node v26.3.0 reports the same slices and counts. + expect(calls).toEqual([ + ["AAAA", 4], + ["BBBB", 8], + ["CCCC", 12], + ["DDDD", 16], + ["EEEE", 20], + ["FFFF", 24], + ["Z", 25], + ]); + } finally { + s?.destroy(); + serverSocket?.destroy(); + server.close(); + } + }); + it("onread: a declined tail does not carry over to the next connection", async () => { let connections = 0; - const { server, port } = await listen(c => c.end(++connections === 1 ? "AAAABBBBCCCC" : "xyz")); + const { server, port } = await listen(c => { + // The client destroys the first connection with unread bytes, which can reset it. + c.on("error", () => {}); + c.end(++connections === 1 ? "AAAABBBBCCCC" : "xyz"); + }); const calls: string[] = []; const first = Promise.withResolvers(); const second = Promise.withResolvers(); From 3afefb2e90df46f950bd2105b6540510c8bb3cc0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:12:42 +0000 Subject: [PATCH 11/11] node:net: shorten the kOnreadEpoch comment --- src/js/node/net.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index e9eef77e0a58..b51f153f3a1f 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -191,8 +191,7 @@ const kUpgradeAttached = Symbol("kUpgradeAttached"); const kOnreadTail = Symbol("kOnreadTail"); // Bytes of the chunk deliver() is slicing that the onread callback has not received yet. const kOnreadSlicing = Symbol("kOnreadSlicing"); -// Bumped by dropOnreadTail(): deliver() stops a chunk whose connection was replaced under it. `connecting` does -// not say that: the old connection keeps delivering while a connect() waits for its lookup. +// Bumped by dropOnreadTail(), so deliver() stops a chunk whose connection was replaced under it. const kOnreadEpoch = Symbol("kOnreadEpoch"); const kOnreadDraining = Symbol("kOnreadDraining"); const kOnreadBuffer = Symbol("kOnreadBuffer");