Repository navigation
Conversation
|
Updated 1:52 AM PT - Jul 10th, 2026
❌ @robobun, your commit 9d3e74f has 1 failures in 🧪 To try this PR locally: bunx bun-pr 33382That installs a local version of the PR into your bun-33382 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR adds ChangesSocket onread implementation and tests
Sequence Diagram(s)sequenceDiagram
participant Native socket handle
participant SocketHandlersOnRead
participant onread.callback
Native socket handle->>SocketHandlersOnRead: incoming chunk
SocketHandlersOnRead->>onread.callback: deliver buffer slice and nread
onread.callback-->>SocketHandlersOnRead: return true, false, or pause
SocketHandlersOnRead->>SocketHandlersOnRead: store pending remainder or defer end
Related Issues: None referenced in the summaries. Related PRs: None referenced in the summaries. Suggested labels: Suggested reviewers: None identified from the provided information. Poem 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/js/node/net.ts`:
- Around line 1280-1286: The read loop in the net socket receive path can keep
running after user JS tears down the socket, so re-check liveness immediately
after the callback. Update the callback handling in the
`self[kBufferCb].$call(...)` path to re-validate `self.destroyed` (and related
stop conditions used by this loop) before calling `onreadNextBuffer(self)` or
continuing, so a callback that `destroy()`s, `end()`s, or `close()`s the socket
cannot be invoked again on the next slice.
In `@test/js/node/net/node-net.test.ts`:
- Line 1024: The server-side socket error handlers in the net tests are
swallowing failures, which can leave write callbacks unresolved and hide the
real cause of a hang. Update the affected `c.on("error", ...)` cases in the
pause/resume and related tests to forward the error into the existing
client-side rejection or hang-guard promise instead of discarding it, so
failures surface through the test logic tied to `secondWritten.promise` and the
other corresponding promises.
- Around line 1010-1018: The withServer helper only waits for the successful
listen callback, so listen failures can hang the test instead of rejecting.
Update withServer to use createServer and server.listen with proper error
handling so the promise rejects on the server's error event, and make sure the
cleanup in the try/finally still closes the server. Use the withServer helper
and server.listen call as the entry points for the fix.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: be5f5ad7-4f62-4386-aea5-26c82d2173a3
📒 Files selected for processing (2)
src/js/node/net.tstest/js/node/net/node-net.test.ts
Not a duplicate of #29139#29139 wires its this[khandlers] = {
...SocketHandlers2,
data(socket, buffer) {
onread.callback(buffer.length, buffer); // Bun's chunk, unbounded nread, no pause
},
};So Review feedback (73f9300)All three findings were valid and are addressed.
|
|
Pushed 5818833 for the second review pass. One of the three findings turned out to be a real data-loss bug this PR would have shipped.
|
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/node/net.ts:1494—kBufferPendingis per-connection state but is never cleared on reconnect — if the callback stopped mid-chunk (returnedfalse/ destroyed / threw) and the user then calls.connect()again on the sameSocket, theprocess.nextTickresume()scheduled byconnect()will replay the held-back bytes from the old connection to the callback (and add them tobytesRead) before any byte of the new connection arrives. Node has no equivalent — unaccepted bytes stay in the closed fd's kernel buffer and vanish. Addingself[kBufferPending] = null;toinitSocketHandle()(or to theif (this.destroyed)reset block inconnect()) would fix it.Extended reasoning...
What the bug is
kBufferPendingholds the bytes of an already-received chunk that theonreadcallback declined (it returnedfalse, threw, or destroyed the socket mid-chunk). It is state that belongs to a specific connection — those bytes came from one particular peer over one particular fd. But it is only ever assigned in three places: the constructor (line 1494,null),onreadDeliver(line 1294, the leftover subarray), andonreadFlushPending(line 1322,nullright before re-delivering). Nothing in the destroy or reconnect path touches it.initSocketHandle()(net.ts:3819–3830) is the "reset per-connection state" hook — it calls_undestroy(), clears_sockname/kclosed/kended— andSocket.prototype.connect()calls it whenever it creates a fresh handle for a reused Socket (line 1942). It does not clearkBufferPending, so the stale subarray survives into the new connection.The code path that triggers it
const s = new Socket({ onread: { buffer: Buffer.alloc(64), callback(n, b) { s.destroy(); } } }); s.connect(portA);- Peer A sends ≥128 bytes in one chunk.
SocketHandlersOnRead.data→onreadDeliver: iteration 1 copies 64 bytes, callback destroys the socket,self.destroyedis now true →stopped = true, break. Line 1294:self[kBufferPending] = chunk.subarray(64)— the remaining bytes of connection A's chunk. (Any path that setsstoppedwithoffset < totalworks:return false, throw, or destroy.) - After
'close', user callss.connect(portB).connect()(line 1733–1739) schedulesprocess.nextTick(() => { if (!this.isPaused()) this.resume(); }). Then at line 1928this.destroyedis true so_handleis nulled; at line 1940–1942 a fresh detached handle is created andinitSocketHandle(this)runs →_undestroy()clearsself.destroyed.kBufferPendingis untouched. - Next tick fires.
onreadStopnever calledDuplex.prototype.pause(), soisPaused()is false →this.resume()runs.Socket.prototype.resume(line 2098) callsonreadFlushPending(this)first. onreadFlushPending(line 1319–1325):pendingis the stale subarray from step 2, it is nulled out,pending.length > 0 && !self.destroyed(now false after_undestroy) →onreadDeliver(self, pending). Connection A's held-back bytes are copied into the user's buffer, handed to the callback, and added tobytesRead— before a single byte of connection B has been read.
Why existing code doesn't prevent it
onreadFlushPending's only guard isself.destroyed, which_undestroy()has already cleared by the time the nextTick fires.onreadStopearly-returns when destroyed and doesn't clear the pending bytes either. Nothing ties the pending buffer to the handle it came from.Node comparison and impact
Node has no JS-side equivalent of
kBufferPending: the user's buffer is theuv_buf_t, so bytes the callback declines simply stay unread in the old fd's kernel receive buffer and vanish when that fd is closed. A reconnect on the samenet.Sockettherefore starts clean. In Bun after this PR, the callback instead sees[tail of connection A][connection B], which is straight data corruption for anything parsing a protocol.The trigger is narrow — it needs
onread+ the callback stopping mid-chunk + reusing the sameSocketinstance via.connect()after destroy — so this is filed as a nit rather than a blocker. But it's new state introduced by this PR and the fix is a one-liner.Fix
Add
self[kBufferPending] = null;toinitSocketHandle()(alongside thekclosed/kendedresets), or to theif (this.destroyed) { this._handle = null; ... }block at line 1928 inSocket.prototype.connect. Either drops the stale bytes exactly as closing the old fd does in Node.
|
Pushed 4634e6f. The reconnect finding was real, and it was the worst one so far.
|
|
Pushed 35814a7. Another data-loss bug out of this round.
|
There was a problem hiding this comment.
♻️ Duplicate comments (2)
test/js/node/net/node-net.test.ts (2)
1402-1467: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
serverB's error handler swallows failures with no forwarding, unlikeserverA's documented exception.Line 1409 (
serverA) is intentionally justified with a comment explaining the client destroys mid-stream. Line 1413 (serverB) has no such justification, and no premature destroy happens againstserverBbeforeended.promiseresolves — an unexpected server error here would silently hang the test instead of surfacing a clear failure.🐛 Proposed fix
const serverB = createServer(c => { - c.on("error", () => {}); + c.on("error", err => ended.reject(err)); c.end(second); });
endedis declared later in the same test function but assigned well beforeserverB's connection listener can fire (only after reconnecting toportB), so this closure reference is safe.As per coding guidelines, "Wire EVERY failure event (
error,close,abort, process exit) to reject the awaited promise — never throw inside event callbacks."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/js/node/net/node-net.test.ts` around lines 1402 - 1467, The test’s serverB error handling is swallowing failures, which can leave ended.promise hanging instead of failing clearly. Update the node-net test around the Socket reconnect flow so serverB’s connection path forwards any unexpected error to the awaited promise/rejection path, similar to how the socket error handler rejects firstCall and ended. Keep serverA’s existing justified error suppression, but make serverB’s listener or its surrounding promise wiring surface errors from createServer, c.end(second), or the connection callback into ended.promise so the test fails deterministically.Source: Coding guidelines
1307-1366: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winServer-side TLS error swallowed without forwarding to the hang-guard promise.
c.on("error", () => {})(line 1312) discards errors from the server-side TLS socket, but unlike the mid-stream-destroy tests, nothing here destroys the client prematurely beforefirstCall/endEventsettle. An unexpected server-side error (e.g. TLS write failure) would leave the test hanging onawait firstCall.promise/await endEvent.promiseuntil the runner's generic timeout, with no diagnostic — the same failure mode already fixed elsewhere in this file for the non-TLS tests.🐛 Proposed fix
const server = tls.createServer({ cert: tlsCert.cert, key: tlsCert.key }, c => { - c.on("error", () => {}); + c.on("error", err => { + firstCall.reject(err); + endEvent.reject(err); + }); c.end(payload); // payload and FIN in one go });
firstCall/endEventare declared later in the same test function's scope but assigned before the connection listener can ever fire, so this closure reference is safe.As per coding guidelines, "Wire EVERY failure event (
error,close,abort, process exit) to reject the awaited promise — never throw inside event callbacks."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/js/node/net/node-net.test.ts` around lines 1307 - 1366, The TLS test currently swallows server-side errors by using c.on("error", () => {}), which can leave firstCall and endEvent waiting forever on unexpected failures. Update the test around the tls.createServer callback to forward any server socket error into the same hang-guard promises used in this test, similar to the other error-handling patterns in node-net.test.ts. Keep the existing firstCall and endEvent Promise.withResolvers wiring, but make sure any server-side error rejects them so the test fails fast with diagnostics instead of timing out.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@test/js/node/net/node-net.test.ts`:
- Around line 1402-1467: The test’s serverB error handling is swallowing
failures, which can leave ended.promise hanging instead of failing clearly.
Update the node-net test around the Socket reconnect flow so serverB’s
connection path forwards any unexpected error to the awaited promise/rejection
path, similar to how the socket error handler rejects firstCall and ended. Keep
serverA’s existing justified error suppression, but make serverB’s listener or
its surrounding promise wiring surface errors from createServer, c.end(second),
or the connection callback into ended.promise so the test fails
deterministically.
- Around line 1307-1366: The TLS test currently swallows server-side errors by
using c.on("error", () => {}), which can leave firstCall and endEvent waiting
forever on unexpected failures. Update the test around the tls.createServer
callback to forward any server socket error into the same hang-guard promises
used in this test, similar to the other error-handling patterns in
node-net.test.ts. Keep the existing firstCall and endEvent Promise.withResolvers
wiring, but make sure any server-side error rejects them so the test fails fast
with diagnostics instead of timing out.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 29080497-9ff6-4446-8538-a2143745acdf
📒 Files selected for processing (2)
src/js/node/net.tstest/js/node/net/node-net.test.ts
|
Pushed 09b6010, tests only. Both findings were right: the TLS end-of-stream test and the reconnect test's second server only see the client tear down after the test body has finished, so The two servers whose client does destroy mid-stream ( 8 onread tests still pass and still fail against |
Status: ready for review, CI is red on a cross-branch flakeThe diff is green. After rebasing onto main (9d3e74f), build 71398 finished 283 jobs passed, 1 failed. The I've used my one re-roll and won't push an empty commit; here's the evidence so a maintainer doesn't have to re-derive it. (Commits after that re-roll are review fixes and the rebase, not retriggers.) The single red lane, and why it isn't this diff
That test hits the same failure on 8 of the last 30 builds across 7 branches — including 71402 for No socket code, JS or native. Other branches hitting it: The test does create its CONNECT proxy with The one I chased down pre-rebase
What the diff is verified against
|
There was a problem hiding this comment.
All prior review feedback has been addressed (including the resume() ordering in 9e014ce, with a new test covering it) and I found nothing further. That said, this is a substantial rewrite of the onread delivery path that also touches the shared end/close handler via endReadableSide and introduces JS-side byte-holding to emulate Node's kernel-level readStop(), so it warrants a maintainer's look — particularly the design choice and coordination with #29139.
Extended reasoning...
Overview
This PR rewrites net.Socket's onread option in src/js/node/net.ts to match Node's documented contract: the callback receives the user's own buffer, nread is bounded by buffer.length, the factory form is honoured, return false stops reads, and pause()/resume() behave as documented. Because Bun receives its own chunk from the native layer rather than reading directly into the user's buffer, the implementation slices each chunk into buffer-sized deliveries and holds undelivered bytes in JS (kBufferPending). That design brought several hazards (post-destroy delivery, TLS multi-dispatch overwrite, FIN-before-drain, reconnect replay, pause-across-connect, resume-ordering) — each was surfaced in review, fixed, and given its own test. The diff is ~200 lines of implementation in net.ts plus ~670 lines of new tests (11 tests) in node-net.test.ts.
Security risks
None identified. The change is confined to the read-side delivery path of node:net sockets; no auth, crypto, filesystem, or permission surfaces are touched. The two TLS tests use rejectUnauthorized: false against a local in-process server with a harness cert, which is the standard pattern. Input handling is bounded (buffer-length slicing) and no untrusted lengths are trusted for allocation.
Level of scrutiny
High. src/js/node/net.ts is core Node compatibility code, and this PR modifies the shared SocketHandlers2.end/close path via the new endReadableSide() — that path is exercised by every net/tls/http socket, not just onread ones. The author verified non-onread sockets are byte-for-byte unchanged (onreadIsPaused is always false there) and ran test/js/node/net/ + node-tls-connect + node-http with no new failures, but a maintainer should confirm that reasoning. The JS-side byte-holding design is also a genuine architectural decision (Node never holds undelivered bytes in JS because the user's buffer is the uv_buf_t), and the interaction with pause()/resume()/initSocketHandle/reconnect is intricate enough that the state machine deserves a human read.
Other factors
- Review history: nine rounds of automated review feedback, all addressed with fix commits and dedicated regression tests. Every guard was verified load-bearing by the author (deleting it fails its test). The final commit 9e014ce reorders
Duplex.prototype.resumebeforeonreadFlushPendingso apause()from within the flush survives, with a new testkeeps a pause() the callback takes during resume()'s flush. - Test quality: strong. Tests await conditions rather than sleeping, wire failure paths to reject, avoid kernel-framing dependencies (all
nreadassertions are range-bounded), pinhost: "127.0.0.1", and each isolates one invariant. All 11 fail againstmain'snet.ts. - Related work: #29139 implements
onreadfor the stream-wrap handle path with the same symbol names; the author's analysis says the two are complementary, but a maintainer should decide sequencing. - CI: the author reports remaining red lanes are infra (darwin artifact download timeout hitting many branches) and known flakes, not this diff.
Given the scope, the shared-path modification, and the design decision involved, this should not be auto-approved.
|
Agreed this needs a human, and the review is right about where the risk sits. Two things to make that read cheaper, and one correction to my own earlier phrasing. The shared
|
| line | write | reachable for a non-onread socket? |
|---|---|---|
| 1297 | onreadDeliver tail |
no: only called from SocketHandlersOnRead.data, or from onreadFlushPending past its if (!pending) return true guard |
| 1326, 1331 | onreadQueuePending |
no: only called from SocketHandlersOnRead.data |
| 1340 | onreadFlushPending |
no: after the same guard |
| 1537 | constructor | yes, = null |
| 2183 | pause() |
gated on this[kBufferCb] |
| 3882 | initSocketHandle |
gated on self[kBufferCb] |
and kBufferCb is only ever set non-null at line 1626, inside the validated onread block. So for a socket with no onread, kBufferPending is permanently null, which makes onreadIsPaused() permanently false, which makes endReadableSide, read() and _read() identical to what they were. SocketHandlersOnRead is a separate table that only an onread socket ever gets installed.
One place non-onread sockets do change, which I should have called out
resume() (9e014ce) now runs Duplex.prototype.resume.$call(this) before this._handle?.resume?.() rather than after. That is a real ordering change for every socket, not just onread ones. It is inert: both are synchronous, nothing can arrive between them on a single-threaded loop, neither reads the other's state, and the resume_ nextTick that Duplex.prototype.resume schedules calls read(0) → _handle.resume() anyway. test/js/node/net/ + node-tls-connect + node-http run together at 330 pass, no new failures. But "byte-for-byte unchanged" was too strong for resume() specifically, and a reviewer should look at that line rather than take the claim.
Design and #29139
The JS-side byte-holding is the part I'd most want a second opinion on. Node never needs it because the user's buffer is the uv_buf_t, so bytes the callback declines simply stay unread in the kernel. Bun is handed its own chunk, so emulating return false means holding the remainder somewhere, and every hazard this PR fixed after the first commit came out of that one decision. An alternative is native useUserBuffer support so reads land in the user's buffer directly, which would delete kBufferPending and everything hanging off it. That is a much larger change, and #29139 already sketches the stream-wrap half of it.
Sequencing with #29139 is a maintainer call. As it stands the two touch disjoint paths (it wires onStreamRead behind $isCallable(handle.readStart); this covers the native uSockets data handler, which it leaves on the old forwarding code) and they share symbol names, so either order rebases mechanically.
The onread option handed the callback Bun's internal read chunk with
nread set to that chunk's length, so `buffer.subarray(0, nread)` read
past the end of the user's buffer. The buffer-factory form was never
called, and returning false from the callback did not stop reads.
Keep the onread state on the socket (buffer/callback/factory, as Node
does) and copy each chunk into the user's buffer in buffer-sized
slices, one callback per slice, so nread is always <= buffer.length and
the callback gets back the buffer it supplied. Consult the factory
before the first read and after every delivery. Returning false stops
the kernel reads and holds the undelivered bytes until resume() hands
them over.
Also match Node's acceptance check: an onread option that is not
{ buffer: Uint8Array | Function, callback: Function } is ignored rather
than throwing, leaving the socket emitting 'data'.
…cket The onread callback runs user JS, so it can tear the socket down between two slices of the same chunk. Re-check destroyed after it returns. Tests: surface server-side listen and connection errors instead of swallowing them, and cover the destroy-from-callback case.
The TLS read loops (openssl.c's restart after a full plaintext buffer,
SSLWrapper::handle_reading when its 64KB buffer fills) keep decrypting
and dispatching without re-checking the socket's pause flag, and
pause() cannot stop a TLS socket wrapped over a generic Duplex at all.
The bytes the paused callback had not taken were overwritten by the
next chunk, so tls.connect({ socket: duplex, onread }) lost data and
kept calling a callback that had returned false: a 4096 + 4096 byte
exchange delivered 4608 bytes over 9 calls instead of 8192 over 1.
Hold the new chunk behind the undelivered bytes instead, so resume()
hands the callback the whole stream in order.
Tests: cover it over a Duplex-backed TLS socket, and stop asserting on
nread values that depend on how the kernel frames the payload.
kBufferPending belongs to one connection, but initSocketHandle never cleared it. Reusing a Socket after the callback stopped mid-chunk replayed the old connection's bytes into it before the new one sent anything: 64 callbacks carrying connection A's payload where Node delivers 1 and lets the rest vanish with the closed fd.
A FIN that lands while the onread callback has bytes held back ended the readable side through the inherited end/close handlers, which destroyed the socket before resume() could deliver them. Over a Duplex-backed TLS socket, where the transport never sees pause(), a 4096-byte payload written with an immediate FIN delivered 512 bytes and dropped 3584. Route both handlers through endReadableSide(), which defers push(null) until the held bytes drain, the way Node leaves the FIN unread behind them. Non-onread sockets never have pending bytes, so nothing changes. Also queue chunks that arrive while stopped in an array and concatenate once on flush, instead of reallocating the whole backlog per chunk.
The TLS end-of-stream test and the reconnect test's second server only ever see the client tear down after the body has finished, so an error there is a real failure. Forward it into the promises the body awaits instead of hanging until the runner's timeout. The two servers whose client destroys mid-stream keep their documented swallow.
withServer binds to 127.0.0.1 but the five tests through it connected without a host, so the client resolved "localhost" and only reached the server because happy eyeballs falls through to IPv4. Hand the run body the whole connect target instead, matching the sibling tests that pass the literal address.
Node documents pause() on an onread socket as behaving the way a false return does: readStop() halts the next delivery even part-way through a chunk. Bun kept slicing, so a callback reaching for backpressure with this.pause() still took all 64 slices of a 4096-byte chunk where Node hands it 1. pause() now parks an onread socket in the same state a false return produces, holding nothing back, and the delivery loop stops as soon as it sees that state. resume() already drives both back out of it.
The new pause() test asserted bytesRead === buffer.length after one delivery, which only holds when the first recv() returned at least a buffer's worth. Every other nread assertion in this suite was already moved off that, so bound it the same way: exactly one delivery, counted, and within the buffer.
Two of this PR's own changes cancelled out: pause() parks an onread socket by setting kBufferPending, and initSocketHandle cleared it unconditionally so a reconnect would not replay the old connection's bytes. A socket paused before its first connect() therefore lost the marker the moment connect() attached a handle, and the callback fired on all 64 slices of a 4096-byte chunk where Node delivers none. Derive the marker from isPaused() instead of wiping it, which is the same thing Node's `if (!socket.isPaused()) socket.read(0)` gate decides. Held-back bytes still die with the connection they arrived on; a pause the user never lifted still belongs to the socket.
Node delivers asynchronously, so by the time an onread callback can call pause() the stream is already flowing again and that pause sticks. Bun flushes the held bytes synchronously inside resume(), so the callback's pause landed first and resume()'s own tail call into Duplex.prototype.resume then wiped it: isPaused() read false where Node reads true, which also defeated the carry-over initSocketHandle derives from it. Run the tail call first. The delivery loop gates on kBufferPending, not on the flowing flag, so the flush is unaffected, and a callback that pauses now does so last.
9e014ce to
9d3e74f
Compare
|
Heads-up from #42313 (approved, not merged yet). It turns
|
|
Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-10, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main. |
net.Socket'sonreadoption is the zero-allocation read path: the callback is supposed to get back the buffer it supplied, holding at mostbuffer.lengthbytes, and returningfalseis supposed to stop reads. None of that held.Repro
b === buftruefalse(Bun's internal chunk)nread <= buf.lengthnreadis 4096 against a 64-byte bufferbufferas a factory functionreturn falseCode written to the documented contract reads out of range with
buffer.subarray(0, nread), never sees its pooled buffers reused, and cannot apply backpressure.Cause
src/js/node/net.tsinstalled adatahandler that forwarded Bun's chunk straight through:Node instead pushes a
CustomBufferJSListenerthat uses the user's buffer as theuv_buf_tfor every read, sonreadis bounded by the buffer and the callback'sfalsereturn reachesreadStop().Fix
Keep Node's per-socket state (buffer, callback, factory) on the socket and emulate the listener in JS:
nread <= buffer.lengthand the callback always gets its own buffer back;initSocketHandle+onStreamRead;falsereturn stops the kernel reads (pause + unref, asSocket.prototype.pause()does) and holds the bytes the callback has not taken;resume()hands those over first, and reads only restart if the callback accepts them;pause()parks the socket in that same state, so a callback reaching for backpressure withthis.pause()stops the rest of the chunk too, the way Node'sreadStop()does. The docs promise it ("pause() and resume() will also behave as expected") and it was taking all 64 slices of a 4096-byte chunk where Node hands it 1;bytesReadcounts bytes delivered to the callback, as Node's nativebytesReaddoes.The acceptance check now matches Node too: an
onreadthat is not{ buffer: Uint8Array | Function, callback: Function }is ignored (socket keeps emitting'data') instead of throwingTypeError.Holding bytes back safely
Node never holds undelivered bytes in JS: the user's buffer is the
uv_buf_t, so anything the callback declines simply stays unread in the kernel. Emulating the contract means Bun has to hold them, and that brought four hazards with it. Each is fixed with its own test, and each test fails when its guard is removed:destroy()the socket between two slices of the same chunk.onreadDeliverre-checksdestroyedafter each callback; without it, 7 more callbacks land on a dead socket.openssl.c's restart after a full plaintext buffer,SSLWrapper::handle_readingwhen its 64 KB buffer fills) keep decrypting and dispatching without re-checking the socket's pause flag, andpause()cannot reach a TLS socket wrapped over a genericDuplexat all. A 4096 + 4096 byte exchange delivered 4608 bytes over 9 calls instead of 8192 over 1. Incoming chunks now queue behind the undelivered ones.'end'before the drain. A FIN landing while bytes are held back ended the readable side through the inheritedend/closehandlers, which destroyed the socket beforeresume()could deliver them:c.end(4096 bytes)produced 512 delivered and 3584 lost. Both handlers go throughendReadableSide(), which deferspush(null)until the held bytes drain. Non-onread sockets never have pending bytes (kBufferPendingis only written behind thekBufferCbgate), soonreadIsPaused()is always false there and that path is unchanged. The one genuinely shared change isresume()now making itsDuplex.prototype.resumetail call first; see the audit in the comments.kBufferPendingis per-connection butinitSocketHandle()did not clear it, so reusing aSocketreplayed the old connection's bytes into the new one: 64 callbacks carrying connection A's payload where Node delivers 1.pause()parked a socket by settingkBufferPending, that sameinitSocketHandle()clear wiped it, sos.pause(); s.connect(...)delivered everything. The marker is now derived fromisPaused(), which is the gate Node reaches throughif (!socket.isPaused()) socket.read(0): held-back bytes still die with their connection, an unlifted pause still belongs to the socket.resume()flushed the held bytes before its tail call intoDuplex.prototype.resume, so a callback that paused during that synchronous flush had its pause overwritten a line later. Node never hits this because its delivery is asynchronous, so the pause lands last. The tail call runs first now; the delivery loop keys offkBufferPendingrather than the flowing flag, so the flush is unaffected.Verification
bun bd test test/js/node/net/node-net.test.ts -t "net.Socket onread"— 11 tests covering buffer identity, thenreadbound, no'data'events, pause-on-false and pause-by-method then resume with nothing lost, the factory call pattern, the six holding-bytes cases above, and the ignored-option shape. All 11 fail againstmain'snet.ts.Because
endReadableSidesits on the sharedend/closepath andresume()is on every socket's,test/js/node/net/,node-tls-connect.test.tsandnode-http.test.tswere run together: 330 pass, no new failures.no test proof · iteration 8 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/net/node-net.test.ts