Add -sNODERAWSOCKETS backend for real TCP & UDP on Node.js#27080
Add -sNODERAWSOCKETS backend for real TCP & UDP on Node.js#27080guybedford wants to merge 6 commits into
Conversation
sbc100
left a comment
There was a problem hiding this comment.
Nice! I've not yet reviewed the meat of libsockfs.js but looks good so far.
sbc100
left a comment
There was a problem hiding this comment.
I still need to review the details of libsockfs_node.js but the general shape here LGTM!
97ce010 to
43d6cd4
Compare
sbc100
left a comment
There was a problem hiding this comment.
Nice work on all the test cases. I can't say I've read all the test code yet though..
Can you confirm they all run on linux and/or macOS nativly too? (at least the ones where it makes sense that they could).
17092cd to
035cd77
Compare
This comment was marked as outdated.
This comment was marked as outdated.
d179b41 to
5f24cd5
Compare
|
@sbc100 this is ready for final review to land. The tests are comprehensive, and I've also tested it in real world end-to-end Rust applications. Any outstanding issues should be fairly straightforward guarded followups under this feature. The last topic that does also come up here is DNS, but I plan to make a follow-on PR for this instead. |
cc52bbe to
131b198
Compare
|
All the Node.js PRs this is built on have now landed upstream for the next release, and I've tested all the conditional paths against local builds. |
Adds a new NODERAWSOCKETS setting that backs the POSIX sockets API directly with Node.js's node:net and node:dgram, giving real, non-blocking TCP and UDP sockets without WebSockets, an external proxy process, or pthreads. This is the sockets counterpart to NODERAWFS: where NODERAWFS gives direct access to the host filesystem, this gives direct access to host sockets. Unlike PROXY_POSIX_SOCKETS this is single-threaded and event-driven: socket readiness is delivered through the same emscripten_set_socket_*_callback hooks the default WebSocket backend uses, so it drops into existing readiness reactors unchanged. Under -pthread the socket syscalls are proxied to the main thread, so the backend always runs on node's event loop and a SharedArrayBuffer heap is safe. Supported: * TCP clients: connect, send, recv, shutdown and close, with non-blocking semantics and backpressure (send reports EAGAIN rather than buffering unboundedly). * TCP servers: bind, listen, accept, getsockname/getpeername. * UDP: bind, connect, sendto/recvfrom, with connected-peer filtering. * IPv4 and IPv6 (AF_INET6): TCP and UDP over v6, including IPV6_V6ONLY. * get/setsockopt: SO_ERROR, SO_KEEPALIVE and TCP_KEEPIDLE, TCP_NODELAY, SO_RCVBUF/SO_SNDBUF, SO_BROADCAST, IP_TTL, SO_REUSEPORT and IPV6_V6ONLY. Options are mirrored to a cache (the getsockopt source of truth) and projected onto the live socket; we only report options we can actually honor (e.g. SO_REUSEADDR reads back as 1 since libuv forces it on, and IPV6_V6ONLY returns EINVAL if changed after bind). Binding is eager and synchronous, so a conflict surfaces as EADDRINUSE at bind() and getsockname() reports the kernel-assigned ephemeral port immediately - there is no deferred-bind or lazy-handle promotion. A bound socket is a role-neutral handle, adopted as-is by listen() (server.listen) or connect() (net.Socket), and released by close() only if it was never adopted. Bind-time options (ipv6Only, reusePort) are passed to the handle at construction. The bind primitive is selected once per capability: * the public, synchronous net.BoundHandle (and dgram bindSync/connectSync) when the Node.js runtime provides them; and * the private tcp_wrap/udp_wrap bindings as a fallback on Node.js versions that do not (bind6/send6 for IPv6). Details: * new node backend in src/lib/libsockfs_node.js, pulled in only under -sNODERAWSOCKETS, implementing the sock_ops contract * __syscall_setsockopt and __syscall_shutdown now live in JS, routing to the backend under NODERAWSOCKETS (else reporting the option/feature as unsupported), avoiding a libstubs variation * tests under test/sockets exercise TCP echo, server accept/echo (including listen-without-bind autobind), client source-port bind plus synchronous EADDRINUSE, client semantics (EISCONN, half-close, EPIPE), backpressure, connection refused, UDP echo/connect, and IPv6 TCP/UDP over ::1 (including IPV6_V6ONLY before/after bind); all build and run natively against the host stack and run under node, including PROXY_TO_PTHREAD variants
Adds a new NODERAWSOCKETS setting that backs the POSIX sockets API directly with Node.js's node:net and node:dgram, giving real, non-blocking TCP and UDP sockets without WebSockets, an external proxy process, or pthreads. This is the sockets counterpart to NODERAWFS: where NODERAWFS gives direct access to the host filesystem, this gives direct access to host sockets. Unlike PROXY_POSIX_SOCKETS this is single-threaded and event-driven: socket readiness is delivered through the same emscripten_set_socket_*_callback hooks the default WebSocket backend uses, so it drops into existing readiness reactors unchanged. Under -pthread the socket syscalls are proxied to the main thread, so the backend always runs on node's event loop and a SharedArrayBuffer heap is safe. Supported: * TCP clients: connect, send, recv, shutdown and close, with non-blocking semantics and backpressure (send reports EAGAIN rather than buffering unboundedly). * TCP servers: bind, listen, accept, getsockname/getpeername. * UDP: bind, connect, sendto/recvfrom, with connected-peer filtering. * IPv4 and IPv6 (AF_INET6): TCP and UDP over v6, including IPV6_V6ONLY. * get/setsockopt: SO_ERROR, SO_KEEPALIVE and TCP_KEEPIDLE, TCP_NODELAY, SO_RCVBUF/SO_SNDBUF, SO_BROADCAST, IP_TTL, SO_REUSEPORT and IPV6_V6ONLY. Options are mirrored to a cache (the getsockopt source of truth) and projected onto the live socket; we only report options we can actually honor (e.g. SO_REUSEADDR reads back as 1 since libuv forces it on, and IPV6_V6ONLY returns EINVAL if changed after bind). Binding is eager and synchronous, so a conflict surfaces as EADDRINUSE at bind() and getsockname() reports the kernel-assigned ephemeral port immediately - there is no deferred-bind or lazy-handle promotion. A bound socket is a role-neutral handle, adopted as-is by listen() (server.listen) or connect() (net.Socket), and released by close() only if it was never adopted. Bind-time options (ipv6Only, reusePort) are passed to the handle at construction. The bind primitive is selected once per capability: * the public, synchronous net.BoundHandle (and dgram bindSync/connectSync) when the Node.js runtime provides them; and * the private tcp_wrap/udp_wrap bindings as a fallback on Node.js versions that do not (bind6/send6 for IPv6). Details: * new node backend in src/lib/libsockfs_node.js, pulled in only under -sNODERAWSOCKETS, implementing the sock_ops contract * __syscall_setsockopt and __syscall_shutdown now live in JS, routing to the backend under NODERAWSOCKETS (else reporting the option/feature as unsupported), avoiding a libstubs variation * tests under test/sockets exercise TCP echo, server accept/echo (including listen-without-bind autobind), client source-port bind plus synchronous EADDRINUSE, client semantics (EISCONN, half-close, EPIPE), backpressure, connection refused, UDP echo/connect, and IPv6 TCP/UDP over ::1 (including IPV6_V6ONLY before/after bind); all build and run natively against the host stack and run under node, including PROXY_TO_PTHREAD variants
sbc100
left a comment
There was a problem hiding this comment.
LGTM
Lots of comments but they are pretty much all just nits.
|
|
||
| // If enabled, the POSIX sockets API is backed by Node.js's ``node:net`` | ||
| // module, giving real non-blocking outgoing TCP sockets with no WebSockets, | ||
| // proxy process or pthreads. This is the sockets counterpart to NODERAWFS: |
There was a problem hiding this comment.
Use :ref: here when referring to another setting.
| // | ||
| // It supports full TCP (outgoing connect plus bind, listen and accept for | ||
| // servers) and UDP. TCP clients use the public node:net API when possible, | ||
| // falling back to the private tcp_wrap/udp_wrap handles on older Node.js. |
There was a problem hiding this comment.
using backticks for node:net and tcp_wrap / udp_wrap ?
| // It is event-driven. Socket readiness comes through the same | ||
| // ``emscripten_set_socket_*_callback`` hooks the WebSocket backend uses, so it | ||
| // works with existing readiness reactors. It cannot be combined with the | ||
| // WebSocket emulation, PROXY_POSIX_SOCKETS or SOCKET_WEBRTC. |
| // works with existing readiness reactors. It cannot be combined with the | ||
| // WebSocket emulation, PROXY_POSIX_SOCKETS or SOCKET_WEBRTC. | ||
| // | ||
| // It works under -pthread with PROXY_TO_PTHREAD, where main() and every socket |
| var NodeSockFSLibrary = { | ||
| $nodeSockOps__deps: ['$SOCKFS', '$ERRNO_CODES'], |
There was a problem hiding this comment.
Should we have something like throw new Error("NODERAWFS is currently only supported on Node.js environment.") like we do for NODERAWFS? (At least in debug mode/ASSERTIONS)
| def handle(self): | ||
| data = self.request.recv(64) | ||
| if data: | ||
| self.request.sendall(data) |
There was a problem hiding this comment.
Maybe define EchoHandler at the top level of this file to avoid duplicating it?
| static struct sockaddr_in dest; | ||
| static uint16_t src_port = 0; | ||
| static bool connected = false; | ||
| static bool ping_sent = false; |
There was a problem hiding this comment.
I'm not sure if I already asked this, and I'm in two minds myself, but maybe it makes the tests more readable to just elide all the static specifiers?
| if data: | ||
| self.request.sendall(data) | ||
|
|
||
| # Reserve a free loopback port for the client's bound source port. |
There was a problem hiding this comment.
Can you explain how this works? My nieve reading of this is that this parent process (i.e. the test runner) would now "own" this port. How does calling bind here reserve it for the child process?
| self.do_runf('sockets/test_tcp_client_bind.c', 'CLIENT BIND PASS', | ||
| cflags=['-sNODERAWSOCKETS'], args=[str(port), str(src_port)]) | ||
| finally: | ||
| server.shutdown() |
There was a problem hiding this comment.
I don't suppose threading.Thread or socketserver.TCPServer can be used as context managers to make the cleanup automagical here?
| # ephemeral port, the client sends a datagram, the server echoes it back. | ||
| self.do_runf('sockets/test_udp_echo.c', 'UDP ECHO PASS', cflags=['-sNODERAWSOCKETS'] + args) | ||
|
|
||
| @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) |
There was a problem hiding this comment.
We have an existing decorator called also_with_proxy_to_pthread in test/test_browser.py, you could duplicate that or move it to decorators.py and use it from there?
|
If you would like to get some testing the unreleased version of node we could add some node-nightly tests to test-node-compat in We already do some testing with oldest supported node, LTS node, and newest node. We could add nightly there too, although that would be find as a followup too. It might actually good to get that done before node makes the release so that if there are bugs there they don't get shipping and time bombs that explode on the 26+ deployment. |
This adds a new
-sNODERAWSOCKETSsetting that for supporting direct full sockets on Node.js via thenode:netfor TCP andnode:dgramfor UDP modules, without needingws, an external proxy process, or pthreads.node:netAPIs.node:dgramAPIsTo support these embeddings without JSPI being mandatory requires using
process.binding('tcp_wrap')andprocess.binding('udp_wrap')in Node.js, which are used here to support older versions of Node.js.Further, to avoid having to rely on private APIs in modern Node.js I actually implemented upstream Node.js PRs to make public API surface area available for the full embedding in the following:
Then this PR conditionally checks these features and uses the public APIs as defined by them when it is able to, falling back to
tcp_wrapandudp_wraponly when not supported. While Node.js with these features has not yet been released, as soon as all three have landed, we can rely on this surface area for modern 26+ versions of Node.js compat.Note: AI was used to create this PR, under my review.