From bc0bc0f36ac92ab91987561372d06871b8ee8b4c Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:11:40 -0700 Subject: [PATCH 1/6] Support blocking accept() and recv() on JS sockets under pthreads and ASYNCIFY/JSPI --- site/source/docs/porting/networking.rst | 21 +-- .../tools_reference/settings_reference.rst | 4 +- src/lib/libsockfs_node.js | 22 +-- src/lib/libsyscall.js | 104 +++++++++++++- src/settings.js | 4 +- src/struct_info.json | 1 + src/struct_info_generated.json | 1 + src/struct_info_generated_wasm64.json | 1 + test/sockets/test_nonblock_flags.c | 5 +- test/sockets/test_tcp_blocking.c | 133 ++++++++++++++++++ test/test_sockets_node.py | 23 ++- 11 files changed, 275 insertions(+), 44 deletions(-) create mode 100644 test/sockets/test_tcp_blocking.c diff --git a/site/source/docs/porting/networking.rst b/site/source/docs/porting/networking.rst index b6bd9359660aa..38890c14d705a 100644 --- a/site/source/docs/porting/networking.rst +++ b/site/source/docs/porting/networking.rst @@ -62,15 +62,18 @@ sockets API with real host TCP, UDP and ``AF_UNIX`` stream sockets (see Non-blocking sockets (``SOCK_NONBLOCK``, ``fcntl(F_SETFL, O_NONBLOCK)`` or ``ioctl(FIONBIO)``) behave as on Linux. -Blocking sockets cannot actually block. An operation on a blocking socket that -would need to wait (``accept()``, ``recv()``/``read()``) fails with ``EAGAIN`` -instead, a blocking ``connect()`` returns ``0`` before the connection has -completed, and a blocking ``send()`` never waits: it buffers without limit -(only a non-blocking socket is bounded by the write buffer's high-water mark -and reports ``EAGAIN``). Builds with ``ASSERTIONS`` print a warning the first -time a blocking socket returns ``EAGAIN``. Applications should use non-blocking -sockets together with ``poll()`` or ``epoll``, which can wait when called from -a pthread, or when using :ref:`ASYNCIFY`. +Blocking ``accept()``, ``recv()``, ``recvfrom()`` and ``recvmsg()`` wait when +called from a pthread (including ``main()`` under :ref:`PROXY_TO_PTHREAD`), or +when using :ref:`ASYNCIFY` (including JSPI), just like ``poll()`` and +``epoll_wait()``; ``MSG_DONTWAIT`` still returns ``EAGAIN`` without waiting. +Where no stack can wait (the main thread of a plain build) these fail with +``EAGAIN`` instead, and builds with ``ASSERTIONS`` print a warning the first +time that happens. Other blocking operations never wait: a blocking +``connect()`` returns ``0`` before the connection has completed, and a +blocking ``send()`` buffers without limit (only a non-blocking socket is +bounded by the write buffer's high-water mark and reports ``EAGAIN``). +Applications can otherwise use non-blocking sockets together with ``poll()`` +or ``epoll``. Full POSIX Sockets over WebSocket Proxy Server ============================================== diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index abf4ee150bf5f..4cb6477aed647 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -592,7 +592,9 @@ Node.js. 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 or :ref:`PROXY_POSIX_SOCKETS`. +WebSocket emulation or :ref:`PROXY_POSIX_SOCKETS`. Blocking ``accept()`` +and ``recv()`` wait (like ``poll()``) from a pthread or under +:ref:`ASYNCIFY`/JSPI. It works under -pthread with :ref:`PROXY_TO_PTHREAD`, where main() and every socket syscall run on a single worker alongside the node handles and their event diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index 481a9c0c396bd..f2f80736a1e91 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -56,11 +56,7 @@ null; var NodeSockFSLibrary = { // Node plumbing shared by the interface methods below. - $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$inetPton4', '$inetPton6', -#if ASSERTIONS - '$warnOnce', -#endif - ], + $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$inetPton4', '$inetPton6'], $nodeSockHelpers: { // node builtins, resolved once each. getBuiltinModule works in both // CommonJS and ESM output, with require as the fallback. @@ -253,16 +249,6 @@ var NodeSockFSLibrary = { connectInProgress(sock) { if (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}}) throw new FS.ErrnoError({{{ cDefs.EINPROGRESS }}}); }, - // Operations that would block return EAGAIN even on a blocking fd, since - // there is no way to block here. - wouldBlock(sock) { -#if ASSERTIONS - if (!(sock.stream.flags & {{{ cDefs.O_NONBLOCK }}})) { - warnOnce('NODERAWSOCKETS: a blocking socket operation would block, returning EAGAIN instead (blocking I/O is not supported, use O_NONBLOCK with poll/epoll)'); - } -#endif - return new FS.ErrnoError({{{ cDefs.EAGAIN }}}); - }, // 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 @@ -725,7 +711,7 @@ var NodeSockFSLibrary = { listensock.error = null; throw new FS.ErrnoError(e); } - if (!listensock.pending.length) throw nodeSockHelpers.wouldBlock(listensock); + if (!listensock.pending.length) throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); return listensock.pending.shift(); }, sendmsg(sock, buffer, offset, length, addr, port) { @@ -807,7 +793,7 @@ var NodeSockFSLibrary = { sock.error = null; throw new FS.ErrnoError(derr); } - throw nodeSockHelpers.wouldBlock(sock); + throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); } // A datagram is atomic: return up to length bytes and drop the rest. var dd = dgram.data; @@ -821,7 +807,7 @@ var NodeSockFSLibrary = { if (!sock.connection) { throw new FS.ErrnoError({{{ cDefs.ENOTCONN }}}); } - throw nodeSockHelpers.wouldBlock(sock); + throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); } var q = queued.data; var bytesRead = Math.min(length, q.length); diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index 38fa259ba4c87..3e51abe18c551 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -410,9 +410,92 @@ var SyscallsLibrary = { return -{{{ cDefs.ENOSYS }}}; // unsupported feature #endif }, - __syscall_accept4__deps: ['$getSocketFromFD', '$writeSockaddr'], - __syscall_accept4: (fd, addr, len, flags, u1, u2) => { + // Run a receive-side socket syscall body `op(sock)` that may block. The + // backend is strictly synchronous: `op` throws EAGAIN when it would block, + // whatever the fd's mode. A blocking socket (no O_NONBLOCK, no MSG_DONTWAIT) + // then waits for readiness where the calling stack can - a sync-proxied + // pthread (PROXY_SYNC_ASYNC) awaits the returned Promise, ASYNCIFY/JSPI + // suspends on it - and retries. Every other outcome returns synchronously + // (under JSPI a Suspending import only suspends on a Promise), so a + // non-blocking call stays callable from any stack, and where no stack can + // wait (the event-loop thread itself) the EAGAIN surfaces unchanged. + $sockCall__internal: true, + $sockCall__deps: ['$getSocketFromFD', +#if PTHREADS || ASYNCIFY + '$sockCallAsync', +#endif +#if ASYNCIFY + '$Asyncify', +#endif +#if ASSERTIONS + '$warnOnce', +#endif + ], + $sockCall: (fd, dontWait, op) => { +#if PTHREADS + // A sync-proxied caller awaits a thenable even for an immediate result. + if (PThread.currentProxiedOperationCallerThread) return sockCallAsync(fd, dontWait, op); +#endif +#if ASYNCIFY == 1 + // The rewind re-enters here after the wait has already run `op`, so it + // must go back through handleAsync rather than run `op` again. + if (Asyncify.state === Asyncify.State.Rewinding) return Asyncify.handleAsync(() => {}); +#endif var sock = getSocketFromFD(fd); + try { + return op(sock); + } catch (e) { + if (e.name !== 'ErrnoError' || e.errno !== {{{ cDefs.EAGAIN }}} || dontWait || (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}})) throw e; +#if ASYNCIFY + return Asyncify.handleAsync(() => sockCallAsync(fd, dontWait, op)); +#else +#if ASSERTIONS + warnOnce('a blocking socket operation would block, returning EAGAIN instead (this stack cannot block: use O_NONBLOCK with poll/epoll, or call from a pthread or with ASYNCIFY/JSPI)'); +#endif + throw e; +#endif + } + }, +#if PTHREADS || ASYNCIFY + // Async sockCall(): run `op`, and on a would-block wait on the socket's node + // wait-queue until poll() reports something to consume (readable, hung up or + // errored; readiness is re-derived on each wake, the wake flags are just the + // trigger), then retry. Resolves to the result or -errno, as the synchronous + // syscall returns. + $sockCallAsync__internal: true, + $sockCallAsync__deps: ['$getSocketFromFD'], + $sockCallAsync: async (fd, dontWait, op) => { + try { + var sock = getSocketFromFD(fd); + for (;;) { + try { + return op(sock); + } catch (e) { + if (e.name !== 'ErrnoError' || e.errno !== {{{ cDefs.EAGAIN }}} || dontWait || (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}})) throw e; + } + var ready = () => sock.sock_ops.poll(sock) & {{{ cDefs.POLLIN | cDefs.POLLERR | cDefs.POLLHUP }}}; + if (!ready()) { + await new Promise((resolve) => { + var reg = sock.stream.node.addListener(() => { + if (!ready()) return; + reg.listeners.delete(reg.entry); + resolve(); + }); + }); + } + } + } catch (e) { + if (e.name !== 'ErrnoError') throw e; + return -e.errno; + } + }, +#endif + __syscall_accept4__deps: ['$sockCall', '$writeSockaddr'], +#if PTHREADS || ASYNCIFY + __syscall_accept4__async: true, +#endif + __syscall_accept4: (fd, addr, len, flags, u1, u2) => { + return sockCall(fd, false, (sock) => { var newsock = sock.sock_ops.accept(sock); #if NODERAWSOCKETS // Linux: the accepted fd's status flags come only from `flags`, never from @@ -426,6 +509,7 @@ var SyscallsLibrary = { #endif } return newsock.stream.fd; + }); }, __syscall_bind__deps: ['$getSocketFromFD', '$getSocketAddress'], __syscall_bind: (fd, addr, len, u1, u2, u3) => { @@ -440,9 +524,12 @@ var SyscallsLibrary = { sock.sock_ops.listen(sock, backlog); return 0; }, - __syscall_recvfrom__deps: ['$getSocketFromFD', '$writeSockaddr'], + __syscall_recvfrom__deps: ['$sockCall', '$writeSockaddr'], +#if PTHREADS || ASYNCIFY + __syscall_recvfrom__async: true, +#endif __syscall_recvfrom: (fd, buf, len, flags, addr, alen) => { - var sock = getSocketFromFD(fd); + return sockCall(fd, flags & {{{ cDefs.MSG_DONTWAIT }}}, (sock) => { var msg = sock.sock_ops.recvmsg(sock, len, flags); if (!msg) return 0; // socket is closed if (addr) { @@ -453,6 +540,7 @@ var SyscallsLibrary = { } HEAPU8.set(msg.buffer, buf); return msg.buffer.byteLength; + }); }, __syscall_sendto__deps: ['$getSocketFromFD', '$getSocketAddress'], __syscall_sendto: (fd, buf, len, flags, addr, alen) => { @@ -529,9 +617,12 @@ var SyscallsLibrary = { // write the buffer return sock.sock_ops.sendmsg(sock, view, 0, total, addr, port); }, - __syscall_recvmsg__deps: ['$getSocketFromFD', '$writeSockaddr'], + __syscall_recvmsg__deps: ['$sockCall', '$writeSockaddr'], +#if PTHREADS || ASYNCIFY + __syscall_recvmsg__async: true, +#endif __syscall_recvmsg: (fd, message, flags, u1, u2, u3) => { - var sock = getSocketFromFD(fd); + return sockCall(fd, flags & {{{ cDefs.MSG_DONTWAIT }}}, (sock) => { var iov = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_iov, '*') }}}; var num = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_iovlen, 'i32') }}}; // get the total amount of data we can read across all arrays @@ -587,6 +678,7 @@ var SyscallsLibrary = { // MSG_CTRUNC return bytesRead; + }); }, #endif // ~PROXY_POSIX_SOCKETS==0 __syscall_fchdir: (fd) => { diff --git a/src/settings.js b/src/settings.js index 6029ab6307f26..c3741400b37b0 100644 --- a/src/settings.js +++ b/src/settings.js @@ -420,7 +420,9 @@ var PROXY_POSIX_SOCKETS = false; // 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 or :ref:`PROXY_POSIX_SOCKETS`. +// WebSocket emulation or :ref:`PROXY_POSIX_SOCKETS`. Blocking ``accept()`` +// and ``recv()`` wait (like ``poll()``) from a pthread or under +// :ref:`ASYNCIFY`/JSPI. // // It works under -pthread with :ref:`PROXY_TO_PTHREAD`, where main() and every socket // syscall run on a single worker alongside the node handles and their event diff --git a/src/struct_info.json b/src/struct_info.json index 9d68f3a0f7659..bb58c996de57a 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -322,6 +322,7 @@ "SOCK_CLOEXEC", "SOCK_NONBLOCK", "MSG_PEEK", + "MSG_DONTWAIT", "AF_INET", "AF_UNSPEC", "AF_INET6", diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index e8cad551d543c..0155bf9906a61 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -338,6 +338,7 @@ "KMOD_RCTRL": 128, "KMOD_RSHIFT": 2, "MAP_PRIVATE": 2, + "MSG_DONTWAIT": 64, "MSG_PEEK": 2, "NCCS": 32, "NI_NAMEREQD": 8, diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index c08719f390c1f..5f58d25b272b0 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -338,6 +338,7 @@ "KMOD_RCTRL": 128, "KMOD_RSHIFT": 2, "MAP_PRIVATE": 2, + "MSG_DONTWAIT": 64, "MSG_PEEK": 2, "NCCS": 32, "NI_NAMEREQD": 8, diff --git a/test/sockets/test_nonblock_flags.c b/test/sockets/test_nonblock_flags.c index 01a46d4fcd8e3..b63b2bd362900 100644 --- a/test/sockets/test_nonblock_flags.c +++ b/test/sockets/test_nonblock_flags.c @@ -100,10 +100,6 @@ int main(void) { assert(is_nonblock(nonblocking_fd)); assert(accept(nonblocking_fd, NULL, NULL) == -1 && errno == EAGAIN); -#ifdef __EMSCRIPTEN__ - // A blocking accept cannot block, so it would-blocks too. - assert(accept(blocking_fd, NULL, NULL) == -1 && errno == EAGAIN); -#endif check_accept(blocking_fd, (struct sockaddr*)&blocking_addr, sizeof(blocking_addr), 0, 0); check_accept(blocking_fd, (struct sockaddr*)&blocking_addr, sizeof(blocking_addr), SOCK_NONBLOCK | SOCK_CLOEXEC, 1); @@ -128,6 +124,7 @@ int main(void) { assert(client_fd >= 0); check_connect(client_fd, (struct sockaddr*)&un, sizeof(un)); close(client_fd); + close(accept(unix_fd, NULL, NULL)); // drain that connection from the queue check_accept(unix_fd, (struct sockaddr*)&un, sizeof(un), 0, 0); check_accept(unix_fd, (struct sockaddr*)&un, sizeof(un), SOCK_NONBLOCK, 1); close(unix_fd); diff --git a/test/sockets/test_tcp_blocking.c b/test/sockets/test_tcp_blocking.c new file mode 100644 index 0000000000000..898ab514a88d7 --- /dev/null +++ b/test/sockets/test_tcp_blocking.c @@ -0,0 +1,133 @@ +/* + * 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. + * + * Blocking accept() and recv() on real sockets. Each blocking call is made + * with nothing to consume and is woken by an event that arrives *after* it has + * blocked - the peer acts on a delay (from another thread under -pthread, or a + * timer under JSPI) - so the call must suspend (the proxied worker under + * PROXY_TO_PTHREAD, or the calling stack under JSPI) and be woken through the + * socket's readiness wait-queue. Non-blocking calls and MSG_DONTWAIT on the + * same empty sockets still return EAGAIN immediately, and a call with data + * already pending returns without waiting. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN_PTHREADS__ +#include +#endif + +int listen_fd = -1, client_fd = -1, peer_fd = -1; +struct sockaddr_in addr; + +void start_connect(void* arg) { + client_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); + assert(client_fd >= 0); + int r = connect(client_fd, (struct sockaddr*)&addr, sizeof(addr)); + assert(r == 0 || errno == EINPROGRESS); +} + +void send_ping(void* arg) { + assert(send(client_fd, "ping", 4, 0) == 4); +} + +void close_client(void* arg) { + close(client_fd); + client_fd = -1; +} + +// Run `fn` only once the blocking call on the main stack has parked. +#ifdef __EMSCRIPTEN_PTHREADS__ +// Under PROXY_TO_PTHREAD main() runs on a worker parked in the blocking call, +// so its event loop can't fire a timer - the wake is a cross-thread notify +// from a second thread. +void* delayed(void* fn) { + usleep(100000); + ((void (*)(void*))fn)(NULL); + return NULL; +} + +void later(void (*fn)(void*)) { + pthread_t t; + assert(pthread_create(&t, NULL, delayed, (void*)fn) == 0); + assert(pthread_detach(t) == 0); +} +#else +void later(void (*fn)(void*)) { + emscripten_async_call(fn, NULL, 100); +} +#endif + +int main(void) { + listen_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(listen_fd >= 0); + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + assert(bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == 0); + assert(listen(listen_fd, 4) == 0); + socklen_t l = sizeof(addr); + assert(getsockname(listen_fd, (struct sockaddr*)&addr, &l) == 0); + + // Non-blocking: nothing pending is EAGAIN right away, never a wait. + assert(fcntl(listen_fd, F_SETFL, O_NONBLOCK) == 0); + assert(accept(listen_fd, NULL, NULL) == -1 && errno == EAGAIN); + assert(fcntl(listen_fd, F_SETFL, 0) == 0); + + // Blocking accept(), woken by a connection that arrives after it blocked. + later(start_connect); + struct sockaddr_in peer; + socklen_t pl = sizeof(peer); + peer_fd = accept(listen_fd, (struct sockaddr*)&peer, &pl); + assert(peer_fd >= 0); + assert(peer.sin_family == AF_INET && ntohs(peer.sin_port) != 0); + assert(!(fcntl(peer_fd, F_GETFL) & O_NONBLOCK)); + + // Blocking recv(): MSG_DONTWAIT on the empty socket is EAGAIN at once, then + // a plain recv() blocks until the peer's data arrives. + char buf[8]; + assert(recv(peer_fd, buf, sizeof(buf), MSG_DONTWAIT) == -1 && errno == EAGAIN); + later(send_ping); + assert(recv(peer_fd, buf, sizeof(buf), 0) == 4); + assert(memcmp(buf, "ping", 4) == 0); + + // Blocking recv() woken by the peer closing: EOF. + later(close_client); + assert(recv(peer_fd, buf, sizeof(buf), 0) == 0); + close(peer_fd); + + // A connection already pending when accept() is called (poll() has reported + // the listener readable) is returned without waiting, and data already + // queued satisfies recv() without waiting. + client_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(client_fd >= 0); + assert(connect(client_fd, (struct sockaddr*)&addr, sizeof(addr)) == 0); + struct pollfd p = { .fd = listen_fd, .events = POLLIN }; + assert(poll(&p, 1, -1) == 1 && (p.revents & POLLIN)); + peer_fd = accept(listen_fd, NULL, NULL); + assert(peer_fd >= 0); + assert(send(client_fd, "pong", 4, 0) == 4); + p.fd = peer_fd; + assert(poll(&p, 1, -1) == 1 && (p.revents & POLLIN)); + assert(recv(peer_fd, buf, sizeof(buf), 0) == 4); + assert(memcmp(buf, "pong", 4) == 0); + close(peer_fd); + close(client_fd); + + close(listen_fd); + printf("done\n"); + return 0; +} diff --git a/test/test_sockets_node.py b/test/test_sockets_node.py index 414d27cd37d63..e8974625561f5 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -246,6 +246,21 @@ def test_noderawsockets_epoll_socket_blocking_jspi(self): self.do_runf('sockets/test_epoll_socket_blocking.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_tcp_blocking(self): + # Blocking accept() and recv() on empty sockets wait on the socket's + # readiness wait-queue and are woken by a connection, data, or the peer + # closing after they blocked, with main() proxied to a worker so the wait + # can suspend. A non-blocking call or MSG_DONTWAIT still returns EAGAIN at + # once, and pending data is returned without waiting. + self.do_runf('sockets/test_tcp_blocking.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) + + @requires_jspi_node + def test_noderawsockets_tcp_blocking_jspi(self): + # Same, but the blocking calls suspend the wasm stack under JSPI. + self.do_runf('sockets/test_tcp_blocking.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_epoll_rdhup(self): # A blocking epoll_wait reports EPOLLRDHUP when the TCP peer half-closes its # write side (FIN), distinct from a full EPOLLHUP, and only when requested. @@ -254,11 +269,9 @@ def test_noderawsockets_epoll_rdhup(self): def test_noderawsockets_nonblock_flags(self): # socket()/accept4() SOCK_NONBLOCK, FIONBIO, no listener flag inheritance on - # accept, non-blocking connect EINPROGRESS (TCP and AF_UNIX), and a warning - # when a blocking fd would-blocks. - out = self.do_runf('sockets/test_nonblock_flags.c', 'done\n', - cflags=['-sNODERAWSOCKETS', '-sNODERAWFS', '-sASSERTIONS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) - self.assertContained('a blocking socket operation would block', out) + # accept, and non-blocking connect EINPROGRESS (TCP and AF_UNIX). + self.do_runf('sockets/test_nonblock_flags.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-sNODERAWFS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) @requires_jspi_node def test_noderawsockets_epoll_rdhup_jspi(self): From bf9ae4dba1e1b08a2ca8bb5ac5fddc1f2d7fea5c Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:31:39 -0700 Subject: [PATCH 2/6] Rebaseline codesize --- 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 2599da95e6573..65507236fe6de 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": 270695, + "a.out.js": 270762, "a.out.nodebug.wasm": 588289, - "total": 858984, + "total": 859051, "sent": [ "IMG_Init", "IMG_Load", From 54626cb1428370a46e9dc4f0b7df535053675e88 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 12:51:32 -0700 Subject: [PATCH 3/6] JSPI-only suspension, surface pending TCP recv error, always park after EAGAIN, error-while-blocked test --- site/source/docs/porting/networking.rst | 4 +- .../tools_reference/settings_reference.rst | 3 +- src/lib/libsockfs_node.js | 7 ++ src/lib/libsyscall.js | 82 +++++++++-------- src/settings.js | 3 +- test/sockets/test_tcp_blocking_error.c | 91 +++++++++++++++++++ test/test_sockets_node.py | 12 +++ 7 files changed, 160 insertions(+), 42 deletions(-) create mode 100644 test/sockets/test_tcp_blocking_error.c diff --git a/site/source/docs/porting/networking.rst b/site/source/docs/porting/networking.rst index 38890c14d705a..49c796448884c 100644 --- a/site/source/docs/porting/networking.rst +++ b/site/source/docs/porting/networking.rst @@ -64,8 +64,8 @@ Non-blocking sockets (``SOCK_NONBLOCK``, ``fcntl(F_SETFL, O_NONBLOCK)`` or Blocking ``accept()``, ``recv()``, ``recvfrom()`` and ``recvmsg()`` wait when called from a pthread (including ``main()`` under :ref:`PROXY_TO_PTHREAD`), or -when using :ref:`ASYNCIFY` (including JSPI), just like ``poll()`` and -``epoll_wait()``; ``MSG_DONTWAIT`` still returns ``EAGAIN`` without waiting. +when using :ref:`JSPI`, just like ``poll()`` and ``epoll_wait()``; +``MSG_DONTWAIT`` still returns ``EAGAIN`` without waiting. Where no stack can wait (the main thread of a plain build) these fail with ``EAGAIN`` instead, and builds with ``ASSERTIONS`` print a warning the first time that happens. Other blocking operations never wait: a blocking diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index 4cb6477aed647..d27e5256620dc 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -593,8 +593,7 @@ 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 or :ref:`PROXY_POSIX_SOCKETS`. Blocking ``accept()`` -and ``recv()`` wait (like ``poll()``) from a pthread or under -:ref:`ASYNCIFY`/JSPI. +and ``recv()`` wait (like ``poll()``) from a pthread or under :ref:`JSPI`. It works under -pthread with :ref:`PROXY_TO_PTHREAD`, where main() and every socket syscall run on a single worker alongside the node handles and their event diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index f2f80736a1e91..6f944ac03f789 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -807,6 +807,13 @@ var NodeSockFSLibrary = { if (!sock.connection) { throw new FS.ErrnoError({{{ cDefs.ENOTCONN }}}); } + // A pending error (poll reports it readable for this) is returned and + // cleared here, as Linux does, rather than reporting EAGAIN forever. + if (sock.error) { + var serr = sock.error; + sock.error = null; + throw new FS.ErrnoError(serr); + } throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); } var q = queued.data; diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index 3e51abe18c551..0859ee322328c 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -414,17 +414,17 @@ var SyscallsLibrary = { // backend is strictly synchronous: `op` throws EAGAIN when it would block, // whatever the fd's mode. A blocking socket (no O_NONBLOCK, no MSG_DONTWAIT) // then waits for readiness where the calling stack can - a sync-proxied - // pthread (PROXY_SYNC_ASYNC) awaits the returned Promise, ASYNCIFY/JSPI - // suspends on it - and retries. Every other outcome returns synchronously - // (under JSPI a Suspending import only suspends on a Promise), so a - // non-blocking call stays callable from any stack, and where no stack can - // wait (the event-loop thread itself) the EAGAIN surfaces unchanged. + // pthread (PROXY_SYNC_ASYNC) awaits the returned Promise, JSPI suspends on + // it - and retries. Every other outcome returns synchronously (a JSPI + // Suspending import only suspends on a Promise), so a non-blocking call + // stays callable from any stack, and where no stack can wait (the event-loop + // thread itself) the EAGAIN surfaces unchanged. $sockCall__internal: true, $sockCall__deps: ['$getSocketFromFD', -#if PTHREADS || ASYNCIFY - '$sockCallAsync', +#if PTHREADS || JSPI + '$sockCallAsync', '$sockWouldBlock', #endif -#if ASYNCIFY +#if JSPI '$Asyncify', #endif #if ASSERTIONS @@ -435,35 +435,48 @@ var SyscallsLibrary = { #if PTHREADS // A sync-proxied caller awaits a thenable even for an immediate result. if (PThread.currentProxiedOperationCallerThread) return sockCallAsync(fd, dontWait, op); -#endif -#if ASYNCIFY == 1 - // The rewind re-enters here after the wait has already run `op`, so it - // must go back through handleAsync rather than run `op` again. - if (Asyncify.state === Asyncify.State.Rewinding) return Asyncify.handleAsync(() => {}); #endif var sock = getSocketFromFD(fd); +#if JSPI try { return op(sock); } catch (e) { - if (e.name !== 'ErrnoError' || e.errno !== {{{ cDefs.EAGAIN }}} || dontWait || (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}})) throw e; -#if ASYNCIFY + if (!sockWouldBlock(e, sock, dontWait)) throw e; + // handleAsync keeps the runtime alive across the suspension (EXIT_RUNTIME + // would otherwise tear it down from an event-loop callback while main() + // is parked here). return Asyncify.handleAsync(() => sockCallAsync(fd, dontWait, op)); + } #else #if ASSERTIONS - warnOnce('a blocking socket operation would block, returning EAGAIN instead (this stack cannot block: use O_NONBLOCK with poll/epoll, or call from a pthread or with ASYNCIFY/JSPI)'); -#endif + try { + return op(sock); + } catch (e) { + if (e.name === 'ErrnoError' && e.errno === {{{ cDefs.EAGAIN }}} && !dontWait && !(sock.stream.flags & {{{ cDefs.O_NONBLOCK }}})) { + warnOnce('a blocking socket operation would block, returning EAGAIN instead (this stack cannot block: use O_NONBLOCK with poll/epoll, or call from a pthread or with JSPI)'); + } throw e; -#endif } +#else + return op(sock); +#endif +#endif }, -#if PTHREADS || ASYNCIFY - // Async sockCall(): run `op`, and on a would-block wait on the socket's node +#if PTHREADS || JSPI + // Whether a failed `op` on a blocking socket should wait and retry. + $sockWouldBlock__internal: true, + $sockWouldBlock: (e, sock, dontWait) => + e.name === 'ErrnoError' && e.errno === {{{ cDefs.EAGAIN }}} && !dontWait && !(sock.stream.flags & {{{ cDefs.O_NONBLOCK }}}), + // Async sockCall(): run `op`, and on a would-block park on the socket's node // wait-queue until poll() reports something to consume (readable, hung up or // errored; readiness is re-derived on each wake, the wake flags are just the - // trigger), then retry. Resolves to the result or -errno, as the synchronous - // syscall returns. + // trigger), then retry. Always parks after an EAGAIN rather than re-checking + // readiness first: nothing can have changed since `op` ran, and a backend + // whose poll() disagrees with its recv must wait, not spin. Resolves to the + // result or -errno: wrapSyscallFunction's catch only covers the synchronous + // part of the syscall, so a rejection would escape it. $sockCallAsync__internal: true, - $sockCallAsync__deps: ['$getSocketFromFD'], + $sockCallAsync__deps: ['$getSocketFromFD', '$sockWouldBlock'], $sockCallAsync: async (fd, dontWait, op) => { try { var sock = getSocketFromFD(fd); @@ -471,18 +484,15 @@ var SyscallsLibrary = { try { return op(sock); } catch (e) { - if (e.name !== 'ErrnoError' || e.errno !== {{{ cDefs.EAGAIN }}} || dontWait || (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}})) throw e; + if (!sockWouldBlock(e, sock, dontWait)) throw e; } - var ready = () => sock.sock_ops.poll(sock) & {{{ cDefs.POLLIN | cDefs.POLLERR | cDefs.POLLHUP }}}; - if (!ready()) { - await new Promise((resolve) => { - var reg = sock.stream.node.addListener(() => { - if (!ready()) return; - reg.listeners.delete(reg.entry); - resolve(); - }); + await new Promise((resolve) => { + var reg = sock.stream.node.addListener(() => { + if (!(sock.sock_ops.poll(sock) & {{{ cDefs.POLLIN | cDefs.POLLERR | cDefs.POLLHUP }}})) return; + reg.listeners.delete(reg.entry); + resolve(); }); - } + }); } } catch (e) { if (e.name !== 'ErrnoError') throw e; @@ -491,7 +501,7 @@ var SyscallsLibrary = { }, #endif __syscall_accept4__deps: ['$sockCall', '$writeSockaddr'], -#if PTHREADS || ASYNCIFY +#if PTHREADS || JSPI __syscall_accept4__async: true, #endif __syscall_accept4: (fd, addr, len, flags, u1, u2) => { @@ -525,7 +535,7 @@ var SyscallsLibrary = { return 0; }, __syscall_recvfrom__deps: ['$sockCall', '$writeSockaddr'], -#if PTHREADS || ASYNCIFY +#if PTHREADS || JSPI __syscall_recvfrom__async: true, #endif __syscall_recvfrom: (fd, buf, len, flags, addr, alen) => { @@ -618,7 +628,7 @@ var SyscallsLibrary = { return sock.sock_ops.sendmsg(sock, view, 0, total, addr, port); }, __syscall_recvmsg__deps: ['$sockCall', '$writeSockaddr'], -#if PTHREADS || ASYNCIFY +#if PTHREADS || JSPI __syscall_recvmsg__async: true, #endif __syscall_recvmsg: (fd, message, flags, u1, u2, u3) => { diff --git a/src/settings.js b/src/settings.js index c3741400b37b0..05139038881c5 100644 --- a/src/settings.js +++ b/src/settings.js @@ -421,8 +421,7 @@ var PROXY_POSIX_SOCKETS = false; // ``emscripten_set_socket_*_callback`` hooks the WebSocket backend uses, so it // works with existing readiness reactors. It cannot be combined with the // WebSocket emulation or :ref:`PROXY_POSIX_SOCKETS`. Blocking ``accept()`` -// and ``recv()`` wait (like ``poll()``) from a pthread or under -// :ref:`ASYNCIFY`/JSPI. +// and ``recv()`` wait (like ``poll()``) from a pthread or under :ref:`JSPI`. // // It works under -pthread with :ref:`PROXY_TO_PTHREAD`, where main() and every socket // syscall run on a single worker alongside the node handles and their event diff --git a/test/sockets/test_tcp_blocking_error.c b/test/sockets/test_tcp_blocking_error.c new file mode 100644 index 0000000000000..b36782aeb5686 --- /dev/null +++ b/test/sockets/test_tcp_blocking_error.c @@ -0,0 +1,91 @@ +/* + * 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 blocking recv() on a connected TCP socket is woken by a connection error + * that lands after it has blocked, and returns that error. Node drains the + * kernel buffer eagerly so a real peer cannot reliably produce an RST here; the + * error is injected on the socket object from the JS side instead (on the + * main thread, where the socket lives: from a second thread under + * PROXY_TO_PTHREAD, or a timer under JSPI). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN_PTHREADS__ +#include +#endif + +int listen_fd = -1, client_fd = -1, peer_fd = -1; +struct sockaddr_in addr; + +void inject_error(void* arg) { + MAIN_THREAD_EM_ASM({ + var sock = SOCKFS.getSocket($0); + sock.error = $1; + SOCKFS.emit('error', [$0, $1, 'injected']); + }, peer_fd, ECONNRESET); +} + +#ifdef __EMSCRIPTEN_PTHREADS__ +void* delayed(void* arg) { + usleep(100000); // let recv() block first + inject_error(NULL); + return NULL; +} +#endif + +int main(void) { + listen_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(listen_fd >= 0); + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + assert(bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == 0); + assert(listen(listen_fd, 4) == 0); + socklen_t l = sizeof(addr); + assert(getsockname(listen_fd, (struct sockaddr*)&addr, &l) == 0); + + client_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(client_fd >= 0); + assert(connect(client_fd, (struct sockaddr*)&addr, sizeof(addr)) == 0); + peer_fd = accept(listen_fd, NULL, NULL); // blocks until the connection lands + assert(peer_fd >= 0); + +#ifdef __EMSCRIPTEN_PTHREADS__ + pthread_t t; + assert(pthread_create(&t, NULL, delayed, NULL) == 0); +#else + emscripten_async_call(inject_error, NULL, 100); +#endif + + char buf[8]; + assert(recv(peer_fd, buf, sizeof(buf), 0) == -1); // blocks; only the error can wake it + assert(errno == ECONNRESET); + // The error was consumed: SO_ERROR is clear, and the socket is back to + // would-block (still readable-wait, no data). + int err = -1; + socklen_t el = sizeof(err); + assert(getsockopt(peer_fd, SOL_SOCKET, SO_ERROR, &err, &el) == 0 && err == 0); + assert(recv(peer_fd, buf, sizeof(buf), MSG_DONTWAIT) == -1 && errno == EAGAIN); + +#ifdef __EMSCRIPTEN_PTHREADS__ + assert(pthread_join(t, NULL) == 0); +#endif + close(peer_fd); + close(client_fd); + close(listen_fd); + printf("done\n"); + return 0; +} diff --git a/test/test_sockets_node.py b/test/test_sockets_node.py index e8974625561f5..86be8f4a0da21 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -261,6 +261,18 @@ def test_noderawsockets_tcp_blocking_jspi(self): self.do_runf('sockets/test_tcp_blocking.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_tcp_blocking_error(self): + # A blocking recv() is woken by a connection error that lands after it + # blocked, and returns it (rather than spinning on EAGAIN while poll() + # reports POLLERR). + self.do_runf('sockets/test_tcp_blocking_error.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) + + @requires_jspi_node + def test_noderawsockets_tcp_blocking_error_jspi(self): + self.do_runf('sockets/test_tcp_blocking_error.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_epoll_rdhup(self): # A blocking epoll_wait reports EPOLLRDHUP when the TCP peer half-closes its # write side (FIN), distinct from a full EPOLLHUP, and only when requested. From 0d8f861d690ef06f98a6448619cc0721611614da Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 13:03:38 -0700 Subject: [PATCH 4/6] Restore ASYNCIFY=1 support, rebaseline --- site/source/docs/porting/networking.rst | 4 +-- .../tools_reference/settings_reference.rst | 3 ++- src/lib/libsyscall.js | 27 +++++++++++-------- src/settings.js | 3 ++- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/site/source/docs/porting/networking.rst b/site/source/docs/porting/networking.rst index 49c796448884c..0df33b890b59f 100644 --- a/site/source/docs/porting/networking.rst +++ b/site/source/docs/porting/networking.rst @@ -64,8 +64,8 @@ Non-blocking sockets (``SOCK_NONBLOCK``, ``fcntl(F_SETFL, O_NONBLOCK)`` or Blocking ``accept()``, ``recv()``, ``recvfrom()`` and ``recvmsg()`` wait when called from a pthread (including ``main()`` under :ref:`PROXY_TO_PTHREAD`), or -when using :ref:`JSPI`, just like ``poll()`` and ``epoll_wait()``; -``MSG_DONTWAIT`` still returns ``EAGAIN`` without waiting. +when using :ref:`ASYNCIFY` or :ref:`JSPI`, just like ``poll()`` and +``epoll_wait()``; ``MSG_DONTWAIT`` still returns ``EAGAIN`` without waiting. Where no stack can wait (the main thread of a plain build) these fail with ``EAGAIN`` instead, and builds with ``ASSERTIONS`` print a warning the first time that happens. Other blocking operations never wait: a blocking diff --git a/site/source/docs/tools_reference/settings_reference.rst b/site/source/docs/tools_reference/settings_reference.rst index d27e5256620dc..4cb6477aed647 100644 --- a/site/source/docs/tools_reference/settings_reference.rst +++ b/site/source/docs/tools_reference/settings_reference.rst @@ -593,7 +593,8 @@ 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 or :ref:`PROXY_POSIX_SOCKETS`. Blocking ``accept()`` -and ``recv()`` wait (like ``poll()``) from a pthread or under :ref:`JSPI`. +and ``recv()`` wait (like ``poll()``) from a pthread or under +:ref:`ASYNCIFY`/JSPI. It works under -pthread with :ref:`PROXY_TO_PTHREAD`, where main() and every socket syscall run on a single worker alongside the node handles and their event diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index 0859ee322328c..c1eaa2d1f1da2 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -414,17 +414,17 @@ var SyscallsLibrary = { // backend is strictly synchronous: `op` throws EAGAIN when it would block, // whatever the fd's mode. A blocking socket (no O_NONBLOCK, no MSG_DONTWAIT) // then waits for readiness where the calling stack can - a sync-proxied - // pthread (PROXY_SYNC_ASYNC) awaits the returned Promise, JSPI suspends on - // it - and retries. Every other outcome returns synchronously (a JSPI - // Suspending import only suspends on a Promise), so a non-blocking call + // pthread (PROXY_SYNC_ASYNC) awaits the returned Promise, ASYNCIFY/JSPI + // suspends on it - and retries. Every other outcome returns synchronously (a + // JSPI Suspending import only suspends on a Promise), so a non-blocking call // stays callable from any stack, and where no stack can wait (the event-loop // thread itself) the EAGAIN surfaces unchanged. $sockCall__internal: true, $sockCall__deps: ['$getSocketFromFD', -#if PTHREADS || JSPI +#if PTHREADS || ASYNCIFY '$sockCallAsync', '$sockWouldBlock', #endif -#if JSPI +#if ASYNCIFY '$Asyncify', #endif #if ASSERTIONS @@ -435,9 +435,14 @@ var SyscallsLibrary = { #if PTHREADS // A sync-proxied caller awaits a thenable even for an immediate result. if (PThread.currentProxiedOperationCallerThread) return sockCallAsync(fd, dontWait, op); +#endif +#if ASYNCIFY == 1 + // The rewind re-enters here after the wait has already run `op`, so it + // must go back through handleAsync rather than run `op` again. + if (Asyncify.state === Asyncify.State.Rewinding) return Asyncify.handleAsync(() => {}); #endif var sock = getSocketFromFD(fd); -#if JSPI +#if ASYNCIFY try { return op(sock); } catch (e) { @@ -453,7 +458,7 @@ var SyscallsLibrary = { return op(sock); } catch (e) { if (e.name === 'ErrnoError' && e.errno === {{{ cDefs.EAGAIN }}} && !dontWait && !(sock.stream.flags & {{{ cDefs.O_NONBLOCK }}})) { - warnOnce('a blocking socket operation would block, returning EAGAIN instead (this stack cannot block: use O_NONBLOCK with poll/epoll, or call from a pthread or with JSPI)'); + warnOnce('a blocking socket operation would block, returning EAGAIN instead (this stack cannot block: use O_NONBLOCK with poll/epoll, or call from a pthread or with ASYNCIFY/JSPI)'); } throw e; } @@ -462,7 +467,7 @@ var SyscallsLibrary = { #endif #endif }, -#if PTHREADS || JSPI +#if PTHREADS || ASYNCIFY // Whether a failed `op` on a blocking socket should wait and retry. $sockWouldBlock__internal: true, $sockWouldBlock: (e, sock, dontWait) => @@ -501,7 +506,7 @@ var SyscallsLibrary = { }, #endif __syscall_accept4__deps: ['$sockCall', '$writeSockaddr'], -#if PTHREADS || JSPI +#if PTHREADS || ASYNCIFY __syscall_accept4__async: true, #endif __syscall_accept4: (fd, addr, len, flags, u1, u2) => { @@ -535,7 +540,7 @@ var SyscallsLibrary = { return 0; }, __syscall_recvfrom__deps: ['$sockCall', '$writeSockaddr'], -#if PTHREADS || JSPI +#if PTHREADS || ASYNCIFY __syscall_recvfrom__async: true, #endif __syscall_recvfrom: (fd, buf, len, flags, addr, alen) => { @@ -628,7 +633,7 @@ var SyscallsLibrary = { return sock.sock_ops.sendmsg(sock, view, 0, total, addr, port); }, __syscall_recvmsg__deps: ['$sockCall', '$writeSockaddr'], -#if PTHREADS || JSPI +#if PTHREADS || ASYNCIFY __syscall_recvmsg__async: true, #endif __syscall_recvmsg: (fd, message, flags, u1, u2, u3) => { diff --git a/src/settings.js b/src/settings.js index 05139038881c5..c3741400b37b0 100644 --- a/src/settings.js +++ b/src/settings.js @@ -421,7 +421,8 @@ var PROXY_POSIX_SOCKETS = false; // ``emscripten_set_socket_*_callback`` hooks the WebSocket backend uses, so it // works with existing readiness reactors. It cannot be combined with the // WebSocket emulation or :ref:`PROXY_POSIX_SOCKETS`. Blocking ``accept()`` -// and ``recv()`` wait (like ``poll()``) from a pthread or under :ref:`JSPI`. +// and ``recv()`` wait (like ``poll()``) from a pthread or under +// :ref:`ASYNCIFY`/JSPI. // // It works under -pthread with :ref:`PROXY_TO_PTHREAD`, where main() and every socket // syscall run on a single worker alongside the node handles and their event From 842fb9ba94152fb435842990d0948e5287632794 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 13:08:55 -0700 Subject: [PATCH 5/6] ASYNCIFY test variants; pending recv error takes precedence over EOF --- src/lib/libsockfs_node.js | 11 ++++++----- test/test_sockets_node.py | 9 +++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index 6f944ac03f789..c77e52dc197a6 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -803,17 +803,18 @@ var NodeSockFSLibrary = { } var queued = sock.recv_queue[0]; if (!queued) { - if (sock.readClosed) return null; // EOF - if (!sock.connection) { - throw new FS.ErrnoError({{{ cDefs.ENOTCONN }}}); - } // A pending error (poll reports it readable for this) is returned and - // cleared here, as Linux does, rather than reporting EAGAIN forever. + // cleared here, as Linux does, rather than reporting EAGAIN forever. It + // takes precedence over EOF: node emits 'close' right after 'error'. if (sock.error) { var serr = sock.error; sock.error = null; throw new FS.ErrnoError(serr); } + 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; diff --git a/test/test_sockets_node.py b/test/test_sockets_node.py index 86be8f4a0da21..c6be23cadb8d3 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -261,6 +261,11 @@ def test_noderawsockets_tcp_blocking_jspi(self): self.do_runf('sockets/test_tcp_blocking.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_tcp_blocking_asyncify(self): + # Same under ASYNCIFY, where the import is re-entered on rewind. + self.do_runf('sockets/test_tcp_blocking.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-sASYNCIFY', '-sEXIT_RUNTIME']) + def test_noderawsockets_tcp_blocking_error(self): # A blocking recv() is woken by a connection error that lands after it # blocked, and returns it (rather than spinning on EAGAIN while poll() @@ -273,6 +278,10 @@ def test_noderawsockets_tcp_blocking_error_jspi(self): self.do_runf('sockets/test_tcp_blocking_error.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + def test_noderawsockets_tcp_blocking_error_asyncify(self): + self.do_runf('sockets/test_tcp_blocking_error.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-sASYNCIFY', '-sEXIT_RUNTIME']) + def test_noderawsockets_epoll_rdhup(self): # A blocking epoll_wait reports EPOLLRDHUP when the TCP peer half-closes its # write side (FIN), distinct from a full EPOLLHUP, and only when requested. From bdadfe644e4635f309255c6c5c190dbe2c95ef27 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 17:20:07 -0700 Subject: [PATCH 6/6] 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 65507236fe6de..5d2ec71c6e568 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": 270762, + "a.out.js": 270739, "a.out.nodebug.wasm": 588289, - "total": 859051, + "total": 859028, "sent": [ "IMG_Init", "IMG_Load",