socket: set Handlers.mode=.client for Windows named-pipe Bun.connect#30150
Conversation
|
Warning Rate limit exceeded
To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesClient Socket Handler Lifecycle Fix
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Review rate limit: 0/5 reviews remaining, refill in 2 minutes and 13 seconds. Comment |
|
Updated 3:05 AM PT - May 3rd, 2026
❌ @robobun, your commit 7dd9d40 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 30150That installs a local version of the PR into your bun-30150 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — targeted regression fix restoring is_server=false for the client connect path, with Windows named-pipe regression tests.
Extended reasoning...
Overview
This PR fixes a regression in Listener.connectInner() where SocketConfig.fromJS was called with is_server=true instead of false (flipped accidentally in #23755). The only effect of that argument is setting handlers.mode, and on the Windows named-pipe early-return branch the resulting .server mode caused Handlers.markInactive() to do @fieldParentPtr("handlers", this) against a standalone heap-allocated Handlers block — a heap-buffer-overflow under ASAN and a leak in release. The non-pipe branch was unaffected because it already had an explicit handlers_ptr.mode = .client override.
The fix is two lines of production code: restore the false argument, and add the same defensive handlers_ptr.mode = .client to the named-pipe branch to mirror the non-pipe branch. Two Windows-only subprocess regression tests are added.
Security risks
None. This is a memory-safety/lifecycle fix on the client connect path; no auth, crypto, input parsing, or trust-boundary changes. If anything it removes an out-of-bounds read.
Level of scrutiny
Low-to-moderate. I verified in Handlers.zig that is_server is consumed only at mode = if (is_server) .server else .client (line 136) and nowhere else in SocketConfig.fromGenerated / Handlers.fromGenerated, so the flip has no other side effects. markInactive (lines 80–97) confirms the .server branch does @fieldParentPtr and the .client branch does allocator.destroy, exactly as the PR description states. The change restores pre-#23755 behavior.
Other factors
The PR description includes a thorough audit of all other standalone allocator.create(Handlers) sites confirming they're already correct. Tests are isolated in subprocesses so an ASAN crash surfaces as a non-zero exit rather than killing the runner, and are gated to Windows where the affected branch is reachable. No outstanding reviewer comments; bug-hunting system found nothing.
|
@robobun fix conflicts |
connectInner() passed is_server=true to SocketConfig.fromJS (regressed in 4a06991; it was false before), so the handlers start with .mode=.server. The non-pipe path overrides that to .client right after copying into the standalone allocator.create(Handlers) block, but the Windows named-pipe branch did not. With .mode left as .server, Handlers.markInactive() on close/fail takes the server branch and does @fieldParentPtr("handlers", this) to reach an enclosing Listener — but the block is a bare Handlers, so reading listen_socket.listener falls past the allocation (heap-buffer-overflow under ASAN) and the .client branch that frees the block is skipped (leak). Restore is_server=false at the connectInner call site and mirror the defensive .mode=.client override on the named-pipe branch to match the non-pipe path.
ed4eaae to
28be36b
Compare
There was a problem hiding this comment.
LGTM — minimal, well-justified restoration of the pre-#23755 is_server=false value plus a defensive .mode = .client to mirror the non-pipe branch. The flagged connection.unix leak in the non-TLS named-pipe arm is pre-existing and minor; fine to address separately.
Extended reasoning...
Overview
Two-line Zig fix in src/bun.js/api/bun/socket/Listener.zig:
SocketConfig.fromJS(vm, opts, globalObject, true)→falseinconnectInner()— restores the value that was accidentally flipped in #23755. Verified thatis_serverflows only intoHandlers.fromGeneratedwhere it sets.mode = if (is_server) .server else .client(Handlers.zig:136); nothing else branches on it.- Adds
handlers_ptr.mode = .clientafter the standaloneallocator.create(Handlers)in the Windows named-pipe branch, mirroring the identical assignment that already exists on the non-pipe path a few lines down. Redundant after fix (1), but keeps the two heap-Handlers copies symmetric.
Plus two new Windows-only subprocess tests in test/js/bun/net/socket.test.ts exercising named-pipe connect open→close and connect-failure cleanup.
Security risks
None. No user-controlled input handling changes; this is a lifecycle/cleanup correctness flag. The fix removes a heap-buffer-overflow (@fieldParentPtr past a standalone allocation) rather than introducing one.
Level of scrutiny
Low-to-medium. Socket lifecycle code is sensitive, but this change is a targeted regression fix: a single boolean restored to its documented pre-refactor value, plus a one-line assignment copied from the adjacent branch. The PR description traces the regression to a specific commit, audits the other allocator.create(Handlers) sites, and adds regression tests. markInactive() (Handlers.zig:80–97) confirms the .server/.client semantics described.
Other factors
- The one inline finding (non-TLS named-pipe arm leaks the
connection.unixslice) is explicitly pre-existing —.connection = nullin that arm predates this PR. It's a small per-connect string leak on Windows only and does not block this fix. - The 3 CI failures (
bake/dev-and-prod,cli/hot,bun-install-lifecycle-scripts) are in unrelated test files, not the new socket tests. - Jarred's "fix conflicts" comment appears to have been addressed (diff applies cleanly to current main).
The non-TLS Windows named-pipe arm in connectInner() was the only
construction path that left tcp.connection = null instead of adopting the
heap-owned connection.unix slice (the TLS arm right above and the non-pipe
arm below both adopt it). On the success return the errdefer doesn't fire,
so the duped pipe-path bytes leaked per Bun.connect({unix: pipe}) on
Windows.
Mirror the TLS arm: deinit any old connection on the reuse path and assign
connection so TCPSocket deinit frees it.
There was a problem hiding this comment.
LGTM — the connection adoption in 7dd9d40 addresses the pre-existing leak I flagged, and the non-TLS arm now mirrors the TLS/non-pipe arms.
Extended reasoning...
Overview
This PR fixes a heap-buffer-overflow / leak in the Windows named-pipe Bun.connect() path. Three changes in src/bun.js/api/bun/socket/Listener.zig:
SocketConfig.fromJS(..., true)→falseinconnectInner(), restoring the pre-#23755 value.- Defensive
handlers_ptr.mode = .clienton the named-pipe branch, mirroring the existing override on the non-pipe branch. - Adopt
connectioninto the non-TLS named-pipeTCPSocket(addressing the pre-existing leak I flagged on the previous revision).
Plus two Windows-only subprocess tests in test/js/bun/net/socket.test.ts.
Verification
I confirmed via Handlers.zig:136 that is_server is only used to set .mode = if (is_server) .server else .client — nothing else in SocketConfig.fromGenerated / Handlers.fromGenerated branches on it. The non-pipe path already had an explicit handlers_ptr.mode = .client override (line ~810), so flipping the upstream flag is a no-op everywhere except the Windows named-pipe early-return — which is exactly what's being fixed. markInactive() at Handlers.zig:84-95 confirms the described .server → @fieldParentPtr vs .client → allocator.destroy divergence.
For the connection adoption: socket.zig:1400-1403 shows the socket's cleanup path does connection.deinit(), so ownership transfer is sound. The new code is byte-for-byte the same pattern as the TLS arm directly above it and the non-pipe arm below.
Security risks
None. This is a memory-lifecycle correctness fix (preventing an OOB read and a leak); no new attack surface, parsing, or auth logic.
Level of scrutiny
Medium — socket lifecycle code in Zig with manual memory management. However, every change here either restores prior behavior (the boolean flip) or copies an existing proven pattern from a sibling arm in the same function. The PR description includes a thorough root-cause analysis with commit references, and zig:check-all passes for all targets including both Windows arches.
Other factors
- My previous review flagged the
connectionleak in the non-TLS arm; commit 7dd9d40 addresses it exactly as suggested (mirror the TLS arm). - The bug-hunting system found no issues on this revision.
- The robobun CI failure (
test-http-should-emit-close-when-connection-is-aborted.tstimeout) is on the earlier commit 28be36b, in an unrelated HTTP test that doesn't go through the named-pipe branch; the non-pipeconnectInnerpath is provably unchanged in behavior. - New tests are properly gated with
describe.skipIf(!isWindows)and use subprocesses so ASAN crashes surface as test failures.
…ven-sh#30150) ## Repro Windows only: ```js await Bun.connect({ unix: '\\\\.\\pipe\\x', socket: { data() {}, open() {}, close() {} }, }); // then close (or fail) the connection ``` On close, `Handlers.markInactive()` hits `active_connections == 0` with `.mode == .server` and does `@fieldParentPtr("handlers", this)` expecting an enclosing `Listener` — but the handlers live in a standalone `allocator.create(Handlers)` block, so reading `listen_socket.listener` falls past the allocation. Under ASAN that's a heap-buffer-overflow; on release it reads garbage and — because the `.client` branch is skipped — leaks the block. ## Cause `connectInner()` calls `SocketConfig.fromJS(vm, opts, globalObject, true)` at `Listener.zig:564`. The last argument is `is_server`, which feeds `handlers.mode`. It was `false` until 4a06991 (oven-sh#23755) flipped it during a bindings-generator refactor. The non-pipe path at :797 has always had an explicit `handlers_ptr.mode = .client` after copying into the heap block (it was `handlers_ptr.is_server = false` before oven-sh#26539), which masked the flip everywhere except the Windows named-pipe early-return at :655–656, which never had one. `is_server` is only used to set `handlers.mode`; nothing else in `SocketConfig.fromGenerated` / `Handlers.fromGenerated` branches on it. ## Fix - Restore `is_server=false` at the `connectInner` call site (this is the client connect path). - Add the same defensive `handlers_ptr.mode = .client` on the named-pipe branch to mirror the non-pipe branch, so the two copies into a standalone `Handlers` block look the same. Audited the other standalone `allocator.create(Handlers)` sites: - `socket.zig:1557` — sourced from `Handlers.fromJS(..., false)`, already `.client`. - `socket.zig:2062` — explicit `.mode = if (is_server) .duplex_server else .client`. ## Verification `bun run zig:check-all` passes (all targets, including both Windows arches). New Windows-only tests in `test/js/bun/net/socket.test.ts`: - Listen on a named pipe, `Bun.connect` to it, close → clean exit. - `Bun.connect` to a non-existent pipe → rejects, clean exit. Both are spawned in a subprocess so an ASAN crash surfaces as a non-zero exit instead of killing the test runner. Skipped on non-Windows (the `if (Environment.isWindows)` branch is unreachable there, and the non-pipe path's :797 override already covers it). --------- Co-authored-by: robobun <robobun@users.noreply.github.com>
Repro
Windows only:
On close,
Handlers.markInactive()hitsactive_connections == 0with.mode == .serverand does@fieldParentPtr("handlers", this)expecting an enclosingListener— but the handlers live in a standaloneallocator.create(Handlers)block, so readinglisten_socket.listenerfalls past the allocation. Under ASAN that's a heap-buffer-overflow; on release it reads garbage and — because the.clientbranch is skipped — leaks the block.Cause
connectInner()callsSocketConfig.fromJS(vm, opts, globalObject, true)atListener.zig:564. The last argument isis_server, which feedshandlers.mode. It wasfalseuntil 4a06991 (#23755) flipped it during a bindings-generator refactor.The non-pipe path at :797 has always had an explicit
handlers_ptr.mode = .clientafter copying into the heap block (it washandlers_ptr.is_server = falsebefore #26539), which masked the flip everywhere except the Windows named-pipe early-return at :655–656, which never had one.is_serveris only used to sethandlers.mode; nothing else inSocketConfig.fromGenerated/Handlers.fromGeneratedbranches on it.Fix
is_server=falseat theconnectInnercall site (this is the client connect path).handlers_ptr.mode = .clienton the named-pipe branch to mirror the non-pipe branch, so the two copies into a standaloneHandlersblock look the same.Audited the other standalone
allocator.create(Handlers)sites:socket.zig:1557— sourced fromHandlers.fromJS(..., false), already.client.socket.zig:2062— explicit.mode = if (is_server) .duplex_server else .client.Verification
bun run zig:check-allpasses (all targets, including both Windows arches).New Windows-only tests in
test/js/bun/net/socket.test.ts:Bun.connectto it, close → clean exit.Bun.connectto a non-existent pipe → rejects, clean exit.Both are spawned in a subprocess so an ASAN crash surfaces as a non-zero exit instead of killing the test runner. Skipped on non-Windows (the
if (Environment.isWindows)branch is unreachable there, and the non-pipe path's :797 override already covers it).