Skip to content

fix(net): dispatch net.connect/createConnection reached as a bound value, and stop ext-lib handle ids colliding#6407

Open
proggeramlug wants to merge 2 commits into
mainfrom
fix/net-connect-dispatch-ext-symbols
Open

fix(net): dispatch net.connect/createConnection reached as a bound value, and stop ext-lib handle ids colliding#6407
proggeramlug wants to merge 2 commits into
mainfrom
fix/net-connect-dispatch-ext-symbols

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Three defects on the path a bundled mysql2 takes to open its socket. Each one is generic — none is mysql2-specific.

1. net.connect had no arm in the native-module dispatch

const net = require("net");
net.connect(port, host, cb);   // never opened the connection

Reached as a bound value through turbopack's CJS externals wrapper, the call arrives at the runtime's native-module dispatch rather than the static codegen table. connect / createConnection were not registered as callable exports there, so they read as non-callable. Both names now route to the same event-driven socket factory the static path uses.

2. The stdlib bound to the wrong twin symbol

js_net_socket_connect and the listener entry points have twins in both the bundled stdlib and the ext-net staticlib — #5010/#5021's twin-symbol disease. Binding to the wrong one splits the socket registry from the .on('data') listener registry, and the handshake then hangs with the bytes silently dropped.

perry-ext-net now exports distinct js_ext_net_socket_connect / js_ext_net_socket_on / _once / _remove_listener / _remove_all_listeners symbols, and the stdlib's dispatch is cfg-split so it calls the implementation that owns the registries in that build.

3. Every ext lib minted handle ids privately from 1

They all draw from the shared [1, 0x40000) band, so ext-net's socket #1 and ext-http-server's server #1 were the same number — and the composite handle-method dispatch routed the call to whichever extension thought it owned it. That is how socket.on('data', …) got claimed by the HTTP server: the listener registered on the wrong object and the socket's bytes reached nobody.

New perry_ffi::reserve_handle_id() mints a globally-unique id without storing a value, for subsystems that keep their own object map. ext-net now uses it.

Test

test_gap_net_connect_bound_valuetypeof net.connect / typeof net.createConnection, a full socket round trip through connect() as a bound value with a connect-listener callback, and the same again through createConnection(). Byte-identical to node.

Found while compiling a real Next.js + MySQL app.

Summary by CodeRabbit

  • New Features

    • Added support for net.connect and net.createConnection as callable APIs, including correct function arity.
    • Improved TCP socket connection and event-listener handling across supported runtime configurations.
    • Expanded Buffer prototype method coverage.
  • Bug Fixes

    • Fixed socket handle allocation and recognition across bundled and external networking components.
    • Prevented networking symbol conflicts in combined builds.
  • Tests

    • Added coverage for TCP connections using both net.connect and net.createConnection.

…lue, and stop ext-lib handle ids colliding

Three defects on the path a bundled `mysql2` takes to open its socket.

**1. `net.connect` had no arm in the native-module dispatch.** `const net =
require('net'); net.connect(port, host)` — how turbopack's CJS externals wrapper
calls it — arrives at the runtime's native-module dispatch rather than the static
codegen table. `connect` / `createConnection` were not registered as callable
exports there, so they read as non-callable and the connection was never opened.
Both names now route to the same event-driven socket factory the static path uses.

**2. The stdlib bound to the wrong twin symbol.** `js_net_socket_connect` and the
listener entry points have twins in both the bundled stdlib and the ext-net
staticlib (#5010/#5021's twin-symbol disease). Binding to the wrong one splits the
socket registry from the `.on('data')` listener registry, and the handshake then
hangs with the bytes silently dropped. perry-ext-net now exports distinct
`js_ext_net_socket_connect` / `js_ext_net_socket_on` / `_once` /
`_remove_listener` / `_remove_all_listeners` symbols, and the stdlib's dispatch is
cfg-split to call the implementation that OWNS the registries in that build.

**3. Every ext lib minted handle ids privately from 1.** They all draw from the
shared `[1, 0x40000)` band, so ext-net socket #1 and ext-http-server's server #1
were the same number — and the composite handle-method dispatch routed the call to
whichever extension *thought* it owned it. That is how `socket.on('data', …)` got
claimed by the HTTP server: the listener registered on the wrong object and the
socket's bytes reached nobody. New `perry_ffi::reserve_handle_id()` mints a
globally-unique id without storing a value, for subsystems that keep their own
object map; ext-net now uses it.

Found while compiling a real Next.js + MySQL app. Covered by
`test_gap_net_connect_bound_value` — `typeof net.connect`, a full socket round
trip through `connect()` as a bound value with a connect-listener callback, and
the same through `createConnection()`. Byte-identical to node.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds shared handle-id reservation, distinct ext-net socket symbols, runtime dispatch for net.connect and net.createConnection, expanded Buffer prototype methods, and regression coverage for both connection factories.

Changes

Net connection and listener routing

Layer / File(s) Summary
Shared handle allocation and ext-net symbols
crates/perry-ffi/src/handle.rs, crates/perry-ffi/src/lib.rs, crates/perry-ext-net/src/lib.rs
Ext-net sockets reserve globally shared handle ids and expose distinct forwarding symbols for socket connection and listener operations.
Net callable recognition and dispatch
crates/perry-runtime/src/object/native_module/callable_export_check.rs, crates/perry-runtime/src/object/native_module/callable_exports.rs, crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs, crates/perry-stdlib/src/common/dispatch/init.rs
net.connect and net.createConnection are recognized as callable exports, report arity 3, and dispatch to bundled or external socket implementations.
External listener dispatch wiring
crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs
Listener registration and removal operations use the distinct ext-net FFI symbols.
Net connection regression coverage
test-files/test_gap_net_connect_bound_value.ts
The test checks callable types and performs TCP echo round trips through both connection factories.

Buffer prototype method surface

Layer / File(s) Summary
Expanded Buffer prototype methods
crates/perry-runtime/src/object/native_module/callable_exports.rs
The Buffer prototype method table now includes a broader dispatcher-aligned method set.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Runtime
  participant NetDispatcher
  participant NativeDispatch
  participant ExtNetSocket
  Runtime->>NetDispatcher: read net.connect or net.createConnection
  NetDispatcher->>NativeDispatch: forward module, method, and arguments
  NativeDispatch->>ExtNetSocket: create socket through ext-net entry point
  ExtNetSocket-->>Runtime: return socket handle
Loading

Possibly related PRs

  • PerryTS/perry#5643: Related handle-id allocation and freelist reuse changes in crates/perry-ffi/src/handle.rs.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the fixes, but it does not use the repository's required PR template sections or checklist. Rewrite it with the required Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist sections, and add commands/tests run.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main changes: bound-value net dispatch, ext-symbol split, and handle-id collision fixes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/net-connect-dispatch-ext-symbols

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test-files/test_gap_net_connect_bound_value.ts (1)

1-49: 📐 Maintainability & Code Quality | 🔵 Trivial

Use Node 26.5.0 for this gap test. .node-version pins that version.

🤖 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-files/test_gap_net_connect_bound_value.ts` around lines 1 - 49, Update
the gap test around the net.connect and net.createConnection calls to require
and run with Node 26.5.0, matching the version pinned by .node-version. Keep the
existing socket and server behavior unchanged.

Source: Path instructions

🤖 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 `@crates/perry-ffi/src/handle.rs`:
- Around line 479-491: Add a public free_handle_id API in handle.rs that
recycles IDs reserved by reserve_handle_id without requiring an entry in
HANDLES, and re-export it from lib.rs. Update perry-ext-net’s socket destruction
path to call free_handle_id for the socket’s reserved handle after removing its
own registry state.

---

Nitpick comments:
In `@test-files/test_gap_net_connect_bound_value.ts`:
- Around line 1-49: Update the gap test around the net.connect and
net.createConnection calls to require and run with Node 26.5.0, matching the
version pinned by .node-version. Keep the existing socket and server behavior
unchanged.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 489f03a5-0262-4549-b531-23a21753ff4d

📥 Commits

Reviewing files that changed from the base of the PR and between baefb2a and 08f5c9f.

📒 Files selected for processing (9)
  • crates/perry-ext-net/src/lib.rs
  • crates/perry-ffi/src/handle.rs
  • crates/perry-ffi/src/lib.rs
  • crates/perry-runtime/src/object/native_module/callable_export_check.rs
  • crates/perry-runtime/src/object/native_module/callable_exports.rs
  • crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs
  • crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs
  • crates/perry-stdlib/src/common/dispatch/init.rs
  • test-files/test_gap_net_connect_bound_value.ts

Comment on lines +479 to +491
/// Reserve a globally-unique handle id WITHOUT storing a value in the FFI
/// registry. For a subsystem that keeps its own object map (perry-ext-net's
/// socket registry) but must not alias another library's ids: every ext lib
/// that mints ids privately from 1 collides with the others in the shared
/// `[1, 0x40000)` band, and the composite handle-method dispatch then routes a
/// call to whichever extension *thinks* it owns that number. That is how
/// `socket.on('data', …)` on ext-net socket #1 got claimed by ext-http-server
/// (whose server was also #1) and the mysql2 handshake hung: the listener
/// registered on the HTTP server and the socket's bytes reached nobody.
pub fn reserve_handle_id() -> Handle {
pop_free_handle().unwrap_or_else(next_fresh_handle_id)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Handle ID leak: provide a way to recycle reserved handles.

reserve_handle_id allocates an ID, but there is no corresponding public API to free it. Since the existing take_handle function only recycles IDs that were successfully removed from the HANDLES registry, reserved IDs managed by external subsystems (like perry-ext-net's sockets) will leak when those sockets are destroyed.

Because the handle ID space is bounded ([1, 0x40000)), this leak will eventually exhaust the range and trigger the panic in next_fresh_handle_id, crashing long-running applications.

🐛 Proposed fix to expose a recycle function
 pub fn reserve_handle_id() -> Handle {
     pop_free_handle().unwrap_or_else(next_fresh_handle_id)
 }
+
+/// Recycle a globally-unique handle id that was previously reserved via `reserve_handle_id`.
+pub fn free_handle_id(handle: Handle) {
+    recycle_handle(handle);
+}

You will also need to re-export free_handle_id in crates/perry-ffi/src/lib.rs and ensure that perry-ext-net calls it when a socket is destroyed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Reserve a globally-unique handle id WITHOUT storing a value in the FFI
/// registry. For a subsystem that keeps its own object map (perry-ext-net's
/// socket registry) but must not alias another library's ids: every ext lib
/// that mints ids privately from 1 collides with the others in the shared
/// `[1, 0x40000)` band, and the composite handle-method dispatch then routes a
/// call to whichever extension *thinks* it owns that number. That is how
/// `socket.on('data', …)` on ext-net socket #1 got claimed by ext-http-server
/// (whose server was also #1) and the mysql2 handshake hung: the listener
/// registered on the HTTP server and the socket's bytes reached nobody.
pub fn reserve_handle_id() -> Handle {
pop_free_handle().unwrap_or_else(next_fresh_handle_id)
}
/// Reserve a globally-unique handle id WITHOUT storing a value in the FFI
/// registry. For a subsystem that keeps its own object map (perry-ext-net's
/// socket registry) but must not alias another library's ids: every ext lib
/// that mints ids privately from 1 collides with the others in the shared
/// `[1, 0x40000)` band, and the composite handle-method dispatch then routes a
/// call to whichever extension *thinks* it owns that number. That is how
/// `socket.on('data', …)` on ext-net socket `#1` got claimed by ext-http-server
/// (whose server was also `#1`) and the mysql2 handshake hung: the listener
/// registered on the HTTP server and the socket's bytes reached nobody.
pub fn reserve_handle_id() -> Handle {
pop_free_handle().unwrap_or_else(next_fresh_handle_id)
}
/// Recycle a globally-unique handle id that was previously reserved via `reserve_handle_id`.
pub fn free_handle_id(handle: Handle) {
recycle_handle(handle);
}
🤖 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 `@crates/perry-ffi/src/handle.rs` around lines 479 - 491, Add a public
free_handle_id API in handle.rs that recycles IDs reserved by reserve_handle_id
without requiring an entry in HANDLES, and re-export it from lib.rs. Update
perry-ext-net’s socket destruction path to call free_handle_id for the socket’s
reserved handle after removing its own registry state.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Two things here — CodeRabbit's handle-leak finding, which I think is underrated, and a real conformance regression.

1. The reserved-id leak is a server-lifetime crash, not a slow leak

next_id()reserve_handle_id() mints an id for every socket, and nothing ever gives it back: take_handle only recycles ids that were in the HANDLES registry, and ext-net keeps its own map, so it never calls it. The band is [1, 0x40000) and next_fresh_handle_id panics on exhaustion:

panic!("perry-ffi handle id range exhausted before reserved Web handle bands");

So a long-running server crashes after ~262k accepted connections. For a net crate that is a "when", not an "if" — and mysql2/Next.js (the workload this PR targets) churn sockets.

2. …but the obvious fix is unsafe, which is why I'm not just pushing one

The tempting free point is PendingNetEvent::Close, where the socket is already dropped from all four registries:

PendingNetEvent::Close(id) => {
    ...
    statics::listeners().lock().unwrap().remove(&id);
    statics::sockets().lock().unwrap().remove(&id);
    ...
}

Recycling id there would be wrong. In Node a socket object stays usable after 'close'socket.destroyed, a late write(), a listener added in the close handler. A JS reference outliving the event would then dispatch on a recycled id and alias a brand-new socket. That converts a slow leak into fast cross-object corruption — precisely the aliasing class this PR exists to fix (ext-net socket #1 vs ext-http-server #1).

The id's lifetime is the lifetime of the JS object, not of the TCP connection, so a correct fix needs the id released when the handle value becomes unreachable (a GC finalizer / weak registry hook), not when the socket closes. That's a design call inside your change, so I'd rather flag it than guess.

Minimum viable alternative if you want to defer the real fix: have next_fresh_handle_id degrade instead of panic!, so exhaustion surfaces as a JS-visible error rather than killing the process.

3. Conformance regression: test_gap_uint8array_source_dispatch

It's the only untriaged failure, and it is not a flake — I ran the node oracle 20× and it is deterministic (unlike test_gap_fs_fd_2749, which I de-flaked separately in #6400). Shifting ids out of the private per-crate counters into the shared pool changes which numbers land where, so a dispatch keyed on a handle value is a plausible culprit. Diagnosing now; will follow up.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Follow-up on the test_gap_uint8array_source_dispatch failure: it does not reproduce against current main.

I built this branch merged with main and ran the fixture — byte-identical to node, all 14 lines (plain / function / iterable / sparse / array / buffer / arraybuffer / uint8 / int16 / nullish / string / symbol / bigint). The oracle is deterministic too (25 node runs, one output), so this isn't an fs_fd-style flaky-oracle case.

The CI run that failed was built against an older merge base; main has moved a good deal since (several dispatch fixes landed today). Pushed the main merge as 54a483381 so CI re-tests on a current tree.

Correction to my earlier comment: I first "verified" the oracle's determinism by running the test from my main-repo checkout, which is parked on an old branch where that file doesn't exist — so I was timing the same module-not-found error 25 times. Re-measured against the real file from origin/main; the determinism conclusion holds, but it now rests on an actual measurement.

The handle-id leak from the previous comment still stands and is unaddressed — that one is a genuine design question, not a stale-CI artifact.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Correcting my previous comment: test_gap_uint8array_source_dispatch still fails with current main merged in (54a483381), so it is not a stale-merge-base artifact. It's real, and it's Linux-only.

What I can establish

  • The harness prints the first diff line as identical (Node.js: plain 5,6 / Perry: plain 5,6), so the mismatch is on a later line — it truncates before showing which.
  • The oracle is deterministic (25 node runs, one output), so this is not an fs_fd-style flaky-oracle case.
  • It does not reproduce on macOS: I built this branch merged with main and the fixture is byte-identical to node across all 14 lines.

Why Linux-only is plausible here

This PR moves ext-lib handle ids out of private per-crate counters into the shared [1, 0x40000) pool, which changes which numbers land where. That test's later lines are exactly the handle-carrying sources — Buffer.from([41, 42]), new ArrayBuffer(2), new Uint8Array(...), new Int16Array(...).

Perry has a documented Linux-only failure class here: a handle-band deref that segfaults on Linux is masked on macOS by its heap layout. So an id collision or a dispatch keyed on a handle value would plausibly show up only on the Linux runner — which matches exactly what we see.

I don't have a Linux box wired up in this session to bisect it, and I'd rather not guess at the failing line.

Where this PR stands

Two open items, both for you:

  1. This regression — needs a Linux repro to find which of the 14 lines diverges.
  2. The reserved-handle-id leak (earlier comment) — reserve_handle_id() mints an id per socket and nothing returns it, and exhaustion panic!s at ~262k connections. The tempting fix (free at PendingNetEvent::Close) is unsafe: a socket stays usable after 'close' in Node, so a stale JS reference would dispatch on a recycled id and alias a new socket — trading a slow leak for fast cross-object corruption, i.e. the very aliasing class this PR fixes. The id's lifetime is the JS object's, not the connection's.

Happy to take either on if you tell me which way you want the handle lifetime handled.

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.

1 participant