diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index 82323a63792ce..131f77b0f5af2 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -588,6 +588,34 @@ 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; TCP servers use a +low-level tcp_wrap handle for a synchronous bind, and UDP uses a udp_wrap +handle for the same reason. + +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 (including PROXY_TO_PTHREAD): like the rest of the +JS filesystem, socket syscalls are proxied to the main thread, where the +node handles and their event loop live. + +Default value: false + .. _websocket_subprotocol: WEBSOCKET_SUBPROTOCOL diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index 5fbfec805bd06..928be4fc50eaf 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -270,6 +270,7 @@ sigs = { __syscall_rmdir__sig: 'ip', __syscall_sendmsg__sig: 'iipippi', __syscall_sendto__sig: 'iippipp', + __syscall_setsockopt__sig: 'iiiippi', __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..30ee9d504de8e 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: {}, @@ -69,6 +73,8 @@ addToLibrary({ pending: [], recv_queue: [], #if SOCKET_WEBRTC +#elif NODERAWSOCKETS + sock_ops: nodeSockOps #else sock_ops: SOCKFS.websocket_sock_ops #endif @@ -726,7 +732,7 @@ addToLibrary({ return res; } - } + }, }, /* diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js new file mode 100644 index 0000000000000..ed839a5aecb99 --- /dev/null +++ b/src/lib/libsockfs_node.js @@ -0,0 +1,547 @@ +/** + * @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. +// +// TCP clients use the public net API: connect() goes through net.Socket and +// never touches a private handle. TCP servers use a low-level tcp_wrap handle, +// because only that gives a synchronous bind() + getsockname() (net.Server's +// listen is async and cannot report the assigned ephemeral port up front). So +// process.binding('tcp_wrap') only fires for bind/listen/accept, plus the rare +// client that bind()s a source port before connect(). +// +// UDP goes entirely through a low-level udp_wrap handle for now: node:dgram has +// no synchronous bind and a dgram.Socket cannot adopt an external handle, so we +// cannot do the same public/private split as TCP. Once node gains a public +// dgram bindSync, UDP can move fully onto node:dgram. +// +// Under -pthread the socket syscalls are proxied to the main thread (as all +// JS-filesystem syscalls are), so this backend always runs on the main thread +// and its event loop. 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'); + }, + // 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 }}}; + }, + // A low-level tcp_wrap TCP handle. Its bind/getsockname are synchronous, so + // a server can bind(:0) and read back the assigned port immediately, which + // a would-blocking getsockname could not do. Only created for servers (and + // the rare bound client), never for a plain connect(). + ensureHandle(sock) { + if (!sock.handle) { + var tcp = process.binding('tcp_wrap'); + sock.handle = new tcp.TCP(tcp.constants.SOCKET); + } + return sock.handle; + }, + // The peer address is already a numeric IP (emscripten resolves names in + // its own DNS layer), so skip node's async DNS lookup. + noLookup(host, _opts, cb) { + cb(null, host, 4); + }, + // A low-level udp_wrap UDP handle. node:dgram has no synchronous bind, and + // a dgram.Socket cannot adopt an external handle, so the whole UDP path + // goes through this handle for now (bind, send, recv). Once node gains a + // public dgram bindSync this can move entirely to node:dgram. + ensureUdpHandle(sock) { + if (sock.udp) return sock.udp; + 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; + } + // A connected datagram socket only accepts datagrams from its peer. + if (sock.daddr !== undefined && + (rinfo.address !== sock.daddr || rinfo.port !== sock.dport)) { + return; + } + var data = new Uint8Array(buf.length); + data.set(buf); + sock.recv_queue.push({ addr: rinfo.address, port: rinfo.port, data }); + SOCKFS.emit('message', sock.stream.fd); + }; + sock.udp = handle; + return handle; + }, + // recvStart must run after the handle is bound (an unbound handle rejects + // it). bind() binds explicitly; an outgoing socket auto-binds on first + // send. Either way we start receiving exactly once. + startUdpRecv(sock) { + if (sock.udp && !sock.udpReceiving) { + sock.udp.recvStart(); + sock.udpReceiving = true; + // An auto-bind on first send leaves saddr/sport unset; read them back + // so getsockname reports the assigned local address. + if (sock.sport === undefined) { + var name = {}; + if (sock.udp.getsockname(name) === 0) { + sock.saddr = name.address; + sock.sport = name.port; + } + } + // node only honors these once the handle is bound, so (re)apply any + // options that were set earlier. + nodeSockOps.applyUdpOptions(sock); + } + }, + // Apply the buffered datagram options to a bound udp_wrap handle. + applyUdpOptions(sock) { + var h = sock.udp; + var o = sock.opts; + if (!h || !o || !sock.udpReceiving) return; + 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_wrap handle, or undefined. + udpBufferSize(sock, recv) { + if (!sock.udp || !sock.udpReceiving) return undefined; + try { 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 { 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 bare bound handle (bind with no subsequent connect/listen). + if (sock.handle && !sock.server && !sock.connection) { + try { sock.handle.close(); } catch (e) {} + } + sock.handle = 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); + var ucode = 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; + } + var handle = nodeSockOps.ensureHandle(sock); + var code = handle.bind(addr, port); + if (code) throw new FS.ErrnoError(nodeSockOps.errnoForCode(code)); + // getsockname both reports the assigned port and forces a deferred bind + // error (e.g. address-in-use) to surface synchronously. + var name = {}; + code = handle.getsockname(name); + if (code) throw new FS.ErrnoError(nodeSockOps.errnoForCode(code)); + sock.saddr = name.address; + sock.sport = name.port; + sock.state = 'bound'; + }, + connect(sock, addr, port) { + if (sock.type === {{{ cDefs.SOCK_DGRAM }}}) { + // Connectionless: just record the default peer and make sure we have a + // handle so replies can be received. + sock.daddr = addr; + sock.dport = port; + nodeSockOps.ensureUdpHandle(sock); + 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.handle) { + // bind() ran first: connect through the already-bound handle so the + // chosen source address/port is honored. This is the only client path + // that uses tcp_wrap. + conn = new net.Socket({ handle: sock.handle, pauseOnCreate: true, allowHalfOpen: true }); + } else { + // Plain client: pure public API, no tcp_wrap handle. + 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. Do it + // synchronously here so getsockname reports the port right away. + if (sock.saddr === undefined) { + nodeSockOps.bind(sock, '0.0.0.0', 0); + } + var handle = nodeSockOps.ensureHandle(sock); + 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(handle, 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); + if (ArrayBuffer.isView(buffer)) { 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)); + var code = 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; + } + if (ArrayBuffer.isView(buffer)) { 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) 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. Node manages reuse, so we just store it. + sock.opts.reuseAddr = !!val; + return 0; + } + } else if (level === 0 /* IPPROTO_IP */) { + if (optname === 2 /* IP_TTL */) { + sock.opts.ttl = val; + nodeSockOps.applyUdpOptions(sock); + 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 = sock.opts.reuseAddr ? 1 : 0; break; // SO_REUSEADDR + default: return -{{{ cDefs.ENOPROTOOPT }}}; + } + } else if (level === 0 /* IPPROTO_IP */) { + if (optname !== 2 /* IP_TTL */) return -{{{ cDefs.ENOPROTOOPT }}}; + val = sock.opts.ttl || 64; + } 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 a0105e2a2871f..48ff6f2742b6c 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) => { - 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, addrlen, flags, d1, d2) => { @@ -445,6 +449,10 @@ var SyscallsLibrary = { __syscall_getsockopt__deps: ['$getSocketFromFD'], __syscall_getsockopt: (fd, level, optname, optval, optlen, d1) => { 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, d1) => { +#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, d1, d2, d3) => { 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 f953afaa3e52c..466d865288e76 100644 --- a/src/settings.js +++ b/src/settings.js @@ -419,6 +419,29 @@ 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; TCP servers use a +// low-level tcp_wrap handle for a synchronous bind, and UDP uses a udp_wrap +// handle for the same reason. +// +// 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 (including PROXY_TO_PTHREAD): like the rest of the +// JS filesystem, socket syscalls are proxied to the main thread, where the +// node handles and their event loop live. +// [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/system/lib/libc/emscripten_syscall_stubs.c b/system/lib/libc/emscripten_syscall_stubs.c index 13ec599a8ee45..a62fcf2ab53d7 100644 --- a/system/lib/libc/emscripten_syscall_stubs.c +++ b/system/lib/libc/emscripten_syscall_stubs.c @@ -248,15 +248,11 @@ weak int __syscall_prlimit64(int pid, int resource, intptr_t new_limit, intptr_t return 0; } -weak int __syscall_setsockopt(int sockfd, int level, int optname, intptr_t optval, size_t optlen, int dummy) { - REPORT(setsockopt); - return -ENOPROTOOPT; // The option is unknown at the level indicated. -} - UNIMPLEMENTED(acct, (intptr_t filename)) UNIMPLEMENTED(mincore, (intptr_t addr, size_t length, intptr_t vec)) UNIMPLEMENTED(recvmmsg, (int sockfd, intptr_t msgvec, size_t vlen, int flags, ...)) UNIMPLEMENTED(sendmmsg, (int sockfd, intptr_t msgvec, size_t vlen, int flags, ...)) -UNIMPLEMENTED(shutdown, (int sockfd, int how, int dummy, int dummy2, int dummy3, int dummy4)) +// __syscall_shutdown is provided in JS (libsyscall.js): it routes to the socket +// backend under NODERAWSOCKETS and otherwise reports the option as unsupported. UNIMPLEMENTED(socketpair, (int domain, int type, int protocol, intptr_t fds, int dummy, int dummy2)) UNIMPLEMENTED(wait4,(int pid, intptr_t wstatus, int options, int rusage)) diff --git a/system/lib/wasmfs/syscalls.cpp b/system/lib/wasmfs/syscalls.cpp index 2a86a5813b76c..aa25c6f5ed4cd 100644 --- a/system/lib/wasmfs/syscalls.cpp +++ b/system/lib/wasmfs/syscalls.cpp @@ -1753,6 +1753,20 @@ int __syscall_getsockopt(int sockfd, return -ENOSYS; } +int __syscall_setsockopt(int sockfd, + int level, + int optname, + intptr_t optval, + size_t optlen, + int dummy) { + return -ENOSYS; +} + +int __syscall_shutdown( + int sockfd, int how, int dummy, int dummy2, int dummy3, int dummy4) { + return -ENOSYS; +} + int __syscall_getsockname( int sockfd, intptr_t addr, intptr_t len, int dummy, int dummy2, int dummy3) { return -ENOSYS; diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 3b3d33f3aff90..6c6d3542fd19f 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": 268089, - "a.out.nodebug.wasm": 587575, - "total": 855664, + "a.out.js": 268239, + "a.out.nodebug.wasm": 587744, + "total": 855983, "sent": [ "IMG_Init", "IMG_Load", @@ -255,6 +255,8 @@ "__syscall_rmdir", "__syscall_sendmsg", "__syscall_sendto", + "__syscall_setsockopt", + "__syscall_shutdown", "__syscall_socket", "__syscall_stat64", "__syscall_statfs64", @@ -1773,6 +1775,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", @@ -2225,8 +2229,6 @@ "__syscall_setpgid", "__syscall_setpriority", "__syscall_setsid", - "__syscall_setsockopt", - "__syscall_shutdown", "__syscall_socketpair", "__syscall_sync", "__syscall_uname", @@ -4093,8 +4095,7 @@ "$__syscall_setdomainname", "$__syscall_setpgid", "$__syscall_setpriority", - "$__syscall_setsockopt", - "$__syscall_shutdown", + "$__syscall_socketpair", "$__syscall_sync", "$__syscall_uname", "$__syscall_wait4", @@ -5086,6 +5087,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..133b876f1a2f3 --- /dev/null +++ b/test/sockets/test_tcp_backpressure.c @@ -0,0 +1,117 @@ +/* + * 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(); + emscripten_force_exit(result); +#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_semantics.c b/test/sockets/test_tcp_client_semantics.c new file mode 100644 index 0000000000000..a40d781dd6381 --- /dev/null +++ b/test/sockets/test_tcp_client_semantics.c @@ -0,0 +1,129 @@ +/* + * 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(); + emscripten_force_exit(result); +#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..b86ac1f0f6151 --- /dev/null +++ b/test/sockets/test_tcp_echo.c @@ -0,0 +1,138 @@ +/* + * 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(); + // An open socket keeps node's event loop alive, so force a true shutdown. + emscripten_force_exit(result); +#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_refused.c b/test/sockets/test_tcp_refused.c new file mode 100644 index 0000000000000..979af46aa638a --- /dev/null +++ b/test/sockets/test_tcp_refused.c @@ -0,0 +1,92 @@ +/* + * 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(); + emscripten_force_exit(result); +#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..54d737cf722a1 --- /dev/null +++ b/test/sockets/test_tcp_server.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 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(); + // Open sockets keep node's event loop alive, so force a true shutdown. + emscripten_force_exit(result); +#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..7b4f9d57c8b82 --- /dev/null +++ b/test/sockets/test_udp_connect.c @@ -0,0 +1,134 @@ +/* + * 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(); + emscripten_force_exit(result); +#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..836bab5a22759 --- /dev/null +++ b/test/sockets/test_udp_echo.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. + */ + +// 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(); + // Open sockets keep node's event loop alive, so force a true shutdown. + emscripten_force_exit(result); +#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/test_sockets.py b/test/test_sockets.py index 8226f48df0dd1..b7ad58092af48 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -347,6 +347,97 @@ 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_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']) + + @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):