Skip to content

net: support Socket({handle, manualStart}) with stream-wrap onread - #29139

Open
alii wants to merge 11 commits into
ali/stdin-A-guesshandletypefrom
ali/stdin-B-socket-handle
Open

alii wants to merge 11 commits into
ali/stdin-A-guesshandletypefrom
ali/stdin-B-socket-handle

Conversation

@alii

@alii alii commented Apr 10, 2026

Copy link
Copy Markdown
Member

Part 2/5 of the process.stdin Node-parity stack (#29126). Stacked on #29137.

Adds net.Socket({handle, manualStart}) support per Node's stream-wrap contract:

  • Constructor accepts {handle, manualStart, pauseOnCreate}; initSocketHandle wires _handle.onread = onStreamRead when readStart is callable
  • onStreamRead(nread, buf): nread>0 → push, UV_EOF → push(null), <0 → destroy
  • pause/resume/read are kBuffer-gated (Node's lib/net.js pattern) — usocket backpressure is independently handled by SocketHandlers.data + _read
  • _read branches on readStart → tryReadStart
  • Drops hardcoded readable:true, writable:true so {writable:false} is honored
  • owner_symbol moves to internal/shared

Tested with a JS mock handle (the native TTY/Pipe handles arrive in parts 3/4). net/stdin/readline/stream suites: 143 pass / 0 fail (3 net failures pre-existing on system bun).

Stack:

@robobun

robobun commented Apr 10, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 8:06 AM PT - May 30th, 2026

❌ @robobun, your commit 4751cff has some failures in Build #59277 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 29139

That installs a local version of the PR into your bun-29139 executable, so you can run:

bun-29139 --bun

@alii

alii commented Apr 10, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@github-actions

Copy link
Copy Markdown
Contributor

Found 6 issues this PR may fix:

  1. TODO: stream.Readable stdio and TODO: stream.Writable stdio (node compat) #25498 - Adds stream-wrap handle plumbing in net.Socket needed for stream.Readable/Writable stdio support
  2. Heredoc stdin lost when importing node:process (Array.fromAsync(process.stdin) resolves to []) #25320 - Stream-wrap onStreamRead and readStart/readStop flow control fixes stdin data delivery
  3. bun doesn't listen to readable event on stdin #5240 - tryReadStart and stream-wrap readStart on resume/read enables readable event on stdin
  4. stdin error if run as a child process #15893 - pauseOnCreate/manualStart handle options govern when stdin begins reading as a child process
  5. dockerode exec.start({ hijack: true, stdin: true }) hangs under Bun but works under Node.js #29012 - dockerode hijack mode relies on handle-based Socket construction and stream-wrap read flow
  6. Bun using restify: stream_wrap error #4957 - restify depends on stream-wrap handle support in net.Socket

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #25498
Fixes #25320
Fixes #5240
Fixes #15893
Fixes #29012
Fixes #4957

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR modifies core net.Socket behavior (pause/resume/read, constructor options, removing hardcoded readable:true, writable:true) as part 2 of a 5-part stack — worth a human look given the breadth of changes to the socket lifecycle.

Extended reasoning...

Overview

This PR adds Socket({handle, manualStart}) support to Bun's node:net by wiring _handle.onread = onStreamRead for stream-wrap handles (those with a callable readStart). It touches src/js/internal/shared.ts (moving owner_symbol), src/js/node/net.ts (core socket logic), and adds a new test file.

Security risks

No security-sensitive code paths (auth, crypto, permissions). The changes are confined to stream reading mechanics. No obvious injection or privilege escalation vectors.

Level of scrutiny

This deserves human review because it:

  • Removes hardcoded readable: true, writable: true from the Duplex constructor call — a behavioral change that affects all Socket instances, not just those with stream-wrap handles.
  • Rewrites pause, resume, and read prototype methods with new kBuffer-gating logic.
  • Adds a new _write guard that returns ERR_METHOD_NOT_IMPLEMENTED for stream-wrap handles.
  • Is explicitly part 2 of a 5-part stack, so the full picture of how these pieces interact is not yet visible in this diff alone.

Other factors

The bug report identifies kBufferGen as set but never used. The PR author is aware this is intentionally deferred to parts 3/4 (native TTY/Pipe handles), but there is no comment in the code noting this, which could confuse future readers. The test suite verifies the mock-handle path and reports 143 passing / 0 failing. No prior human review has been posted.

Comment thread src/js/node/net.ts
@robobun

robobun commented Apr 10, 2026 •

Copy link
Copy Markdown
Collaborator

Rebased, conflict resolved, MERGEABLE — needs a maintainer to merge (CI flaky on an unrelated macOS lane).

What was done

  • The base ali/stdin-A-guesshandletype had moved onto current main (Zig→Rust port), so the real conflict was node_util_binding relocating to node_util_binding.rs. Replanted the net/socket commits onto the new base and re-applied the AF_UNIX-before-SO_TYPE fix in Rust.
  • Fixed the cargo clippy failure (undocumented-unsafe-blocks + borrow-as-ptr on the getsockname/getsockopt FFI) — now green.
  • Cleared the onStreamRead + pause() review findings, all backed by new tests. Claude review: no issues.

CI status
74 lanes green — all builds (incl. both darwin builds), cargo clippy, and every Linux + Windows test lane. The only red is a single macOS test-bun lane, and it rotates between runs: build #59245 had 3 darwin lanes report Expired (agent starvation after a 1h11m build); the re-trigger (#59277) first surfaced darwin-14-x64-test-bun exit 1, and now darwin-14-aarch64-test-bun. The diff is JS + one small Rust FFI reorder + tests (nothing macOS-specific), and the same tests pass on the Linux lanes — so this is pre-existing macOS flake, not this change. Rotating lane = flake signature.

Diff is green on everything that touches this change; just needs a maintainer to merge (or re-run the macOS shard). Ive used my one re-trigger, so I wont push more.

@alii

alii commented Apr 10, 2026

Copy link
Copy Markdown
Member Author

@robobun Rebase main before building

@robobun
robobun force-pushed the ali/stdin-B-socket-handle branch from f8e3a43 to 8f98c94 Compare April 11, 2026 00:20
@alii
alii force-pushed the ali/stdin-B-socket-handle branch from 8f98c94 to 967c6bb Compare April 11, 2026 00:26

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/node/net.ts:758-766 — In the Socket constructor, this.read(0) is called (line ~747) before this[kBuffer] is set to true (line ~761), so if a user-provided handle's readStart() fires onread synchronously, onStreamRead runs with kBuffer still falsy and routes data through self.push() instead of self[kBufferCb] — the user's onread.callback is never invoked for that first chunk. Fix by moving the kBuffer/kBufferCb/kBufferGen setup block to before the this.read(0) call.

    Extended reasoning...

    The bug: In the Socket constructor in src/js/node/net.ts, the block that calls this.read(0) (the \!options.manualStart branch) executes at approximately line 747, but this[kBuffer] = true is set at approximately line 761. This means when new Socket({handle, onread: {buffer, callback}}) is called without manualStart: true, reading is started before the kBuffer flag is established.

    The code path that triggers it: (1) Constructor reaches the handle block and calls this.read(0). (2) Socket.prototype.read checks this[kBuffer] but it is still undefined/false, so it skips its tryReadStart fast-path and falls through to Duplex.prototype.read.(this, 0). (3) Duplex eventually calls Socket.prototype._read, which does NOT guard on kBuffer — it unconditionally calls tryReadStart(this) when socket.readStart is callable. (4) tryReadStart sets reading=true and calls handle.readStart(). (5) If readStart() synchronously invokes this.onread = onStreamRead, then onStreamRead runs before step 6. (6) Only after read(0) returns does line 761 finally set kBuffer=true.

    Why existing code doesn't prevent it: Socket.prototype.read has a kBuffer guard, but Socket.prototype._read does not — it calls tryReadStart unconditionally when the handle has readStart. This asymmetry means the guard in read() can be bypassed via the _read → tryReadStart path.

    Impact: For new Socket({handle, onread:{buffer, callback}}) with a custom JS handle whose readStart() fires onread synchronously, the first data chunk is routed through self.push(arrayBuffer) rather than self[kBufferCb](nread, userBuf). The user's onread.callback is never called for that initial chunk. The PR introduces this exact use case (custom JS mock handles), and all tests work around the bug by always using manualStart: true.

    Addressing the refutation: The refutation argues that native handles (libuv, Bun TCP, pipe, TLS) are asynchronous and never fire onread synchronously from readStart(), making the bug implausible in practice. This is correct for native handles. However, the PR's stated primary use case is custom JS mock handles ("The native TTY/Pipe handles arrive in parts 3/4"), and these pure-JS objects CAN call onread synchronously from readStart(). All existing tests use manualStart: true precisely because of this ordering issue — that's a workaround, not a design choice. The ordering is objectively incorrect regardless of whether native handles trigger it.

    Step-by-step proof: Given: const myBuf = Buffer.alloc(16); const calls = []; const socket = new Socket({ handle, onread: { buffer: myBuf, callback: (n, b) => calls.push({n, b}) } }); where handle.readStart() synchronously calls handle.onread(5, Buffer.from('hello')). Trace: (1) constructor calls this.read(0) — at this point this[kBuffer] is still undefined. (2) Socket.prototype.read skips tryReadStart (kBuffer falsy) but calls Duplex.prototype.read. (3) Duplex calls _read, which calls tryReadStart. (4) tryReadStart calls handle.readStart(). (5) handle.readStart calls onStreamRead.call(handle, 5, handleBuf). (6) In onStreamRead: self[kBuffer] is falsy → takes the self.push(arrayBuffer) branch. (7) Constructor resumes; this[kBuffer] = true is set. Result: calls.length === 0. The callback was never called for the first chunk.

    Fix: Move the if (onread) { ... this[kBuffer] = true; this[kBufferCb] = ...; this[kBufferGen] = ...; } block to before the if (this._handle && $isCallable(this._handle.readStart) && options.readable \!== false) block that triggers read(0).

Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts
@robobun
robobun force-pushed the ali/stdin-A-guesshandletype branch from bf5e6a9 to 3448ab4 Compare April 11, 2026 02:58
@robobun
robobun force-pushed the ali/stdin-B-socket-handle branch from d430101 to ab0c9c6 Compare April 11, 2026 03:25
Comment thread src/js/node/net.ts
Comment thread src/js/node/net.ts
@robobun
robobun force-pushed the ali/stdin-A-guesshandletype branch from 477d9a0 to 03a89b3 Compare April 11, 2026 07:18
Comment thread src/js/node/net.ts
Comment thread src/js/node/net.ts
Comment thread src/bun.js/node/node_util_binding.zig Outdated
@alii

alii commented May 30, 2026

Copy link
Copy Markdown
Member Author

@robobun rebase and get this mergable

@robobun
robobun force-pushed the ali/stdin-A-guesshandletype branch from 679ec4b to f940460 Compare May 30, 2026 09:15
@robobun
robobun force-pushed the ali/stdin-B-socket-handle branch from 85262d2 to 11bc1c4 Compare May 30, 2026 09:36
Comment thread src/js/node/net.ts
Comment thread src/js/node/net.ts Outdated
@robobun
robobun force-pushed the ali/stdin-B-socket-handle branch from 11bc1c4 to dbc7793 Compare May 30, 2026 09:59
alii and others added 6 commits May 30, 2026 10:06
Node's net.Socket accepts a {handle} option (a stream-wrap with
readStart/readStop/onread) and wires onStreamRead per
lib/internal/stream_base_commons.js. This adds the same plumbing:

- Constructor accepts {handle, manualStart, pauseOnCreate}
- initSocketHandle wires _handle.onread = onStreamRead when readStart
  is callable
- onStreamRead(nread, buf): nread>0 → push, UV_EOF → push(null),
  nread<0 → destroy(ErrnoException)
- pause/resume/read are kBuffer-gated (Node's pattern) and no longer
  call _handle.pause()/resume() unconditionally — usocket backpressure
  is handled separately by SocketHandlers.data + _read
- _read branches on readStart → tryReadStart for stream-wrap handles
- Drop hardcoded readable:true/writable:true so {writable:false} works
- _write/endNT guard for stream-wrap handles (write support is part 5)

owner_symbol moves to internal/shared so the handle's [owner_symbol]
back-ref is consistent.

Part 2/5 of the process.stdin Node-parity stack (#29126).
Reverts the kBufferGen removal from the previous commit. The symbol
isn't dead — Node's onread.{buffer,callback} contract is that the
callback receives the user's buffer. onStreamRead now copies the
handle's chunk into the user buffer before invoking the callback.

(Restores 8f98c9444d which was overwritten by the prior force-push.)
…rder

Socket.prototype.{pause,resume,read} were kBuffer-gated, which skipped
_handle.pause()/resume() for regular Bun TCP sockets (no onread option)
and broke pauseOnConnect. Branch on readStop/readStart instead: stream-
wrap handles use the kBuffer-gated readStop/readStart path (Node's
lib/net.js), usocket handles keep the unconditional native pause/resume.

Also:
- Move kBuffer/kBufferCb/kBufferGen assignment before the constructor's
  read(0) so a handle whose readStart fires onread synchronously routes
  through kBufferCb instead of push().
- Check readStop() return in the pauseOnCreate branch (matches pause()).
- Clamp nread to userBuf.byteLength in onStreamRead's copy path so the
  callback's (nread, buf) contract holds.

Fixes test-net-server-pause-on-connect.js timeout across all platforms.
robobun added 2 commits May 30, 2026 10:07
AF_UNIX DGRAM/SEQPACKET sockets now return PIPE instead of UNKNOWN,
matching uv_guess_handle.
Match Node's stream_base_commons.js onStreamRead:
- call _unrefTimer() on every invocation so an actively-reading
  stream-wrap socket doesn't fire 'timeout'
- in the non-onread branch, push arrayBuffer.subarray(0, nread) when the
  handle reports fewer bytes than the buffer it allocated, instead of
  pushing trailing garbage
@robobun
robobun force-pushed the ali/stdin-B-socket-handle branch from fb211d7 to cbbf986 Compare May 30, 2026 10:07
Comment thread src/js/node/net.ts Outdated
robobun added 3 commits May 30, 2026 10:52
…-as-ptr

Move the SAFETY comment directly above each getsockname/getsockopt unsafe
block and pass the socklen_t out-params through core::ptr::from_mut instead
of an implicit &mut→*mut coercion.
Match Node's lib/net.js (truthy this._handle.reading) and stay symmetric
with resume()/read() which gate on !handle.reading. A stream-wrap handle
that never started reading (no reading property) is no longer readStop()'d
by an early pause().
@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Heads-up from #42313 (approved, not merged yet). It turns net.Socket.prototype.bytesRead into a getter that reads this._handle.bytesRead, with no setter, as in node.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants