Skip to content

node:net: add net.Server and cloudflare:node connectHandler - #7306

Merged
guybedford merged 9 commits into
mainfrom
gbedford/net-server
Sep 12, 2026
Merged

guybedford merged 9 commits into
mainfrom
gbedford/net-server

Conversation

@guybedford

@guybedford guybedford commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Follows #7299.

This implements net.Server over the virtual port table from #7299, and a connectHandler in cloudflare:node that routes inbound platform sockets to it, the connect() analogue of httpServerHandler. listen(N) means what it means on Linux: the accepted socket's local port is N, and each Durable Object instance is its own host.

Scoping: a Durable Object instance has its own port table for its lifetime, so two instances in one isolate each bind 25565 with no conflict and the table dies with the instance. Everything else in the isolate shares one table, so a server listened on in one request is reachable from every other (httpServerHandler lookups are scope-aware too: an http.Server listening inside a Durable Object is found from that object's requests). Owners capture the table they bound in and release through it, since release may run in another request's context or in the finalization backstop with none.

Declared listeners: the isolate table is seeded with the ports the platform delivers inbound connections on (workerd's sockets entries targeting the worker, as bound, so a configured port of 0 is resolved; production would fill this from the wrangler connect[] triggers). With a declared set, net.Server.listen(N) at the entrypoint must use a declared port (EADDRNOTAVAIL otherwise), listen(0) takes the first unclaimed declared port, and every declared port claimed makes listen(0) EADDRINUSE. Binding itself is role-neutral: new BoundSocket() (port 0) always takes an ephemeral port, so egress binders such as Emscripten's per-connection bind(0.0.0.0:0) can never starve servers; when a server adopts such a socket (listen(bound)) it is re-homed onto an unclaimed declared port, so Node's new BoundSocket() + listen(bound) idiom lands where connections arrive. Inside a Durable Object no ports are declared, so the port is chosen freely and matched by the caller's stub.connect('host:N'). http.Server is reached through httpServerHandler, not a listener, so it is unconstrained and its listen(0) never takes a declared port. Client-side reservations (new BoundSocket({ port }) adopted by a client, net.connect({ localPort })) are egress and not constrained, which also means an explicitly named declared port can be taken for egress; likewise http.Server.listen(<declared>) claims it, after which inbound TCP on that port finds no net.Server.

net.Server:

  • listen(port | options | boundSocket) reserves the port and installs a connect handler; listen(boundSocket) / listen({ handle }) adopt the reservation (re-homing a port-0 socket onto a declared port where one exists), giving the bind(2) / listen(2) split directly. server.address() and accepted sockets' localAddress report the bound label (the host as given, 0.0.0.0 by default)
  • host, ipv6Only, reusePort honored as in node:net: add net.BoundSocket #7299; reusePort listeners share connections round-robin; an in-use port is reported via 'error' (EADDRINUSE) as in Node; path and foreign handles are rejected
  • 'listening' / 'connection' / 'close' / 'drop', address(), listening, close() (waits for open connections; ERR_SERVER_NOT_RUNNING when not listening; a listen() before the drain completes cancels the pending 'close'), maxConnections, getConnections(), pauseOnConnect, allowHalfOpen, keepAliveInitialDelay validation, ref()/unref() no-ops, Symbol.asyncDispose
  • Inbound platform sockets are wrapped as net.Sockets: same _handle shape as an outbound connection, localAddress/localPort are the server's bound address, as with an accepted fd (the CONNECT authority only routes the connection; the platform owns that endpoint, nothing is released per connection), remoteAddress from opened.remoteAddress, or the gateway address with a port of its own when the platform reports no peer, socket.server set, read loop started unless paused. The handler promise resolves on the socket's 'close', keeping the inbound request alive for the connection's lifetime

cloudflare:node:

import net from 'node:net';
import { connectHandler } from 'cloudflare:node';

net.createServer((socket) => socket.pipe(socket)).listen(25565);
export default connectHandler();

Addressing: every namespace owns a synthetic host address from 240.1.0.0/16 (reserved and never routed; Hyperdrive's synthetic hosts use 240.0.0.0/16), and 240.1.255.254 is a gateway standing for peers the platform does not identify (service bindings, Durable Object stubs). Where Linux reports the interface a connection actually uses, that is what a socket reports: a wildcard bind resolves to the host address at connect(2) and on accepted sockets, and an unidentified peer appears behind the gateway with a distinct port per connection, as behind a NAT. server.address() and a pre-connect BoundSocket.address() keep reporting the wildcard, as Linux does. A connected or accepted socket therefore never reports the unspecified address as its own or its peer's, which systems software treats as "no address". If the platform later reports an outbound socket's real source (SocketInfo.localAddress is empty for outbound sockets today) or the real peer (#7304 for the TCP listener path), those replace the synthetic values.

connectHandler() takes no arguments: routing is by the port the socket arrived on, so socket.localPort === server.address().port by construction. handleAsNodeConnection(socket, env, ctx) is the direct form.

Runtime: sockets are now bound before services start, so the ports they actually got are known while workers are created, and a TCP socket's connect() handler receives the bound endpoint as its CONNECT authority (truthful for port 0). IoContext gains a scope identity object and Worker::Api a getInboundListeners() (default empty; WorkerdApi fills it from the bound sockets targeting the worker), exposed through cloudflare-internal:sockets.

Observable changes for existing code:

  • Inbound connect() sockets are half-open. Before, the peer's FIN force-closed the handler's writable and settled socket.closed; after, the writable stays open until the handler closes it or returns, so a handler can reply after the peer has finished sending (any send-then-shutdown protocol was previously unservable). A handler that reads to EOF and then awaits socket.closed without closing its writer now waits for the peer's full close or its own return.
  • Socket.closed settles when the readable hits EOF after the writable was already closed (maybeCloseWriteSide returned early without resolving it). This can settle closed while a writer.close() flush is still in flight; callers that await writer.close() first are unaffected.
  • httpServerHandler lookups are scope-aware (Durable Object table first, then isolate).
  • socket.localAddress on an outbound net.connect() is the namespace's synthetic host address (240.1.0.1 for the isolate) once connecting, rather than 0.0.0.0.

Two latent net.Socket issues surfaced by the prompt closed are fixed: onConnectionClosed emitted 'end' directly and could duplicate the read loop's own EOF (now push(null) + read(0), Node's model), and connect() on a live socket left the old handle attached so its EOF ended the reconnected socket and the Duplex auto-end closed the new connection's writable (the old handle is detached first; the read loop and close continuation are bound to their handle).

Tests: net-nodejs-test.js (no declared listeners, arbitrary ports) covers listen variants and errors, listen(boundSocket) adoption, a reply-after-FIN round-trip, connection counting and deferred 'close', no 'close' while re-listening, pauseOnConnect, maxConnections / 'drop', reusePort round-robin routing, and a write after reconnect. New net-server-nodejs-test.js declares two sockets entries on port 0 and covers the declared-port rules (listen(0) claiming in order, EADDRINUSE when exhausted, EADDRNOTAVAIL for undeclared, role-neutral ephemeral new BoundSocket() and its re-home on listen(bound), http.Server unconstrained), a real inbound TCP connection on a port-0 listener reaching the server that listen(0)'d with socket.localPort === server.address().port, and Durable Object scoping (two instances binding 25565 with distinct host addresses, the stub caller behind the gateway, reservations released across requests in the right table, entrypoint bindings invisible inside an instance). connect-half-open-test.js covers the half-open reply and closed settling at the platform level.

Follow-ups, not in this PR: loopback, so that net.connect() to 127.0.0.1 / the host address with a port in the current namespace reaches the namespace's own net.Server (the one structural gap in the Linux picture); real peer identity on inbound sockets (#7304 for the TCP listener path; service bindings and stubs stay behind the gateway) and a real egress source, both platform work at the commented hook points; UDP tables once #7130 lands. The Run workers-sdk tests job asserts the former createServer() stub and passes once cloudflare/workers-sdk#15601 lands.

@guybedford
guybedford requested review from a team as code owners September 10, 2026 17:28
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

The generated output of @cloudflare/workers-types matches the snapshot in types/generated-snapshot 🎉

Comment thread src/cloudflare/internal/http.ts Outdated
Comment thread src/node/internal/internal_net.ts
Comment thread src/node/internal/internal_net.ts
@ask-bonk

ask-bonk Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

I'm Bonk, and I've done a quick review of your PR. PR #7306 adds virtual-port-backed net.Server routing via cloudflare:node.

  1. P1 reusePort replaces and loses active server handlers. Suggestion posted.
  2. P2 A second close() during active connection draining incorrectly succeeds. Suggestion posted.
  3. P2 keepAliveInitialDelay lacks Node-compatible validation and clamping. Suggestion posted.

github run

@codecov-commenter

codecov-commenter commented Sep 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 13.51706% with 659 lines in your changes missing coverage. Please review.
✅ Project coverage is 37.25%. Comparing base (3a1aba7) to head (87dc724).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/workerd/api/node/tests/net-nodejs-test.js 0.00% 363 Missing ⚠️
...c/workerd/api/node/tests/net-server-nodejs-test.js 0.00% 238 Missing ⚠️
src/workerd/api/tests/connect-half-open-test.js 0.00% 39 Missing ⚠️
src/workerd/server/server.c++ 79.77% 8 Missing and 10 partials ⚠️
src/workerd/io/io-context.c++ 83.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7306      +/-   ##
==========================================
- Coverage   37.33%   37.25%   -0.09%     
==========================================
  Files         807      810       +3     
  Lines      253192   253975     +783     
  Branches    20087    20103      +16     
==========================================
+ Hits        94525    94609      +84     
- Misses     147260   147951     +691     
- Partials    11407    11415       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sockets are resolved and bound up front and handed to listenOnSockets()
already bound, so the port a socket actually got (for a configured port
of 0) is known while workers are being created. The CONNECT authority
handed to a TCP socket's connect() handler is built from the bound
endpoint, so it is truthful for port 0.
…nd listeners

IoContext gains an identity object for JS state scoped to it, and
Worker::Api reports the inbound socket listeners configured to deliver
to the worker (workerd fills these from the bound sockets targeting the
worker's service). cloudflare-internal:sockets exposes both:
getPortScopeKey() returns the current Durable Object's key or undefined,
and getInboundListeners() the declared listeners as bound.
…f-close

An inbound socket delivered to a connect() handler is now created with
allowHalfOpen, so the handler can still reply after the peer has
finished sending; previously the write side was force-closed on the
peer's FIN, which breaks any send-then-shutdown client. And
maybeCloseWriteSide now resolves `closed` on its early-return path:
when the writable was already closed by the time the readable hit EOF,
both sides are done but `closed` never settled. The test lives in its
own self-bound worker since connect-handler-test.js is also embedded by
tail-worker-test, which asserts its exact trace stream.
…nnect

onConnectionClosed emitted 'end' directly, which could duplicate the
read loop's own EOF once `closed` settles promptly; push(null) is
idempotent, with read(0) so 'end' still fires on a socket nobody reads.
The read loop and closed continuation are bound to the handle they
started on, and connect() on a live socket detaches the old handle
before closing it, so its EOF no longer ends the reconnected socket (and
the Duplex auto-end no longer closes the new connection's writable).
@guybedford
guybedford force-pushed the gbedford/net-server branch 3 times, most recently from c81fad3 to ede0dc3 Compare September 11, 2026 02:17
…d declared listeners

A Durable Object instance gets its own port table for its lifetime and
binds ports as a separate host would; everything else in the isolate
shares one table, so a server is reachable from every request. The
isolate table is seeded with the ports the platform delivers inbound
connections on: binding port 0 takes an unclaimed declared port when any
are declared, and a declared entry outlives its binders. Owners capture
the table they bound in and release through it, since release may run in
another request's context or in the finalization backstop with none.
Inbound routing looks in the current Durable Object's table before the
isolate's. http.Server allocates an ephemeral port for 0, never a
declared one, since it is reached through httpServerHandler.

Each table also owns a synthetic host address from 240.1.0.0/16 (a
reserved, never-routed block; Hyperdrive's synthetic hosts use
240.0.0.0/16), and 240.1.255.254 is the gateway that stands for peers
the platform does not identify, so a connected or accepted socket never
reports the unspecified address as its own or its peer's.
listen(port | options | boundSocket) reserves the port in the scope's
port table and installs a connect handler there; listen(boundSocket)
adopts the reservation, giving the bind(2) / listen(2) split directly.
Where the platform declares the ports it delivers connections on, only
those may be listened on (EADDRNOTAVAIL otherwise) and listen(0) takes
the first unclaimed one; inside a Durable Object the port is chosen
freely. An in-use port is reported via 'error' as in Node; pipes and
foreign handles are rejected; a listen() before a previous close() has
drained cancels the pending 'close'. Inbound platform sockets routed to
the handler are wrapped as net.Sockets with the platform-owned local
endpoint (family unknown for a hostname label), and the handler resolves
when the connection closes so the inbound request lives that long.
reusePort listeners share connections round-robin. Also 'connection' /
'listening' / 'close' / 'drop', address(), close() waiting for
connections, maxConnections, getConnections(), pauseOnConnect,
keepAliveInitialDelay validation, ref()/unref() no-ops and
Symbol.asyncDispose.
Comment thread src/node/internal/internal_net.ts
Comment thread src/workerd/api/node/tests/net-server-nodejs-test.js Outdated
Comment thread src/workerd/api/node/tests/net-nodejs-test.js Outdated
The connect() analogue of httpServerHandler: routes an inbound platform
socket to the net.Server listening on the port the socket arrived on,
taken from its declared local address (the CONNECT authority for a
service binding or Durable Object stub, the bound listener address for a
sockets entry), so socket.localPort === server.address().port by
construction. handleAsNodeConnection is the direct form. Lookups, for
http too, see the current Durable Object's table before the isolate's.
The returned promise resolves when the connection closes.
@guybedford
guybedford merged commit 17e4f5a into main Sep 12, 2026
24 of 25 checks passed
@guybedford
guybedford deleted the gbedford/net-server branch September 12, 2026 16:07
guybedford added a commit that referenced this pull request Sep 14, 2026
…7357)

The virtual port table is shared state, and the isolate is the one unit that
shares state: module scope and top-level listen() run once per isolate, and the
platform routes between isolates before a port is consulted. #7306 gave each
Durable Object instance its own table, which made a listen() at module
top-level and one in a Durable Object handler bind in different tables in the
same worker, and left a server registered during module evaluation inside a
Durable Object invisible to httpServerHandler from a stateless request.

One table per isolate, for http and net alike, as in Node. Removes the
IoContext scope key, the per-scope host address, and the table threading
through socket owners.
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.

4 participants