From 9171c639a01e5420cd78309c593d49d87ca7fb70 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 9 Jun 2026 16:22:13 -0700 Subject: [PATCH 1/7] Add -sNODERAWSOCKETS backend for real TCP and UDP sockets via node:net 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 --- .../tools_reference/settings_reference.rst | 31 + src/lib/libsigs.js | 1 + src/lib/libsockfs.js | 18 +- src/lib/libsockfs_node.js | 723 ++++++++++++++++++ src/lib/libsyscall.js | 24 +- src/modules.mjs | 4 + src/settings.js | 26 + src/struct_info.json | 26 +- src/struct_info_generated.json | 4 + src/struct_info_generated_wasm64.json | 4 + system/lib/libc/emscripten_syscall_stubs.c | 6 - system/lib/wasmfs/syscalls.cpp | 18 + .../test_codesize_hello_dylink_all.json | 16 +- test/sockets/test_tcp_backpressure.c | 120 +++ test/sockets/test_tcp_client_bind.c | 147 ++++ test/sockets/test_tcp_client_semantics.c | 132 ++++ test/sockets/test_tcp_echo.c | 140 ++++ test/sockets/test_tcp_ipv6.c | 188 +++++ test/sockets/test_tcp_refused.c | 95 +++ test/sockets/test_tcp_server.c | 190 +++++ test/sockets/test_udp_connect.c | 137 ++++ test/sockets/test_udp_echo.c | 149 ++++ test/sockets/test_udp_ipv6.c | 132 ++++ test/test_sockets.py | 132 ++++ tools/link.py | 2 + tools/settings.py | 3 + 26 files changed, 2439 insertions(+), 29 deletions(-) create mode 100644 src/lib/libsockfs_node.js create mode 100644 test/sockets/test_tcp_backpressure.c create mode 100644 test/sockets/test_tcp_client_bind.c create mode 100644 test/sockets/test_tcp_client_semantics.c create mode 100644 test/sockets/test_tcp_echo.c create mode 100644 test/sockets/test_tcp_ipv6.c create mode 100644 test/sockets/test_tcp_refused.c create mode 100644 test/sockets/test_tcp_server.c create mode 100644 test/sockets/test_udp_connect.c create mode 100644 test/sockets/test_udp_echo.c create mode 100644 test/sockets/test_udp_ipv6.c diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index 3d6a0fe34d9ef..0ec490766735b 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -588,6 +588,37 @@ sockets calls from browser to native world. Default value: false +.. _noderawsockets: + +NODERAWSOCKETS +============== + +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: +where NODERAWFS gives direct access to the host filesystem, this gives +direct access to host sockets. It only works under node and is ignored +elsewhere. + +It supports full TCP (outgoing connect plus bind, listen and accept for +servers) and UDP. TCP clients use the public node:net API. bind needs a +synchronous bind() + getsockname(), so it uses the public node APIs that +provide them when present - net.BoundHandle for TCP and dgram +bindSync/connectSync for UDP - and falls back to the private tcp_wrap/udp_wrap +handles on older Node.js versions that lack them. + +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. + +It works under -pthread with PROXY_TO_PTHREAD, where main() and every socket +syscall run on a single worker alongside the node handles and their event +loop. As with the WebSocket backend, sharing a socket across threads under a +plain -pthread build (without PROXY_TO_PTHREAD) is not supported. + +Default value: false + .. _websocket_subprotocol: WEBSOCKET_SUBPROTOCOL diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index 3159fcf42c9f1..746ee98ff5cef 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -271,6 +271,7 @@ sigs = { __syscall_rmdir__sig: 'ip', __syscall_sendmsg__sig: 'iipiiii', __syscall_sendto__sig: 'iippipi', + __syscall_setsockopt__sig: 'iiiipii', __syscall_shutdown__sig: 'iiiiiii', __syscall_socket__sig: 'iiiiiii', __syscall_stat64__sig: 'ipp', diff --git a/src/lib/libsockfs.js b/src/lib/libsockfs.js index a9e99be7729ee..66bcdcb162a42 100644 --- a/src/lib/libsockfs.js +++ b/src/lib/libsockfs.js @@ -8,7 +8,11 @@ addToLibrary({ $SOCKFS__postset: () => { addAtInit('SOCKFS.root = FS.mount(SOCKFS, {}, null);'); }, - $SOCKFS__deps: ['$FS'], + $SOCKFS__deps: ['$FS', +#if NODERAWSOCKETS + '$nodeSockOps', +#endif + ], $SOCKFS: { #if expectToReceiveOnModule('websocket') websocketArgs: {}, @@ -44,8 +48,12 @@ addToLibrary({ return FS.createNode(null, '/', {{{ cDefs.S_IFDIR | 0o777 }}}, 0); }, createSocket(family, type, protocol) { - // Emscripten only supports AF_INET - if (family != {{{ cDefs.AF_INET }}}) { + if (family != {{{ cDefs.AF_INET }}} +#if NODERAWSOCKETS + // The node:net backend supports IPv6; other backends are IPv4 only. + && family != {{{ cDefs.AF_INET6 }}} +#endif + ) { throw new FS.ErrnoError({{{ cDefs.EAFNOSUPPORT }}}); } type &= ~{{{ cDefs.SOCK_CLOEXEC | cDefs.SOCK_NONBLOCK }}}; // Some applications may pass it; it makes no sense for a single process. @@ -69,6 +77,8 @@ addToLibrary({ pending: [], recv_queue: [], #if SOCKET_WEBRTC +#elif NODERAWSOCKETS + sock_ops: nodeSockOps #else sock_ops: SOCKFS.websocket_sock_ops #endif @@ -726,7 +736,7 @@ addToLibrary({ return res; } - } + }, }, /* diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js new file mode 100644 index 0000000000000..584fd96654910 --- /dev/null +++ b/src/lib/libsockfs_node.js @@ -0,0 +1,723 @@ +/** + * @license + * Copyright 2026 The Emscripten Authors + * SPDX-License-Identifier: MIT + */ + +// TCP and UDP over node:net / node:dgram (-sNODERAWSOCKETS). This implements +// the same sock_ops contract and SOCKFS.emit readiness callbacks as the +// WebSocket backend, so existing readiness reactors work unchanged. +// +// bind() is eager and synchronous: it produces a role-neutral bound handle and +// records the kernel-assigned name immediately, so getsockname() needs no +// promotion, a conflict surfaces right here as EADDRINUSE, and the handle is +// adopted as-is by listen() (server.listen) or connect() (net.Socket). The bind +// primitive is chosen once per capability: the public, synchronous +// net.BoundHandle when the runtime offers it, else the private tcp_wrap binding +// as a fallback (net.Server's listen is async and cannot report an assigned +// ephemeral port up front, so it can't drive bind on its own). connect() goes +// through net.Socket, adopting the bound handle when one exists so an explicit +// source address/port is honored, and otherwise letting the kernel assign one. +// +// UDP uses the public node:dgram socket when it exposes a synchronous bindSync +// (a recent node addition that ships alongside connectSync), giving the +// bind(:0) + getsockname() and a real connect() that libc needs up front. There +// connect() is a real kernel connect, so the OS filters non-peer datagrams and +// surfaces async errors (e.g. ICMP ECONNREFUSED). Older node has no synchronous +// dgram bind/connect, so it falls back to a low-level udp_wrap handle and a +// connect() emulated in JS (record the peer, filter in udpDeliver). The choice +// is made per socket via useDgram(). +// +// Under -pthread with PROXY_TO_PTHREAD, main() and every socket syscall run on +// the same worker, so the node handles, their event loop and the readiness +// callbacks all live on that one thread (a socket is not shared across threads, +// just as in the WebSocket backend). Payloads are copied out of (possibly +// shared) wasm memory before being handed to node, so a SharedArrayBuffer heap +// is safe. + +var NodeSockFSLibrary = { + $nodeSockOps__deps: ['$SOCKFS', '$ERRNO_CODES'], + $nodeSockOps: { + // node builtins, resolved once each. getBuiltinModule works in both + // CommonJS and ESM output, with require as the fallback. + getNet() { + return nodeSockOps.netModule ??= (process.getBuiltinModule || require)('net'); + }, + getUtil() { + return nodeSockOps.utilModule ??= (process.getBuiltinModule || require)('util'); + }, + getDgram() { + return nodeSockOps.dgramModule ??= (process.getBuiltinModule || require)('dgram'); + }, + // True when node:dgram exposes both synchronous bindSync and connectSync + // (a recent addition), letting UDP run entirely on the public API. A runtime + // missing either falls back to the private udp_wrap handle, which provides + // both, so we never end up on a half-supported public path. + useDgram() { + var proto = nodeSockOps.getDgram().Socket.prototype; + return nodeSockOps.dgramSync ??= + typeof proto.bindSync == 'function' && typeof proto.connectSync == 'function'; + }, + // Queue a received datagram and signal readiness. A connected datagram + // socket only accepts datagrams from its peer. Shared by both backends. + udpDeliver(sock, address, port, data) { + if (sock.daddr !== undefined && (address !== sock.daddr || port !== sock.dport)) { + return; + } + sock.recv_queue.push({ addr: address, port, data }); + SOCKFS.emit('message', sock.stream.fd); + }, + // Map a node error (its `.code` string) to an emscripten errno. Most node + // codes are errno names already; a few are node-specific and aliased here. + errnoForNode(e) { + var code = e && e.code; + if (code === 'ERR_SOCKET_DGRAM_NOT_CONNECTED') return {{{ cDefs.ENOTCONN }}}; + if (code === 'ERR_SOCKET_BAD_PORT') return {{{ cDefs.EINVAL }}}; + return (code && ERRNO_CODES[code]) || {{{ cDefs.EIO }}}; + }, + // Map a libuv result code (negative errno, as returned by the low-level + // handle's bind/getsockname) to an emscripten errno. + errnoForCode(code) { + var name = nodeSockOps.getUtil().getSystemErrorName(code); + return (name && ERRNO_CODES[name]) || {{{ cDefs.EINVAL }}}; + }, + // TCP binds eagerly and synchronously, so there is no deferred bind and no + // lazy handle promotion - the only difference between the two backends is how + // a bound handle is produced: the public net.BoundHandle when node offers it, + // else the private tcp_wrap binding. Chosen once, like useDgram(). + useBoundHandle() { + return nodeSockOps.boundHandleOk ??= + typeof nodeSockOps.getNet().BoundHandle == 'function'; + }, + // Synchronously bind a TCP socket to addr:port (0 = ephemeral) and record the + // kernel-assigned name immediately. sock.bound is the resulting role-neutral + // handle - a net.BoundHandle, or a raw tcp_wrap handle - adopted as-is by + // listen() (server.listen) and connect() (net.Socket). So getsockname() needs + // no promotion, a conflict surfaces here as EADDRINUSE (exactly when POSIX + // bind() would), and close() releases it if unadopted. + bindHandle(sock, addr, port) { + var o = sock.opts || {}; + if (nodeSockOps.useBoundHandle()) { + var bh; + // The constructor binds synchronously and throws a bind conflict + // (EADDRINUSE etc.) right here; address() on the bound handle is safe. + // ipv6Only/reusePort are bind-time options, applied here from the cache. + try { + bh = new (nodeSockOps.getNet().BoundHandle)({ + host: addr, port, ipv6Only: o.ipv6Only, reusePort: o.reusePort, + }); + } + catch (e) { throw new FS.ErrnoError(nodeSockOps.errnoForNode(e)); } + var n = bh.address(); + sock.bound = bh; + sock.saddr = n.address; + sock.sport = n.port; + return; + } + var tcp; + try { + tcp = process.binding('tcp_wrap'); + } catch (e) { + throw new FS.ErrnoError({{{ cDefs.EOPNOTSUPP }}}); + } + var handle = new tcp.TCP(tcp.constants.SOCKET); + // bind6 for IPv6 literals, honoring IPV6_V6ONLY via the bind flags. + var code = addr.includes(':') + ? handle.bind6(addr, port, o.ipv6Only ? 1 /* UV_TCP_IPV6ONLY */ : 0) + : handle.bind(addr, port); + if (!code) { + var name = {}; + code = handle.getsockname(name); + if (!code) { + sock.bound = handle; + sock.saddr = name.address; + sock.sport = name.port; + return; + } + } + try { handle.close(); } catch (e) {} + throw new FS.ErrnoError(nodeSockOps.errnoForCode(code)); + }, + // The peer address is already a numeric IP (emscripten resolves names in + // its own DNS layer), so skip node's async DNS lookup. The family follows + // the literal: a colon means IPv6. + noLookup(host, _opts, cb) { + cb(null, host, host.includes(':') ? 6 : 4); + }, + // The UDP backing object. With a synchronous dgram bindSync available we use + // a public node:dgram socket (sock.udpPublic); otherwise we fall back to a + // private udp_wrap handle, which is the only older-node way to get a + // synchronous bind() + getsockname(). Either way recv wiring funnels through + // udpDeliver, so bind/send/recv/poll/close stay backend-agnostic. + ensureUdpHandle(sock) { + if (sock.udp) return sock.udp; + if (nodeSockOps.useDgram()) { + var socket = nodeSockOps.getDgram().createSocket(sock.family === {{{ cDefs.AF_INET6 }}} ? 'udp6' : 'udp4'); + socket.on('message', (msg, rinfo) => { + var data = new Uint8Array(msg.length); + data.set(msg); + nodeSockOps.udpDeliver(sock, rinfo.address, rinfo.port, data); + }); + socket.on('error', (e) => { + sock.error = nodeSockOps.errnoForNode(e); + SOCKFS.emit('error', [sock.stream.fd, sock.error, (e && e.message) || 'udp error']); + }); + sock.udpPublic = true; + return sock.udp = socket; + } + var udp = process.binding('udp_wrap'); + var handle = new udp.UDP(); + sock.sendWrap = udp.SendWrap; + handle.onmessage = (nread, _h, buf, rinfo) => { + if (nread < 0) { + sock.error = nodeSockOps.errnoForCode(nread); + SOCKFS.emit('error', [sock.stream.fd, sock.error, 'udp error']); + return; + } + var data = new Uint8Array(buf.length); + data.set(buf); + nodeSockOps.udpDeliver(sock, rinfo.address, rinfo.port, data); + }; + return sock.udp = handle; + }, + // Begin receiving exactly once. A udp_wrap handle needs an explicit + // recvStart after it is bound; a public dgram socket receives automatically + // once bound, so we only need to ensure a bind. An outgoing socket that + // never called bind() auto-binds to an ephemeral port here so getsockname + // reports the assigned local address. + startUdpRecv(sock) { + if (!sock.udp || sock.udpReceiving) return; + if (sock.udpPublic) { + if (sock.sport === undefined) { + var a = sock.udp.bindSync({ address: sock.family === {{{ cDefs.AF_INET6 }}} ? '::' : '0.0.0.0', port: 0 }); + sock.saddr = a.address; + sock.sport = a.port; + } + } else { + sock.udp.recvStart(); + if (sock.sport === undefined) { + var name = {}; + if (sock.udp.getsockname(name) === 0) { + sock.saddr = name.address; + sock.sport = name.port; + } + } + } + sock.udpReceiving = true; + // node only honors these once the socket is bound, so (re)apply any + // options that were set earlier. + nodeSockOps.applyUdpOptions(sock); + }, + // Apply the buffered datagram options to a bound UDP socket. + applyUdpOptions(sock) { + var h = sock.udp; + var o = sock.opts; + if (!h || !o || !sock.udpReceiving) return; + if (sock.udpPublic) { + if (o.ttl !== undefined) { try { h.setTTL(o.ttl); } catch (e) {} } + if (o.broadcast !== undefined) { try { h.setBroadcast(!!o.broadcast); } catch (e) {} } + if (o.recvBuf !== undefined) { try { h.setRecvBufferSize(o.recvBuf); } catch (e) {} } + if (o.sendBuf !== undefined) { try { h.setSendBufferSize(o.sendBuf); } catch (e) {} } + } else { + if (o.ttl !== undefined) { try { h.setTTL(o.ttl); } catch (e) {} } + if (o.broadcast !== undefined) { try { h.setBroadcast(o.broadcast ? 1 : 0); } catch (e) {} } + if (o.recvBuf !== undefined) { try { h.bufferSize(o.recvBuf, true, {}); } catch (e) {} } + if (o.sendBuf !== undefined) { try { h.bufferSize(o.sendBuf, false, {}); } catch (e) {} } + } + }, + // The live OS buffer size from a bound UDP socket, or undefined. + udpBufferSize(sock, recv) { + if (!sock.udp || !sock.udpReceiving) return undefined; + try { + if (sock.udpPublic) return recv ? sock.udp.getRecvBufferSize() : sock.udp.getSendBufferSize(); + return sock.udp.bufferSize(0, recv, {}); + } catch (e) { return undefined; } + }, + // Replay buffered opts once the socket is live. + applyOptions(sock) { + var conn = sock.connection; + var o = sock.opts; + if (!conn || !o) return; + if (o.noDelay !== undefined) { + try { conn.setNoDelay(!!o.noDelay); } catch (e) {} + } + nodeSockOps.applyKeepAlive(sock); + }, + // The keepalive tunables arrive from C in seconds, but node wants + // milliseconds, so we scale by 1000. A non-positive value keeps node's + // default for that field. + applyKeepAlive(sock) { + var conn = sock.connection; + var o = sock.opts; + if (!conn || !o || o.keepAlive === undefined) return; + try { + conn.setKeepAlive( + !!o.keepAlive, + (o.keepAliveIdle || 0) * 1000, + (o.keepAliveIntvl || 0) * 1000, + o.keepAliveCnt || 0); + } catch (e) {} + }, + // Forward a connected node socket's events onto sock. + wireConnection(sock, conn) { + sock.connection = conn; + conn.on('data', (buf) => { + var data = new Uint8Array(buf.length); + data.set(buf); + sock.recv_queue.push({ addr: sock.daddr, port: sock.dport, data }); + sock.recv_bytes = (sock.recv_bytes || 0) + data.length; + // If the peer outruns the reader, pause node and resume in recvmsg. + if (sock.recv_bytes >= 262144 /* 256 KiB */) { + try { conn.pause(); } catch (e) {} + sock.paused = true; + } + SOCKFS.emit('message', sock.stream.fd); + }); + // A peer FIN surfaces as EOF to the reader. + conn.on('end', () => { + sock.readClosed = true; + SOCKFS.emit('message', sock.stream.fd); + }); + conn.on('close', () => { + sock.readClosed = true; + sock.state = 'closed'; + SOCKFS.emit('close', sock.stream.fd); + }); + // Backpressure relieved, so we are writable again. + conn.on('drain', () => { + sock.writeBlocked = false; + SOCKFS.emit('open', sock.stream.fd); + }); + conn.on('error', (e) => { + sock.error = nodeSockOps.errnoForNode(e); + // Let a failed connect resolve so SO_ERROR can be read. + if (sock.state === 'connecting') sock.state = 'connected'; + SOCKFS.emit('error', [sock.stream.fd, sock.error, (e && e.message) || 'socket error']); + }); + }, + poll(sock) { + // A listener is readable when a connection is waiting to be accepted. + if (sock.server) { + return sock.pending.length ? ({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}}) : 0; + } + // UDP is connectionless: always writable, readable when a datagram waits. + if (sock.type === {{{ cDefs.SOCK_DGRAM }}}) { + var dmask = {{{ cDefs.POLLOUT }}}; + if (sock.recv_queue.length || sock.error) dmask |= ({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}}); + return dmask; + } + var mask = 0; + if (sock.recv_queue.length || sock.readClosed || sock.error) { + mask |= ({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}}); + } + if (sock.error) { + // Mark writable on error so SO_ERROR can be read. + mask |= {{{ cDefs.POLLOUT }}}; + } else if (sock.connection && sock.state === 'connected' && !sock.writeBlocked) { + mask |= {{{ cDefs.POLLOUT }}}; + } + if (sock.readClosed) mask |= {{{ cDefs.POLLHUP }}}; + return mask; + }, + ioctl(sock, request, arg) { + switch (request) { + case {{{ cDefs.FIONREAD }}}: + var bytes = sock.recv_queue.length ? sock.recv_queue[0].data.length : 0; + {{{ makeSetValue('arg', '0', 'bytes', 'i32') }}}; + return 0; + case {{{ cDefs.FIONBIO }}}: + var on = {{{ makeGetValue('arg', '0', 'i32') }}}; + if (on) sock.stream.flags |= {{{ cDefs.O_NONBLOCK }}}; + else sock.stream.flags &= ~{{{ cDefs.O_NONBLOCK }}}; + return 0; + default: + return {{{ cDefs.EINVAL }}}; + } + }, + close(sock) { + sock.state = 'closed'; + if (sock.udp) { + try { + if (sock.udpPublic) sock.udp.close(); + else { sock.udp.recvStop(); sock.udp.close(); } + } catch (e) {} + sock.udp = null; + } + if (sock.server) { try { sock.server.close(); } catch (e) {} sock.server = null; } + if (sock.connection) { try { sock.connection.destroy(); } catch (e) {} sock.connection = null; } + // A bound handle that was never adopted by listen()/connect() is ours to + // release; once adopted the server/connection owns it. + if (sock.bound && !sock.server && !sock.connection) { + try { sock.bound.close(); } catch (e) {} + } + sock.bound = null; + return 0; + }, + // how: SHUT_RD 0, SHUT_WR 1, SHUT_RDWR 2 (musl sys/socket.h). + shutdown(sock, how) { + if (!sock.connection) throw new FS.ErrnoError({{{ cDefs.ENOTCONN }}}); + if (how === 0 || how === 2) { + // No more reads: subsequent recv returns EOF. + sock.readClosed = true; + } + if (how === 1 || how === 2) { + // Half-close the write side (sends FIN); later sends fail with EPIPE. + sock.writeShutdown = true; + try { sock.connection.end(); } catch (e) {} + } + SOCKFS.emit('message', sock.stream.fd); + return 0; + }, + bind(sock, addr, port) { + if (sock.saddr !== undefined || sock.sport !== undefined) { + throw new FS.ErrnoError({{{ cDefs.EINVAL }}}); // already bound + } + if (sock.type === {{{ cDefs.SOCK_DGRAM }}}) { + var udp = nodeSockOps.ensureUdpHandle(sock); + if (sock.udpPublic) { + var a; + // bindSync throws synchronously (e.g. EADDRINUSE) and returns the + // bound address, including the OS-assigned port for port 0. + try { a = udp.bindSync({ address: addr, port }); } + catch (e) { throw new FS.ErrnoError(nodeSockOps.errnoForNode(e)); } + sock.saddr = a.address; + sock.sport = a.port; + } else { + var ucode = addr.includes(':') ? udp.bind6(addr, port, 0) : udp.bind(addr, port, 0); + if (ucode) throw new FS.ErrnoError(nodeSockOps.errnoForCode(ucode)); + var uname = {}; + ucode = udp.getsockname(uname); + if (ucode) throw new FS.ErrnoError(nodeSockOps.errnoForCode(ucode)); + sock.saddr = uname.address; + sock.sport = uname.port; + } + sock.state = 'bound'; + nodeSockOps.startUdpRecv(sock); + return; + } + // TCP binds eagerly and synchronously: the kernel-assigned port (even for + // a bind(:0)) is known immediately, getsockname() needs no promotion, and a + // conflict surfaces right here as EADDRINUSE. + nodeSockOps.bindHandle(sock, addr, port); + sock.state = 'bound'; + }, + connect(sock, addr, port) { + if (sock.type === {{{ cDefs.SOCK_DGRAM }}}) { + sock.daddr = addr; + sock.dport = port; + var udp = nodeSockOps.ensureUdpHandle(sock); + if (sock.udpPublic) { + // Real kernel connect: the OS filters non-peer datagrams and reports + // async errors (e.g. ICMP ECONNREFUSED) on the socket. connectSync + // binds first if needed and throws synchronously; a re-connect just + // replaces the peer. + if (sock.udpConnected) { try { udp.disconnect(); } catch (e) {} } + try { udp.connectSync(port, addr); } + catch (e) { throw new FS.ErrnoError(nodeSockOps.errnoForNode(e)); } + sock.udpConnected = true; + var a = udp.address(); + sock.saddr = a.address; + sock.sport = a.port; + sock.udpReceiving = true; // a bound dgram socket already receives + nodeSockOps.applyUdpOptions(sock); + return; + } + // Older node has no synchronous dgram connect, so just record the peer + // and enforce it in JS (see udpDeliver and sendmsg); replies arrive once + // the socket is bound (an explicit bind or the auto-bind on first send). + return; + } + if (sock.server) throw new FS.ErrnoError({{{ cDefs.EOPNOTSUPP }}}); + if (sock.connection) { + throw new FS.ErrnoError(sock.state === 'connecting' ? {{{ cDefs.EALREADY }}} : {{{ cDefs.EISCONN }}}); + } + sock.daddr = addr; + sock.dport = port; + sock.state = 'connecting'; + var net = nodeSockOps.getNet(); + var conn; + if (sock.bound) { + // A prior bind() produced a real, already-bound handle; connect through + // it so the bound source address/port is honored by the kernel. + conn = new net.Socket({ handle: sock.bound, pauseOnCreate: true, allowHalfOpen: true }); + } else { + // Unbound client: let the kernel assign the source address/port. + conn = new net.Socket({ allowHalfOpen: true }); + } + conn.once('connect', () => { + sock.state = 'connected'; + sock.saddr = conn.localAddress; + sock.sport = conn.localPort; + sock.daddr = conn.remoteAddress || addr; + sock.dport = conn.remotePort || port; + try { conn.resume(); } catch (e) {} + nodeSockOps.applyOptions(sock); + SOCKFS.emit('open', sock.stream.fd); + }); + nodeSockOps.wireConnection(sock, conn); + conn.connect({ host: addr, port, lookup: nodeSockOps.noLookup }); + }, + listen(sock, backlog) { + if (sock.type !== {{{ cDefs.SOCK_STREAM }}}) throw new FS.ErrnoError({{{ cDefs.EOPNOTSUPP }}}); // not a stream socket + if (sock.server) throw new FS.ErrnoError({{{ cDefs.EINVAL }}}); // already listening + if (sock.connection) throw new FS.ErrnoError({{{ cDefs.EINVAL }}}); // a connected socket cannot listen + // POSIX listen without a prior bind auto-binds an ephemeral port. The bind + // is eager and synchronous (bindHandle), so the assigned port is known and + // any conflict surfaces before we listen. + if (!sock.bound) { + nodeSockOps.bindHandle(sock, '0.0.0.0', 0); + sock.state = 'bound'; + } + var server = new (nodeSockOps.getNet().Server)({ pauseOnConnect: true, allowHalfOpen: true }); + sock.server = server; + sock.state = 'listen'; + server.on('connection', (conn) => { + var newsock = SOCKFS.createSocket(sock.family, sock.type, sock.protocol); + newsock.state = 'connected'; + newsock.saddr = conn.localAddress; + newsock.sport = conn.localPort; + newsock.daddr = conn.remoteAddress; + newsock.dport = conn.remotePort; + nodeSockOps.wireConnection(newsock, conn); + try { conn.resume(); } catch (e) {} // paused by pauseOnConnect + sock.pending.push(newsock); + SOCKFS.emit('connection', newsock.stream.fd); + }); + server.on('error', (e) => { + sock.error = nodeSockOps.errnoForNode(e); + SOCKFS.emit('error', [sock.stream.fd, sock.error, (e && e.message) || 'listen error']); + }); + // listen on the already-bound handle: accept would-blocks until a + // connection arrives, surfaced through poll/accept. + server.listen(sock.bound, backlog || 511); + }, + accept(listensock) { + if (!listensock.server) throw new FS.ErrnoError({{{ cDefs.EINVAL }}}); + // Surface a real listen error (e.g. late address-in-use) rather than + // masking it as would-block. + if (listensock.error) { + var e = listensock.error; + listensock.error = null; + throw new FS.ErrnoError(e); + } + if (!listensock.pending.length) throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); + var newsock = listensock.pending.shift(); + newsock.stream.flags = listensock.stream.flags; + return newsock; + }, + sendmsg(sock, buffer, offset, length, addr, port) { + if (sock.type === {{{ cDefs.SOCK_DGRAM }}}) { + // A connected datagram socket rejects an explicit destination. + if (sock.daddr !== undefined && addr !== undefined) { + throw new FS.ErrnoError({{{ cDefs.EISCONN }}}); + } + if (addr === undefined || port === undefined) { + addr = sock.daddr; + port = sock.dport; + if (addr === undefined || port === undefined) throw new FS.ErrnoError({{{ cDefs.EDESTADDRREQ }}}); + } + var handle = nodeSockOps.ensureUdpHandle(sock); + // A public dgram send() would do an async implicit bind, so bind (and + // start receiving) synchronously up front; udp_wrap auto-binds on send, + // so it starts receiving afterwards. + if (sock.udpPublic) nodeSockOps.startUdpRecv(sock); + offset += buffer.byteOffset; + buffer = buffer.buffer; + // Copy out of (possibly shared) wasm memory: the datagram must stay + // stable until the asynchronous send completes. + var msg = Buffer.from(buffer.slice(offset, offset + length)); + if (sock.udpPublic) { + // Async errors surface on the 'error' event (read via SO_ERROR). A + // real-connected socket sends to its kernel peer with no address. + if (sock.udpConnected) handle.send(msg); + else handle.send(msg, port, addr); + } else { + var code = addr.includes(':') + ? handle.send6(new sock.sendWrap(), [msg], 1, port, addr, false) + : handle.send(new sock.sendWrap(), [msg], 1, port, addr, false); + if (code < 0) throw new FS.ErrnoError(nodeSockOps.errnoForCode(code)); + // The send auto-bound an unbound socket, so replies can be received. + nodeSockOps.startUdpRecv(sock); + } + return length; + } + // Writing after a write-shutdown is a broken pipe, regardless of peer. + if (sock.writeShutdown) { + throw new FS.ErrnoError({{{ cDefs.EPIPE }}}); + } + var conn = sock.connection; + if (!conn || sock.state === 'closed') { + throw new FS.ErrnoError({{{ cDefs.ENOTCONN }}}); + } + // Bound node's write buffer to its high-water mark: a non-blocking socket + // only accepts up to the remaining headroom, would-blocking when there is + // none, and short-writes the rest (which POSIX send() is allowed to do). + if (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}}) { + var headroom = conn.writableHighWaterMark - conn.writableLength; + if (headroom <= 0) throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); + if (length > headroom) length = headroom; + } + offset += buffer.byteOffset; + buffer = buffer.buffer; + var data = new Uint8Array(buffer.slice(offset, offset + length)); + var ok; + try { + ok = conn.write(data); + } catch (e) { + throw new FS.ErrnoError(nodeSockOps.errnoForNode(e)); + } + if (!ok) sock.writeBlocked = true; // cleared on 'drain', gates poll's POLLOUT + return length; + }, + recvmsg(sock, length) { + if (sock.type === {{{ cDefs.SOCK_DGRAM }}}) { + var dgram = sock.recv_queue.shift(); + if (!dgram) { + // poll reports the socket readable on a pending error, so surface + // (and clear) it here rather than spinning on EAGAIN. + if (sock.error) { + var derr = sock.error; + sock.error = null; + throw new FS.ErrnoError(derr); + } + throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); + } + // A datagram is atomic: return up to length bytes and drop the rest. + var dd = dgram.data; + return { buffer: dd.subarray(0, Math.min(length, dd.length)), addr: dgram.addr, port: dgram.port }; + } + var queued = sock.recv_queue.shift(); + if (!queued) { + if (sock.readClosed) return null; // EOF + if (!sock.connection) { + throw new FS.ErrnoError({{{ cDefs.ENOTCONN }}}); + } + throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); + } + var q = queued.data; + var bytesRead = Math.min(length, q.length); + var res = { buffer: q.subarray(0, bytesRead), addr: queued.addr, port: queued.port }; + if (bytesRead < q.length) { + queued.data = q.subarray(bytesRead); + sock.recv_queue.unshift(queued); + } + sock.recv_bytes = Math.max(0, (sock.recv_bytes || 0) - bytesRead); + if (sock.paused && sock.recv_bytes < 262144 && sock.connection) { + sock.paused = false; + try { sock.connection.resume(); } catch (e) {} + } + return res; + }, + setsockopt(sock, level, optname, optval, optlen) { + sock.opts ||= {}; + var val = {{{ makeGetValue('optval', 0, 'i32') }}}; + if (level === {{{ cDefs.SOL_SOCKET }}}) { + switch (optname) { + case 9: // SO_KEEPALIVE + sock.opts.keepAlive = !!val; + nodeSockOps.applyKeepAlive(sock); + return 0; + case 8: // SO_RCVBUF. Applied to the udp_wrap handle; Node TCP cannot. + sock.opts.recvBuf = val; + nodeSockOps.applyUdpOptions(sock); + return 0; + case 7: // SO_SNDBUF. Applied to the udp_wrap handle; Node TCP cannot. + sock.opts.sendBuf = val; + nodeSockOps.applyUdpOptions(sock); + return 0; + case 6: // SO_BROADCAST (datagram sockets) + sock.opts.broadcast = !!val; + nodeSockOps.applyUdpOptions(sock); + return 0; + case 2: // SO_REUSEADDR. libuv forces SO_REUSEADDR on at bind, so this + // is effectively always enabled; accept and ignore (getsockopt + // reports 1). It cannot be turned off. + return 0; + case {{{ cDefs.SO_REUSEPORT }}}: // SO_REUSEPORT. Bind-time: cached and + // passed to the BoundHandle at bind. Set after bind has no effect. + sock.opts.reusePort = !!val; + return 0; + } + } else if (level === {{{ cDefs.IPPROTO_IP }}}) { + if (optname === 2 /* IP_TTL */) { + sock.opts.ttl = val; + nodeSockOps.applyUdpOptions(sock); + return 0; + } + } else if (level === {{{ cDefs.IPPROTO_IPV6 }}}) { + if (optname === {{{ cDefs.IPV6_V6ONLY }}}) { + // Bind-time only: IPV6_V6ONLY cannot change once the socket is bound, + // so reject a late change (POSIX returns EINVAL). Before any + // bind/connect/listen we cache it for the BoundHandle constructor. + if (sock.state) return -{{{ cDefs.EINVAL }}}; + sock.opts.ipv6Only = !!val; + return 0; + } + } else if (level === {{{ cDefs.IPPROTO_TCP }}}) { + switch (optname) { + case 1: // TCP_NODELAY + sock.opts.noDelay = !!val; + if (sock.connection) { try { sock.connection.setNoDelay(!!val); } catch (e) {} } + return 0; + case 4: // TCP_KEEPIDLE (seconds) + sock.opts.keepAliveIdle = val; + nodeSockOps.applyKeepAlive(sock); + return 0; + case 5: // TCP_KEEPINTVL (seconds) + sock.opts.keepAliveIntvl = val; + nodeSockOps.applyKeepAlive(sock); + return 0; + case 6: // TCP_KEEPCNT (probe count) + sock.opts.keepAliveCnt = val; + nodeSockOps.applyKeepAlive(sock); + return 0; + } + } + // Accept unknown options silently, like a permissive stack. + return 0; + }, + getsockopt(sock, level, optname, optval, optlen) { + sock.opts ||= {}; + var val; + if (level === {{{ cDefs.SOL_SOCKET }}}) { + switch (optname) { + case {{{ cDefs.SO_ERROR }}}: + {{{ makeSetValue('optval', 0, 'sock.error || 0', 'i32') }}}; + {{{ makeSetValue('optlen', 0, 4, 'i32') }}}; + sock.error = null; // SO_ERROR reads and clears + return 0; + case 9: val = sock.opts.keepAlive ? 1 : 0; break; // SO_KEEPALIVE + // SO_RCVBUF/SO_SNDBUF: report the live value from the udp_wrap handle + // when bound, else the stored/default. + case 8: val = nodeSockOps.udpBufferSize(sock, true) ?? (sock.opts.recvBuf || 65536); break; + case 7: val = nodeSockOps.udpBufferSize(sock, false) ?? (sock.opts.sendBuf || 65536); break; + case 6: val = sock.opts.broadcast ? 1 : 0; break; // SO_BROADCAST + case 2: val = 1; break; // SO_REUSEADDR: libuv forces it on at bind + case {{{ cDefs.SO_REUSEPORT }}}: val = sock.opts.reusePort ? 1 : 0; break; + default: return -{{{ cDefs.ENOPROTOOPT }}}; + } + } else if (level === {{{ cDefs.IPPROTO_IP }}}) { + if (optname !== 2 /* IP_TTL */) return -{{{ cDefs.ENOPROTOOPT }}}; + val = sock.opts.ttl || 64; + } else if (level === {{{ cDefs.IPPROTO_IPV6 }}}) { + if (optname !== {{{ cDefs.IPV6_V6ONLY }}}) return -{{{ cDefs.ENOPROTOOPT }}}; + val = sock.opts.ipv6Only ? 1 : 0; + } else if (level === {{{ cDefs.IPPROTO_TCP }}}) { + switch (optname) { + case 1: val = sock.opts.noDelay ? 1 : 0; break; // TCP_NODELAY + case 4: val = sock.opts.keepAliveIdle || 0; break; // TCP_KEEPIDLE + case 5: val = sock.opts.keepAliveIntvl || 0; break;// TCP_KEEPINTVL + case 6: val = sock.opts.keepAliveCnt || 0; break; // TCP_KEEPCNT + default: return -{{{ cDefs.ENOPROTOOPT }}}; + } + } else { + return -{{{ cDefs.ENOPROTOOPT }}}; + } + {{{ makeSetValue('optval', 0, 'val', 'i32') }}}; + {{{ makeSetValue('optlen', 0, 4, 'i32') }}}; + return 0; + } + }, +}; + +addToLibrary(NodeSockFSLibrary); diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index afcec470a58f2..5ce677a1d27e7 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -389,8 +389,12 @@ var SyscallsLibrary = { }, __syscall_shutdown__deps: ['$getSocketFromFD'], __syscall_shutdown: (fd, how, u1, u2, u3, u4) => { - getSocketFromFD(fd); + var sock = getSocketFromFD(fd); +#if NODERAWSOCKETS + return sock.sock_ops.shutdown(sock, how); +#else return -{{{ cDefs.ENOSYS }}}; // unsupported feature +#endif }, __syscall_accept4__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], __syscall_accept4: (fd, addr, len, flags, u1, u2) => { @@ -445,6 +449,10 @@ var SyscallsLibrary = { __syscall_getsockopt__deps: ['$getSocketFromFD'], __syscall_getsockopt: (fd, level, optname, optval, optlen, unused) => { var sock = getSocketFromFD(fd); +#if NODERAWSOCKETS + // The node:net backend handles all socket options. + return sock.sock_ops.getsockopt(sock, level, optname, optval, optlen); +#else // Minimal getsockopt aimed at resolving https://github.com/emscripten-core/emscripten/issues/2211 // so only supports SOL_SOCKET with SO_ERROR. if (level === {{{ cDefs.SOL_SOCKET }}}) { @@ -456,6 +464,20 @@ var SyscallsLibrary = { } } return -{{{ cDefs.ENOPROTOOPT }}}; // The option is unknown at the level indicated. +#endif + }, + // Defined in JS rather than as a weak native stub so the node:net backend can + // provide it without a separate libstubs variation. Without that backend it + // just reports the option as unknown. + __syscall_setsockopt__deps: ['$getSocketFromFD'], + __syscall_setsockopt: (fd, level, optname, optval, optlen, unused) => { +#if NODERAWSOCKETS + var sock = getSocketFromFD(fd); + return sock.sock_ops.setsockopt(sock, level, optname, optval, optlen); +#else + getSocketFromFD(fd); // validate the fd (and keep this syscall's catch reachable) + return -{{{ cDefs.ENOPROTOOPT }}}; // The option is unknown at the level indicated. +#endif }, __syscall_sendmsg__deps: ['$getSocketFromFD', '$getSocketAddress'], __syscall_sendmsg: (fd, message, flags, u1, u2, u3) => { diff --git a/src/modules.mjs b/src/modules.mjs index 83fcf1d86d8b3..bc6aa5295f5fa 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -115,6 +115,10 @@ function calculateLibraries() { 'libsockfs.js', // ok to include it by default since it's only used if the syscall is used ); + if (NODERAWSOCKETS) { + libraries.push('libsockfs_node.js'); + } + if (NODERAWFS) { // NODERAWFS requires NODEFS libraries.push('libnodefs.js'); diff --git a/src/settings.js b/src/settings.js index 3443e70c33669..c0057ce84bc0c 100644 --- a/src/settings.js +++ b/src/settings.js @@ -419,6 +419,32 @@ var WEBSOCKET_URL = 'ws://'; // [link] var PROXY_POSIX_SOCKETS = false; +// 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: +// where NODERAWFS gives direct access to the host filesystem, this gives +// direct access to host sockets. It only works under node and is ignored +// elsewhere. +// +// It supports full TCP (outgoing connect plus bind, listen and accept for +// servers) and UDP. TCP clients use the public node:net API. bind needs a +// synchronous bind() + getsockname(), so it uses the public node APIs that +// provide them when present - net.BoundHandle for TCP and dgram +// bindSync/connectSync for UDP - and falls back to the private tcp_wrap/udp_wrap +// handles on older Node.js versions that lack them. +// +// 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. +// +// It works under -pthread with PROXY_TO_PTHREAD, where main() and every socket +// syscall run on a single worker alongside the node handles and their event +// loop. As with the WebSocket backend, sharing a socket across threads under a +// plain -pthread build (without PROXY_TO_PTHREAD) is not supported. +// [link] +var NODERAWSOCKETS = false; + // A string containing a comma separated list of WebSocket subprotocols // as would be present in the Sec-WebSocket-Protocol header. // You can set 'null', if you don't want to specify it. diff --git a/src/struct_info.json b/src/struct_info.json index f4ecbc84811a3..0362f1a08eecc 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -236,12 +236,15 @@ } }, { - "file": "netinet/in.h", - "defines": [ - "IPPROTO_UDP", - "IPPROTO_TCP", - "INADDR_LOOPBACK" - ] + "file": "netinet/in.h", + "defines": [ + "IPPROTO_IP", + "IPPROTO_IPV6", + "IPPROTO_UDP", + "IPPROTO_TCP", + "IPV6_V6ONLY", + "INADDR_LOOPBACK" + ] }, { "file": "bits/fcntl.h", @@ -289,11 +292,12 @@ "SOCK_STREAM", "SOCK_CLOEXEC", "SOCK_NONBLOCK", - "AF_INET", - "AF_UNSPEC", - "AF_INET6", - "SOL_SOCKET", - "SO_ERROR" + "AF_INET", + "AF_UNSPEC", + "AF_INET6", + "SOL_SOCKET", + "SO_ERROR", + "SO_REUSEPORT" ] }, { diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index c946883c44794..c0dded01e8e71 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -307,8 +307,11 @@ "IEXTEN": 32768, "IMAXBEL": 8192, "INADDR_LOOPBACK": 2130706433, + "IPPROTO_IP": 0, + "IPPROTO_IPV6": 41, "IPPROTO_TCP": 6, "IPPROTO_UDP": 17, + "IPV6_V6ONLY": 26, "ISIG": 1, "IUTF8": 16384, "IXON": 1024, @@ -414,6 +417,7 @@ "SOCK_STREAM": 1, "SOL_SOCKET": 1, "SO_ERROR": 4, + "SO_REUSEPORT": 15, "SYMLOOP_MAX": 40, "S_IALLUGO": 4095, "S_IFBLK": 24576, diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index 399843f16e941..036fe0ec64f20 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -307,8 +307,11 @@ "IEXTEN": 32768, "IMAXBEL": 8192, "INADDR_LOOPBACK": 2130706433, + "IPPROTO_IP": 0, + "IPPROTO_IPV6": 41, "IPPROTO_TCP": 6, "IPPROTO_UDP": 17, + "IPV6_V6ONLY": 26, "ISIG": 1, "IUTF8": 16384, "IXON": 1024, @@ -414,6 +417,7 @@ "SOCK_STREAM": 1, "SOL_SOCKET": 1, "SO_ERROR": 4, + "SO_REUSEPORT": 15, "SYMLOOP_MAX": 40, "S_IALLUGO": 4095, "S_IFBLK": 24576, diff --git a/system/lib/libc/emscripten_syscall_stubs.c b/system/lib/libc/emscripten_syscall_stubs.c index cf942294ad927..50625f0fd30d9 100644 --- a/system/lib/libc/emscripten_syscall_stubs.c +++ b/system/lib/libc/emscripten_syscall_stubs.c @@ -248,11 +248,6 @@ weak int __syscall_prlimit64(pid_t pid, int resource, intptr_t new_limit, intptr return 0; } -weak int __syscall_setsockopt(int sockfd, int level, int optname, intptr_t optval, socklen_t optlen, int unused) { - REPORT(setsockopt); - return -ENOPROTOOPT; // The option is unknown at the level indicated. -} - weak pid_t __syscall_wait4(pid_t pid, intptr_t wstatus, int options, int rusage) { REPORT(wait4); return -1; @@ -262,5 +257,4 @@ UNIMPLEMENTED(acct, (intptr_t filename)) UNIMPLEMENTED(mincore, (intptr_t addr, size_t length, intptr_t vec)) UNIMPLEMENTED(recvmmsg, (int sockfd, intptr_t msgvec, unsigned int vlen, unsigned int flags, intptr_t timeout)) UNIMPLEMENTED(sendmmsg, (int sockfd, intptr_t msgvec, unsigned int vlen, unsigned int flags)) -UNIMPLEMENTED(shutdown, (int sockfd, int how, int unused1, int unused2, int unused3, int unused4)) UNIMPLEMENTED(socketpair, (int domain, int type, int protocol, intptr_t fds, int unused1, int unused2)) diff --git a/system/lib/wasmfs/syscalls.cpp b/system/lib/wasmfs/syscalls.cpp index 373b8ac036c78..da96533d66bc4 100644 --- a/system/lib/wasmfs/syscalls.cpp +++ b/system/lib/wasmfs/syscalls.cpp @@ -1767,6 +1767,24 @@ int __syscall_getsockopt(int sockfd, return -ENOSYS; } +int __syscall_setsockopt(int sockfd, + int level, + int optname, + intptr_t optval, + socklen_t optlen, + int unused) { + return -ENOSYS; +} + +int __syscall_shutdown(int sockfd, + int how, + int unused1, + int unused2, + int unused3, + int unused4) { + return -ENOSYS; +} + int __syscall_getsockname(int sockfd, intptr_t addr, intptr_t len, diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index cafa136774d70..4e8d2cb0e5754 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { - "a.out.js": 268255, - "a.out.nodebug.wasm": 587630, - "total": 855885, + "a.out.js": 268405, + "a.out.nodebug.wasm": 587733, + "total": 856138, "sent": [ "IMG_Init", "IMG_Load", @@ -256,6 +256,8 @@ "__syscall_rmdir", "__syscall_sendmsg", "__syscall_sendto", + "__syscall_setsockopt", + "__syscall_shutdown", "__syscall_socket", "__syscall_stat64", "__syscall_statfs64", @@ -1775,6 +1777,8 @@ "env.__syscall_rmdir", "env.__syscall_sendmsg", "env.__syscall_sendto", + "env.__syscall_setsockopt", + "env.__syscall_shutdown", "env.__syscall_socket", "env.__syscall_stat64", "env.__syscall_statfs64", @@ -2227,8 +2231,6 @@ "__syscall_setpgid", "__syscall_setpriority", "__syscall_setsid", - "__syscall_setsockopt", - "__syscall_shutdown", "__syscall_socketpair", "__syscall_sync", "__syscall_uname", @@ -4096,8 +4098,7 @@ "$__syscall_setdomainname", "$__syscall_setpgid", "$__syscall_setpriority", - "$__syscall_setsockopt", - "$__syscall_shutdown", + "$__syscall_socketpair", "$__syscall_sync", "$__syscall_uname", "$__syscall_wait4", @@ -5089,6 +5090,7 @@ "$shm_open", "$shm_unlink", "$shr", + "$shutdown", "$sift", "$sigaddset", "$sigaltstack", diff --git a/test/sockets/test_tcp_backpressure.c b/test/sockets/test_tcp_backpressure.c new file mode 100644 index 0000000000000..f4db3e8ecb7fb --- /dev/null +++ b/test/sockets/test_tcp_backpressure.c @@ -0,0 +1,120 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Write-side backpressure. We connect to a sink server (argv[1]) that accepts + * but never reads, then send non-blocking until the kernel + node buffers fill + * and send() reports EAGAIN. That proves writes are bounded rather than + * buffered without limit. Plain POSIX, also runs natively. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +int fd = -1; +bool connected = false; +static char chunk[65536]; +// Safety cap so a misbehaving stack that never backpressures can't run forever. +static long long sent_total = 0; +static const long long CAP = 512LL * 1024 * 1024; + +static void finish(int result) { + printf(result == 0 ? "BACKPRESSURE PASS\n" : "BACKPRESSURE FAIL\n"); + if (fd >= 0) close(fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + // The socket is closed and the main loop cancelled, so node's event loop + // drains and the process exits naturally with status 0. On failure abort() + // to surface a non-zero exit code to the test harness. + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void main_loop(void) { + fd_set fdw; + struct timeval tv = {0}; + FD_ZERO(&fdw); + FD_SET(fd, &fdw); + select(64, NULL, &fdw, NULL, &tv); + + if (!connected && FD_ISSET(fd, &fdw)) { + int err = 0; + socklen_t l = sizeof(err); + getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err != 0) { + printf("connect failed: %s\n", strerror(err)); + finish(1); + return; + } + connected = true; + } + + if (!connected) return; + + // Push hard. The peer never reads, so this must eventually would-block. + while (sent_total < CAP) { + ssize_t n = send(fd, chunk, sizeof(chunk), 0); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + printf("backpressure after %lld bytes\n", sent_total); + finish(0); + } else { + printf("send failed: %s\n", strerror(errno)); + finish(1); + } + return; + } + sent_total += n; + } + printf("no backpressure after %lld bytes\n", sent_total); + finish(1); +} + +int main(int argc, char** argv) { + assert(argc > 1 && "usage: test_tcp_backpressure "); + + fd = socket(AF_INET, SOCK_STREAM, 0); + assert(fd >= 0); + fcntl(fd, F_SETFL, O_NONBLOCK); + + struct sockaddr_in dest; + memset(&dest, 0, sizeof(dest)); + dest.sin_family = AF_INET; + dest.sin_port = htons(atoi(argv[1])); + inet_pton(AF_INET, "127.0.0.1", &dest.sin_addr); + + int r = connect(fd, (struct sockaddr*)&dest, sizeof(dest)); + if (r != 0 && errno != EINPROGRESS) { + perror("connect"); + return 1; + } + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_tcp_client_bind.c b/test/sockets/test_tcp_client_bind.c new file mode 100644 index 0000000000000..5a935d2f64bd3 --- /dev/null +++ b/test/sockets/test_tcp_client_bind.c @@ -0,0 +1,147 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Under -sNODERAWSOCKETS TCP binds eagerly and synchronously, so: a client that + * bind()s an explicit source port has it honored by connect() (getsockname + * reports it), and a bind() that conflicts with a port already in use fails + * synchronously with EADDRINUSE - exactly where POSIX reports it - rather than + * being deferred. argv: . The same code builds + * and runs natively against the host stack. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +static int client_fd = -1; +static struct sockaddr_in dest; +static uint16_t src_port = 0; +static bool connected = false; +static bool ping_sent = false; + +static void finish(int result) { + printf(result == 0 ? "CLIENT BIND PASS\n" : "CLIENT BIND FAIL\n"); + if (client_fd >= 0) close(client_fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static struct sockaddr_in loopback(uint16_t port) { + struct sockaddr_in a; + memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = htons(port); + return a; +} + +static void main_loop(void) { + fd_set fdr, fdw; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_ZERO(&fdw); + FD_SET(client_fd, &fdr); + FD_SET(client_fd, &fdw); + select(client_fd + 1, &fdr, &fdw, NULL, &tv); + + if (!connected && FD_ISSET(client_fd, &fdw)) { + int err = 0; + socklen_t l = sizeof(err); + getsockopt(client_fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err != 0) { + printf("connect failed: %s\n", strerror(err)); + finish(1); + return; + } + connected = true; + + // The explicitly bound source port must be the one in use. + struct sockaddr_in sa; + socklen_t sl = sizeof(sa); + assert(getsockname(client_fd, (struct sockaddr*)&sa, &sl) == 0); + if (sa.sin_port != htons(src_port)) { + printf("source port not honored: bound %u, got %u\n", src_port, ntohs(sa.sin_port)); + finish(1); + return; + } + } + + if (connected && !ping_sent && FD_ISSET(client_fd, &fdw)) { + if (send(client_fd, "ping", 4, 0) == 4) ping_sent = true; + } + + if (ping_sent && FD_ISSET(client_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(client_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "ping", 4) == 0) { + finish(0); + } else if (n == 0) { + printf("peer closed unexpectedly\n"); + finish(1); + } + } +} + +int main(int argc, char** argv) { + assert(argc > 2 && "usage: test_tcp_client_bind "); + int port = atoi(argv[1]); + src_port = (uint16_t)atoi(argv[2]); + + // A bind() to the port the echo server is already listening on must fail + // synchronously with EADDRINUSE (eager bind, no deferral). + int busy = socket(AF_INET, SOCK_STREAM, 0); + assert(busy >= 0); + struct sockaddr_in inuse = loopback((uint16_t)port); + int br = bind(busy, (struct sockaddr*)&inuse, sizeof(inuse)); + if (!(br == -1 && errno == EADDRINUSE)) { + printf("expected EADDRINUSE binding a busy port, got r=%d errno=%d\n", br, errno); + finish(1); + return 0; + } + close(busy); + + client_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(client_fd >= 0); + fcntl(client_fd, F_SETFL, O_NONBLOCK); + + // Bind the client to the chosen free source port before connecting. + struct sockaddr_in src = loopback(src_port); + assert(bind(client_fd, (struct sockaddr*)&src, sizeof(src)) == 0); + + dest = loopback((uint16_t)port); + int r = connect(client_fd, (struct sockaddr*)&dest, sizeof(dest)); + if (r != 0 && errno != EINPROGRESS) { + perror("connect"); + return 1; + } + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_tcp_client_semantics.c b/test/sockets/test_tcp_client_semantics.c new file mode 100644 index 0000000000000..bb45554f06090 --- /dev/null +++ b/test/sockets/test_tcp_client_semantics.c @@ -0,0 +1,132 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Outgoing TCP client error/state semantics against a loopback echo server + * started by the test harness (port in argv[1]). Checks connecting twice gives + * EISCONN, that shutdown(SHUT_WR) half-closes the write side while reads still + * work, and that writing after that gives EPIPE. Plain POSIX, also runs + * natively. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +int fd = -1; +struct sockaddr_in dest; +bool connected = false; +bool ping_sent = false; +bool echoed = false; + +static void finish(int result) { + printf(result == 0 ? "CLIENT SEMANTICS PASS\n" : "CLIENT SEMANTICS FAIL\n"); + if (fd >= 0) close(fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + // The socket is closed and the main loop cancelled, so node's event loop + // drains and the process exits naturally with status 0. On failure abort() + // to surface a non-zero exit code to the test harness. + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void main_loop(void) { + fd_set fdr, fdw; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_ZERO(&fdw); + FD_SET(fd, &fdr); + FD_SET(fd, &fdw); + select(64, &fdr, &fdw, NULL, &tv); + + if (!connected && FD_ISSET(fd, &fdw)) { + int err = 0; + socklen_t l = sizeof(err); + getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err != 0) { + printf("connect failed: %s\n", strerror(err)); + finish(1); + return; + } + connected = true; + + // Connecting an already-connected socket must report EISCONN. + int r = connect(fd, (struct sockaddr*)&dest, sizeof(dest)); + assert(r == -1 && errno == EISCONN); + } + + if (connected && !ping_sent && FD_ISSET(fd, &fdw)) { + if (send(fd, "ping", 4, 0) == 4) ping_sent = true; + } + + if (ping_sent && !echoed && FD_ISSET(fd, &fdr)) { + char buf[4]; + ssize_t n = recv(fd, buf, sizeof(buf), 0); + if (n != 4 || memcmp(buf, "ping", 4) != 0) { + printf("unexpected echo n=%zd\n", n); + finish(1); + return; + } + echoed = true; + + // Half-close the write side. The read side must still be usable, so this + // returns 0 rather than tearing the socket down. + assert(shutdown(fd, SHUT_WR) == 0); + + // Writing after a write-shutdown is a broken pipe. + ssize_t w = send(fd, "more", 4, 0); + assert(w == -1 && errno == EPIPE); + + finish(0); + } +} + +int main(int argc, char** argv) { + assert(argc > 1 && "usage: test_tcp_client_semantics "); + signal(SIGPIPE, SIG_IGN); // so the EPIPE write does not kill us natively + + fd = socket(AF_INET, SOCK_STREAM, 0); + assert(fd >= 0); + fcntl(fd, F_SETFL, O_NONBLOCK); + + memset(&dest, 0, sizeof(dest)); + dest.sin_family = AF_INET; + dest.sin_port = htons(atoi(argv[1])); + inet_pton(AF_INET, "127.0.0.1", &dest.sin_addr); + + int r = connect(fd, (struct sockaddr*)&dest, sizeof(dest)); + if (r != 0 && errno != EINPROGRESS) { + perror("connect"); + return 1; + } + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_tcp_echo.c b/test/sockets/test_tcp_echo.c new file mode 100644 index 0000000000000..c560cc254505b --- /dev/null +++ b/test/sockets/test_tcp_echo.c @@ -0,0 +1,140 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Outgoing TCP echo client. We connect to a loopback echo server started by + * the test harness, whose port arrives as argv[1], then do a non-blocking + * connect, send "ping" and recv the echo, all driven by select in the main + * loop. This is plain POSIX and also builds and runs natively, so the same + * code can be checked against the host stack. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +int client_fd = -1; +struct sockaddr_in dest; +bool connected = false; +bool ping_sent = false; + +static void finish(int result) { + printf(result == 0 ? "TCP ECHO PASS\n" : "TCP ECHO FAIL\n"); + if (client_fd >= 0) close(client_fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + // The socket is closed and the main loop cancelled, so node's event loop + // drains and the process exits naturally with status 0. On failure abort() + // to surface a non-zero exit code to the test harness. + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void main_loop(void) { + fd_set fdr, fdw; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_ZERO(&fdw); + FD_SET(client_fd, &fdr); + FD_SET(client_fd, &fdw); + select(64, &fdr, &fdw, NULL, &tv); + + // connect completion + if (!connected && FD_ISSET(client_fd, &fdw)) { + int err = 0; + socklen_t l = sizeof(err); + getsockopt(client_fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err != 0) { + printf("connect failed: %s\n", strerror(err)); + finish(1); + return; + } + connected = true; + printf("connected\n"); + + // getpeername goes through emscripten's own address layer, reading the + // backend's sock fields. Check it reports the server we connected to. + struct sockaddr_in pa; + socklen_t pl = sizeof(pa); + assert(getpeername(client_fd, (struct sockaddr*)&pa, &pl) == 0); + assert(pa.sin_port == dest.sin_port); + assert(pa.sin_addr.s_addr == dest.sin_addr.s_addr); + } + + // send ping + if (connected && !ping_sent && FD_ISSET(client_fd, &fdw)) { + if (send(client_fd, "ping", 4, 0) == 4) ping_sent = true; + } + + // receive the echoed ping + if (ping_sent && FD_ISSET(client_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(client_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "ping", 4) == 0) { + finish(0); + } else if (n == 0) { + printf("peer closed unexpectedly\n"); + finish(1); + } + } +} + +int main(int argc, char** argv) { + assert(argc > 1 && "usage: test_tcp_echo "); + int port = atoi(argv[1]); + + client_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(client_fd >= 0); + fcntl(client_fd, F_SETFL, O_NONBLOCK); + + // Exercise the setsockopt/getsockopt path and check a round-trip. + int one = 1; + assert(setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) == 0); + assert(setsockopt(client_fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one)) == 0); + int got = 0; + socklen_t gl = sizeof(got); + // POSIX only promises a nonzero value for a set boolean option, not exactly 1 + // (macOS reports the internal flag bit, for example). + assert(getsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &got, &gl) == 0 && got != 0); + + memset(&dest, 0, sizeof(dest)); + dest.sin_family = AF_INET; + dest.sin_port = htons(port); + inet_pton(AF_INET, "127.0.0.1", &dest.sin_addr); + printf("connecting to 127.0.0.1:%d\n", port); + + int r = connect(client_fd, (struct sockaddr*)&dest, sizeof(dest)); + if (r != 0 && errno != EINPROGRESS) { + perror("connect"); + return 1; + } + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_tcp_ipv6.c b/test/sockets/test_tcp_ipv6.c new file mode 100644 index 0000000000000..2edfac63fbed8 --- /dev/null +++ b/test/sockets/test_tcp_ipv6.c @@ -0,0 +1,188 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Self-contained IPv6 TCP loopback accept+echo over ::1. Mirrors the IPv4 + * server test but with AF_INET6/sockaddr_in6, exercising bind(:0)+getsockname, + * listen, accept, non-blocking connect, send and recv over a real IPv6 socket. + * Plain POSIX; also builds and runs natively. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +static int listen_fd = -1; +static int client_fd = -1; +static int peer_fd = -1; +static struct sockaddr_in6 dest; +static bool connected = false; +static bool ping_sent = false; +static bool pong_sent = false; + +static void set_nonblocking(int fd) { fcntl(fd, F_SETFL, O_NONBLOCK); } + +static void finish(int result) { + printf(result == 0 ? "TCP IPV6 PASS\n" : "TCP IPV6 FAIL\n"); + if (listen_fd >= 0) close(listen_fd); + if (client_fd >= 0) close(client_fd); + if (peer_fd >= 0) close(peer_fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void start_client(void) { + if (client_fd >= 0) close(client_fd); + client_fd = socket(AF_INET6, SOCK_STREAM, 0); + assert(client_fd >= 0); + set_nonblocking(client_fd); + connected = false; + ping_sent = false; + int r = connect(client_fd, (struct sockaddr*)&dest, sizeof(dest)); + if (r != 0 && errno != EINPROGRESS) { + perror("connect"); + finish(1); + } +} + +static void main_loop(void) { + fd_set fdr, fdw; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_ZERO(&fdw); + FD_SET(listen_fd, &fdr); + FD_SET(client_fd, &fdr); + FD_SET(client_fd, &fdw); + if (peer_fd >= 0) FD_SET(peer_fd, &fdr); + select(64, &fdr, &fdw, NULL, &tv); + + if (peer_fd < 0 && FD_ISSET(listen_fd, &fdr)) { + struct sockaddr_in6 ca; + socklen_t cl = sizeof(ca); + peer_fd = accept(listen_fd, (struct sockaddr*)&ca, &cl); + if (peer_fd >= 0) { + char ip[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &ca.sin6_addr, ip, sizeof(ip)); + printf("accepted from [%s]:%u\n", ip, (unsigned)ntohs(ca.sin6_port)); + set_nonblocking(peer_fd); + } + } + + if (!connected && FD_ISSET(client_fd, &fdw)) { + int err = 0; + socklen_t l = sizeof(err); + getsockopt(client_fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err == ECONNREFUSED || err == ECONNRESET) { + start_client(); + return; + } + if (err != 0) { + printf("connect failed: %s\n", strerror(err)); + finish(1); + return; + } + connected = true; + printf("connected\n"); + } + + if (connected && !ping_sent && FD_ISSET(client_fd, &fdw)) { + if (send(client_fd, "ping", 4, 0) == 4) ping_sent = true; + } + + if (peer_fd >= 0 && !pong_sent && FD_ISSET(peer_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(peer_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "ping", 4) == 0) { + send(peer_fd, "pong", 4, 0); + pong_sent = true; + } + } + + if (ping_sent && FD_ISSET(client_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(client_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "pong", 4) == 0) { + finish(0); + } else if (n == 0) { + printf("peer closed unexpectedly\n"); + finish(1); + } + } +} + +int main(void) { + listen_fd = socket(AF_INET6, SOCK_STREAM, 0); + assert(listen_fd >= 0); + + // IPV6_V6ONLY is a bind-time option: settable (and readable) before bind. + int v6only = 1, got = 0; + socklen_t gl = sizeof(got); + assert(setsockopt(listen_fd, IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) == 0); + assert(getsockopt(listen_fd, IPPROTO_IPV6, IPV6_V6ONLY, &got, &gl) == 0 && got == 1); + + struct sockaddr_in6 addr; + memset(&addr, 0, sizeof(addr)); + addr.sin6_family = AF_INET6; + addr.sin6_port = htons(0); // ephemeral + assert(inet_pton(AF_INET6, "::1", &addr.sin6_addr) == 1); + if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { + perror("bind"); + return 1; + } + + // After bind, IPV6_V6ONLY is immutable: POSIX reports EINVAL. + errno = 0; + assert(setsockopt(listen_fd, IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) == -1 && + errno == EINVAL); + if (listen(listen_fd, 4) != 0) { + perror("listen"); + return 1; + } + + struct sockaddr_in6 la; + socklen_t ll = sizeof(la); + if (getsockname(listen_fd, (struct sockaddr*)&la, &ll) != 0) { + perror("getsockname"); + return 1; + } + assert(la.sin6_family == AF_INET6); + assert(ntohs(la.sin6_port) != 0); + printf("listening on [::1]:%u\n", (unsigned)ntohs(la.sin6_port)); + set_nonblocking(listen_fd); + + memset(&dest, 0, sizeof(dest)); + dest.sin6_family = AF_INET6; + dest.sin6_port = la.sin6_port; + assert(inet_pton(AF_INET6, "::1", &dest.sin6_addr) == 1); + start_client(); + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_tcp_refused.c b/test/sockets/test_tcp_refused.c new file mode 100644 index 0000000000000..e445fe8b4062f --- /dev/null +++ b/test/sockets/test_tcp_refused.c @@ -0,0 +1,95 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * A non-blocking connect to a loopback port with nothing listening must + * surface ECONNREFUSED via SO_ERROR. Self-contained and also runs natively. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +int fd = -1; + +static void finish(int result) { + printf(result == 0 ? "REFUSED PASS\n" : "REFUSED FAIL\n"); + if (fd >= 0) close(fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + // The socket is closed and the main loop cancelled, so node's event loop + // drains and the process exits naturally with status 0. On failure abort() + // to surface a non-zero exit code to the test harness. + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void main_loop(void) { + fd_set fdw; + struct timeval tv = {0}; + FD_ZERO(&fdw); + FD_SET(fd, &fdw); + select(64, NULL, &fdw, NULL, &tv); + + if (FD_ISSET(fd, &fdw)) { + int err = 0; + socklen_t l = sizeof(err); + getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err == 0) return; // not resolved yet + printf("connect resolved with errno %d (%s)\n", err, strerror(err)); + finish(err == ECONNREFUSED ? 0 : 1); + } +} + +int main(void) { + fd = socket(AF_INET, SOCK_STREAM, 0); + assert(fd >= 0); + fcntl(fd, F_SETFL, O_NONBLOCK); + + struct sockaddr_in dest; + memset(&dest, 0, sizeof(dest)); + dest.sin_family = AF_INET; + dest.sin_port = htons(1); // nothing listens on loopback port 1 + inet_pton(AF_INET, "127.0.0.1", &dest.sin_addr); + + // A non-blocking connect may return 0 (emscripten) or -1/EINPROGRESS + // (native), or refuse synchronously. The async failure is checked via + // SO_ERROR in the main loop below. + int r = connect(fd, (struct sockaddr*)&dest, sizeof(dest)); + if (r == -1 && errno == ECONNREFUSED) { + printf("connect resolved with errno %d (%s)\n", errno, strerror(errno)); + printf("REFUSED PASS\n"); + return 0; + } + if (r == -1 && errno != EINPROGRESS) { + perror("connect"); + return 1; + } + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_tcp_server.c b/test/sockets/test_tcp_server.c new file mode 100644 index 0000000000000..88a67e92c4f3e --- /dev/null +++ b/test/sockets/test_tcp_server.c @@ -0,0 +1,190 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Self-contained TCP loopback accept+echo. A listener and a client live in one + * process, both non-blocking, driven by select in the main loop. Exercises + * bind(:0) + getsockname (synchronous ephemeral port), listen, accept, + * non-blocking connect, send and recv. This is plain POSIX and also builds and + * runs natively, so the same code can be checked against the host stack. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +int listen_fd = -1; +int client_fd = -1; +int peer_fd = -1; // accepted (server-side) connection +struct sockaddr_in dest; +bool connected = false; +bool ping_sent = false; +bool pong_sent = false; + +static void set_nonblocking(int fd) { + fcntl(fd, F_SETFL, O_NONBLOCK); +} + +static void finish(int result) { + printf(result == 0 ? "TCP SERVER PASS\n" : "TCP SERVER FAIL\n"); + if (listen_fd >= 0) close(listen_fd); + if (client_fd >= 0) close(client_fd); + if (peer_fd >= 0) close(peer_fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + // The sockets are closed and the main loop cancelled, so node's event loop + // drains and the process exits naturally with status 0. On failure abort() + // to surface a non-zero exit code to the test harness. + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void start_client(void) { + if (client_fd >= 0) close(client_fd); + client_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(client_fd >= 0); + set_nonblocking(client_fd); + connected = false; + ping_sent = false; + int r = connect(client_fd, (struct sockaddr*)&dest, sizeof(dest)); + if (r != 0 && errno != EINPROGRESS) { + perror("connect"); + finish(1); + } +} + +static void main_loop(void) { + fd_set fdr, fdw; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_ZERO(&fdw); + FD_SET(listen_fd, &fdr); + FD_SET(client_fd, &fdr); + FD_SET(client_fd, &fdw); + if (peer_fd >= 0) FD_SET(peer_fd, &fdr); + select(64, &fdr, &fdw, NULL, &tv); + + // server: accept the incoming connection + if (peer_fd < 0 && FD_ISSET(listen_fd, &fdr)) { + struct sockaddr_in ca; + socklen_t cl = sizeof(ca); + peer_fd = accept(listen_fd, (struct sockaddr*)&ca, &cl); + if (peer_fd >= 0) { + set_nonblocking(peer_fd); + printf("accepted from %s:%u\n", inet_ntoa(ca.sin_addr), (unsigned)ntohs(ca.sin_port)); + } + } + + // client: connect completion (retry while the listener is coming up) + if (!connected && FD_ISSET(client_fd, &fdw)) { + int err = 0; + socklen_t l = sizeof(err); + getsockopt(client_fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err == ECONNREFUSED || err == ECONNRESET) { + start_client(); + return; + } + if (err != 0) { + printf("connect failed: %s\n", strerror(err)); + finish(1); + return; + } + connected = true; + printf("connected\n"); + } + + // client: send ping + if (connected && !ping_sent && FD_ISSET(client_fd, &fdw)) { + if (send(client_fd, "ping", 4, 0) == 4) ping_sent = true; + } + + // server: echo ping -> pong + if (peer_fd >= 0 && !pong_sent && FD_ISSET(peer_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(peer_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "ping", 4) == 0) { + send(peer_fd, "pong", 4, 0); + pong_sent = true; + } + } + + // client: receive pong + if (ping_sent && FD_ISSET(client_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(client_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "pong", 4) == 0) { + finish(0); + } else if (n == 0) { + printf("peer closed unexpectedly\n"); + finish(1); + } + } +} + +int main(void) { + listen_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(listen_fd >= 0); + +#ifndef NO_EXPLICIT_BIND + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(0); // ephemeral + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { + perror("bind"); + return 1; + } +#endif + // With NO_EXPLICIT_BIND, listen() must auto-bind an ephemeral port (POSIX), + // and getsockname() below must still report it. + if (listen(listen_fd, 4) != 0) { + perror("listen"); + return 1; + } + + // The OS-assigned ephemeral port must be readable synchronously. + struct sockaddr_in la; + socklen_t ll = sizeof(la); + if (getsockname(listen_fd, (struct sockaddr*)&la, &ll) != 0) { + perror("getsockname"); + return 1; + } + assert(ntohs(la.sin_port) != 0); + printf("listening on 127.0.0.1:%u\n", (unsigned)ntohs(la.sin_port)); + set_nonblocking(listen_fd); + + memset(&dest, 0, sizeof(dest)); + dest.sin_family = AF_INET; + dest.sin_port = la.sin_port; + inet_pton(AF_INET, "127.0.0.1", &dest.sin_addr); + start_client(); + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_udp_connect.c b/test/sockets/test_udp_connect.c new file mode 100644 index 0000000000000..47b3247daa8e8 --- /dev/null +++ b/test/sockets/test_udp_connect.c @@ -0,0 +1,137 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Connected UDP semantics. A client connect()s to a loopback server, which + * means: sendto() with an explicit address must fail with EISCONN, send() + * without an address goes to the peer, and datagrams from anyone other than + * the peer are not delivered. A third "other" socket sends junk to the client + * to prove that filtering. Plain POSIX, also runs natively. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +int server_fd = -1; +int client_fd = -1; +int other_fd = -1; +struct sockaddr_in server_addr; +bool echoed = false; + +static void set_nonblocking(int fd) { + fcntl(fd, F_SETFL, O_NONBLOCK); +} + +static void finish(int result) { + printf(result == 0 ? "UDP CONNECT PASS\n" : "UDP CONNECT FAIL\n"); + if (server_fd >= 0) close(server_fd); + if (client_fd >= 0) close(client_fd); + if (other_fd >= 0) close(other_fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + // The sockets are closed and the main loop cancelled, so node's event loop + // drains and the process exits naturally with status 0. On failure abort() + // to surface a non-zero exit code to the test harness. + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void main_loop(void) { + fd_set fdr; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_SET(server_fd, &fdr); + FD_SET(client_fd, &fdr); + select(64, &fdr, NULL, NULL, &tv); + + // server: receive the peer's ping and echo a pong back to it + if (!echoed && FD_ISSET(server_fd, &fdr)) { + char buf[8]; + struct sockaddr_in src; + socklen_t sl = sizeof(src); + ssize_t n = recvfrom(server_fd, buf, sizeof(buf), 0, (struct sockaddr*)&src, &sl); + if (n == 4 && memcmp(buf, "ping", 4) == 0) { + sendto(server_fd, "pong", 4, 0, (struct sockaddr*)&src, sl); + echoed = true; + } + } + + // client: the only datagram it should ever see is the peer's pong, never the + // "junk" sent by the unrelated socket. + if (FD_ISSET(client_fd, &fdr)) { + char buf[8]; + ssize_t n = recv(client_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "pong", 4) == 0) { + finish(0); + } else if (n > 0) { + printf("client received non-peer datagram (%.*s)\n", (int)n, buf); + finish(1); + } + } +} + +int main(void) { + server_fd = socket(AF_INET, SOCK_DGRAM, 0); + assert(server_fd >= 0); + memset(&server_addr, 0, sizeof(server_addr)); + server_addr.sin_family = AF_INET; + server_addr.sin_port = htons(0); + inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); + assert(bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == 0); + socklen_t sl = sizeof(server_addr); + assert(getsockname(server_fd, (struct sockaddr*)&server_addr, &sl) == 0); + set_nonblocking(server_fd); + + client_fd = socket(AF_INET, SOCK_DGRAM, 0); + assert(client_fd >= 0); + assert(connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == 0); + + // sendto() with an explicit destination on a connected datagram socket fails. + ssize_t r = sendto(client_fd, "x", 1, 0, (struct sockaddr*)&server_addr, sizeof(server_addr)); + assert(r == -1 && errno == EISCONN); + + set_nonblocking(client_fd); + assert(send(client_fd, "ping", 4, 0) == 4); + + // Learn the client's auto-bound port so the "other" socket can target it. + struct sockaddr_in client_addr; + socklen_t cl = sizeof(client_addr); + assert(getsockname(client_fd, (struct sockaddr*)&client_addr, &cl) == 0); + assert(ntohs(client_addr.sin_port) != 0); + + other_fd = socket(AF_INET, SOCK_DGRAM, 0); + assert(other_fd >= 0); + sendto(other_fd, "junk", 4, 0, (struct sockaddr*)&client_addr, sizeof(client_addr)); + + printf("connected to 127.0.0.1:%u, client port %u\n", + (unsigned)ntohs(server_addr.sin_port), (unsigned)ntohs(client_addr.sin_port)); + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_udp_echo.c b/test/sockets/test_udp_echo.c new file mode 100644 index 0000000000000..8f5aa355b532c --- /dev/null +++ b/test/sockets/test_udp_echo.c @@ -0,0 +1,149 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Self-contained UDP loopback echo. A server and a client live in one process, + * both non-blocking, driven by select in the main loop. The server binds(:0) + * and reads its assigned port via getsockname (synchronous), the client sends + * a datagram to it, the server echoes it back to the sender. This is plain + * POSIX and also builds and runs natively. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +int server_fd = -1; +int client_fd = -1; +struct sockaddr_in dest; +bool ping_sent = false; +bool pong_sent = false; + +static void set_nonblocking(int fd) { + fcntl(fd, F_SETFL, O_NONBLOCK); +} + +static void finish(int result) { + printf(result == 0 ? "UDP ECHO PASS\n" : "UDP ECHO FAIL\n"); + if (server_fd >= 0) close(server_fd); + if (client_fd >= 0) close(client_fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + // The sockets are closed and the main loop cancelled, so node's event loop + // drains and the process exits naturally with status 0. On failure abort() + // to surface a non-zero exit code to the test harness. + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void main_loop(void) { + fd_set fdr, fdw; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_ZERO(&fdw); + FD_SET(server_fd, &fdr); + FD_SET(client_fd, &fdr); + FD_SET(client_fd, &fdw); + select(64, &fdr, &fdw, NULL, &tv); + + // client: send ping + if (!ping_sent && FD_ISSET(client_fd, &fdw)) { + if (sendto(client_fd, "ping", 4, 0, (struct sockaddr*)&dest, sizeof(dest)) == 4) { + ping_sent = true; + } + } + + // server: echo ping -> pong back to the sender + if (!pong_sent && FD_ISSET(server_fd, &fdr)) { + char buf[4]; + struct sockaddr_in src; + socklen_t sl = sizeof(src); + ssize_t n = recvfrom(server_fd, buf, sizeof(buf), 0, (struct sockaddr*)&src, &sl); + if (n == 4 && memcmp(buf, "ping", 4) == 0) { + printf("server got ping from %s:%u\n", inet_ntoa(src.sin_addr), (unsigned)ntohs(src.sin_port)); + sendto(server_fd, "pong", 4, 0, (struct sockaddr*)&src, sl); + pong_sent = true; + } + } + + // client: receive pong + if (ping_sent && FD_ISSET(client_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(client_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "pong", 4) == 0) { + finish(0); + } + } +} + +int main(void) { + server_fd = socket(AF_INET, SOCK_DGRAM, 0); + client_fd = socket(AF_INET, SOCK_DGRAM, 0); + assert(server_fd >= 0 && client_fd >= 0); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(0); // ephemeral + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { + perror("bind"); + return 1; + } + + // The OS-assigned ephemeral port must be readable synchronously. + struct sockaddr_in la; + socklen_t ll = sizeof(la); + if (getsockname(server_fd, (struct sockaddr*)&la, &ll) != 0) { + perror("getsockname"); + return 1; + } + assert(ntohs(la.sin_port) != 0); + printf("listening on 127.0.0.1:%u\n", (unsigned)ntohs(la.sin_port)); + + // Datagram socket options round-trip on the bound socket. + int ttl = 64, on = 1, rcv = 131072, got; + socklen_t gl = sizeof(got); + assert(setsockopt(server_fd, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl)) == 0); + assert(setsockopt(server_fd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on)) == 0); + assert(setsockopt(server_fd, SOL_SOCKET, SO_RCVBUF, &rcv, sizeof(rcv)) == 0); + assert(getsockopt(server_fd, IPPROTO_IP, IP_TTL, &got, &gl) == 0 && got == 64); + assert(getsockopt(server_fd, SOL_SOCKET, SO_BROADCAST, &got, &gl) == 0 && got != 0); + assert(getsockopt(server_fd, SOL_SOCKET, SO_RCVBUF, &got, &gl) == 0 && got > 0); + + set_nonblocking(server_fd); + set_nonblocking(client_fd); + + memset(&dest, 0, sizeof(dest)); + dest.sin_family = AF_INET; + dest.sin_port = la.sin_port; + inet_pton(AF_INET, "127.0.0.1", &dest.sin_addr); + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/sockets/test_udp_ipv6.c b/test/sockets/test_udp_ipv6.c new file mode 100644 index 0000000000000..8028e44440789 --- /dev/null +++ b/test/sockets/test_udp_ipv6.c @@ -0,0 +1,132 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Self-contained IPv6 UDP loopback echo over ::1. Mirrors the IPv4 UDP test but + * with AF_INET6/sockaddr_in6: the server binds(:0)+getsockname, the client + * sends a datagram, the server echoes it back to the sender. Plain POSIX; also + * builds and runs natively. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN__ +#include +#endif + +static int server_fd = -1; +static int client_fd = -1; +static struct sockaddr_in6 dest; +static bool ping_sent = false; +static bool pong_sent = false; + +static void set_nonblocking(int fd) { fcntl(fd, F_SETFL, O_NONBLOCK); } + +static void finish(int result) { + printf(result == 0 ? "UDP IPV6 PASS\n" : "UDP IPV6 FAIL\n"); + if (server_fd >= 0) close(server_fd); + if (client_fd >= 0) close(client_fd); +#ifdef __EMSCRIPTEN__ + emscripten_cancel_main_loop(); + if (result != 0) abort(); +#else + exit(result); +#endif +} + +static void main_loop(void) { + fd_set fdr, fdw; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_ZERO(&fdw); + FD_SET(server_fd, &fdr); + FD_SET(client_fd, &fdr); + FD_SET(client_fd, &fdw); + select(64, &fdr, &fdw, NULL, &tv); + + if (!ping_sent && FD_ISSET(client_fd, &fdw)) { + if (sendto(client_fd, "ping", 4, 0, (struct sockaddr*)&dest, sizeof(dest)) == 4) { + ping_sent = true; + } + } + + if (!pong_sent && FD_ISSET(server_fd, &fdr)) { + char buf[4]; + struct sockaddr_in6 src; + socklen_t sl = sizeof(src); + ssize_t n = recvfrom(server_fd, buf, sizeof(buf), 0, (struct sockaddr*)&src, &sl); + if (n == 4 && memcmp(buf, "ping", 4) == 0) { + char ip[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &src.sin6_addr, ip, sizeof(ip)); + printf("server got ping from [%s]:%u\n", ip, (unsigned)ntohs(src.sin6_port)); + sendto(server_fd, "pong", 4, 0, (struct sockaddr*)&src, sl); + pong_sent = true; + } + } + + if (ping_sent && FD_ISSET(client_fd, &fdr)) { + char buf[4]; + ssize_t n = recv(client_fd, buf, sizeof(buf), 0); + if (n == 4 && memcmp(buf, "pong", 4) == 0) { + finish(0); + } + } +} + +int main(void) { + server_fd = socket(AF_INET6, SOCK_DGRAM, 0); + client_fd = socket(AF_INET6, SOCK_DGRAM, 0); + assert(server_fd >= 0 && client_fd >= 0); + + struct sockaddr_in6 addr; + memset(&addr, 0, sizeof(addr)); + addr.sin6_family = AF_INET6; + addr.sin6_port = htons(0); // ephemeral + assert(inet_pton(AF_INET6, "::1", &addr.sin6_addr) == 1); + if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { + perror("bind"); + return 1; + } + + struct sockaddr_in6 la; + socklen_t ll = sizeof(la); + if (getsockname(server_fd, (struct sockaddr*)&la, &ll) != 0) { + perror("getsockname"); + return 1; + } + assert(la.sin6_family == AF_INET6); + assert(ntohs(la.sin6_port) != 0); + printf("listening on [::1]:%u\n", (unsigned)ntohs(la.sin6_port)); + + set_nonblocking(server_fd); + set_nonblocking(client_fd); + + memset(&dest, 0, sizeof(dest)); + dest.sin6_family = AF_INET6; + dest.sin6_port = la.sin6_port; + assert(inet_pton(AF_INET6, "::1", &dest.sin6_addr) == 1); + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop(main_loop, 0, 0); +#else + while (1) { + main_loop(); + usleep(1000); + } +#endif + return 0; +} diff --git a/test/test_sockets.py b/test/test_sockets.py index 8226f48df0dd1..d5b23204ced9e 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -347,6 +347,138 @@ def test_nodejs_sockets_echo(self, harness_class, port, args): def test_nodejs_sockets_connect_failure(self): self.do_runf('sockets/test_sockets_echo_client.c', r'connect failed: (Connection refused|Host is unreachable)', regex=True, cflags=['-DSOCKK=666'], assert_returncode=NON_ZERO) + def _run_against_echo_server(self, src, expected, extra=None): + # Start a loopback TCP echo server on an ephemeral port and run the test + # against it, passing the port as argv[1]. + import socketserver + import threading + + class EchoHandler(socketserver.BaseRequestHandler): + def handle(self): + data = self.request.recv(64) + if data: + self.request.sendall(data) + + server = socketserver.TCPServer(('127.0.0.1', 0), EchoHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + self.do_runf(src, expected, cflags=['-sNODERAWSOCKETS'] + (extra or []), args=[str(port)]) + finally: + server.shutdown() + server.server_close() + thread.join() + + # The 'pthread' variant proves the backend works when socket syscalls are + # proxied to the main thread: with PROXY_TO_PTHREAD, main() runs on a worker + # and every socket call funnels to the main thread where node:net lives. + @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) + def test_noderawsockets_echo(self, args): + # With -sNODERAWSOCKETS the client does a non-blocking connect, send and + # recv over a real OS socket against a loopback echo server we run here. + self._run_against_echo_server('sockets/test_tcp_echo.c', 'TCP ECHO PASS', args) + + def test_noderawsockets_client_bind(self): + # A client that bind()s an explicit source port has it honored by connect(), + # and the plain client path never realizes a private tcp_wrap handle. We + # allocate a free source port here and pass it alongside the echo server's. + import socket + import socketserver + import threading + + class EchoHandler(socketserver.BaseRequestHandler): + def handle(self): + data = self.request.recv(64) + if data: + self.request.sendall(data) + + # Reserve a free loopback port for the client's bound source port. + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('127.0.0.1', 0)) + src_port = s.getsockname()[1] + s.close() + + server = socketserver.TCPServer(('127.0.0.1', 0), EchoHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + self.do_runf('sockets/test_tcp_client_bind.c', 'CLIENT BIND PASS', + cflags=['-sNODERAWSOCKETS'], args=[str(port), str(src_port)]) + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_noderawsockets_client_semantics(self): + # EISCONN on a second connect, shutdown(SHUT_WR) leaving reads working, and + # EPIPE on a write after that. + self._run_against_echo_server('sockets/test_tcp_client_semantics.c', 'CLIENT SEMANTICS PASS') + + def test_noderawsockets_refused(self): + # A connect to a loopback port with nothing listening reports ECONNREFUSED. + self.do_runf('sockets/test_tcp_refused.c', 'REFUSED PASS', cflags=['-sNODERAWSOCKETS']) + + def test_noderawsockets_backpressure(self): + # A sink server that accepts but never reads, so the client's writes fill + # the buffers and send() reports EAGAIN rather than buffering unboundedly. + import socketserver + import threading + + done = threading.Event() + + class SinkHandler(socketserver.BaseRequestHandler): + def handle(self): + done.wait(30) # hold the connection open without ever reading + + server = socketserver.TCPServer(('127.0.0.1', 0), SinkHandler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + self.do_runf('sockets/test_tcp_backpressure.c', 'BACKPRESSURE PASS', + cflags=['-sNODERAWSOCKETS'], args=[str(port)]) + finally: + done.set() + server.shutdown() + server.server_close() + thread.join() + + @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) + def test_noderawsockets_server(self, args): + # Self-contained loopback accept+echo, exercising bind(:0)+getsockname + # (synchronous ephemeral port), listen, accept, non-blocking connect, send + # and recv over real OS sockets via the tcp_wrap server path. + self.do_runf('sockets/test_tcp_server.c', 'TCP SERVER PASS', cflags=['-sNODERAWSOCKETS'] + args) + + def test_noderawsockets_server_autobind(self): + # listen() without a prior bind() must auto-bind an ephemeral port and + # getsockname() must report it (POSIX), then accept+echo as usual. + self.do_runf('sockets/test_tcp_server.c', 'TCP SERVER PASS', + cflags=['-sNODERAWSOCKETS', '-DNO_EXPLICIT_BIND']) + + def test_noderawsockets_tcp_ipv6(self): + # Self-contained IPv6 TCP loopback accept+echo over ::1: bind(:0)+getsockname, + # listen, accept, non-blocking connect, send/recv on AF_INET6 sockets. + self.do_runf('sockets/test_tcp_ipv6.c', 'TCP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) + + def test_noderawsockets_udp_ipv6(self): + # Self-contained IPv6 UDP loopback echo over ::1 on AF_INET6 sockets. + self.do_runf('sockets/test_udp_ipv6.c', 'UDP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) + + @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) + def test_noderawsockets_udp(self, args): + # Self-contained loopback UDP echo: the server binds(:0)+getsockname for its + # 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']]}) + def test_noderawsockets_udp_connect(self, args): + # Connected UDP: sendto() with an address gives EISCONN, send() reaches the + # peer, and datagrams from a non-peer socket are filtered out. + self.do_runf('sockets/test_udp_connect.c', 'UDP CONNECT PASS', cflags=['-sNODERAWSOCKETS'] + args) + @requires_native_clang @requires_python_dev_packages def test_nodejs_sockets_echo_subprotocol(self): diff --git a/tools/link.py b/tools/link.py index 0a01e7544c008..746d530fc38e2 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1818,6 +1818,8 @@ def get_full_import_name(name): # Node-specific settings only make sense if ENVIRONMENT_MAY_BE_NODE if settings.NODERAWFS: diagnostics.warning('unused-command-line-argument', 'NODERAWFS ignored since `node` not in `ENVIRONMENT`') + if settings.NODERAWSOCKETS: + diagnostics.warning('unused-command-line-argument', 'NODERAWSOCKETS ignored since `node` not in `ENVIRONMENT`') if settings.NODE_CODE_CACHING: diagnostics.warning('unused-command-line-argument', 'NODE_CODE_CACHING ignored since `node` not in `ENVIRONMENT`') diff --git a/tools/settings.py b/tools/settings.py index fcdd6a2a419f1..8b75d5cd5bb09 100644 --- a/tools/settings.py +++ b/tools/settings.py @@ -153,6 +153,9 @@ ('CROSS_ORIGIN_STORAGE', 'SINGLE_FILE', 'the .wasm binary is inlined directly into the JS output and has no fetchable URL to key the hash on'), ('CROSS_ORIGIN_STORAGE', 'NO_WASM_ASYNC_COMPILATION', 'synchronous instantiation does not use the COS fetch path'), ('CROSS_ORIGIN_STORAGE', 'SIDE_MODULE', 'no JS glue is emitted to carry the hash or perform the COS lookup'), + ('NODERAWSOCKETS', 'WASMFS', 'the node:net backend is not wired into WASMFS sockets'), + ('NODERAWSOCKETS', 'PROXY_POSIX_SOCKETS', 'they are alternative socket backends'), + ('NODERAWSOCKETS', 'SOCKET_WEBRTC', 'they are alternative socket backends'), ] EXPERIMENTAL_SETTINGS = { From 73e9a14eacc9dd6040f3e09ae40c9fe69451050c Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 17 Jun 2026 12:32:14 -0700 Subject: [PATCH 2/7] fix ci --- test/test_sockets.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/test_sockets.py b/test/test_sockets.py index d5b23204ced9e..3689bee8862aa 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -49,6 +49,22 @@ def decorated(self, *args, **kwargs): return decorated +def has_ipv6_loopback(): + # Some CI containers have no IPv6 loopback, so bind(::1) fails with + # EADDRNOTAVAIL. Probe once so the IPv6 tests can skip there. + if not socket.has_ipv6: + return False + try: + s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + try: + s.bind(('::1', 0)) + finally: + s.close() + return True + except OSError: + return False + + def clean_process(p): if getattr(p, 'exitcode', None) is None and getattr(p, 'returncode', None) is None: # ask nicely (to try and catch the children) @@ -461,10 +477,14 @@ def test_noderawsockets_server_autobind(self): def test_noderawsockets_tcp_ipv6(self): # Self-contained IPv6 TCP loopback accept+echo over ::1: bind(:0)+getsockname, # listen, accept, non-blocking connect, send/recv on AF_INET6 sockets. + if not has_ipv6_loopback(): + self.skipTest('no IPv6 loopback available') self.do_runf('sockets/test_tcp_ipv6.c', 'TCP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) def test_noderawsockets_udp_ipv6(self): # Self-contained IPv6 UDP loopback echo over ::1 on AF_INET6 sockets. + if not has_ipv6_loopback(): + self.skipTest('no IPv6 loopback available') self.do_runf('sockets/test_udp_ipv6.c', 'UDP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) From 55845b9b1c0f43e89d8767a4b3a754145fcbea62 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 17 Jun 2026 12:36:45 -0700 Subject: [PATCH 3/7] Add -sNODERAWSOCKETS backend for real TCP and UDP sockets via node:net 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 --- test/test_sockets.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/test_sockets.py b/test/test_sockets.py index 3689bee8862aa..d6358d03bedfc 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -55,11 +55,8 @@ def has_ipv6_loopback(): if not socket.has_ipv6: return False try: - s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s: s.bind(('::1', 0)) - finally: - s.close() return True except OSError: return False From b6d74009df79a04e2506656edf9898c39ffd41cf Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 19 Jun 2026 16:33:38 -0700 Subject: [PATCH 4/7] BoundHandle -> BoundSocket --- .../tools_reference/settings_reference.rst | 7 ++----- src/lib/libsockfs_node.js | 20 +++++++++---------- src/settings.js | 7 ++----- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index 0ec490766735b..47b24d730f176 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -601,11 +601,8 @@ direct access to host sockets. It only works under node and is ignored elsewhere. It supports full TCP (outgoing connect plus bind, listen and accept for -servers) and UDP. TCP clients use the public node:net API. bind needs a -synchronous bind() + getsockname(), so it uses the public node APIs that -provide them when present - net.BoundHandle for TCP and dgram -bindSync/connectSync for UDP - and falls back to the private tcp_wrap/udp_wrap -handles on older Node.js versions that lack them. +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. It is event-driven. Socket readiness comes through the same ``emscripten_set_socket_*_callback`` hooks the WebSocket backend uses, so it diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index 584fd96654910..50efa719a4955 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -13,7 +13,7 @@ // promotion, a conflict surfaces right here as EADDRINUSE, and the handle is // adopted as-is by listen() (server.listen) or connect() (net.Socket). The bind // primitive is chosen once per capability: the public, synchronous -// net.BoundHandle when the runtime offers it, else the private tcp_wrap binding +// net.BoundSocket when the runtime offers it, else the private tcp_wrap binding // as a fallback (net.Server's listen is async and cannot report an assigned // ephemeral port up front, so it can't drive bind on its own). connect() goes // through net.Socket, adopting the bound handle when one exists so an explicit @@ -83,27 +83,27 @@ var NodeSockFSLibrary = { }, // TCP binds eagerly and synchronously, so there is no deferred bind and no // lazy handle promotion - the only difference between the two backends is how - // a bound handle is produced: the public net.BoundHandle when node offers it, + // a bound handle is produced: the public net.BoundSocket when node offers it, // else the private tcp_wrap binding. Chosen once, like useDgram(). - useBoundHandle() { - return nodeSockOps.boundHandleOk ??= - typeof nodeSockOps.getNet().BoundHandle == 'function'; + useBoundSocket() { + return nodeSockOps.boundSocketOk ??= + typeof nodeSockOps.getNet().BoundSocket == 'function'; }, // Synchronously bind a TCP socket to addr:port (0 = ephemeral) and record the // kernel-assigned name immediately. sock.bound is the resulting role-neutral - // handle - a net.BoundHandle, or a raw tcp_wrap handle - adopted as-is by + // handle - a net.BoundSocket, or a raw tcp_wrap handle - adopted as-is by // listen() (server.listen) and connect() (net.Socket). So getsockname() needs // no promotion, a conflict surfaces here as EADDRINUSE (exactly when POSIX // bind() would), and close() releases it if unadopted. bindHandle(sock, addr, port) { var o = sock.opts || {}; - if (nodeSockOps.useBoundHandle()) { + if (nodeSockOps.useBoundSocket()) { var bh; // The constructor binds synchronously and throws a bind conflict // (EADDRINUSE etc.) right here; address() on the bound handle is safe. // ipv6Only/reusePort are bind-time options, applied here from the cache. try { - bh = new (nodeSockOps.getNet().BoundHandle)({ + bh = new (nodeSockOps.getNet().BoundSocket)({ host: addr, port, ipv6Only: o.ipv6Only, reusePort: o.reusePort, }); } @@ -634,7 +634,7 @@ var NodeSockFSLibrary = { // reports 1). It cannot be turned off. return 0; case {{{ cDefs.SO_REUSEPORT }}}: // SO_REUSEPORT. Bind-time: cached and - // passed to the BoundHandle at bind. Set after bind has no effect. + // passed to the BoundSocket at bind. Set after bind has no effect. sock.opts.reusePort = !!val; return 0; } @@ -648,7 +648,7 @@ var NodeSockFSLibrary = { if (optname === {{{ cDefs.IPV6_V6ONLY }}}) { // Bind-time only: IPV6_V6ONLY cannot change once the socket is bound, // so reject a late change (POSIX returns EINVAL). Before any - // bind/connect/listen we cache it for the BoundHandle constructor. + // bind/connect/listen we cache it for the BoundSocket constructor. if (sock.state) return -{{{ cDefs.EINVAL }}}; sock.opts.ipv6Only = !!val; return 0; diff --git a/src/settings.js b/src/settings.js index c0057ce84bc0c..3d6ddbcc8b7c5 100644 --- a/src/settings.js +++ b/src/settings.js @@ -427,11 +427,8 @@ var PROXY_POSIX_SOCKETS = false; // elsewhere. // // It supports full TCP (outgoing connect plus bind, listen and accept for -// servers) and UDP. TCP clients use the public node:net API. bind needs a -// synchronous bind() + getsockname(), so it uses the public node APIs that -// provide them when present - net.BoundHandle for TCP and dgram -// bindSync/connectSync for UDP - and falls back to the private tcp_wrap/udp_wrap -// handles on older Node.js versions that lack them. +// 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. // // It is event-driven. Socket readiness comes through the same // ``emscripten_set_socket_*_callback`` hooks the WebSocket backend uses, so it From a2395ba803144e587eeb4e0ded8913029b1f86df Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 19 Jun 2026 17:43:10 -0700 Subject: [PATCH 5/7] ci: rerun From 64b1db10bd5cd1f569e68af19d6aff6fc8e3eb85 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 22 Jun 2026 12:40:16 -0700 Subject: [PATCH 6/7] rebaseline --- test/codesize/test_codesize_hello_dylink_all.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 4e8d2cb0e5754..b44d20944a9e4 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { "a.out.js": 268405, - "a.out.nodebug.wasm": 587733, - "total": 856138, + "a.out.nodebug.wasm": 587800, + "total": 856205, "sent": [ "IMG_Init", "IMG_Load", From 4a39a4a8d100d52083539a8d8488080dbb41ccd0 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 22 Jun 2026 18:21:21 -0700 Subject: [PATCH 7/7] Add asynchronous DNS resolution (emscripten_dns_lookup_async) Under -sNODERAWSOCKETS getaddrinfo() previously fabricated fake addresses via DNS.lookup_name. This adds real resolution backed by node:dns, plus a general asynchronous getaddrinfo so clients can resolve names without blocking. getaddrinfo() now resolves numeric addresses and /etc/hosts entries (read fresh through emscripten's FS) synchronously, and returns a full addrinfo linked list (one node per resolved address) rather than a single entry. For a real hostname: - without JSPI it returns EAI_AGAIN (no synchronous DNS); resolve it via the async API below and read the result - under JSPI it suspends the wasm stack on the real node:dns lookup and returns the resolved addresses directly (gated on ASYNCIFY == 2; non-JSPI unchanged) The async API (available in all builds, not just -sNODERAWSOCKETS): - emscripten_dns_lookup_async(node, service, hint) takes the same inputs as getaddrinfo() and returns a pollable fd that becomes readable - and delivers the emscripten_set_socket_message_callback - when resolution completes - emscripten_dns_lookup_result(fd, struct addrinfo **res) reads the outcome: 0 on success, writing the addrinfo list head to *res (freed with freeaddrinfo, as for getaddrinfo), or an EAI_* code on failure - with -sNODERAWSOCKETS a hostname is resolved via node:dns; otherwise (and for numeric/ /etc/hosts names) resolution is synchronous and the fd is simply readable on the next turn, so integration code need not branch on the backend Memory is minted only when the caller takes the result, so closing the fd without reading leaks nothing; the whole addrinfo chain is owned by the caller and freed uniformly by freeaddrinfo. Internally getaddrinfo is split into reusable stages - parse (getAddrInfo), resolve (resolveAddrInfo, node:dns), and mint (writeAddrInfoList) - threading a single descriptor through, which both the sync and async entry points share. - freeaddrinfo now walks and frees the whole ai_next chain (previously only the head node + its ai_addr) - adds EAI_AGAIN to the generated struct info Tested with test_dns_async (static /etc/hosts, multi-address list, async localhost), test_dns_callback (completion via the socket message callback), test_dns_async_net (real hostname over the network), test_dns_async_default (the async API without -sNODERAWSOCKETS), and test_dns_jspi (JSPI blocking resolution), including -pthread/PROXY_TO_PTHREAD variants. --- .../docs/api_reference/emscripten.h.rst | 30 ++++ src/lib/libcore.js | 148 ++++++++++++------ src/lib/libsigs.js | 2 + src/lib/libsockfs.js | 85 ++++++++++ src/lib/libsockfs_node.js | 63 +++++++- src/struct_info.json | 3 +- src/struct_info_generated.json | 1 + src/struct_info_generated_wasm64.json | 1 + system/include/emscripten/emscripten.h | 14 ++ .../lib/libc/musl/src/network/freeaddrinfo.c | 14 +- test/codesize/test_codesize_hello_O0.json | 8 +- .../test_codesize_hello_dylink_all.json | 8 +- .../test_codesize_minimal_O0.expected.js | 2 + test/codesize/test_codesize_minimal_O0.json | 8 +- test/codesize/test_unoptimized_code_size.json | 12 +- test/sockets/test_dns_async.c | 124 +++++++++++++++ test/sockets/test_dns_async_default.c | 62 ++++++++ test/sockets/test_dns_async_net.c | 67 ++++++++ test/sockets/test_dns_callback.c | 57 +++++++ test/sockets/test_dns_jspi.c | 38 +++++ test/test_sockets.py | 36 +++++ 21 files changed, 713 insertions(+), 70 deletions(-) create mode 100644 test/sockets/test_dns_async.c create mode 100644 test/sockets/test_dns_async_default.c create mode 100644 test/sockets/test_dns_async_net.c create mode 100644 test/sockets/test_dns_callback.c create mode 100644 test/sockets/test_dns_jspi.c diff --git a/site/source/docs/api_reference/emscripten.h.rst b/site/source/docs/api_reference/emscripten.h.rst index fdb9567ffcd86..f48339b222074 100644 --- a/site/source/docs/api_reference/emscripten.h.rst +++ b/site/source/docs/api_reference/emscripten.h.rst @@ -1280,6 +1280,36 @@ Functions :param em_socket_callback callback: Pointer to a callback function. The callback returns a file descriptor and the arbitrary ``userData`` passed to this function. +.. c:function:: int emscripten_dns_lookup_async(const char *node, const char *service, const struct addrinfo *hints) + + Asynchronous :c:func:`getaddrinfo`. Takes the same ``node``/``service``/``hints`` + inputs and returns a file descriptor that signals completion in two + interchangeable ways - it becomes readable (via ``poll``/``select``), and it + delivers the socket message callback registered with + :c:func:`emscripten_set_socket_message_callback` for that fd. Read the result + with :c:func:`emscripten_dns_lookup_result`. The caller owns the fd and should + ``close()`` it. + + With ``-sNODERAWSOCKETS`` a hostname is resolved asynchronously via ``node:dns``; + otherwise (and for numeric or ``/etc/hosts`` names) resolution is synchronous, + as :c:func:`getaddrinfo`, and the fd is simply readable on the next turn. + + :param node: The hostname or numeric address to resolve. + :param service: The service name or port string (may be ``NULL``). + :param hints: ``addrinfo`` filter (``ai_family``/``ai_socktype``/etc.; may be ``NULL``). + :returns: A pollable file descriptor, or ``-1`` on failure to start the lookup. + + +.. c:function:: int emscripten_dns_lookup_result(int fd, struct addrinfo **res) + + Reads the outcome of a lookup started by :c:func:`emscripten_dns_lookup_async`, + once its ``fd`` is readable. + + :param int fd: The file descriptor returned by :c:func:`emscripten_dns_lookup_async`. + :param res: On success, receives the head of the resulting ``addrinfo`` list (free it with :c:func:`freeaddrinfo`, as for :c:func:`getaddrinfo`). + :returns: ``0`` on success, or an ``EAI_*`` error code on failure (``EAI_AGAIN`` if the lookup has not completed yet). + + Unaligned types =============== diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 3e14c02af08f9..4c70b1d79aae4 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -947,53 +947,61 @@ addToLibrary({ return inetPton4(DNS.lookup_name(nameString)); }, - getaddrinfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl'], - getaddrinfo__proxy: 'sync', - getaddrinfo: (node, service, hint, out) => { - // Note getaddrinfo currently only returns a single addrinfo with ai_next defaulting to NULL. When NULL - // hints are specified or ai_family set to AF_UNSPEC or ai_socktype or ai_protocol set to 0 then we - // really should provide a linked list of suitable addrinfo values. - var addrs = []; - var canon = null; - var addr = 0; - var port = 0; - var flags = 0; - var family = {{{ cDefs.AF_UNSPEC }}}; - var type = 0; - var proto = 0; - var ai, last; - - function allocaddrinfo(family, type, proto, canon, addr, port) { - var sa, salen, ai; - var errno; - - salen = family === {{{ cDefs.AF_INET6 }}} ? + // The encode/mint stage: turn a resolved descriptor ({entries, type, proto, + // port}, addr in parsed inetPton form) into an addrinfo linked list and return + // the head (0 for an empty list). This is the sole point that mints C memory, + // and the whole chain is freed uniformly by freeaddrinfo - one ownership rule. + // (A future ring/aio backend would add a sibling encoder here, e.g. one that + // writes into a caller buffer, without touching parse/resolve.) + $writeAddrInfoList__deps: ['$inetNtop4', '$inetNtop6', '$writeSockaddr', 'malloc'], + $writeAddrInfoList: (desc) => { + var head = 0, prev = 0; + for (var entry of desc.entries) { + var family = entry.family; + var salen = family === {{{ cDefs.AF_INET6 }}} ? {{{ C_STRUCTS.sockaddr_in6.__size__ }}} : {{{ C_STRUCTS.sockaddr_in.__size__ }}}; - addr = family === {{{ cDefs.AF_INET6 }}} ? - inetNtop6(addr) : - inetNtop4(addr); - sa = _malloc(salen); - errno = writeSockaddr(sa, family, addr, port); + var sa = _malloc(salen); + var errno = writeSockaddr(sa, family, family === {{{ cDefs.AF_INET6 }}} ? inetNtop6(entry.addr) : inetNtop4(entry.addr), desc.port); #if ASSERTIONS assert(!errno); #endif - - ai = _malloc({{{ C_STRUCTS.addrinfo.__size__ }}}); + var ai = _malloc({{{ C_STRUCTS.addrinfo.__size__ }}}); {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_family, 'family', 'i32') }}}; - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_socktype, 'type', 'i32') }}}; - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_protocol, 'proto', 'i32') }}}; - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_canonname, 'canon', '*') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_socktype, 'desc.type', 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_protocol, 'desc.proto', 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_canonname, '0', '*') }}}; {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addr, 'sa', '*') }}}; - if (family === {{{ cDefs.AF_INET6 }}}) { - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addrlen, C_STRUCTS.sockaddr_in6.__size__, 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addrlen, 'salen', 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_next, '0', 'i32') }}}; + if (prev) { + {{{ makeSetValue('prev', C_STRUCTS.addrinfo.ai_next, 'ai', '*') }}}; } else { - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addrlen, C_STRUCTS.sockaddr_in.__size__, 'i32') }}}; + head = ai; } - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_next, '0', 'i32') }}}; - - return ai; + prev = ai; } + return head; + }, + + // Shared getaddrinfo core. Allocates nothing: returns a resolved descriptor + // {entries, type, proto, port} (entries are {family, addr} with addr in parsed + // inetPton form), a negative EAI_* code on failure, or - under NODERAWSOCKETS, + // for a hostname needing DNS - a {node, family, type, proto, port} descriptor + // (no entries) for the caller to resolve. The result is minted from a + // descriptor by writeAddrInfoList at the point ownership passes to the caller. + $getAddrInfo__deps: ['$DNS', '$inetPton4', '$inetPton6', 'htonl', '$UTF8ToString', +#if NODERAWSOCKETS + '$nodeSockOps', +#endif + ], + $getAddrInfo: (node, service, hint) => { + var addr = 0; + var port = 0; + var flags = 0; + var family = {{{ cDefs.AF_UNSPEC }}}; + var type = 0; + var proto = 0; if (hint) { flags = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_flags, 'i32') }}}; @@ -1063,9 +1071,7 @@ addToLibrary({ addr = [0, 0, 0, _htonl(1)]; } } - ai = allocaddrinfo(family, type, proto, null, addr, port); - {{{ makeSetValue('out', '0', 'ai', '*') }}}; - return 0; + return { entries: [{ family, addr }], type, proto, port }; } // @@ -1096,9 +1102,7 @@ addToLibrary({ } } if (addr != null) { - ai = allocaddrinfo(family, type, proto, node, addr, port); - {{{ makeSetValue('out', '0', 'ai', '*') }}}; - return 0; + return { entries: [{ family, addr }], type, proto, port }; } if (flags & {{{ cDefs.AI_NUMERICHOST }}}) { return {{{ cDefs.EAI_NONAME }}}; @@ -1107,6 +1111,22 @@ addToLibrary({ // // try as a hostname // +#if NODERAWSOCKETS + // /etc/hosts resolves synchronously (read fresh through emscripten's FS). + var hosts = nodeSockOps.readHosts(node).filter((e) => + family === {{{ cDefs.AF_UNSPEC }}} || e.family === family); + if (hosts.length) { + var entries = hosts.map((e) => ({ + family: e.family, + addr: e.family === {{{ cDefs.AF_INET6 }}} ? inetPton6(e.addr) : inetPton4(e.addr), + })); + return { entries, type, proto, port }; + } + // A real hostname needs a DNS lookup; hand the request back to the caller to + // resolve asynchronously (getaddrinfo suspends under JSPI / returns + // EAI_AGAIN otherwise; emscripten_dns_lookup_async drives the poll-fd flow). + return { node, family, type, proto, port }; +#else // resolve the hostname to a temporary fake address node = DNS.lookup_name(node); addr = inetPton4(node); @@ -1115,9 +1135,45 @@ addToLibrary({ } else if (family === {{{ cDefs.AF_INET6 }}}) { addr = [0, 0, _htonl(0xffff), addr]; } - ai = allocaddrinfo(family, type, proto, null, addr, port); - {{{ makeSetValue('out', '0', 'ai', '*') }}}; - return 0; + return { entries: [{ family, addr }], type, proto, port }; +#endif + }, + + getaddrinfo__deps: ['$getAddrInfo', '$writeAddrInfoList', +#if NODERAWSOCKETS && ASYNCIFY == 2 + '$nodeSockOps', +#endif + ], + getaddrinfo__proxy: 'sync', +#if NODERAWSOCKETS && ASYNCIFY == 2 + // Under JSPI a hostname miss suspends the wasm stack on the real node:dns + // lookup (returning a promise) rather than reporting EAI_AGAIN. A resolved + // descriptor (numeric/hosts) or error does not suspend. + getaddrinfo__async: true, +#endif + getaddrinfo: (node, service, hint, out) => { + // parse -> (resolve) -> mint. One descriptor threads through all three. + var desc = getAddrInfo(node, service, hint); + if (typeof desc === 'object') { + if (desc.entries) { + {{{ makeSetValue('out', '0', 'writeAddrInfoList(desc)', '*') }}}; + return 0; + } +#if NODERAWSOCKETS && ASYNCIFY == 2 + // JSPI: suspend on the real node:dns lookup, which fills desc.entries, then + // mint from the same descriptor. + return nodeSockOps.resolveAddrInfo(desc).then((eai) => { + if (eai) return eai; + {{{ makeSetValue('out', '0', 'writeAddrInfoList(desc)', '*') }}}; + return 0; + }); +#elif NODERAWSOCKETS + // No synchronous DNS available: numeric and /etc/hosts names resolve above, + // anything else must be resolved out-of-band via emscripten_dns_lookup_async. + return {{{ cDefs.EAI_AGAIN }}}; +#endif + } + return desc; }, getnameinfo__deps: ['$DNS', '$readSockaddr', '$stringToUTF8'], diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index 746ee98ff5cef..01488af90e86d 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -633,6 +633,8 @@ sigs = { emscripten_destroy_audio_context__sig: 'vi', emscripten_destroy_web_audio_node__sig: 'vi', emscripten_destroy_worker__sig: 'vi', + emscripten_dns_lookup_async__sig: 'ippp', + emscripten_dns_lookup_result__sig: 'iip', emscripten_enter_soft_fullscreen__sig: 'ipp', emscripten_err__sig: 'vp', emscripten_errn__sig: 'vpp', diff --git a/src/lib/libsockfs.js b/src/lib/libsockfs.js index 66bcdcb162a42..f71cf9687521e 100644 --- a/src/lib/libsockfs.js +++ b/src/lib/libsockfs.js @@ -24,6 +24,17 @@ addToLibrary({ emit(event, param) { SOCKFS.callbacks[event]?.(param); }, + // Mark an async-completion pseudo-socket ready: flip it readable and deliver + // its message callback. Skipped if the fd was closed (and possibly recycled) + // before completion. This (with the sock.dns hooks in stream_ops poll/close) + // is the generic pollable-completion seam: a future ring/aio completion fd + // would reuse the same readiness mechanism rather than re-adding one. + finishDns(sock) { + sock.dnsDone = true; + if (SOCKFS.getSocket(sock.stream.fd) === sock) { + SOCKFS.emit('message', sock.stream.fd); + } + }, mount(mount) { #if expectToReceiveOnModule('websocket') // The incoming Module['websocket'] can be used for configuring @@ -116,6 +127,11 @@ addToLibrary({ stream_ops: { poll(stream) { var sock = stream.node.sock; + // A DNS request fd (emscripten_dns_lookup_async) is readable once the + // lookup completes; read the result with emscripten_dns_lookup_result. + if (sock.dns) { + return sock.dnsDone ? ({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}}) : 0; + } return sock.sock_ops.poll(sock); }, ioctl(stream, request, varargs) { @@ -138,6 +154,8 @@ addToLibrary({ }, close(stream) { var sock = stream.node.sock; + // A DNS request fd is a pseudo-socket with no backend resources. + if (sock.dns) return; sock.sock_ops.close(sock); } }, @@ -788,4 +806,71 @@ addToLibrary({ emscripten_set_socket_close_callback__deps: ['$_setNetworkCallback'], emscripten_set_socket_close_callback: (userData, callback) => _setNetworkCallback('close', userData, callback), + + // Asynchronous getaddrinfo: same (node, service, hint) inputs as the sync call. + // Returns a pollable fd that becomes readable (and delivers the socket message + // callback) when resolution completes; read the result with + // emscripten_dns_lookup_result. Returns -1 on failure to allocate the fd. + // Without -sNODERAWSOCKETS this resolves synchronously (like getaddrinfo) and + // the fd is simply readable on the next turn. + emscripten_dns_lookup_async__deps: ['$SOCKFS', '$getAddrInfo', '$safeSetTimeout', +#if NODERAWSOCKETS + '$nodeSockOps', +#endif + ], + emscripten_dns_lookup_async__proxy: 'sync', + emscripten_dns_lookup_async: (node, service, hint) => { + var sock; + try { + sock = SOCKFS.createSocket({{{ cDefs.AF_INET }}}, {{{ cDefs.SOCK_STREAM }}}, 0); + } catch (e) { + return -1; + } + sock.dns = true; + // Read the request synchronously (the input pointers are only valid now). No + // C memory is allocated here; the resolved descriptor is stashed on the sock + // and minted into an addrinfo only when the caller takes it via + // emscripten_dns_lookup_result. + var desc = getAddrInfo(node, service, hint); +#if NODERAWSOCKETS + if (typeof desc === 'object' && !desc.entries) { + // A real hostname: resolve via node:dns (fills desc.entries), then stash + // the same descriptor for the caller to mint from. + nodeSockOps.resolveAddrInfo(desc).then((eai) => { + sock.dnsResult = eai; + sock.dnsDesc = desc; + SOCKFS.finishDns(sock); + }); + return sock.stream.fd; + } +#endif + // Resolved synchronously (numeric/`/etc/hosts`/fake/null-node success, or a + // validation error). Deliver readiness on a later turn regardless, so the + // contract is uniformly async (the caller can poll or attach a listener + // first); safeSetTimeout keeps the runtime alive until it fires. + if (typeof desc === 'object') { + sock.dnsDesc = desc; + sock.dnsResult = 0; + } else { + sock.dnsResult = desc; + } + safeSetTimeout(() => SOCKFS.finishDns(sock), 0); + return sock.stream.fd; + }, + + // Read the outcome of a completed async lookup: 0 on success - minting the + // addrinfo list and writing its head to *res (freed with freeaddrinfo, as for + // getaddrinfo) - or an EAI_* code on failure (EAI_AGAIN if not yet complete). + // The memory is allocated here, so a caller that closes the fd without reading + // leaks nothing. The caller owns the fd and should close() it. + emscripten_dns_lookup_result__deps: ['$SOCKFS', '$writeAddrInfoList'], + emscripten_dns_lookup_result__proxy: 'sync', + emscripten_dns_lookup_result: (fd, res) => { + var sock = SOCKFS.getSocket(fd); + if (!sock || !sock.dns || !sock.dnsDone) return {{{ cDefs.EAI_AGAIN }}}; + if (sock.dnsResult === 0) { + {{{ makeSetValue('res', '0', 'writeAddrInfoList(sock.dnsDesc)', '*') }}}; + } + return sock.dnsResult; + }, }); diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index 50efa719a4955..747969857240a 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -36,7 +36,7 @@ // is safe. var NodeSockFSLibrary = { - $nodeSockOps__deps: ['$SOCKFS', '$ERRNO_CODES'], + $nodeSockOps__deps: ['$SOCKFS', '$ERRNO_CODES', '$FS', '$inetPton4', '$inetPton6'], $nodeSockOps: { // node builtins, resolved once each. getBuiltinModule works in both // CommonJS and ESM output, with require as the fallback. @@ -49,6 +49,67 @@ var NodeSockFSLibrary = { getDgram() { return nodeSockOps.dgramModule ??= (process.getBuiltinModule || require)('dgram'); }, + getDns() { + return nodeSockOps.dnsModule ??= (process.getBuiltinModule || require)('dns'); + }, + // Look up `name` in /etc/hosts, read fresh on each call through emscripten's + // FS so live edits (MEMFS or a mounted real fs) are honored. Returns a list + // of {family, addr}; a missing or unreadable file is just empty. + readHosts(name) { + var out = []; + var text; + try { + text = FS.readFile('/etc/hosts', { encoding: 'utf8' }); + } catch (e) { + return out; + } + for (var line of text.split('\n')) { + var hash = line.indexOf('#'); + if (hash !== -1) line = line.slice(0, hash); + var parts = line.split(/\s+/).filter((p) => p.length); + if (parts.length < 2 || !parts.slice(1).includes(name)) continue; + var addr = parts[0]; + out.push({ family: addr.includes(':') ? {{{ cDefs.AF_INET6 }}} : {{{ cDefs.AF_INET }}}, addr }); + } + return out; + }, + // Map a node:dns error to an EAI_* code. node:dns surfaces either getaddrinfo + // EAI_* names or libuv/system codes; the transient ones become EAI_AGAIN and + // everything else a hard "name not found". + eaiForDns(e) { + switch (e && e.code) { + case 'EAI_AGAIN': + case 'ETIMEDOUT': + case 'ESERVFAIL': + case 'EREFUSED': + return {{{ cDefs.EAI_AGAIN }}}; + default: + return {{{ cDefs.EAI_NONAME }}}; + } + }, + // The resolve stage: take a needs-DNS descriptor (from getAddrInfo) and fill + // in desc.entries via node:dns. Returns a promise of the EAI_* code (0 on + // success). Pure: no fd, no C allocation - just name -> addresses, so a + // future ring/aio backend can reuse it verbatim. + resolveAddrInfo(desc) { + var opts = { all: true }; + if (desc.family === {{{ cDefs.AF_INET }}}) opts.family = 4; + else if (desc.family === {{{ cDefs.AF_INET6 }}}) opts.family = 6; + return new Promise((resolve) => { + nodeSockOps.getDns().lookup(desc.node, opts, (err, addresses) => { + if (err) { + resolve(nodeSockOps.eaiForDns(err)); + } else { + desc.entries = addresses.map((a) => { + var fam = a.family === 6 ? {{{ cDefs.AF_INET6 }}} : {{{ cDefs.AF_INET }}}; + return { family: fam, addr: fam === {{{ cDefs.AF_INET6 }}} ? inetPton6(a.address) : inetPton4(a.address) }; + }); + resolve(addresses.length ? 0 : {{{ cDefs.EAI_NONAME }}}); + } + }); + }); + }, + // True when node:dgram exposes both synchronous bindSync and connectSync // (a recent addition), letting UDP run entirely on the public API. A runtime // missing either falls back to the private udp_wrap handle, which provides diff --git a/src/struct_info.json b/src/struct_info.json index 0362f1a08eecc..17ec7292d5055 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -181,7 +181,8 @@ "NI_NAMEREQD", "EAI_NONAME", "EAI_SOCKTYPE", - "EAI_BADFLAGS" + "EAI_BADFLAGS", + "EAI_AGAIN" ], "structs": { "addrinfo": [ diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index c0dded01e8e71..def08117fc243 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -63,6 +63,7 @@ "EADV": 122, "EAFNOSUPPORT": 5, "EAGAIN": 6, + "EAI_AGAIN": -3, "EAI_BADFLAGS": -1, "EAI_FAMILY": -6, "EAI_NONAME": -2, diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index 036fe0ec64f20..2ca455b24fb7f 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -63,6 +63,7 @@ "EADV": 122, "EAFNOSUPPORT": 5, "EAGAIN": 6, + "EAI_AGAIN": -3, "EAI_BADFLAGS": -1, "EAI_FAMILY": -6, "EAI_NONAME": -2, diff --git a/system/include/emscripten/emscripten.h b/system/include/emscripten/emscripten.h index 43d2f2899dd0e..5e4f71ee26bc2 100644 --- a/system/include/emscripten/emscripten.h +++ b/system/include/emscripten/emscripten.h @@ -68,6 +68,20 @@ void emscripten_set_socket_connection_callback(void *userData, em_socket_callbac void emscripten_set_socket_message_callback(void *userData, em_socket_callback callback); void emscripten_set_socket_close_callback(void *userData, em_socket_callback callback); +// Asynchronous getaddrinfo. emscripten_dns_lookup_async() takes the same +// node/service/hints inputs as getaddrinfo() and returns a file descriptor that +// becomes readable (via poll/select, and which also delivers the socket message +// callback) once resolution completes, or -1 on error. Once readable, call +// emscripten_dns_lookup_result() to read the outcome: 0 on success - writing the +// addrinfo list head to *res (free it with freeaddrinfo, as for getaddrinfo) - +// or an EAI_* code on failure. The caller owns the fd and should close() it. +// With -sNODERAWSOCKETS a hostname is resolved asynchronously via node:dns; +// otherwise resolution is synchronous (as getaddrinfo) and the fd is simply +// readable on the next turn. +struct addrinfo; +int emscripten_dns_lookup_async(const char *node, const char *service, const struct addrinfo *hints); +int emscripten_dns_lookup_result(int fd, struct addrinfo **res); + void _emscripten_push_main_loop_blocker(em_arg_callback_func func, void *arg, const char *name); void _emscripten_push_uncounted_main_loop_blocker(em_arg_callback_func func, void *arg, const char *name); #define emscripten_push_main_loop_blocker(func, arg) \ diff --git a/system/lib/libc/musl/src/network/freeaddrinfo.c b/system/lib/libc/musl/src/network/freeaddrinfo.c index c4016d9f7c246..6e075a261adea 100644 --- a/system/lib/libc/musl/src/network/freeaddrinfo.c +++ b/system/lib/libc/musl/src/network/freeaddrinfo.c @@ -7,11 +7,15 @@ void freeaddrinfo(struct addrinfo *p) { #if __EMSCRIPTEN__ - // Emscripten's usage of this structure is very simple: we always allocate - // ai_addr, and do not use the linked list aspect at all. There is also no - // aliasing with aibuf. - free(p->ai_addr); - free(p); + // Emscripten allocates each node and its ai_addr separately (no aibuf block, + // no aliasing), and getaddrinfo may return a linked list, so walk it freeing + // each node and its address. + while (p) { + struct addrinfo *next = p->ai_next; + free(p->ai_addr); + free(p); + p = next; + } #else size_t cnt; for (cnt=1; p->ai_next; cnt++, p=p->ai_next); diff --git a/test/codesize/test_codesize_hello_O0.json b/test/codesize/test_codesize_hello_O0.json index 35c3470b5e2e5..db9f46ac3c731 100644 --- a/test/codesize/test_codesize_hello_O0.json +++ b/test/codesize/test_codesize_hello_O0.json @@ -1,10 +1,10 @@ { - "a.out.js": 23483, - "a.out.js.gz": 8523, + "a.out.js": 23513, + "a.out.js.gz": 8535, "a.out.nodebug.wasm": 15115, "a.out.nodebug.wasm.gz": 7464, - "total": 38598, - "total_gz": 15987, + "total": 38628, + "total_gz": 15999, "sent": [ "fd_write" ], diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index b44d20944a9e4..e5b3f603c6f28 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { - "a.out.js": 268405, - "a.out.nodebug.wasm": 587800, - "total": 856205, + "a.out.js": 269053, + "a.out.nodebug.wasm": 587823, + "total": 856876, "sent": [ "IMG_Init", "IMG_Load", @@ -451,6 +451,8 @@ "emscripten_date_now", "emscripten_debugger", "emscripten_destroy_worker", + "emscripten_dns_lookup_async", + "emscripten_dns_lookup_result", "emscripten_enter_soft_fullscreen", "emscripten_err", "emscripten_errn", diff --git a/test/codesize/test_codesize_minimal_O0.expected.js b/test/codesize/test_codesize_minimal_O0.expected.js index b97d7b35a7ad0..4260c17cf94f9 100644 --- a/test/codesize/test_codesize_minimal_O0.expected.js +++ b/test/codesize/test_codesize_minimal_O0.expected.js @@ -891,6 +891,8 @@ Module['FS_createPreloadedFile'] = FS.createPreloadedFile; 'inetNtop6', 'readSockaddr', 'writeSockaddr', + 'writeAddrInfoList', + 'getAddrInfo', 'readEmAsmArgs', 'jstoi_q', 'getExecutableName', diff --git a/test/codesize/test_codesize_minimal_O0.json b/test/codesize/test_codesize_minimal_O0.json index 325e0d534493a..bb2e112e94746 100644 --- a/test/codesize/test_codesize_minimal_O0.json +++ b/test/codesize/test_codesize_minimal_O0.json @@ -1,10 +1,10 @@ { - "a.out.js": 18723, - "a.out.js.gz": 6778, + "a.out.js": 18753, + "a.out.js.gz": 6789, "a.out.nodebug.wasm": 1015, "a.out.nodebug.wasm.gz": 602, - "total": 19738, - "total_gz": 7380, + "total": 19768, + "total_gz": 7391, "sent": [], "imports": [], "exports": [ diff --git a/test/codesize/test_unoptimized_code_size.json b/test/codesize/test_unoptimized_code_size.json index 3e8dbb92eef88..f829d431d7fee 100644 --- a/test/codesize/test_unoptimized_code_size.json +++ b/test/codesize/test_unoptimized_code_size.json @@ -1,16 +1,16 @@ { - "hello_world.js": 55305, - "hello_world.js.gz": 17405, + "hello_world.js": 55345, + "hello_world.js.gz": 17419, "hello_world.wasm": 15115, "hello_world.wasm.gz": 7464, "no_asserts.js": 25683, "no_asserts.js.gz": 8690, "no_asserts.wasm": 12229, "no_asserts.wasm.gz": 6004, - "strict.js": 53033, - "strict.js.gz": 16620, + "strict.js": 53073, + "strict.js.gz": 16634, "strict.wasm": 15115, "strict.wasm.gz": 7461, - "total": 176480, - "total_gz": 63644 + "total": 176560, + "total_gz": 63672 } diff --git a/test/sockets/test_dns_async.c b/test/sockets/test_dns_async.c new file mode 100644 index 0000000000000..21c2a2cdbe6ee --- /dev/null +++ b/test/sockets/test_dns_async.c @@ -0,0 +1,124 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Exercises the -sNODERAWSOCKETS DNS path. getaddrinfo() resolves numeric and + * /etc/hosts names synchronously (the latter read through emscripten's FS) and + * returns EAI_AGAIN for a real hostname. emscripten_dns_lookup_async() is the + * asynchronous getaddrinfo: it takes the same node/service/hints and returns a + * pollable fd; emscripten_dns_lookup_result() then yields the addrinfo payload. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int lookup_fd = -1; + +static void fail(const char* why) { + printf("DNS ASYNC FAIL: %s\n", why); + abort(); +} + +// getaddrinfo() of an AF_INET hostname, returning its first address (or *err). +static unsigned ipv4_of(const char* name, int* err_out) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* res = NULL; + int err = getaddrinfo(name, NULL, &hints, &res); + if (err_out) *err_out = err; + if (err != 0) return 0; + unsigned addr = ((struct sockaddr_in*)res->ai_addr)->sin_addr.s_addr; + freeaddrinfo(res); + return addr; +} + +static void main_loop(void) { + fd_set fdr; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_SET(lookup_fd, &fdr); + select(lookup_fd + 1, &fdr, NULL, NULL, &tv); + if (!FD_ISSET(lookup_fd, &fdr)) { + return; // resolution still pending + } + + // The result is delivered directly as an addrinfo payload, in the same format + // getaddrinfo() produces (and freed the same way). + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(lookup_fd, &res); + close(lookup_fd); + if (result != 0) fail("async lookup failed"); + assert(res); + if (res->ai_socktype != SOCK_STREAM) fail("async result lost the requested socktype"); + unsigned addr = ((struct sockaddr_in*)res->ai_addr)->sin_addr.s_addr; + freeaddrinfo(res); + if (addr != htonl(INADDR_LOOPBACK)) fail("localhost did not resolve to 127.0.0.1"); + + printf("DNS ASYNC PASS\n"); + emscripten_cancel_main_loop(); +} + +int main(void) { + // Seed /etc/hosts (through emscripten's FS) with names node:dns could never + // resolve, including one mapped to multiple addresses. + mkdir("/etc", 0777); + FILE* f = fopen("/etc/hosts", "w"); + assert(f); + fputs("# test hosts\n" + "10.1.2.3 statichost.test\n" + "192.0.2.1 multi.test\n" + "192.0.2.2 multi.test\n", + f); + fclose(f); + + // /etc/hosts resolves synchronously through getaddrinfo. + int err = 0; + unsigned static_addr = ipv4_of("statichost.test", &err); + if (err != 0) fail("static host not resolved from /etc/hosts"); + if (static_addr != inet_addr("10.1.2.3")) fail("static host wrong address"); + + // A name with several addresses comes back as an addrinfo linked list, freed + // as a whole by freeaddrinfo. + struct addrinfo mhints = {0}; + mhints.ai_family = AF_INET; + mhints.ai_socktype = SOCK_STREAM; + struct addrinfo* mres = NULL; + if (getaddrinfo("multi.test", NULL, &mhints, &mres) != 0) fail("multi host not resolved"); + int count = 0, seen1 = 0, seen2 = 0; + for (struct addrinfo* ai = mres; ai; ai = ai->ai_next) { + unsigned a = ((struct sockaddr_in*)ai->ai_addr)->sin_addr.s_addr; + if (a == inet_addr("192.0.2.1")) seen1 = 1; + if (a == inet_addr("192.0.2.2")) seen2 = 1; + count++; + } + freeaddrinfo(mres); + if (count != 2 || !seen1 || !seen2) fail("multi host did not return both addresses"); + + // A real hostname not in /etc/hosts has no synchronous resolution. + ipv4_of("localhost", &err); + if (err != EAI_AGAIN) fail("real hostname should be EAI_AGAIN"); + + // Resolve it asynchronously; the result arrives via the pollable fd. + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + lookup_fd = emscripten_dns_lookup_async("localhost", NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + emscripten_set_main_loop(main_loop, 0, 0); + return 0; +} diff --git a/test/sockets/test_dns_async_default.c b/test/sockets/test_dns_async_default.c new file mode 100644 index 0000000000000..543d3ae033dbb --- /dev/null +++ b/test/sockets/test_dns_async_default.c @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * The async getaddrinfo API is available without -sNODERAWSOCKETS too: there it + * resolves synchronously (the same fake address getaddrinfo() returns) and the + * fd is simply readable on the next turn, so integration code need not branch on + * the backend. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int lookup_fd = -1; + +static void fail(const char* why) { + printf("DNS ASYNC DEFAULT FAIL: %s\n", why); + abort(); +} + +static void main_loop(void) { + fd_set fdr; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_SET(lookup_fd, &fdr); + select(lookup_fd + 1, &fdr, NULL, NULL, &tv); + if (!FD_ISSET(lookup_fd, &fdr)) { + return; + } + + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(lookup_fd, &res); + close(lookup_fd); + if (result != 0) fail("async lookup failed"); + assert(res && res->ai_addr); + if (res->ai_socktype != SOCK_STREAM) fail("async result lost the requested socktype"); + freeaddrinfo(res); + + printf("DNS ASYNC DEFAULT PASS\n"); + emscripten_cancel_main_loop(); +} + +int main(void) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + lookup_fd = emscripten_dns_lookup_async("example.com", NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + emscripten_set_main_loop(main_loop, 0, 0); + return 0; +} diff --git a/test/sockets/test_dns_async_net.c b/test/sockets/test_dns_async_net.c new file mode 100644 index 0000000000000..a473161b30ccd --- /dev/null +++ b/test/sockets/test_dns_async_net.c @@ -0,0 +1,67 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Asynchronous getaddrinfo over the real network with -sNODERAWSOCKETS: a real + * public hostname has no synchronous resolution (EAI_AGAIN), then resolves via + * emscripten_dns_lookup_async(), whose result is delivered as an addrinfo + * payload. This hits the real network (like test_getaddrinfo). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char* HOST = "google.com"; + +static int lookup_fd = -1; + +static void fail(const char* why) { + printf("DNS ASYNC NET FAIL: %s\n", why); + abort(); +} + +static void main_loop(void) { + fd_set fdr; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_SET(lookup_fd, &fdr); + select(lookup_fd + 1, &fdr, NULL, NULL, &tv); + if (!FD_ISSET(lookup_fd, &fdr)) { + return; // resolution still in flight + } + + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(lookup_fd, &res); + close(lookup_fd); + if (result != 0) fail("async lookup failed"); + assert(res && res->ai_addr); + freeaddrinfo(res); + + printf("DNS ASYNC NET PASS\n"); + emscripten_cancel_main_loop(); +} + +int main(void) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + // No synchronous DNS without JSPI: the name is EAI_AGAIN up front. + struct addrinfo* res = NULL; + if (getaddrinfo(HOST, NULL, &hints, &res) != EAI_AGAIN) fail("host should be EAI_AGAIN"); + + lookup_fd = emscripten_dns_lookup_async(HOST, NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + emscripten_set_main_loop(main_loop, 0, 0); + return 0; +} diff --git a/test/sockets/test_dns_callback.c b/test/sockets/test_dns_callback.c new file mode 100644 index 0000000000000..cd6408985985c --- /dev/null +++ b/test/sockets/test_dns_callback.c @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * The fd returned by emscripten_dns_lookup_async() signals completion not only + * by becoming readable, but also by delivering the socket message callback + * registered with emscripten_set_socket_message_callback(). This drives the + * lookup purely through that callback, with no poll/select. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int lookup_fd = -1; + +static void fail(const char* why) { + printf("DNS CALLBACK FAIL: %s\n", why); + abort(); +} + +static void on_message(int fd, void* userData) { + if (fd != lookup_fd) return; // not our lookup + + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(lookup_fd, &res); + close(lookup_fd); + if (result != 0) fail("async lookup failed"); + assert(res); + unsigned addr = ((struct sockaddr_in*)res->ai_addr)->sin_addr.s_addr; + freeaddrinfo(res); + if (addr != htonl(INADDR_LOOPBACK)) fail("localhost did not resolve to 127.0.0.1"); + + printf("DNS CALLBACK PASS\n"); +} + +int main(void) { + emscripten_set_socket_message_callback(NULL, on_message); + + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + lookup_fd = emscripten_dns_lookup_async("localhost", NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + // The pending node:dns lookup keeps the runtime alive; the message callback + // fires on completion. + return 0; +} diff --git a/test/sockets/test_dns_jspi.c b/test/sockets/test_dns_jspi.c new file mode 100644 index 0000000000000..f88bd6f0ab994 --- /dev/null +++ b/test/sockets/test_dns_jspi.c @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * With -sNODERAWSOCKETS under JSPI, getaddrinfo() of a real public hostname + * blocks on the node:dns lookup by suspending the wasm stack, and resolves + * directly - no EAI_AGAIN + async prewarm + retry needed. This resolves over + * the real network. + */ + +#include +#include +#include +#include +#include +#include + +static const char* HOST = "google.com"; + +int main(void) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + struct addrinfo* res = NULL; + int err = getaddrinfo(HOST, NULL, &hints, &res); + if (err != 0) { + printf("DNS JSPI FAIL: getaddrinfo err=%d\n", err); + return 1; + } + assert(res); + freeaddrinfo(res); + + printf("DNS JSPI PASS\n"); + return 0; +} diff --git a/test/test_sockets.py b/test/test_sockets.py index d6358d03bedfc..d892abf5c2c03 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -484,6 +484,42 @@ def test_noderawsockets_udp_ipv6(self): self.skipTest('no IPv6 loopback available') self.do_runf('sockets/test_udp_ipv6.c', 'UDP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) + @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) + def test_noderawsockets_dns_async(self, args): + # getaddrinfo() resolves numeric and /etc/hosts names (read via emscripten's + # FS) synchronously and returns EAI_AGAIN for a real hostname. + # emscripten_dns_lookup_async() is the async getaddrinfo: a pollable fd whose + # emscripten_dns_lookup_result() yields the addrinfo payload directly. + self.do_runf('sockets/test_dns_async.c', 'DNS ASYNC PASS', cflags=['-sNODERAWSOCKETS'] + args) + + @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) + def test_noderawsockets_dns_callback(self, args): + # The async lookup fd also delivers the socket message callback on + # completion, so the lookup can be driven purely via + # emscripten_set_socket_message_callback with no poll/select. + self.do_runf('sockets/test_dns_callback.c', 'DNS CALLBACK PASS', cflags=['-sNODERAWSOCKETS', '-sFORCE_FILESYSTEM'] + args) + + def test_noderawsockets_dns_async_net(self): + # A real public hostname is EAI_AGAIN synchronously, then resolves via the + # async getaddrinfo, whose result is delivered as an addrinfo payload. This + # hits the real network (like test_getaddrinfo). + self.do_runf('sockets/test_dns_async_net.c', 'DNS ASYNC NET PASS', cflags=['-sNODERAWSOCKETS']) + + def test_dns_async_default(self): + # The async getaddrinfo API is available without -sNODERAWSOCKETS, resolving + # synchronously (the same fake address getaddrinfo() returns) and delivering + # it via the pollable fd. + self.do_runf('sockets/test_dns_async_default.c', 'DNS ASYNC DEFAULT PASS') + + @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) + def test_noderawsockets_dns_jspi(self, args): + # Under JSPI, getaddrinfo() of a real public hostname blocks on the + # node:dns lookup (suspending the wasm stack) and resolves directly, + # without the EAI_AGAIN + async retry needed in non-JSPI builds. This + # hits the real network (like test_getaddrinfo). + self.require_jspi() + self.do_runf('sockets/test_dns_jspi.c', 'DNS JSPI PASS', cflags=['-sNODERAWSOCKETS'] + args) + @parameterized({'': [[]], 'pthread': [['-pthread', '-sPROXY_TO_PTHREAD']]}) def test_noderawsockets_udp(self, args): # Self-contained loopback UDP echo: the server binds(:0)+getsockname for its