node:net: add net.Server and cloudflare:node connectHandler - #7306
Merged
Merged
Conversation
|
The generated output of |
Contributor
|
I'm Bonk, and I've done a quick review of your PR. PR #7306 adds virtual-port-backed
|
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
guybedford
force-pushed
the
gbedford/net-server
branch
from
September 10, 2026 19:42
816dcab to
33b85cb
Compare
2 tasks
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
force-pushed
the
gbedford/net-server
branch
3 times, most recently
from
September 11, 2026 02:17
c81fad3 to
ede0dc3
Compare
…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.
guybedford
force-pushed
the
gbedford/net-server
branch
from
September 11, 2026 02:26
ede0dc3 to
0561548
Compare
jasnell
reviewed
Sep 11, 2026
jasnell
reviewed
Sep 11, 2026
jasnell
reviewed
Sep 11, 2026
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
force-pushed
the
gbedford/net-server
branch
from
September 11, 2026 05:04
0561548 to
dc95e9e
Compare
jasnell
approved these changes
Sep 11, 2026
petebacondarwin
approved these changes
Sep 12, 2026
This was referenced Sep 14, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #7299.
This implements
net.Serverover the virtual port table from #7299, and aconnectHandlerincloudflare:nodethat routes inbound platform sockets to it, theconnect()analogue ofhttpServerHandler.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 (
httpServerHandlerlookups are scope-aware too: anhttp.Serverlistening 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
socketsentries targeting the worker, as bound, so a configured port of 0 is resolved; production would fill this from the wranglerconnect[]triggers). With a declared set,net.Server.listen(N)at the entrypoint must use a declared port (EADDRNOTAVAILotherwise),listen(0)takes the first unclaimed declared port, and every declared port claimed makeslisten(0)EADDRINUSE. Binding itself is role-neutral:new BoundSocket()(port 0) always takes an ephemeral port, so egress binders such as Emscripten's per-connectionbind(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'snew 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'sstub.connect('host:N').http.Serveris reached throughhttpServerHandler, not a listener, so it is unconstrained and itslisten(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; likewisehttp.Server.listen(<declared>)claims it, after which inbound TCP on that port finds nonet.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 thebind(2)/listen(2)split directly.server.address()and accepted sockets'localAddressreport the bound label (the host as given,0.0.0.0by default)host,ipv6Only,reusePorthonored as in node:net: add net.BoundSocket #7299;reusePortlisteners share connections round-robin; an in-use port is reported via'error'(EADDRINUSE) as in Node;pathand foreign handles are rejected'listening'/'connection'/'close'/'drop',address(),listening,close()(waits for open connections;ERR_SERVER_NOT_RUNNINGwhen not listening; alisten()before the drain completes cancels the pending'close'),maxConnections,getConnections(),pauseOnConnect,allowHalfOpen,keepAliveInitialDelayvalidation,ref()/unref()no-ops,Symbol.asyncDisposenet.Sockets: same_handleshape as an outbound connection,localAddress/localPortare 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),remoteAddressfromopened.remoteAddress, or the gateway address with a port of its own when the platform reports no peer,socket.serverset, read loop started unless paused. The handler promise resolves on the socket's'close', keeping the inbound request alive for the connection's lifetimecloudflare:node:Addressing: every namespace owns a synthetic host address from
240.1.0.0/16(reserved and never routed; Hyperdrive's synthetic hosts use240.0.0.0/16), and240.1.255.254is 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 atconnect(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-connectBoundSocket.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.localAddressis 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, sosocket.localPort === server.address().portby 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).IoContextgains a scope identity object andWorker::ApiagetInboundListeners()(default empty;WorkerdApifills it from the bound sockets targeting the worker), exposed throughcloudflare-internal:sockets.Observable changes for existing code:
connect()sockets are half-open. Before, the peer's FIN force-closed the handler's writable and settledsocket.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 awaitssocket.closedwithout closing its writer now waits for the peer's full close or its own return.Socket.closedsettles when the readable hits EOF after the writable was already closed (maybeCloseWriteSidereturned early without resolving it). This can settleclosedwhile awriter.close()flush is still in flight; callers that awaitwriter.close()first are unaffected.httpServerHandlerlookups are scope-aware (Durable Object table first, then isolate).socket.localAddresson an outboundnet.connect()is the namespace's synthetic host address (240.1.0.1for the isolate) once connecting, rather than0.0.0.0.Two latent
net.Socketissues surfaced by the promptclosedare fixed:onConnectionClosedemitted'end'directly and could duplicate the read loop's own EOF (nowpush(null)+read(0), Node's model), andconnect()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',reusePortround-robin routing, and a write after reconnect. Newnet-server-nodejs-test.jsdeclares twosocketsentries on port 0 and covers the declared-port rules (listen(0)claiming in order,EADDRINUSEwhen exhausted,EADDRNOTAVAILfor undeclared, role-neutral ephemeralnew BoundSocket()and its re-home onlisten(bound),http.Serverunconstrained), a real inbound TCP connection on a port-0 listener reaching the server thatlisten(0)'d withsocket.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.jscovers the half-open reply andclosedsettling at the platform level.Follow-ups, not in this PR: loopback, so that
net.connect()to127.0.0.1/ the host address with a port in the current namespace reaches the namespace's ownnet.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. TheRun workers-sdk testsjob asserts the formercreateServer()stub and passes once cloudflare/workers-sdk#15601 lands.