From a3b5ba21e38bc1e11c09a9cece016f714244ef26 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 30 Jun 2026 16:58:02 -0700 Subject: [PATCH 1/7] Implement epoll for the JS filesystem Add epoll_create1/epoll_ctl/epoll_wait/epoll_pwait on the legacy (non-WASMFS) JS syscall layer, built on the per-inode readiness wait-queue: level- and edge-triggered modes, EPOLLONESHOT, EPOLLEXCLUSIVE, EPOLLRDHUP, nesting, and blocking waits under PROXY_TO_PTHREAD, ASYNCIFY, and JSPI. Also add emscripten_epoll_set_callback (new experimental ), a non-blocking variant that delivers an epoll set's readiness to a JS callback without ASYNCIFY/JSPI. --- ChangeLog.md | 12 + src/lib/libepoll.js | 403 ++++++++++++++++++ src/lib/libsigs.js | 2 + src/lib/libsyscall.js | 47 +- src/modules.mjs | 1 + src/struct_info.json | 24 ++ src/struct_info_generated.json | 18 + src/struct_info_generated_wasm64.json | 18 + system/include/emscripten/epoll.h | 57 +++ system/include/emscripten/syscalls.h | 1 + system/lib/libc/musl/src/linux/epoll.c | 10 + system/lib/wasmfs/syscalls.cpp | 6 + .../test_codesize_hello_dylink_all.json | 9 +- test/core/test_epoll.c | 98 +++++ test/core/test_epoll_advanced.c | 181 ++++++++ test/core/test_epoll_blocking_asyncify.c | 39 ++ test/core/test_epoll_callback.c | 74 ++++ test/core/test_epoll_callback_close.c | 46 ++ test/core/test_epoll_callback_edge.c | 62 +++ test/core/test_epoll_callback_level.c | 42 ++ test/core/test_epoll_callback_nested.c | 54 +++ test/core/test_epoll_callback_nested_close.c | 47 ++ test/core/test_epoll_callback_overflow.c | 61 +++ test/core/test_epoll_callback_replace.c | 56 +++ test/core/test_epoll_fairness.c | 41 ++ test/core/test_epoll_noderawfs.c | 59 +++ test/core/test_epoll_wait_and_callback.c | 100 +++++ test/sockets/test_epoll_callback.c | 75 ++++ test/sockets/test_epoll_rdhup.c | 79 ++++ test/sockets/test_epoll_socket_blocking.c | 84 ++++ test/test_core.py | 98 +++++ test/test_sockets.py | 50 +++ tools/maint/gen_sig_info.py | 2 + tools/native_sigs.py | 1 + 34 files changed, 1944 insertions(+), 13 deletions(-) create mode 100644 src/lib/libepoll.js create mode 100644 system/include/emscripten/epoll.h create mode 100644 test/core/test_epoll.c create mode 100644 test/core/test_epoll_advanced.c create mode 100644 test/core/test_epoll_blocking_asyncify.c create mode 100644 test/core/test_epoll_callback.c create mode 100644 test/core/test_epoll_callback_close.c create mode 100644 test/core/test_epoll_callback_edge.c create mode 100644 test/core/test_epoll_callback_level.c create mode 100644 test/core/test_epoll_callback_nested.c create mode 100644 test/core/test_epoll_callback_nested_close.c create mode 100644 test/core/test_epoll_callback_overflow.c create mode 100644 test/core/test_epoll_callback_replace.c create mode 100644 test/core/test_epoll_fairness.c create mode 100644 test/core/test_epoll_noderawfs.c create mode 100644 test/core/test_epoll_wait_and_callback.c create mode 100644 test/sockets/test_epoll_callback.c create mode 100644 test/sockets/test_epoll_rdhup.c create mode 100644 test/sockets/test_epoll_socket_blocking.c diff --git a/ChangeLog.md b/ChangeLog.md index 99d84f7ffb075..d50b5eaf40842 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -49,6 +49,18 @@ See docs/process.md for more on how version tagging works. feature will be used when available. Note that this only affects programs that are built with `ALLOW_MEMORY_GROWTH`, which is not enabled by default. (#27096, #27212) +- The `GROWABLE_ARRAYBUFFERS` setting now defaults to 1, which means it will be + used when available. Note that this only affects programs that are built with + `ALLOW_MEMORY_GROWTH`, which is not enabled by default. (#27212) + backends with a `poll` handler must update. (#27207) +- Added support for `epoll` (`epoll_create1`/`epoll_ctl`/`epoll_wait`/ + `epoll_pwait`) on the legacy (non-WASMFS) JS filesystem, including + level- and edge-triggered modes, `EPOLLONESHOT`, `EPOLLEXCLUSIVE`, + `EPOLLRDHUP`, nesting, and blocking waits under `PROXY_TO_PTHREAD`, + `ASYNCIFY`, and `JSPI`. Also added `emscripten_epoll_set_callback` + (in the new ``, experimental), a non-blocking variant + that delivers an epoll set's readiness to a JS callback with no + `ASYNCIFY`/`JSPI`. (#27207) - New `-sNODERAWSOCKETS` setting that backs the POSIX sockets API with real TCP (`node:net`) and UDP (`node:dgram`) sockets on Node.js, with no `ws`, proxy process, or pthreads required. Supports incoming and outgoing TCP, UDP, IPv6, diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js new file mode 100644 index 0000000000000..461600928a07a --- /dev/null +++ b/src/lib/libepoll.js @@ -0,0 +1,403 @@ +/** + * @license + * Copyright 2026 The Emscripten Authors + * SPDX-License-Identifier: MIT + */ + +// epoll(7) for the JS filesystem. The epoll syscalls and the +// emscripten_epoll_set_callback extension build on the per-inode readiness +// wait-queue (FSNode.addListener/notifyListeners) and the synchronous readiness +// derivation ($pollOne) defined in libsyscall.js. + +var EpollLibrary = { + // An epoll instance is a real FS fd whose stream carries an interest map + // `epoll` (fd -> reg) and a ready list (rdlHead/rdlTail). Each registration + // arms a persistent listener on the watched node's wait-queue at EPOLL_CTL_ADD + // (not per-wait), feeding the ready list on each edge so readiness can be + // tracked across waits and up a nesting chain. Being an fd, close(2) reclaims + // it (tearing every registration down) and it can itself be added to another + // epoll. + $newEpollInstance__internal: true, + $newEpollInstance__deps: ['$FS', '$pollOne', '$clearEpollInterest', '$reconcileEpollKeepalive', '$epollEvict'], + $newEpollInstance: () => FS.createStream({ + // Its own (detached) node, so the epoll fd can be watched by a parent epoll + // (nesting) and carry the readiness wait-queue methods. + node: new FS.FSNode(0, 'epoll', 0, 0), + epoll: new Map(), + stream_ops: { + // Readable when any listed registration is currently ready: this is what + // lets an epoll fd be polled/nested. Walks only the ready list (O(ready)); + // edge/oneshot/exclusive are reporting-time concerns, masked out here. A + // closed/reused fd is evicted here too, so a nested epoll that is only ever + // polled (never directly waited) does not accumulate dead registrations. + poll(stream) { + for (var reg = stream.rdlHead, next; reg; reg = next) { + next = reg.rdlNext; + if (FS.getStream(reg.fd)?.shared !== reg.shared) { epollEvict(stream, reg); continue; } + if (pollOne(reg.fd, reg.events & ~{{{ cDefs.EPOLLET | cDefs.EPOLLONESHOT | cDefs.EPOLLEXCLUSIVE }}})) { + return {{{ cDefs.POLLIN }}}; + } + } + return 0; + }, + // close(2): drop the readiness callback interest (if any), then every + // registration's listener (a fired EPOLLONESHOT has already dropped its + // own) from its watched node. + close(stream) { + // FS.close already fires POLLNVAL on this node, waking any parent epoll + // watching this epoll fd so it re-derives and drops the now-stale + // registration (via doEpollWait's shared check). + clearEpollInterest(stream); + reconcileEpollKeepalive(stream); // drop the keepalive if it was held + for (var reg of stream.epoll.values()) { + reg.listener?.listeners.delete(reg.listener.entry); + } + stream.epoll.clear(); + }, + }, + }), + + // Drop an epoll's persistent readiness callback interest: remove its listener + // on the epoll node and free the output buffer. Keepalive is managed by the + // caller (popped on clear/close, kept on replace). + $clearEpollInterest__internal: true, + $clearEpollInterest__deps: ['free'], + $clearEpollInterest: (ep) => { + var it = ep.interest; + if (!it) return; + ep.interest = null; + it.listener.listeners.delete(it.listener.entry); + _free(it.buf); + }, + + // A registered callback keeps the runtime alive only while it can still fire - + // i.e. while the epoll has at least one live registration. Once every watched + // fd is closed the set is terminal (it can never become ready again), so the + // keepalive is dropped and the runtime may exit. Reconciled after any change to + // the callback or the registration count. + $reconcileEpollKeepalive__internal: true, + $reconcileEpollKeepalive: (ep) => { + var want = !!ep.interest && ep.epoll.size > 0; + if (want == !!ep.keepalive) return; + ep.keepalive = want; +#if !MINIMAL_RUNTIME && (EXIT_RUNTIME || PTHREADS) + if (want) { {{{ runtimeKeepalivePush() }}} } else { {{{ runtimeKeepalivePop() }}} } +#endif + }, + + // The ready list (Linux's rdllist): registrations whose readiness edge has + // fired but not yet been consumed by a wait, linked intrusively through + // reg.rdlPrev/reg.rdlNext with head/tail on the epoll stream. Membership + // (reg.onList) is the edge state - a reg is listed on an edge (or when seeded + // ready at ctl), removed when a wait consumes it, and re-listed at the tail if + // a level trigger is still ready. O(1) add/remove, O(delivered) to drain. + $rdllistAdd__internal: true, + $rdllistAdd: (ep, reg) => { + if (reg.onList) return; + reg.onList = true; + reg.rdlPrev = ep.rdlTail; + reg.rdlNext = null; + if (ep.rdlTail) ep.rdlTail.rdlNext = reg; + else ep.rdlHead = reg; + ep.rdlTail = reg; + }, + $rdllistRemove__internal: true, + $rdllistRemove: (ep, reg) => { + if (!reg.onList) return; + reg.onList = false; + if (reg.rdlPrev) reg.rdlPrev.rdlNext = reg.rdlNext; + else ep.rdlHead = reg.rdlNext; + if (reg.rdlNext) reg.rdlNext.rdlPrev = reg.rdlPrev; + else ep.rdlTail = reg.rdlPrev; + reg.rdlPrev = reg.rdlNext = null; + }, + + // Remove a registration from its epoll: off the ready list, unlink its + // watched-node listener (a fired EPOLLONESHOT has none), drop from the interest + // map, and reconcile the callback keepalive. The single eviction primitive, + // used by EPOLL_CTL_DEL, a stale entry at ctl time, and a closed/reused fd seen + // at derive time (doEpollWait or the nesting poll). + $epollEvict__internal: true, + $epollEvict__deps: ['$rdllistRemove', '$reconcileEpollKeepalive'], + $epollEvict: (ep, reg) => { + rdllistRemove(ep, reg); + reg.listener?.listeners.delete(reg.listener.entry); + ep.epoll.delete(reg.fd); + reconcileEpollKeepalive(ep); + }, + + // The heavy lifting behind the epoll syscalls. The `__syscall_epoll_*` entry + // points stay in libsyscall.js (like every other syscall) and resolve the + // epoll stream before calling in here, so `ep` is a known-valid epoll stream. + $epollCtl__internal: true, + $epollCtl__deps: ['$FS', '$pollOne', '$rdllistAdd', '$epollEvict', '$reconcileEpollKeepalive'], + $epollCtl: (ep, op, fd, ev) => { + var target = FS.getStream(fd); + if (!target) return -{{{ cDefs.EBADF }}}; + if (op != {{{ cDefs.EPOLL_CTL_ADD }}} && op != {{{ cDefs.EPOLL_CTL_MOD }}} && op != {{{ cDefs.EPOLL_CTL_DEL }}}) { + return -{{{ cDefs.EINVAL }}}; + } + // An epoll cannot watch itself. + if (fd == ep.fd) return -{{{ cDefs.EINVAL }}}; + + // A registration keys on the open file description (stream.shared) - the + // struct-file analog that dup'd fds share. If this fd's number now resolves + // to a different open (closed and the slot reused), the old registration is + // stale: evict it so ctl sees the fd as fresh, matching Linux's eviction of + // the epitem when the watched file is released. + var cur = ep.epoll.get(fd); + if (cur && target.shared !== cur.shared) { + epollEvict(ep, cur); // stale: this fd number is now a different open + cur = undefined; + } + var has = !!cur; + if (op == {{{ cDefs.EPOLL_CTL_DEL }}}) { + if (!has) return -{{{ cDefs.ENOENT }}}; + epollEvict(ep, cur); + return 0; + } + + var events = {{{ makeGetValue('ev', C_STRUCTS.epoll_event.events, 'u32') }}}; + if (op == {{{ cDefs.EPOLL_CTL_ADD }}}) { + if (has) return -{{{ cDefs.EEXIST }}}; + // Only descriptors with a readiness derivation can be epoll-watched + // (sockets/pipes/epoll itself). Regular files have no poll handler and so + // are not epoll-capable, matching Linux (-EPERM). + if (!target.stream_ops?.poll) return -{{{ cDefs.EPERM }}}; + // Nesting another epoll: reject cycles, and chains deeper than 5 levels of + // epoll (ELOOP) - the Linux cap is EP_MAX_NESTS (4) plus the leaf level. + if (target.epoll) { + var reaches = (from, goal, seen) => { + if (from === goal) return true; + if (!from?.epoll || seen.has(from)) return false; + seen.add(from); + for (var f of from.epoll.keys()) { + if (reaches(FS.getStream(f), goal, seen)) return true; + } + return false; + }; + var depth = (s, seen) => { + if (!s?.epoll || seen.has(s)) return 0; + seen.add(s); + var max = 0; + for (var f of s.epoll.keys()) max = Math.max(max, depth(FS.getStream(f), seen)); + seen.delete(s); + return 1 + max; + }; + if (reaches(target, ep, new Set()) || 1 + depth(target, new Set()) > 5) { + return -{{{ cDefs.ELOOP }}}; + } + } + } else { // EPOLL_CTL_MOD + if (!has) return -{{{ cDefs.ENOENT }}}; + // EPOLLEXCLUSIVE may only be set at ADD time. + if (events & {{{ cDefs.EPOLLEXCLUSIVE }}}) return -{{{ cDefs.EINVAL }}}; + } + + // `data` is opaque user data echoed back by epoll_wait; keep its 8 bytes. + var reg = cur ?? {}; + reg.fd = fd; + reg.shared = target.shared; // open file description: the dup-shared identity + reg.events = events; + reg.dataLo = {{{ makeGetValue('ev', C_STRUCTS.epoll_event.data, 'i32') }}}; + reg.dataHi = {{{ makeGetValue('ev', C_STRUCTS.epoll_event.data + 4, 'i32') }}}; + if (op == {{{ cDefs.EPOLL_CTL_ADD }}}) { ep.epoll.set(fd, reg); reconcileEpollKeepalive(ep); } + // The registration's listener is its edge in the interest graph - present + // only while armed, so a watched node fires nothing for a dead edge. ADD + // installs it; a fired EPOLLONESHOT dropped it, so a MOD re-arm reinstalls it. + // (ep_poll_callback: on an edge, list the reg and wake any waiter on this + // epoll - and through ep.node any parent epoll nesting it.) + if (!reg.listener) { + reg.listener = target.node.addListener(() => { + rdllistAdd(ep, reg); + ep.node.notifyListeners({{{ cDefs.POLLIN }}}); + // EPOLLEXCLUSIVE: when one fd is watched by several epolls, the watched + // node wakes only one of them per edge (round-robin), not all. + }, !!(events & {{{ cDefs.EPOLLEXCLUSIVE }}})); + } + // Arming is itself an event source (ep_insert/ep_modify): a source-based + // model only learns readiness from edges, so sample the level now - the + // (re-)armed fd may already be ready with no producer notify to follow. + if (pollOne(fd, reg.events & ~{{{ cDefs.EPOLLET | cDefs.EPOLLONESHOT | cDefs.EPOLLEXCLUSIVE }}})) { + rdllistAdd(ep, reg); + ep.node.notifyListeners({{{ cDefs.POLLIN }}}); + } + return 0; + }, + + // Consume the ready list (Linux's ep_send_events), writing up to `maxevents` + // epoll_events into `ev` and returning the count. Each listed registration is + // re-derived against its current mask: level-triggered ones still ready are + // re-listed at the tail; edge-triggered ones leave the list until the next + // edge; EPOLLONESHOT ones drop their watched-node listener until re-armed by + // EPOLL_CTL_MOD; a no-longer-ready (spurious) edge is dropped; a closed/reused + // fd is evicted. + $doEpollWait__internal: true, + $doEpollWait__deps: ['$FS', '$pollOne', '$rdllistAdd', '$epollEvict'], + $doEpollWait: (ep, ev, maxevents) => { + // Detach the list and drain from the head: re-armed level triggers and the + // unprocessed remainder go back onto ep's now-empty list, so a single pass + // never revisits an entry. O(delivered), not O(registered). + var node = ep.rdlHead, tail = ep.rdlTail; + ep.rdlHead = ep.rdlTail = null; + var n = 0; + while (node && n < maxevents) { + var next = node.rdlNext; + node.onList = false; + node.rdlPrev = node.rdlNext = null; + var fd = node.fd; + if (FS.getStream(fd)?.shared !== node.shared) { + // The fd closed, or its number was reused for a different open: evict the + // now-stale registration (a surviving dup keeps the open file alive). + // Already detached from the list above, so epollEvict just unlinks the + // listener, drops it from the map, and reconciles the keepalive. + epollEvict(ep, node); + } else { + var revents = pollOne(fd, node.events & ~{{{ cDefs.EPOLLET | cDefs.EPOLLONESHOT | cDefs.EPOLLEXCLUSIVE }}}); + if (revents) { + var out = ev + {{{ C_STRUCTS.epoll_event.__size__ }}} * n; + {{{ makeSetValue('out', C_STRUCTS.epoll_event.events, 'revents', 'u32') }}}; + {{{ makeSetValue('out', C_STRUCTS.epoll_event.data, 'node.dataLo', 'i32') }}}; + {{{ makeSetValue('out', C_STRUCTS.epoll_event.data + 4, 'node.dataHi', 'i32') }}}; + n++; + if (node.events & {{{ cDefs.EPOLLONESHOT }}}) { + // Fired: a dead edge until EPOLL_CTL_MOD re-arms it, so drop its + // listener - the watched node stops poking it (no re-arm needed). + node.listener.listeners.delete(node.listener.entry); + node.listener = null; + } else if (!(node.events & {{{ cDefs.EPOLLET }}})) { + rdllistAdd(ep, node); // level: re-list at tail + } + } + // else: a spurious edge (no longer ready) - drop it from the list. + } + node = next; + } + // Stopped at maxevents with entries left: splice the unprocessed remainder + // (node..tail) back to the FRONT, ahead of any re-armed items, so the next + // wait services them first (round-robin fairness). + if (node) { + node.rdlPrev = null; + tail.rdlNext = ep.rdlHead; + if (ep.rdlHead) ep.rdlHead.rdlPrev = tail; + else ep.rdlTail = tail; + ep.rdlHead = node; + } + return n; + }, + + // The blocking wait behind __syscall_epoll_pwait; `ep` is a known-valid epoll + // stream and `maxevents` already validated by the entry point. + $epollPwait__internal: true, + $epollPwait__deps: ['$doEpollWait'], + $epollPwait: (ep, ev, maxevents, timeout) => { +#if PTHREADS || ASYNCIFY +#if PTHREADS + const isAsyncContext = PThread.currentProxiedOperationCallerThread; +#else + const isAsyncContext = true; +#endif + // Always resolve through a Promise here: when proxied from a worker the + // result is delivered by promise resolution, so a bare value would break + // the proxy (it has no `.then`). Block on the epoll's own readiness - each + // registration's persistent listener wakes ep.node on a leaf edge - and + // re-derive on wake, resolving the count or 0 after `timeout`. + if (isAsyncContext) { + return new Promise((resolve) => { + var count = doEpollWait(ep, ev, maxevents); + if (count || !timeout) { + resolve(count); + return; + } + var done = false; + var reg = ep.node.addListener(() => { + if (done) return; + var c = doEpollWait(ep, ev, maxevents); + if (c) finish(c); + }); + var timer = timeout > 0 ? setTimeout(() => finish(0), timeout) : undefined; + function finish(c) { + if (done) return; + done = true; + reg.listeners.delete(reg.entry); + if (timer) clearTimeout(timer); + resolve(c); + } + }); + } +#endif + var count = doEpollWait(ep, ev, maxevents); +#if ASSERTIONS + if (!count && timeout != 0) warnOnce('non-zero epoll_wait() timeout not supported: ' + timeout) +#endif + return count; + }, + + // Register a persistent readiness callback on an existing epoll fd: instead of + // blocking in epoll_wait, the runtime delivers the ready set to `callback` + // every time the epoll set makes progress. An epoll is a long-lived readiness + // aggregator, so the interest (a single listener on the epoll's own node plus a + // runtime-owned output buffer) is armed once and reused across every delivery - + // no per-spin register/deregister. Per-fd EPOLLET/EPOLLONESHOT apply exactly as + // in epoll_wait (one-shot is a property of the registration, not of this call), + // so a long-lived callback can mix level/edge/oneshot fds in one set. + // + // The interest persists until replaced (call again), cleared (callback == NULL), + // or the epoll fd is closed. It never suspends the stack, so it works without + // ASYNCIFY/JSPI, and it keeps the runtime alive while armed. Returns 0 or -errno. + emscripten_epoll_set_callback__deps: ['$FS', '$doEpollWait', '$clearEpollInterest', '$reconcileEpollKeepalive', '$callUserCallback', 'malloc', 'free'], + emscripten_epoll_set_callback__proxy: 'sync', + emscripten_epoll_set_callback: (epfd, maxevents, callback, userdata) => { + var ep = FS.getStream(epfd); + if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; + // maxevents only matters when (re-)arming; validate before any mutation so a + // bad register call has no side effects (an unregister ignores it). + if (callback && maxevents <= 0) return -{{{ cDefs.EINVAL }}}; + + // Tear down any existing interest first - a second call replaces the + // callback, it does not stack. + clearEpollInterest(ep); + if (!callback) { + reconcileEpollKeepalive(ep); + return 0; + } + + // Runtime-owned output buffer reused across every delivery; freed at clear. + var buf = _malloc(maxevents * {{{ C_STRUCTS.epoll_event.__size__ }}}); + var it = ep.interest = {buf}; + // Producer notifies arrive synchronously (SOCKFS.emit, pipe writes); coalesce + // them into one delivery on a microtask (the callback must not run in the + // producer's/caller's stack), re-deriving the ready set at that tick. A + // microtask keeps delivery latency minimal (no setTimeout clamp). Edges are + // still disjoint from any concurrent blocking epoll_wait on the same epoll - + // that waiter drains synchronously in the producer's stack, ahead of this + // tick - but the tick may now run before vs after the waiter's async + // resumption; that relative ordering is not guaranteed. + function wake() { + if (it.scheduled) return; + it.scheduled = true; + queueMicrotask(() => { + it.scheduled = false; + if (ep.interest !== it) return; // cleared before the tick fired + var c = doEpollWait(ep, buf, maxevents); + if (c) { + callUserCallback(() => {{{ makeDynCall('vipip', 'callback') }}}(epfd, buf, c, userdata)); + // Still ready (overflow past maxevents, or a still-ready level fd + // re-listed): keep draining on the next tick. Note this is NOT a + // blocking epoll_wait loop - there the app owns the loop and may block + // elsewhere. A level-triggered fd that is structurally always ready and + // never drained (e.g. EPOLLOUT on a writable socket) will re-schedule a + // microtask each tick and so starve the event loop; use EPOLLET or + // unregister for such fds. + if (ep.interest === it && ep.rdlHead) wake(); + } + }); + } + it.listener = ep.node.addListener(wake); + reconcileEpollKeepalive(ep); // hold the runtime only while there are live fds + wake(); // deliver initial readiness if the set is already ready + return 0; + }, +}; + +addToLibrary(EpollLibrary); diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index d3a5b64b62605..b4bd6bfae8e02 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -240,6 +240,7 @@ sigs = { __syscall_epoll_create1__sig: 'ii', __syscall_epoll_ctl__sig: 'iiiip', __syscall_epoll_pwait__sig: 'iipiipp', + __syscall_epoll_pwait_nonblocking__sig: 'iipi', __syscall_faccessat__sig: 'iipii', __syscall_fallocate__sig: 'iiijj', __syscall_fchdir__sig: 'ii', @@ -637,6 +638,7 @@ sigs = { emscripten_destroy_web_audio_node__sig: 'vi', emscripten_destroy_worker__sig: 'vi', emscripten_enter_soft_fullscreen__sig: 'ipp', + emscripten_epoll_set_callback__sig: 'iiipp', emscripten_err__sig: 'vp', emscripten_errn__sig: 'vpp', emscripten_exit_fullscreen__sig: 'i', diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index db0dc0bfcd0d0..403cb9bd990cf 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -589,9 +589,7 @@ var SyscallsLibrary = { if (!stream) return {{{ cDefs.POLLNVAL }}}; // Streams without a poll handler (regular files, incl. NODERAWFS/NODEFS // which leave stream_ops unset) are treated as always readable+writable. - var flags = stream.stream_ops?.poll - ? stream.stream_ops.poll(stream) - : {{{ cDefs.POLLIN | cDefs.POLLOUT }}}; + var flags = stream.stream_ops?.poll?.(stream) ?? {{{ cDefs.POLLIN | cDefs.POLLOUT }}}; return flags & (events | {{{ cDefs.POLLERR }}} | {{{ cDefs.POLLHUP }}} | {{{ cDefs.POLLNVAL }}}); }, __syscall_poll__proxy: 'sync', @@ -697,13 +695,42 @@ var SyscallsLibrary = { __syscall_poll_nonblocking: (fds, nfds) => { return doPollSync(fds, nfds); }, - // epoll is not yet implemented in the legacy (non-WASMFS) JS syscall layer. - __syscall_epoll_create1__nothrow: true, - __syscall_epoll_create1: (flags) => -{{{ cDefs.ENOSYS }}}, - __syscall_epoll_ctl__nothrow: true, - __syscall_epoll_ctl: (epfd, op, fd, ev) => -{{{ cDefs.ENOSYS }}}, - __syscall_epoll_pwait__nothrow: true, - __syscall_epoll_pwait: (epfd, ev, maxevents, timeout, sigmask, sigsetsize) => -{{{ cDefs.ENOSYS }}}, + // epoll: the entry points live here (like every other syscall); the heavy + // lifting is in libepoll.js, which they call after resolving the epoll stream. + __syscall_epoll_create1__deps: ['$newEpollInstance'], + __syscall_epoll_create1__proxy: 'sync', + __syscall_epoll_create1: (flags) => { + return newEpollInstance().fd; + }, + __syscall_epoll_ctl__deps: ['$FS', '$epollCtl'], + __syscall_epoll_ctl__proxy: 'sync', + __syscall_epoll_ctl: (epfd, op, fd, ev) => { + var ep = FS.getStream(epfd); + if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; + return epollCtl(ep, op, fd, ev); + }, + __syscall_epoll_pwait__proxy: 'sync', + __syscall_epoll_pwait__async: 'auto', + __syscall_epoll_pwait__deps: ['$FS', '$epollPwait'], + __syscall_epoll_pwait: (epfd, ev, maxevents, timeout, sigmask, sigsetsize) => { + var ep = FS.getStream(epfd); + if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; + if (maxevents <= 0) return -{{{ cDefs.EINVAL }}}; + return epollPwait(ep, ev, maxevents, timeout); + }, + // libc routes zero-timeout epoll_wait()/epoll_pwait() calls here: a plain + // import that never suspends, so probes stay callable from any context (under + // JSPI, __syscall_epoll_pwait is a suspending import and traps when called + // from a stack that wasn't entered through a promising export). Mirrors + // __syscall_poll_nonblocking. + __syscall_epoll_pwait_nonblocking__proxy: 'sync', + __syscall_epoll_pwait_nonblocking__deps: ['$FS', '$doEpollWait'], + __syscall_epoll_pwait_nonblocking: (epfd, ev, maxevents) => { + var ep = FS.getStream(epfd); + if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; + if (maxevents <= 0) return -{{{ cDefs.EINVAL }}}; + return doEpollWait(ep, ev, maxevents); + }, __syscall_getcwd__deps: ['$lengthBytesUTF8', '$stringToUTF8'], __syscall_getcwd: (buf, size) => { if (size === 0) return -{{{ cDefs.EINVAL }}}; diff --git a/src/modules.mjs b/src/modules.mjs index bc6aa5295f5fa..83a7bde92f3a2 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -88,6 +88,7 @@ function calculateLibraries() { if (!WASMFS) { libraries.push('libsyscall.js'); + libraries.push('libepoll.js'); } if (MAIN_MODULE) { diff --git a/src/struct_info.json b/src/struct_info.json index 86b32cc0d3aee..b4457c98e5e71 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -141,6 +141,30 @@ ] } }, + { + "file": "sys/epoll.h", + "defines": [ + "EPOLLIN", + "EPOLLOUT", + "EPOLLERR", + "EPOLLHUP", + "EPOLLRDNORM", + "EPOLLWRNORM", + "EPOLLET", + "EPOLLONESHOT", + "EPOLLEXCLUSIVE", + "EPOLL_CTL_ADD", + "EPOLL_CTL_DEL", + "EPOLL_CTL_MOD", + "EPOLL_CLOEXEC" + ], + "structs": { + "epoll_event": [ + "events", + "data" + ] + } + }, { "file": "time.h", "defines": [ diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index 89ea8ef20bf49..0a30caf01a268 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -260,6 +260,19 @@ "EPERM": 63, "EPFNOSUPPORT": 139, "EPIPE": 64, + "EPOLLERR": 8, + "EPOLLET": -2147483648, + "EPOLLEXCLUSIVE": 268435456, + "EPOLLHUP": 16, + "EPOLLIN": 1, + "EPOLLONESHOT": 1073741824, + "EPOLLOUT": 4, + "EPOLLRDNORM": 64, + "EPOLLWRNORM": 256, + "EPOLL_CLOEXEC": 524288, + "EPOLL_CTL_ADD": 1, + "EPOLL_CTL_DEL": 2, + "EPOLL_CTL_MOD": 3, "EPROTO": 65, "EPROTONOSUPPORT": 66, "EPROTOTYPE": 67, @@ -1019,6 +1032,11 @@ "stack_ptr": 8, "user_data": 16 }, + "epoll_event": { + "__size__": 16, + "data": 8, + "events": 0 + }, "flock": { "__size__": 32, "l_type": 0 diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index 9605a53d336a9..c9abc70ca5f9c 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -260,6 +260,19 @@ "EPERM": 63, "EPFNOSUPPORT": 139, "EPIPE": 64, + "EPOLLERR": 8, + "EPOLLET": -2147483648, + "EPOLLEXCLUSIVE": 268435456, + "EPOLLHUP": 16, + "EPOLLIN": 1, + "EPOLLONESHOT": 1073741824, + "EPOLLOUT": 4, + "EPOLLRDNORM": 64, + "EPOLLWRNORM": 256, + "EPOLL_CLOEXEC": 524288, + "EPOLL_CTL_ADD": 1, + "EPOLL_CTL_DEL": 2, + "EPOLL_CTL_MOD": 3, "EPROTO": 65, "EPROTONOSUPPORT": 66, "EPROTOTYPE": 67, @@ -1019,6 +1032,11 @@ "stack_ptr": 16, "user_data": 32 }, + "epoll_event": { + "__size__": 16, + "data": 8, + "events": 0 + }, "flock": { "__size__": 32, "l_type": 0 diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h new file mode 100644 index 0000000000000..b87f5ce5cbd62 --- /dev/null +++ b/system/include/emscripten/epoll.h @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + */ + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// EXPERIMENTAL. This API is new and may change (signature or semantics) over the +// next few releases; it is not yet covered by Emscripten's stability guarantees. +// +// Register a persistent readiness callback on an existing epoll fd (built with +// epoll_create1/epoll_ctl): instead of blocking in epoll_wait, the runtime calls +// `callback` every time the set makes progress, delivering up to `maxevents` +// ready events. An epoll is a long-lived readiness aggregator, so the interest is +// armed once and reused across every delivery - no per-spin re-arming. Unlike +// epoll_wait it never blocks the calling stack, so it works without ASYNCIFY/JSPI. +// The callback is delivered on the main thread's event loop; under +// PROXY_TO_PTHREAD use a blocking epoll_wait from the pthread instead. +// +// While armed it keeps the runtime alive only as long as it can still fire - i.e. +// while the epoll has at least one open watched fd. Once every watched fd is +// closed the set is terminal (it can never become ready again) and the callback +// stops holding the runtime, so no explicit disposal is required in that case. +// To dispose while open fds remain, either pass a NULL `callback` (any +// `maxevents`) to unregister, or close the epoll fd. There is at most one +// callback per epoll: calling again replaces it (it does not stack). `events` is +// a runtime-owned buffer valid only for the duration of each callback. Returns 0, +// or -errno (-EBADF if `epfd` is not an epoll fd, -EINVAL). +// +// Each registration's trigger mode (set per-fd via epoll_ctl) controls how often +// the callback fires for it - identically to epoll_wait, so one callback can mix +// modes: +// - Level-triggered (the default): the callback fires on the next tick whenever +// the fd is ready, and keeps firing while it stays ready. The runtime - not +// the application - drives the loop, so an fd that is structurally always +// ready and never drained (notably EPOLLOUT on a writable socket) will spin +// the event loop. Use one of the modes below for such fds. +// - EPOLLET (edge-triggered): the callback fires once per readiness edge and +// not again until a fresh edge. Drain the fd fully on each delivery; this is +// the way to watch an always-writable fd without spinning. +// - EPOLLONESHOT: the callback fires once for that fd, then the registration is +// disabled until you re-arm it with epoll_ctl(EPOLL_CTL_MOD). Use it to +// handle an fd exactly once (e.g. before handing it elsewhere). +typedef void (*em_epoll_callback)(int epfd, struct epoll_event *events, int nready, void *userdata); +int emscripten_epoll_set_callback(int epfd, int maxevents, em_epoll_callback callback, void *userdata); + +#ifdef __cplusplus +} +#endif diff --git a/system/include/emscripten/syscalls.h b/system/include/emscripten/syscalls.h index 7864de1efc33f..889ad14d4edb0 100644 --- a/system/include/emscripten/syscalls.h +++ b/system/include/emscripten/syscalls.h @@ -124,6 +124,7 @@ int __syscall_shutdown(int sockfd, int how, int unused1, int unused2, int unused int __syscall_epoll_create1(int flags); int __syscall_epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev); int __syscall_epoll_pwait(int epfd, struct epoll_event *ev, int maxevents, int timeout, const sigset_t *sigmask, size_t sigsetsize); +int __syscall_epoll_pwait_nonblocking(int epfd, struct epoll_event *ev, int maxevents); #ifdef __cplusplus } diff --git a/system/lib/libc/musl/src/linux/epoll.c b/system/lib/libc/musl/src/linux/epoll.c index e56e8f4c8293b..5998d9b28ee93 100644 --- a/system/lib/libc/musl/src/linux/epoll.c +++ b/system/lib/libc/musl/src/linux/epoll.c @@ -25,6 +25,16 @@ int epoll_ctl(int fd, int op, int fd2, struct epoll_event *ev) int epoll_pwait(int fd, struct epoll_event *ev, int cnt, int to, const sigset_t *sigs) { +#ifdef __EMSCRIPTEN__ + // A zero timeout is an instantaneous probe: route it through a plain + // import that never suspends. Under JSPI, __syscall_epoll_pwait is a + // suspending import and so may only be called from a stack entered through + // a promising export — a requirement a readiness probe must not carry + // (e.g. probes from event-loop callbacks). Mirrors poll() above. + if (to == 0) { + return __syscall_ret(__syscall_epoll_pwait_nonblocking(fd, ev, cnt)); + } +#endif int r = __syscall_cp(SYS_epoll_pwait, fd, ev, cnt, to, sigs, _NSIG/8); #ifdef SYS_epoll_wait if (r==-ENOSYS && !sigs) r = __syscall_cp(SYS_epoll_wait, fd, ev, cnt, to); diff --git a/system/lib/wasmfs/syscalls.cpp b/system/lib/wasmfs/syscalls.cpp index 3b70211d152de..a384a7065b29b 100644 --- a/system/lib/wasmfs/syscalls.cpp +++ b/system/lib/wasmfs/syscalls.cpp @@ -1871,4 +1871,10 @@ int __syscall_epoll_pwait(int epfd, return -ENOSYS; } +int __syscall_epoll_pwait_nonblocking(int epfd, + struct epoll_event* ev, + int maxevents) { + return -ENOSYS; +} + } // extern "C" diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 947626e9b936b..3d6ccd52124c5 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": 268621, - "a.out.nodebug.wasm": 587978, - "total": 856599, + "a.out.js": 271668, + "a.out.nodebug.wasm": 588038, + "total": 859706, "sent": [ "IMG_Init", "IMG_Load", @@ -225,6 +225,7 @@ "__syscall_epoll_create1", "__syscall_epoll_ctl", "__syscall_epoll_pwait", + "__syscall_epoll_pwait_nonblocking", "__syscall_faccessat", "__syscall_fallocate", "__syscall_fchdir", @@ -455,6 +456,7 @@ "emscripten_debugger", "emscripten_destroy_worker", "emscripten_enter_soft_fullscreen", + "emscripten_epoll_set_callback", "emscripten_err", "emscripten_errn", "emscripten_exit_fullscreen", @@ -1750,6 +1752,7 @@ "env.__syscall_epoll_create1", "env.__syscall_epoll_ctl", "env.__syscall_epoll_pwait", + "env.__syscall_epoll_pwait_nonblocking", "env.__syscall_faccessat", "env.__syscall_fallocate", "env.__syscall_fchdir", diff --git a/test/core/test_epoll.c b/test/core/test_epoll.c new file mode 100644 index 0000000000000..e9884c529b1fc --- /dev/null +++ b/test/core/test_epoll.c @@ -0,0 +1,98 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Exercises the epoll syscall surface (epoll_create1/epoll_ctl/epoll_wait): + * the interest set, the ADD/MOD/DEL ops with their error returns, readiness + * derivation over a pipe, and that the opaque `data` is echoed back. + */ + +#include +#include +#include +#include +#include +#include + +int main(void) { + int ep = epoll_create1(0); + assert(ep >= 0); + + int p[2]; + assert(pipe(p) == 0); + + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = p[0]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == -1 && errno == EEXIST); + + struct epoll_event out[4]; + // Nothing written yet: the read end is not readable. + assert(epoll_wait(ep, out, 4, 0) == 0); + + // Make the read end readable. + assert(write(p[1], "x", 1) == 1); + assert(epoll_wait(ep, out, 4, 0) == 1); + assert(out[0].events & EPOLLIN); + assert(out[0].data.fd == p[0]); + + // MOD to a condition that is not satisfied (writable on the read end). + ev.events = EPOLLOUT; + assert(epoll_ctl(ep, EPOLL_CTL_MOD, p[0], &ev) == 0); + assert(epoll_wait(ep, out, 4, 0) == 0); + + // DEL, and DEL again -> ENOENT. + assert(epoll_ctl(ep, EPOLL_CTL_DEL, p[0], &ev) == 0); + assert(epoll_ctl(ep, EPOLL_CTL_DEL, p[0], &ev) == -1 && errno == ENOENT); + assert(epoll_wait(ep, out, 4, 0) == 0); + + // Bad epoll fd and bad target fd. + assert(epoll_ctl(ep, EPOLL_CTL_ADD, 9999, &ev) == -1 && errno == EBADF); + assert(epoll_ctl(9999, EPOLL_CTL_ADD, p[0], &ev) == -1 && errno == EBADF); + + // Bad op. + ev.events = EPOLLIN; + assert(epoll_ctl(ep, 999, p[0], &ev) == -1 && errno == EINVAL); + + // An epoll cannot watch itself. + assert(epoll_ctl(ep, EPOLL_CTL_ADD, ep, &ev) == -1 && errno == EINVAL); + + // Regular files are not epoll-capable (no readiness derivation -> EPERM). + int rf = open("/tmp/epoll_regular", O_CREAT | O_RDWR, 0600); + assert(rf >= 0); + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rf, &ev) == -1 && errno == EPERM); + close(rf); + + // maxevents must be positive. + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + assert(epoll_wait(ep, out, 0, 0) == -1 && errno == EINVAL); + assert(epoll_wait(ep, out, -1, 0) == -1 && errno == EINVAL); + + // fd reuse: a registration keys on the open file description, so closing a + // watched fd and reusing its number for a different open must not resurrect + // the registration onto the new fd (which would report wrong readiness). + assert(epoll_ctl(ep, EPOLL_CTL_DEL, p[0], &ev) == 0); // empty the set + int a[2]; + assert(pipe(a) == 0); + ev.data.fd = a[0]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, a[0], &ev) == 0); + close(a[0]); // the registration is now stale + int b[2]; + assert(pipe(b) == 0); + assert(b[0] == a[0]); // b[0] reused a[0]'s freed number + assert(write(b[1], "y", 1) == 1); // the reused fd is readable + assert(epoll_wait(ep, out, 4, 0) == 0); // stale registration must not fire + // The stale entry is gone, so the reused fd adds fresh (evicted, not EEXIST) + // and then reports normally. + ev.data.fd = b[0]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, b[0], &ev) == 0); + assert(epoll_wait(ep, out, 4, 0) == 1 && out[0].data.fd == b[0]); + + close(ep); + close(p[0]); + close(p[1]); + printf("EPOLL PASS\n"); + return 0; +} diff --git a/test/core/test_epoll_advanced.c b/test/core/test_epoll_advanced.c new file mode 100644 index 0000000000000..13021a13a995c --- /dev/null +++ b/test/core/test_epoll_advanced.c @@ -0,0 +1,181 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * The richer epoll semantics, all exercised non-blocking (timeout 0) over pipes: + * EPOLLONESHOT (fire once, re-arm with MOD), EPOLLET (edge-triggered), the + * EPOLLEXCLUSIVE ctl restriction and its round-robin single-wakeup across epolls + * watching one fd, nesting one epoll inside another, ELOOP rejection of cycles + * and over-deep chains, and auto-removal of a registration whose fd is closed. + */ + +#include +#include +#include +#include +#include + +static int ready(int ep) { + struct epoll_event out[4]; + return epoll_wait(ep, out, 4, 0); +} + +static void test_oneshot(void) { + int ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + + struct epoll_event ev = { .events = EPOLLIN | EPOLLONESHOT }; + ev.data.fd = p[0]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + + assert(write(p[1], "x", 1) == 1); + assert(ready(ep) == 1); // fires once + assert(ready(ep) == 0); // silent until re-armed, despite still readable + + ev.events = EPOLLIN | EPOLLONESHOT; + assert(epoll_ctl(ep, EPOLL_CTL_MOD, p[0], &ev) == 0); + assert(ready(ep) == 1); // re-armed -> fires again + + close(ep); close(p[0]); close(p[1]); +} + +static void test_edge(void) { + int ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + + struct epoll_event ev = { .events = EPOLLIN | EPOLLET }; + ev.data.fd = p[0]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + + assert(write(p[1], "x", 1) == 1); + assert(ready(ep) == 1); // reports on the edge + assert(ready(ep) == 0); // not re-reported while continuously ready + + assert(write(p[1], "y", 1) == 1); + assert(ready(ep) == 1); // a fresh write is a fresh edge + + close(ep); close(p[0]); close(p[1]); +} + +static void test_exclusive(void) { + int ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + + struct epoll_event ev = { .events = EPOLLIN | EPOLLEXCLUSIVE }; + ev.data.fd = p[0]; + // EPOLLEXCLUSIVE is accepted at ADD and otherwise functions. + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + assert(write(p[1], "x", 1) == 1); + assert(ready(ep) == 1); + // EPOLLEXCLUSIVE may not be combined with MOD. + assert(epoll_ctl(ep, EPOLL_CTL_MOD, p[0], &ev) == -1 && errno == EINVAL); + + close(ep); close(p[0]); close(p[1]); +} + +static void test_exclusive_wakeup(void) { + // One fd watched by two epolls with EPOLLEXCLUSIVE: each readiness edge wakes + // only one of them, rotating - not both (no thundering herd). Edge-triggered so + // a delivered item is not re-listed, making "who was woken" unambiguous. + int epA = epoll_create1(0), epB = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + struct epoll_event ev = { .events = EPOLLIN | EPOLLET | EPOLLEXCLUSIVE }; + ev.data.fd = p[0]; + assert(epoll_ctl(epA, EPOLL_CTL_ADD, p[0], &ev) == 0); + assert(epoll_ctl(epB, EPOLL_CTL_ADD, p[0], &ev) == 0); + + assert(write(p[1], "x", 1) == 1); // first edge -> exactly one epoll (epA) + assert(ready(epA) == 1); + assert(ready(epB) == 0); + + assert(write(p[1], "y", 1) == 1); // next edge -> the other (epB), round-robin + assert(ready(epA) == 0); + assert(ready(epB) == 1); + + close(epA); close(epB); close(p[0]); close(p[1]); +} + +static void test_nesting(void) { + int epA = epoll_create1(0); + int epB = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = p[0]; + assert(epoll_ctl(epB, EPOLL_CTL_ADD, p[0], &ev) == 0); + + ev.events = EPOLLIN; + ev.data.fd = epB; + assert(epoll_ctl(epA, EPOLL_CTL_ADD, epB, &ev) == 0); + + assert(ready(epA) == 0); // leaf not yet ready -> epB not ready -> epA quiet + assert(write(p[1], "x", 1) == 1); + + struct epoll_event out[4]; + assert(epoll_wait(epA, out, 4, 0) == 1); // leaf readiness propagates to epA + assert(out[0].data.fd == epB); + assert(out[0].events & EPOLLIN); + + close(epA); close(epB); close(p[0]); close(p[1]); +} + +static void test_eloop(void) { + struct epoll_event ev = { .events = EPOLLIN }; + + // A direct cycle is rejected: a watches b, then b watching a closes the loop. + int a = epoll_create1(0), b = epoll_create1(0); + ev.data.fd = b; + assert(epoll_ctl(a, EPOLL_CTL_ADD, b, &ev) == 0); + ev.data.fd = a; + assert(epoll_ctl(b, EPOLL_CTL_ADD, a, &ev) == -1 && errno == ELOOP); + close(a); close(b); + + // A chain six epolls deep is one level too far. Build e[4]->e[5] ... e[1]->e[2] + // (all accepted), then adding e[1] into e[0] would make a 6-level chain. + int e[6]; + for (int i = 0; i < 6; i++) e[i] = epoll_create1(0); + for (int i = 5; i >= 2; i--) { + ev.data.fd = e[i]; + assert(epoll_ctl(e[i - 1], EPOLL_CTL_ADD, e[i], &ev) == 0); + } + ev.data.fd = e[1]; + assert(epoll_ctl(e[0], EPOLL_CTL_ADD, e[1], &ev) == -1 && errno == ELOOP); + for (int i = 0; i < 6; i++) close(e[i]); +} + +static void test_autoremove(void) { + int ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = p[0]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + + // Closing the watched fd drops the registration; the wait neither crashes nor + // reports the dead fd. + close(p[0]); + close(p[1]); + assert(ready(ep) == 0); + + close(ep); +} + +int main(void) { + test_oneshot(); + test_edge(); + test_exclusive(); + test_exclusive_wakeup(); + test_nesting(); + test_eloop(); + test_autoremove(); + printf("EPOLL ADVANCED PASS\n"); + return 0; +} diff --git a/test/core/test_epoll_blocking_asyncify.c b/test/core/test_epoll_blocking_asyncify.c new file mode 100644 index 0000000000000..fccd375b28bcd --- /dev/null +++ b/test/core/test_epoll_blocking_asyncify.c @@ -0,0 +1,39 @@ +/* + * 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 epoll_wait() that suspends the wasm stack (ASYNCIFY/JSPI) and is + * woken by a pipe write scheduled to run only after it has blocked. + */ + +#include +#include +#include +#include +#include + +static int wfd; +static void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } + +int main(void) { + int ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + wfd = p[1]; + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.u32 = 0xabcd; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + + // The write happens only after epoll_wait suspends. + emscripten_async_call(writer, NULL, 0); + + struct epoll_event out[4]; + int n = epoll_wait(ep, out, 4, -1); + assert(n == 1); + assert(out[0].events & EPOLLIN); + assert(out[0].data.u32 == 0xabcd); + printf("done\n"); + return 0; +} diff --git a/test/core/test_epoll_callback.c b/test/core/test_epoll_callback.c new file mode 100644 index 0000000000000..c5d04e9d1e5cd --- /dev/null +++ b/test/core/test_epoll_callback.c @@ -0,0 +1,74 @@ +/* + * 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. + * + * emscripten_epoll_set_callback: a persistent, non-blocking, non-suspending epoll + * readiness callback (no ASYNCIFY/JSPI). A single arm delivers repeatedly. The + * arming itself is an event source - matching Linux, where the set becomes ready + * with no producer wakeup to follow: + * - EPOLL_CTL_ADD of an already-readable fd reports it. + * - EPOLL_CTL_MOD re-arming a still-readable EPOLLONESHOT fd reports it again. + * Clearing the interest (NULL callback) stops delivery and lets the runtime exit. + */ + +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd; +static int fires; + +static void arm_rfd(int op) { + struct epoll_event ev = { .events = EPOLLIN | EPOLLONESHOT }; + ev.data.u32 = 0x1234; + assert(epoll_ctl(ep, op, rfd, &ev) == 0); +} + +static void on_ready(int epfd, struct epoll_event* events, int nready, void* ud) { + assert(epfd == ep); + assert(nready == 1); + assert(events[0].events & EPOLLIN); + assert(events[0].data.u32 == 0x1234); + assert((long)ud == 42); + fires++; + + if (fires == 1) { + // EPOLLONESHOT disabled the registration on this delivery, but the byte is + // still in the pipe (level-readable). Re-arm with MOD WITHOUT draining: with + // no producer event to follow, only the MOD poke can re-evaluate readiness. + arm_rfd(EPOLL_CTL_MOD); + return; + } + + assert(fires == 2); + // Drain, clear the interest, then make the set ready again: with the callback + // cleared there is nothing left to fire, and the runtime exits cleanly. + char b[1]; + assert(read(rfd, b, 1) == 1); + assert(emscripten_epoll_set_callback(ep, 4, NULL, NULL) == 0); + assert(write(wfd, "x", 1) == 1); + arm_rfd(EPOLL_CTL_MOD); + printf("done\n"); +} + +int main(void) { + ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + + // Arm the persistent callback on an empty set: nothing ready, no fire. + assert(emscripten_epoll_set_callback(ep, 4, on_ready, (void*)42) == 0); + + // Make rfd readable, then ADD it. The fd is already ready with no producer + // wakeup to come, so the ADD itself must trigger the first delivery. + assert(write(wfd, "x", 1) == 1); + arm_rfd(EPOLL_CTL_ADD); + return 0; +} diff --git a/test/core/test_epoll_callback_close.c b/test/core/test_epoll_callback_close.c new file mode 100644 index 0000000000000..e0c80b92d5087 --- /dev/null +++ b/test/core/test_epoll_callback_close.c @@ -0,0 +1,46 @@ +/* + * 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 registered callback keeps the runtime alive only while its epoll can still + * fire. Closing the watched fd makes the set terminal (nothing it watches can + * become ready again), so the keepalive is dropped and the process exits with no + * explicit unregister - here over a pipe, exercising the PIPEFS close -> wake -> + * evict path (the same property the sockets test relies on for SOCKFS). + */ + +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd; + +static void on_ready(int epfd, struct epoll_event* ev, int n, void* ud) { + assert(n == 1 && (ev[0].events & EPOLLIN)); + char b[1]; + assert(read(rfd, b, 1) == 1); + printf("done\n"); + // No unregister: closing the watched fd alone must let the runtime exit. + close(rfd); + close(wfd); +} + +int main(void) { + ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rfd; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd, &ev) == 0); + + assert(emscripten_epoll_set_callback(ep, 4, on_ready, 0) == 0); + assert(write(wfd, "x", 1) == 1); + return 0; +} diff --git a/test/core/test_epoll_callback_edge.c b/test/core/test_epoll_callback_edge.c new file mode 100644 index 0000000000000..ce37269e140c7 --- /dev/null +++ b/test/core/test_epoll_callback_edge.c @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * EPOLLET on the callback path: an edge-triggered fd delivers once per edge. It + * must NOT re-fire while it stays continuously readable (the byte is never + * drained), and it fires again only on a fresh edge (a new write). + */ + +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd, fires; + +static void second_edge(void* arg) { + // The fd stayed readable the whole time (fire 1 did not drain it), yet the + // edge-triggered callback did not re-fire. A LEVEL fd would have re-delivered + // (and spun) by now, so fires==1 here is the EPOLLET once-per-edge guarantee. + assert(fires == 1); + assert(write(wfd, "y", 1) == 1); // a fresh edge -> exactly one more delivery +} + +static void on_ready(int e, struct epoll_event* ev, int n, void* ud) { + assert(n == 1); + assert(ev[0].data.fd == rfd); + assert(ev[0].events & EPOLLIN); + fires++; + + if (fires == 1) { + // Do NOT drain: leave the fd readable, then check it stays silent and poke a + // fresh edge. + emscripten_async_call(second_edge, NULL, 0); + return; + } + + assert(fires == 2); + char b[2]; + assert(read(rfd, b, 2) == 2); // drain both bytes + assert(emscripten_epoll_set_callback(ep, 4, NULL, NULL) == 0); + printf("done\n"); +} + +int main(void) { + ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + struct epoll_event ev = { .events = EPOLLIN | EPOLLET }; + ev.data.fd = rfd; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd, &ev) == 0); + + assert(emscripten_epoll_set_callback(ep, 4, on_ready, 0) == 0); + assert(write(wfd, "x", 1) == 1); // first edge + return 0; +} diff --git a/test/core/test_epoll_callback_level.c b/test/core/test_epoll_callback_level.c new file mode 100644 index 0000000000000..20e5c257ecd49 --- /dev/null +++ b/test/core/test_epoll_callback_level.c @@ -0,0 +1,42 @@ +/* + * 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. + * + * Pins the documented level-triggered callback behaviour: an fd that is + * structurally always ready (here a pipe write end, always EPOLLOUT) re-fires + * the callback on every event-loop tick. The runtime drives that loop, so such + * an fd would spin indefinitely - the contract is that the app uses EPOLLET or + * unregisters. This test unregisters after a few deliveries so it terminates. + */ + +#include +#include +#include +#include +#include +#include + +static int ep, fires; + +static void on_ready(int e, struct epoll_event* ev, int n, void* ud) { + assert(n == 1); + assert(ev[0].events & EPOLLOUT); + if (++fires == 3) { // re-fired every tick despite no new event and no drain + assert(emscripten_epoll_set_callback(ep, 4, NULL, NULL) == 0); + printf("done\n"); + } +} + +int main(void) { + ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + struct epoll_event ev = { .events = EPOLLOUT }; // level; a write end is always writable + ev.data.fd = p[1]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[1], &ev) == 0); + + assert(emscripten_epoll_set_callback(ep, 4, on_ready, 0) == 0); + return 0; +} diff --git a/test/core/test_epoll_callback_nested.c b/test/core/test_epoll_callback_nested.c new file mode 100644 index 0000000000000..f56d3b2ee4018 --- /dev/null +++ b/test/core/test_epoll_callback_nested.c @@ -0,0 +1,54 @@ +/* + * 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 readiness callback on an outer epoll that nests an inner one. A single leaf + * edge must propagate two levels - leaf -> inner epoll's wait-queue -> outer + * epoll's registration -> outer epoll's wait-queue -> the callback - and surface + * as readiness on the inner epoll's fd, with no blocking and no ASYNCIFY/JSPI. + */ + +#include +#include +#include +#include +#include +#include + +static int epA, epB, rfd, wfd; + +static void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } + +static void on_ready(int epfd, struct epoll_event* ev, int n, void* ud) { + assert(epfd == epA); + assert(n == 1); + assert(ev[0].data.fd == epB); // the inner epoll, surfaced through nesting + assert(ev[0].events & EPOLLIN); + char b[1]; + assert(read(rfd, b, 1) == 1); // drain the leaf + assert(emscripten_epoll_set_callback(epA, 4, NULL, NULL) == 0); + printf("done\n"); +} + +int main(void) { + epA = epoll_create1(0); + epB = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rfd; + assert(epoll_ctl(epB, EPOLL_CTL_ADD, rfd, &ev) == 0); // leaf in the inner epoll + ev.data.fd = epB; + assert(epoll_ctl(epA, EPOLL_CTL_ADD, epB, &ev) == 0); // inner epoll in the outer + + // Arm the callback on the outer epoll, then write after we return: the leaf + // edge wakes the callback through both levels with no stack switch. + assert(emscripten_epoll_set_callback(epA, 4, on_ready, 0) == 0); + emscripten_async_call(writer, NULL, 0); + return 0; +} diff --git a/test/core/test_epoll_callback_nested_close.c b/test/core/test_epoll_callback_nested_close.c new file mode 100644 index 0000000000000..c552713970e07 --- /dev/null +++ b/test/core/test_epoll_callback_nested_close.c @@ -0,0 +1,47 @@ +/* + * 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. + * + * Closing a nested (inner) epoll wakes the outer epoll watching it, which + * re-derives and drops the now-stale registration. An outer callback that + * watched only the inner then has nothing that can fire, so it stops keeping the + * runtime alive and the process exits - with no explicit unregister, the same + * terminal-set property as closing a leaf fd, one level up. + */ + +#include +#include +#include +#include +#include +#include + +static int epA, epB, rfd, wfd; + +static void on_ready(int epfd, struct epoll_event* ev, int n, void* ud) { + assert(epfd == epA); + assert(n == 1 && ev[0].data.fd == epB); + printf("done\n"); + close(epB); // inner epoll gone -> outer's only registration becomes terminal +} + +int main(void) { + epA = epoll_create1(0); + epB = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rfd; + assert(epoll_ctl(epB, EPOLL_CTL_ADD, rfd, &ev) == 0); // leaf in the inner + ev.data.fd = epB; + assert(epoll_ctl(epA, EPOLL_CTL_ADD, epB, &ev) == 0); // inner in the outer + + assert(emscripten_epoll_set_callback(epA, 4, on_ready, 0) == 0); + assert(write(wfd, "x", 1) == 1); // leaf ready -> propagates up to epA's callback + return 0; +} diff --git a/test/core/test_epoll_callback_overflow.c b/test/core/test_epoll_callback_overflow.c new file mode 100644 index 0000000000000..9906db6d88a01 --- /dev/null +++ b/test/core/test_epoll_callback_overflow.c @@ -0,0 +1,61 @@ +/* + * 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. + * + * emscripten_epoll_set_callback overflow drain: with more ready fds than + * maxevents, the callback re-triggers itself on the next tick to deliver the + * remainder - there is no app loop to re-call it. Three always-readable fds with + * maxevents=1 are all delivered (each exactly once, round-robin) from a single + * arm and a single set of writes, with no further producer events. + */ + +#include +#include +#include +#include +#include +#include + +static int ep; +static int rfd[3]; +static int fires; +static int seen[3]; + +static int index_of(int fd) { + for (int i = 0; i < 3; i++) if (rfd[i] == fd) return i; + return -1; +} + +static void on_ready(int epfd, struct epoll_event* ev, int n, void* ud) { + assert(n == 1); // maxevents == 1 + int i = index_of(ev[0].data.fd); + assert(i >= 0 && !seen[i]); // each fd delivered exactly once (no starvation) + seen[i] = 1; + char b[1]; + assert(read(rfd[i], b, 1) == 1); // drain so it is no longer ready + + if (++fires == 3) { + assert(emscripten_epoll_set_callback(ep, 1, NULL, NULL) == 0); + printf("done\n"); + } +} + +int main(void) { + ep = epoll_create1(0); + for (int i = 0; i < 3; i++) { + int p[2]; + assert(pipe(p) == 0); + rfd[i] = p[0]; + assert(write(p[1], "x", 1) == 1); // read end readable (level) + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rfd[i]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd[i], &ev) == 0); + } + + // One arm, maxevents=1, three ready fds: the callback must deliver all three + // (one per tick via re-trigger), not just the first. + assert(emscripten_epoll_set_callback(ep, 1, on_ready, 0) == 0); + return 0; +} diff --git a/test/core/test_epoll_callback_replace.c b/test/core/test_epoll_callback_replace.c new file mode 100644 index 0000000000000..c60a7cdd0ad91 --- /dev/null +++ b/test/core/test_epoll_callback_replace.c @@ -0,0 +1,56 @@ +/* + * 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. + * + * emscripten_epoll_set_callback registration semantics: there is at most one + * callback per epoll, so a second register replaces the first (callbacks do not + * stack), and a NULL callback unregisters regardless of maxevents (including 0). + */ + +#include +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd; +static int c1, c2; + +static void cb1(int e, struct epoll_event* ev, int n, void* ud) { c1++; } + +static void cb2(int e, struct epoll_event* ev, int n, void* ud) { + c2++; + assert(c1 == 0); // the replaced callback must never have fired + char b[1]; + assert(read(rfd, b, 1) == 1); // drain + + // Unregister with maxevents 0: it is ignored when clearing. Make the set ready + // again to prove no further delivery happens. + assert(emscripten_epoll_set_callback(ep, 0, 0, 0) == 0); + assert(write(wfd, "x", 1) == 1); + printf("done\n"); +} + +int main(void) { + ep = epoll_create1(0); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rfd; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd, &ev) == 0); + + // A non-epoll fd is rejected with -EBADF. + assert(emscripten_epoll_set_callback(rfd, 4, cb1, 0) == -EBADF); + + // Register then immediately replace, before any tick runs: only cb2 is armed. + assert(emscripten_epoll_set_callback(ep, 4, cb1, 0) == 0); + assert(emscripten_epoll_set_callback(ep, 4, cb2, 0) == 0); + assert(write(wfd, "x", 1) == 1); // delivered on the next tick, to cb2 only + return 0; +} diff --git a/test/core/test_epoll_fairness.c b/test/core/test_epoll_fairness.c new file mode 100644 index 0000000000000..d212c586f4616 --- /dev/null +++ b/test/core/test_epoll_fairness.c @@ -0,0 +1,41 @@ +/* + * 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. + * + * Round-robin fairness: with more ready fds than maxevents, successive waits + * must rotate. A delivered level-triggered fd goes to the back of the ready + * list, and the unprocessed remainder is serviced first on the next call, so no + * fd starves. With three always-readable fds and maxevents=1, the reported fd + * cycles a, b, c, a, b, c. + */ + +#include +#include +#include +#include + +int main(void) { + int ep = epoll_create1(0); + int rfd[3]; + for (int i = 0; i < 3; i++) { + int p[2]; + assert(pipe(p) == 0); + rfd[i] = p[0]; + assert(write(p[1], "x", 1) == 1); // read end is now readable (level), never drained + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rfd[i]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd[i], &ev) == 0); + } + + int expect[6] = { rfd[0], rfd[1], rfd[2], rfd[0], rfd[1], rfd[2] }; + struct epoll_event out; + for (int i = 0; i < 6; i++) { + assert(epoll_wait(ep, &out, 1, 0) == 1); + assert(out.data.fd == expect[i]); + } + + printf("done\n"); + return 0; +} diff --git a/test/core/test_epoll_noderawfs.c b/test/core/test_epoll_noderawfs.c new file mode 100644 index 0000000000000..5c87385eccce5 --- /dev/null +++ b/test/core/test_epoll_noderawfs.c @@ -0,0 +1,59 @@ +/* + * 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. + * + * poll()/epoll under -sNODERAWFS, where regular-file streams are backed by + * node's fs and carry no stream_ops. The readiness layer must treat such a + * stream as a plain always-ready file (not dereference a missing poll handler): + * poll reports POLLIN|POLLOUT, epoll_ctl rejects it with EPERM, and a PIPEFS + * pipe (still a real stream_ops-bearing stream under NODERAWFS) works normally. + */ + +#include +#include +#include +#include +#include +#include +#include + +int main(void) { + int rf = open("epoll_noderawfs.tmp", O_CREAT | O_RDWR, 0600); + assert(rf >= 0); + + // A regular file with no poll handler is always readable+writable, and does + // not crash the derivation. + struct pollfd pf = { .fd = rf, .events = POLLIN | POLLOUT }; + assert(poll(&pf, 1, 0) == 1); + assert(pf.revents == (POLLIN | POLLOUT)); + + // ...and is not epoll-capable. + int ep = epoll_create1(0); + assert(ep >= 0); + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rf; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rf, &ev) == -1 && errno == EPERM); + + // A pipe still comes from PIPEFS under NODERAWFS, so epoll works on it. + int p[2]; + assert(pipe(p) == 0); + ev.events = EPOLLIN; + ev.data.fd = p[0]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) == 0); + struct epoll_event out[4]; + assert(epoll_wait(ep, out, 4, 0) == 0); + assert(write(p[1], "x", 1) == 1); + assert(epoll_wait(ep, out, 4, 0) == 1); + assert(out[0].events & EPOLLIN); + assert(out[0].data.fd == p[0]); + + close(ep); + close(p[0]); + close(p[1]); + close(rf); + unlink("epoll_noderawfs.tmp"); + printf("EPOLL NODERAWFS PASS\n"); + return 0; +} diff --git a/test/core/test_epoll_wait_and_callback.c b/test/core/test_epoll_wait_and_callback.c new file mode 100644 index 0000000000000..9b7a6c6ab2a89 --- /dev/null +++ b/test/core/test_epoll_wait_and_callback.c @@ -0,0 +1,100 @@ +/* + * 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 epoll_wait() (suspended under ASYNCIFY/JSPI) and a persistent + * emscripten_epoll_set_callback on the SAME epoll. Both are consumers on the + * epoll's wait-queue, so a readiness edge wakes both - but they share ONE ready + * list, which is consumed rather than copied. So they take DISJOINT slices: no + * edge is ever delivered twice, and together they cover the whole ready set. + * This mirrors Linux, where multiple waiters on one epoll pull different items + * off the shared rdllist (the basis of the multi-waiter work-distribution + * pattern), and an edge-triggered event is reported to exactly one of them. + * + * The split is deterministic: the blocking wait's waiter runs synchronously in + * the producer's stack and drains the ready list immediately, so it wins the one + * edge ready at the instant it is woken; whatever became ready afterwards is left + * on the shared list for the callback's deferred (microtask) tick. What is NOT + * guaranteed is the relative order of the two completions - the callback's tick + * may run before or after the blocking wait's async resumption - so "done" is + * reported once both slices have arrived, whichever lands last. + */ + +#include +#include +#include +#include +#include +#include + +static int ep, rfd[3], wfd[3]; +static int seen[3]; // which fds have been delivered, across BOTH consumers +static int done_printed; // guard: report "done" exactly once + +static int idx(int fd) { + for (int i = 0; i < 3; i++) if (rfd[i] == fd) return i; + return -1; +} + +// Both consumers feed into this; whichever completes the set last prints "done". +// Their completions can interleave in either order, so neither alone can decide. +static void maybe_done(void) { + if (seen[0] && seen[1] && seen[2] && !done_printed) { + done_printed = 1; + assert(emscripten_epoll_set_callback(ep, 8, NULL, NULL) == 0); + printf("done\n"); + } +} + +static void make_ready(void* arg) { + // Runs after epoll_wait has suspended. The first write wakes the blocking + // wait, which drains synchronously and resolves with just the one fd ready at + // that instant; the next two edges land on the shared ready list, with no + // blocking waiter left to take them, for the callback's tick. + for (int i = 0; i < 3; i++) assert(write(wfd[i], "x", 1) == 1); +} + +static void on_ready(int epfd, struct epoll_event* ev, int n, void* ud) { + for (int k = 0; k < n; k++) { + int i = idx(ev[k].data.fd); + assert(i >= 0 && !seen[i]); // disjoint: never an fd the blocking wait took + seen[i] = 1; + } + maybe_done(); +} + +int main(void) { + ep = epoll_create1(0); + for (int i = 0; i < 3; i++) { + int p[2]; + assert(pipe(p) == 0); + rfd[i] = p[0]; + wfd[i] = p[1]; + // Edge-triggered: each readiness is reported once, so "delivered to exactly + // one consumer" is unambiguous (no level re-cycling between the two). + struct epoll_event ev = { .events = EPOLLIN | EPOLLET }; + ev.data.fd = rfd[i]; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd[i], &ev) == 0); + } + + // Arm the callback and schedule the writes, then block. Both consumers are now + // on the epoll's wait-queue with an empty ready list. + assert(emscripten_epoll_set_callback(ep, 8, on_ready, 0) == 0); + emscripten_async_call(make_ready, NULL, 0); + + struct epoll_event out[8]; + int n = epoll_wait(ep, out, 8, -1); // ASYNCIFY/JSPI: suspends until readiness + // Woken on the first edge, the blocking wait sees only what was ready then - + // exactly one fd, not the whole burst that arrived after it drained. + assert(n == 1); + int wi = idx(out[0].data.fd); + assert(wi >= 0 && !seen[wi]); + seen[wi] = 1; + + // The callback (kept alive by its own keepalive) delivers the remaining two + // off the shared list; "done" prints once both slices are in, in either order. + maybe_done(); + return 0; +} diff --git a/test/sockets/test_epoll_callback.c b/test/sockets/test_epoll_callback.c new file mode 100644 index 0000000000000..0b5e30b3cc9b8 --- /dev/null +++ b/test/sockets/test_epoll_callback.c @@ -0,0 +1,75 @@ +/* + * 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. + * + * emscripten_epoll_set_callback woken by real socket readiness (arriving UDP + * datagrams) through the SOCKFS -> wait-queue bridge, with no blocking call and + * no ASYNCIFY/JSPI. A single arm delivers repeatedly: each datagram is a + * separate producer event that re-fires the persistent callback. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int ep, rx, tx; +static struct sockaddr_in addr; +static int fires; + +static void send_one(const char* msg) { + assert(sendto(tx, msg, 4, 0, (struct sockaddr*)&addr, sizeof addr) == 4); +} + +static void on_ready(int epfd, struct epoll_event* ev, int n, void* ud) { + assert(n == 1); + assert(ev[0].events & EPOLLIN); + assert(ev[0].data.fd == rx); + char b[4]; + assert(recv(rx, b, 4, 0) == 4); + fires++; + + if (fires == 1) { + assert(memcmp(b, "one\0", 4) == 0); + send_one("two"); // a second producer event re-fires the same arm + return; + } + assert(fires == 2); + assert(memcmp(b, "two\0", 4) == 0); + printf("done\n"); + // Closing the watched fd makes the epoll terminal - nothing it watches can + // become ready again - so the callback stops keeping the runtime alive and the + // process exits (no explicit unregister needed). + close(rx); + close(tx); +} + +int main(void) { + ep = epoll_create1(0); + rx = socket(AF_INET, SOCK_DGRAM, 0); + tx = socket(AF_INET, SOCK_DGRAM, 0); + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; addr.sin_port = htons(0); + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + assert(bind(rx, (struct sockaddr*)&addr, sizeof addr) == 0); + socklen_t l = sizeof addr; + assert(getsockname(rx, (struct sockaddr*)&addr, &l) == 0); + + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rx; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rx, &ev) == 0); + + // Arm once (no ASYNCIFY), then send the first datagram; it arrives after we + // return and wakes the callback. The callback drives the second send itself. + assert(emscripten_epoll_set_callback(ep, 4, on_ready, 0) == 0); + send_one("one"); + return 0; +} diff --git a/test/sockets/test_epoll_rdhup.c b/test/sockets/test_epoll_rdhup.c new file mode 100644 index 0000000000000..64fb381ff93f4 --- /dev/null +++ b/test/sockets/test_epoll_rdhup.c @@ -0,0 +1,79 @@ +/* + * 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. + * + * EPOLLRDHUP over a real TCP connection: a self-contained loopback client/server + * (blocking, proxied to a worker) establishes a connection, the server + * half-closes its write side (shutdown(SHUT_WR) -> FIN), and a blocking + * epoll_wait on the client reports EPOLLRDHUP - the peer read-side hangup, + * distinct from a full EPOLLHUP. Also checks that EPOLLRDHUP is request-gated: + * a registration that didn't ask for it does not receive it. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(void) { + int listen_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(listen_fd >= 0); + struct sockaddr_in addr; + 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); + + int client_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(client_fd >= 0); + assert(connect(client_fd, (struct sockaddr*)&addr, sizeof(addr)) == 0); + + // The server-side 'connection' can land just after connect() returns, so wait + // for the listener to be readable before accepting. + struct pollfd lp = { .fd = listen_fd, .events = POLLIN }; + assert(poll(&lp, 1, -1) == 1 && (lp.revents & POLLIN)); + int peer_fd = accept(listen_fd, NULL, NULL); + assert(peer_fd >= 0); + + // Server half-closes its write side: the client's read side hangs up (FIN). + assert(shutdown(peer_fd, SHUT_WR) == 0); + + int ep = epoll_create1(0); + struct epoll_event ev = { .events = EPOLLIN | EPOLLRDHUP }; + ev.data.fd = client_fd; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, client_fd, &ev) == 0); + + struct epoll_event out[4]; + int n = epoll_wait(ep, out, 4, -1); // blocks until the FIN arrives + assert(n == 1); + assert(out[0].data.fd == client_fd); + assert(out[0].events & EPOLLRDHUP); // peer read-side hangup reported + assert(!(out[0].events & EPOLLHUP)); // not a full hangup: still half-open + + // EPOLLRDHUP is request-gated: a registration that didn't ask for it doesn't + // get it, even though the read side is hung up (it still reports EPOLLIN). + int ep2 = epoll_create1(0); + ev.events = EPOLLIN; + assert(epoll_ctl(ep2, EPOLL_CTL_ADD, client_fd, &ev) == 0); + assert(epoll_wait(ep2, out, 4, 0) == 1); + assert(out[0].events & EPOLLIN); + assert(!(out[0].events & EPOLLRDHUP)); + + close(ep); + close(ep2); + close(client_fd); + close(peer_fd); + close(listen_fd); + printf("EPOLL RDHUP PASS\n"); + return 0; +} diff --git a/test/sockets/test_epoll_socket_blocking.c b/test/sockets/test_epoll_socket_blocking.c new file mode 100644 index 0000000000000..e9a9e294a8f8a --- /dev/null +++ b/test/sockets/test_epoll_socket_blocking.c @@ -0,0 +1,84 @@ +/* + * 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* epoll_wait() on a real socket is woken by a datagram that arrives + * *after* the wait has already blocked. The datagram is sent on a delay (from + * another thread under -pthread, or a timer under JSPI) so epoll_wait() must + * suspend - the proxied worker under PROXY_TO_PTHREAD, or the calling stack + * under JSPI - and be woken through the unified readiness wait-queue, the same + * SOCKFS.emit bridge poll() rides. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN_PTHREADS__ +#include +#endif + +static int rx = -1, tx = -1; +static struct sockaddr_in addr; + +static void send_ping(void* arg) { + assert(sendto(tx, "ping", 4, 0, (struct sockaddr*)&addr, sizeof(addr)) == 4); +} + +#ifdef __EMSCRIPTEN_PTHREADS__ +static void* sender(void* arg) { + usleep(100000); // let epoll_wait() block first + send_ping(NULL); + return NULL; +} +#endif + +int main(void) { + rx = socket(AF_INET, SOCK_DGRAM, 0); + tx = socket(AF_INET, SOCK_DGRAM, 0); + assert(rx >= 0 && tx >= 0); + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + assert(bind(rx, (struct sockaddr*)&addr, sizeof(addr)) == 0); + socklen_t l = sizeof(addr); + assert(getsockname(rx, (struct sockaddr*)&addr, &l) == 0); + + int ep = epoll_create1(0); + assert(ep >= 0); + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rx; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rx, &ev) == 0); + + // Arrange the datagram to arrive only after epoll_wait() is already blocking, + // so it can only complete by being woken - not by the initial derivation. +#ifdef __EMSCRIPTEN_PTHREADS__ + pthread_t t; + assert(pthread_create(&t, NULL, sender, NULL) == 0); +#else + emscripten_async_call(send_ping, NULL, 100); +#endif + + struct epoll_event out[4]; + int n = epoll_wait(ep, out, 4, -1); // blocks; only the arrival can wake it + assert(n == 1 && (out[0].events & EPOLLIN)); + assert(out[0].data.fd == rx); + + char buf[4]; + assert(recv(rx, buf, sizeof(buf), 0) == 4 && memcmp(buf, "ping", 4) == 0); + + close(ep); + close(rx); + close(tx); + printf("EPOLL SOCKET BLOCKING PASS\n"); + return 0; +} diff --git a/test/test_core.py b/test/test_core.py index 62f1828196608..5be8ec10ef486 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -400,6 +400,19 @@ def decorated(self, *args, **kwargs): return decorator +def needs_epoll(func): + # epoll is implemented in the JS (non-WASMFS) syscall layer and needs the FS. + assert callable(func) + + @wraps(func) + def decorated(self, *args, **kwargs): + if self.get_setting('WASMFS'): + self.skipTest('epoll is implemented in the JS (non-WASMFS) syscall layer') + self.set_setting('FORCE_FILESYSTEM') + return func(self, *args, **kwargs) + return decorated + + def make_no_decorator_for_setting(name): def outer_decorator(note): assert not callable(note) @@ -5773,6 +5786,75 @@ def test_poll(self): self.set_setting('FORCE_FILESYSTEM') self.do_core_test('test_poll.c') + @needs_epoll + def test_epoll(self): + self.do_runf('core/test_epoll.c', 'EPOLL PASS') + + @needs_epoll + def test_epoll_advanced(self): + self.do_runf('core/test_epoll_advanced.c', 'EPOLL ADVANCED PASS') + + @needs_epoll + def test_epoll_fairness(self): + # More ready fds than maxevents: successive waits rotate (round-robin) so no + # fd starves. + self.do_runf('core/test_epoll_fairness.c', 'done\n') + + @needs_epoll + @requires_node + def test_epoll_noderawfs(self): + # Regular-file streams under NODERAWFS carry no stream_ops; the readiness + # layer must not dereference a missing poll handler (poll/epoll on a file). + self.do_runf('core/test_epoll_noderawfs.c', 'EPOLL NODERAWFS PASS', cflags=['-sNODERAWFS']) + + @needs_epoll + def test_epoll_callback(self): + # emscripten_epoll_set_callback delivers an epoll set's readiness by a + # persistent callback with no blocking and no ASYNCIFY/JSPI. + self.do_runf('core/test_epoll_callback.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + + @needs_epoll + def test_epoll_callback_overflow(self): + # maxevents < ready count: the callback re-triggers to drain the remainder + # across ticks (no app loop to re-call it). + self.do_runf('core/test_epoll_callback_overflow.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + + @needs_epoll + def test_epoll_callback_replace(self): + # A second register replaces the callback (no stacking); a NULL callback + # unregisters regardless of maxevents. + self.do_runf('core/test_epoll_callback_replace.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + + @needs_epoll + def test_epoll_callback_close(self): + # Closing the last watched fd makes the epoll terminal, so the callback stops + # keeping the runtime alive and the process exits (no explicit unregister). + self.do_runf('core/test_epoll_callback_close.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + + @needs_epoll + def test_epoll_callback_nested(self): + # A callback on an outer epoll fires when a leaf edge propagates through an + # inner (nested) epoll. + self.do_runf('core/test_epoll_callback_nested.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + + @needs_epoll + def test_epoll_callback_nested_close(self): + # Closing the inner epoll wakes the outer to drop its stale registration, so + # an outer callback watching only the inner stops holding the runtime. + self.do_runf('core/test_epoll_callback_nested_close.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + + @needs_epoll + def test_epoll_callback_edge(self): + # EPOLLET on the callback path: fires once per edge, stays silent while + # continuously readable, re-fires only on a fresh edge. + self.do_runf('core/test_epoll_callback_edge.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + + @needs_epoll + def test_epoll_callback_level(self): + # A structurally-always-ready level fd (EPOLLOUT on a writable end) re-fires + # the callback every tick: documents the spin contract (use EPOLLET/unregister). + self.do_runf('core/test_epoll_callback_level.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + @no_wasmfs('st.f_ffree > st.f_files, same issue than in wasmfs.test_fs_nodefs_statvfs. https://github.com/emscripten-core/emscripten/issues/25035') def test_statvfs(self): self.do_core_test('test_statvfs.c') @@ -9726,6 +9808,22 @@ def test_poll_blocking_asyncify(self): self.skipTest('test requires setTimeout which is not supported under v8') self.do_runf('core/test_poll_blocking_asyncify.c', 'done\n') + @with_asyncify_and_jspi + @needs_epoll + def test_epoll_blocking_asyncify(self): + if self.get_setting('JSPI') and engine_is_v8(self.get_current_js_engine()): + self.skipTest('test requires setTimeout which is not supported under v8') + self.do_runf('core/test_epoll_blocking_asyncify.c', 'done\n') + + @with_asyncify_and_jspi + @needs_epoll + def test_epoll_wait_and_callback(self): + # A suspended blocking epoll_wait and a persistent callback on one epoll + # share a single ready list: they take disjoint slices, never the same edge. + if self.get_setting('JSPI') and engine_is_v8(self.get_current_js_engine()): + self.skipTest('test requires setTimeout which is not supported under v8') + self.do_runf('core/test_epoll_wait_and_callback.c', 'done\n', cflags=['-sEXIT_RUNTIME']) + @parameterized({ '': ([],), 'pthread': (['-pthread'],), diff --git a/test/test_sockets.py b/test/test_sockets.py index ed4e5f2e72034..214eb84da7dd1 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -497,12 +497,62 @@ def test_noderawsockets_udp_ipv6(self): self.skipTest('no IPv6 loopback available') self.do_runf('sockets/test_udp_ipv6.c', 'UDP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) + def test_noderawsockets_epoll_socket_blocking(self): + # A blocking epoll_wait() on a socket is woken by an incoming datagram + # through the unified readiness wait-queue (the SOCKFS.emit bridge), with + # main() proxied to a worker so the wait can suspend. + self.do_runf('sockets/test_epoll_socket_blocking.c', 'EPOLL SOCKET BLOCKING PASS', + cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) + + def test_noderawsockets_epoll_socket_blocking_jspi(self): + # Same, but the blocking epoll_wait() suspends the wasm stack under JSPI. + # NODERAWSOCKETS runs under node rather than the browser, so gate JSPI on + # node's own support (v24) instead of require_jspi's browser-test path. + if 'EMTEST_SKIP_JSPI' in os.environ: + self.skipTest('skipping JSPI (EMTEST_SKIP_JSPI is set)') + if not self.try_require_node_version(24): + self.skipTest('JSPI requires node v24') + if not common.check_node_version(26): + self.node_args += ['--experimental-wasm-stack-switching'] + self.cflags += ['-Wno-experimental'] + self.set_setting('JSPI') + self.do_runf('sockets/test_epoll_socket_blocking.c', 'EPOLL SOCKET BLOCKING PASS', + 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. + self.do_runf('sockets/test_epoll_rdhup.c', 'EPOLL RDHUP PASS', + cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) + + def test_noderawsockets_epoll_rdhup_jspi(self): + # Same, but the blocking calls suspend the wasm stack under JSPI. Gate on + # node's own JSPI support (v24) since NODERAWSOCKETS runs under node. + if 'EMTEST_SKIP_JSPI' in os.environ: + self.skipTest('skipping JSPI (EMTEST_SKIP_JSPI is set)') + if not self.try_require_node_version(24): + self.skipTest('JSPI requires node v24') + if not common.check_node_version(26): + self.node_args += ['--experimental-wasm-stack-switching'] + self.cflags += ['-Wno-experimental'] + self.set_setting('JSPI') + self.do_runf('sockets/test_epoll_rdhup.c', 'EPOLL RDHUP PASS', + cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + @also_with_proxy_to_pthread def test_noderawsockets_udp(self): # 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']) + def test_noderawsockets_epoll_callback(self): + # emscripten_epoll_set_callback woken repeatedly by arriving datagrams on a + # real socket via the SOCKFS -> wait-queue bridge, with no ASYNCIFY/JSPI. + # Not run under PROXY_TO_PTHREAD: the callback fires on the main-thread event + # loop, which is not where the proxied application thread runs (use a blocking + # epoll_wait from a pthread instead). + self.do_runf('sockets/test_epoll_callback.c', 'done', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + @also_with_proxy_to_pthread def test_noderawsockets_udp_connect(self): # Connected UDP: sendto() with an address gives EISCONN, send() reaches the diff --git a/tools/maint/gen_sig_info.py b/tools/maint/gen_sig_info.py index 52707167a61f3..15bc35b2a1e5b 100755 --- a/tools/maint/gen_sig_info.py +++ b/tools/maint/gen_sig_info.py @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -106,6 +107,7 @@ #include #include #include +#include #include // Internal emscripten headers diff --git a/tools/native_sigs.py b/tools/native_sigs.py index f9a7cde215c56..31a3489887145 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -421,6 +421,7 @@ '__syscall_connect': '__p____', '__syscall_epoll_ctl': '____p', '__syscall_epoll_pwait': '__p__pp', + '__syscall_epoll_pwait_nonblocking': '__p_', '__syscall_faccessat': '__p__', '__syscall_fchmodat2': '__p__', '__syscall_fchownat': '__p___', From 36f94cb485c7a117cb3f19b365d1e2ce675b9f2a Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 1 Jul 2026 12:15:38 -0700 Subject: [PATCH 2/7] pr feedback --- test/sockets/test_epoll_rdhup.c | 2 +- test/sockets/test_epoll_socket_blocking.c | 5 +- test/sockets/test_tcp_backpressure.c | 2 +- test/sockets/test_tcp_client_bind.c | 2 +- test/sockets/test_tcp_client_semantics.c | 2 +- test/sockets/test_tcp_echo.c | 2 +- test/sockets/test_tcp_ipv6.c | 2 +- test/sockets/test_tcp_refused.c | 2 +- test/sockets/test_tcp_server.c | 2 +- test/sockets/test_udp_connect.c | 2 +- test/sockets/test_udp_echo.c | 2 +- test/sockets/test_udp_ipv6.c | 2 +- test/test_core.py | 54 -------------------- test/test_other.py | 48 ++++++++++++++++++ test/test_sockets.py | 62 ++++++++++------------- 15 files changed, 91 insertions(+), 100 deletions(-) diff --git a/test/sockets/test_epoll_rdhup.c b/test/sockets/test_epoll_rdhup.c index 64fb381ff93f4..7fcf78a70e84f 100644 --- a/test/sockets/test_epoll_rdhup.c +++ b/test/sockets/test_epoll_rdhup.c @@ -74,6 +74,6 @@ int main(void) { close(client_fd); close(peer_fd); close(listen_fd); - printf("EPOLL RDHUP PASS\n"); + printf("done\n"); return 0; } diff --git a/test/sockets/test_epoll_socket_blocking.c b/test/sockets/test_epoll_socket_blocking.c index e9a9e294a8f8a..f71f157f6ab72 100644 --- a/test/sockets/test_epoll_socket_blocking.c +++ b/test/sockets/test_epoll_socket_blocking.c @@ -62,6 +62,9 @@ int main(void) { // Arrange the datagram to arrive only after epoll_wait() is already blocking, // so it can only complete by being woken - not by the initial derivation. #ifdef __EMSCRIPTEN_PTHREADS__ + // Under PROXY_TO_PTHREAD main() runs on a worker that parks in epoll_wait, so + // its event loop can't fire an emscripten_async_call timer - the wake is a + // cross-thread memory notify. Send from a separate thread instead. pthread_t t; assert(pthread_create(&t, NULL, sender, NULL) == 0); #else @@ -79,6 +82,6 @@ int main(void) { close(ep); close(rx); close(tx); - printf("EPOLL SOCKET BLOCKING PASS\n"); + printf("done\n"); return 0; } diff --git a/test/sockets/test_tcp_backpressure.c b/test/sockets/test_tcp_backpressure.c index 993515526fad7..a10174ed0d6e6 100644 --- a/test/sockets/test_tcp_backpressure.c +++ b/test/sockets/test_tcp_backpressure.c @@ -36,7 +36,7 @@ long long sent_total = 0; const long long CAP = 512LL * 1024 * 1024; void test_success(void) { - printf("BACKPRESSURE PASS\n"); + printf("done\n"); if (fd >= 0) close(fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_client_bind.c b/test/sockets/test_tcp_client_bind.c index 59e5b1d188668..c9fea5d3f4dec 100644 --- a/test/sockets/test_tcp_client_bind.c +++ b/test/sockets/test_tcp_client_bind.c @@ -36,7 +36,7 @@ bool connected = false; bool ping_sent = false; void test_success(void) { - printf("CLIENT BIND PASS\n"); + printf("done\n"); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_client_semantics.c b/test/sockets/test_tcp_client_semantics.c index 7cc83680cbaad..588b3a4dabb6f 100644 --- a/test/sockets/test_tcp_client_semantics.c +++ b/test/sockets/test_tcp_client_semantics.c @@ -39,7 +39,7 @@ bool ping_sent = false; bool echoed = false; void test_success(void) { - printf("CLIENT SEMANTICS PASS\n"); + printf("done\n"); if (fd >= 0) close(fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_echo.c b/test/sockets/test_tcp_echo.c index b5204a49b1287..a7c0457b0bd0c 100644 --- a/test/sockets/test_tcp_echo.c +++ b/test/sockets/test_tcp_echo.c @@ -36,7 +36,7 @@ bool connected = false; bool ping_sent = false; void test_success(void) { - printf("TCP ECHO PASS\n"); + printf("done\n"); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ // The socket is closed and the main loop cancelled, so node's event loop diff --git a/test/sockets/test_tcp_ipv6.c b/test/sockets/test_tcp_ipv6.c index 4e4a20330c14d..b9cdd80f34991 100644 --- a/test/sockets/test_tcp_ipv6.c +++ b/test/sockets/test_tcp_ipv6.c @@ -39,7 +39,7 @@ bool pong_sent = false; void set_nonblocking(int fd) { fcntl(fd, F_SETFL, O_NONBLOCK); } void test_success(void) { - printf("TCP IPV6 PASS\n"); + printf("done\n"); if (listen_fd >= 0) close(listen_fd); if (client_fd >= 0) close(client_fd); if (peer_fd >= 0) close(peer_fd); diff --git a/test/sockets/test_tcp_refused.c b/test/sockets/test_tcp_refused.c index 29cd9e3ca6a23..b4e1664c0776b 100644 --- a/test/sockets/test_tcp_refused.c +++ b/test/sockets/test_tcp_refused.c @@ -29,7 +29,7 @@ int fd = -1; void test_success(void) { - printf("REFUSED PASS\n"); + printf("done\n"); if (fd >= 0) close(fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_server.c b/test/sockets/test_tcp_server.c index 677cd8d2c925d..7b3921a7617c0 100644 --- a/test/sockets/test_tcp_server.c +++ b/test/sockets/test_tcp_server.c @@ -42,7 +42,7 @@ void set_nonblocking(int fd) { } void test_success(void) { - printf("TCP SERVER PASS\n"); + printf("done\n"); if (listen_fd >= 0) close(listen_fd); if (client_fd >= 0) close(client_fd); if (peer_fd >= 0) close(peer_fd); diff --git a/test/sockets/test_udp_connect.c b/test/sockets/test_udp_connect.c index 4af53a3cf1908..403bcbf1c18c2 100644 --- a/test/sockets/test_udp_connect.c +++ b/test/sockets/test_udp_connect.c @@ -40,7 +40,7 @@ void set_nonblocking(int fd) { } void test_success(void) { - printf("UDP CONNECT PASS\n"); + printf("done\n"); if (server_fd >= 0) close(server_fd); if (client_fd >= 0) close(client_fd); if (other_fd >= 0) close(other_fd); diff --git a/test/sockets/test_udp_echo.c b/test/sockets/test_udp_echo.c index c8f3085ee3a6f..bed4b286c17aa 100644 --- a/test/sockets/test_udp_echo.c +++ b/test/sockets/test_udp_echo.c @@ -40,7 +40,7 @@ void set_nonblocking(int fd) { } void test_success(void) { - printf("UDP ECHO PASS\n"); + printf("done\n"); if (server_fd >= 0) close(server_fd); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ diff --git a/test/sockets/test_udp_ipv6.c b/test/sockets/test_udp_ipv6.c index adf66df62e2e4..5fa48ee4a9d89 100644 --- a/test/sockets/test_udp_ipv6.c +++ b/test/sockets/test_udp_ipv6.c @@ -37,7 +37,7 @@ bool pong_sent = false; void set_nonblocking(int fd) { fcntl(fd, F_SETFL, O_NONBLOCK); } void test_success(void) { - printf("UDP IPV6 PASS\n"); + printf("done\n"); if (server_fd >= 0) close(server_fd); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ diff --git a/test/test_core.py b/test/test_core.py index 5be8ec10ef486..bd46824f26219 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -5794,12 +5794,6 @@ def test_epoll(self): def test_epoll_advanced(self): self.do_runf('core/test_epoll_advanced.c', 'EPOLL ADVANCED PASS') - @needs_epoll - def test_epoll_fairness(self): - # More ready fds than maxevents: successive waits rotate (round-robin) so no - # fd starves. - self.do_runf('core/test_epoll_fairness.c', 'done\n') - @needs_epoll @requires_node def test_epoll_noderawfs(self): @@ -5807,54 +5801,6 @@ def test_epoll_noderawfs(self): # layer must not dereference a missing poll handler (poll/epoll on a file). self.do_runf('core/test_epoll_noderawfs.c', 'EPOLL NODERAWFS PASS', cflags=['-sNODERAWFS']) - @needs_epoll - def test_epoll_callback(self): - # emscripten_epoll_set_callback delivers an epoll set's readiness by a - # persistent callback with no blocking and no ASYNCIFY/JSPI. - self.do_runf('core/test_epoll_callback.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - - @needs_epoll - def test_epoll_callback_overflow(self): - # maxevents < ready count: the callback re-triggers to drain the remainder - # across ticks (no app loop to re-call it). - self.do_runf('core/test_epoll_callback_overflow.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - - @needs_epoll - def test_epoll_callback_replace(self): - # A second register replaces the callback (no stacking); a NULL callback - # unregisters regardless of maxevents. - self.do_runf('core/test_epoll_callback_replace.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - - @needs_epoll - def test_epoll_callback_close(self): - # Closing the last watched fd makes the epoll terminal, so the callback stops - # keeping the runtime alive and the process exits (no explicit unregister). - self.do_runf('core/test_epoll_callback_close.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - - @needs_epoll - def test_epoll_callback_nested(self): - # A callback on an outer epoll fires when a leaf edge propagates through an - # inner (nested) epoll. - self.do_runf('core/test_epoll_callback_nested.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - - @needs_epoll - def test_epoll_callback_nested_close(self): - # Closing the inner epoll wakes the outer to drop its stale registration, so - # an outer callback watching only the inner stops holding the runtime. - self.do_runf('core/test_epoll_callback_nested_close.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - - @needs_epoll - def test_epoll_callback_edge(self): - # EPOLLET on the callback path: fires once per edge, stays silent while - # continuously readable, re-fires only on a fresh edge. - self.do_runf('core/test_epoll_callback_edge.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - - @needs_epoll - def test_epoll_callback_level(self): - # A structurally-always-ready level fd (EPOLLOUT on a writable end) re-fires - # the callback every tick: documents the spin contract (use EPOLLET/unregister). - self.do_runf('core/test_epoll_callback_level.c', 'done\n', cflags=['-sEXIT_RUNTIME']) - @no_wasmfs('st.f_ffree > st.f_files, same issue than in wasmfs.test_fs_nodefs_statvfs. https://github.com/emscripten-core/emscripten/issues/25035') def test_statvfs(self): self.do_core_test('test_statvfs.c') diff --git a/test/test_other.py b/test/test_other.py index 1760a280c34e7..494e749a502cf 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13307,6 +13307,54 @@ def test_emscripten_main_loop_settimeout(self): def test_emscripten_main_loop_setimmediate(self): self.do_runf('test_emscripten_main_loop_setimmediate.c') + # epoll is implemented in the JS (non-WASMFS) syscall layer and needs the FS. + # These exercise the JS API/readiness logic, which does not vary by wasm + # config, so they live here rather than in the test_core.py config matrix. + def test_epoll_fairness(self): + # More ready fds than maxevents: successive waits rotate (round-robin) so no + # fd starves. + self.do_runf('core/test_epoll_fairness.c', 'done\n', cflags=['-sFORCE_FILESYSTEM']) + + def test_epoll_callback(self): + # emscripten_epoll_set_callback delivers an epoll set's readiness by a + # persistent callback with no blocking and no ASYNCIFY/JSPI. + self.do_runf('core/test_epoll_callback.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_overflow(self): + # maxevents < ready count: the callback re-triggers to drain the remainder + # across ticks (no app loop to re-call it). + self.do_runf('core/test_epoll_callback_overflow.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_replace(self): + # A second register replaces the callback (no stacking); a NULL callback + # unregisters regardless of maxevents. + self.do_runf('core/test_epoll_callback_replace.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_close(self): + # Closing the last watched fd makes the epoll terminal, so the callback stops + # keeping the runtime alive and the process exits (no explicit unregister). + self.do_runf('core/test_epoll_callback_close.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_nested(self): + # A callback on an outer epoll fires when a leaf edge propagates through an + # inner (nested) epoll. + self.do_runf('core/test_epoll_callback_nested.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_nested_close(self): + # Closing the inner epoll wakes the outer to drop its stale registration, so + # an outer callback watching only the inner stops holding the runtime. + self.do_runf('core/test_epoll_callback_nested_close.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_edge(self): + # EPOLLET on the callback path: fires once per edge, stays silent while + # continuously readable, re-fires only on a fresh edge. + self.do_runf('core/test_epoll_callback_edge.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_level(self): + # A structurally-always-ready level fd (EPOLLOUT on a writable end) re-fires + # the callback every tick: documents the spin contract (use EPOLLET/unregister). + self.do_runf('core/test_epoll_callback_level.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + @requires_pthreads @no_bun('https://github.com/emscripten-core/emscripten/issues/26197') def test_pthread_trap(self): diff --git a/test/test_sockets.py b/test/test_sockets.py index 214eb84da7dd1..f37639cc4d976 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -410,7 +410,7 @@ def _run_against_echo_server(self, src, expected): def test_noderawsockets_echo(self): # 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') + self._run_against_echo_server('sockets/test_tcp_echo.c', 'done\n') def test_noderawsockets_client_bind(self): # A client that bind()s an explicit source port has it honored by connect(), @@ -427,7 +427,7 @@ def test_noderawsockets_client_bind(self): thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - self.do_runf('sockets/test_tcp_client_bind.c', 'CLIENT BIND PASS', + self.do_runf('sockets/test_tcp_client_bind.c', 'done\n', cflags=['-sNODERAWSOCKETS'], args=[str(port), str(src_port)]) finally: server.shutdown() @@ -437,11 +437,11 @@ def test_noderawsockets_client_bind(self): def test_noderawsockets_client_semantics(self): # EISCONN on a second connect, shutdown(SHUT_WR) leaving reads working, # EPIPE on a write after that, and POLLHUP after a full shutdown(SHUT_RDWR). - self._run_against_echo_server('sockets/test_tcp_client_semantics.c', 'CLIENT SEMANTICS PASS') + self._run_against_echo_server('sockets/test_tcp_client_semantics.c', 'done\n') 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']) + self.do_runf('sockets/test_tcp_refused.c', 'done\n', cflags=['-sNODERAWSOCKETS']) def test_noderawsockets_backpressure(self): # A sink server that accepts but never reads, so the client's writes fill @@ -457,7 +457,7 @@ def handle(self): thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - self.do_runf('sockets/test_tcp_backpressure.c', 'BACKPRESSURE PASS', + self.do_runf('sockets/test_tcp_backpressure.c', 'done\n', cflags=['-sNODERAWSOCKETS'], args=[str(port)]) finally: done.set() @@ -470,7 +470,7 @@ def test_noderawsockets_server(self): # 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']) + self.do_runf('sockets/test_tcp_server.c', 'done\n', cflags=['-sNODERAWSOCKETS']) @also_with_proxy_to_pthread def test_noderawsockets_peek(self): @@ -481,7 +481,7 @@ def test_noderawsockets_peek(self): 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', + self.do_runf('sockets/test_tcp_server.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-DNO_EXPLICIT_BIND']) def test_noderawsockets_tcp_ipv6(self): @@ -489,61 +489,55 @@ def test_noderawsockets_tcp_ipv6(self): # listen, accept, non-blocking connect, send/recv on AF_INET6 sockets. if not HAS_IPV6_LOOPBACK: self.skipTest('no IPv6 loopback available') - self.do_runf('sockets/test_tcp_ipv6.c', 'TCP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) + self.do_runf('sockets/test_tcp_ipv6.c', 'done\n', cflags=['-sNODERAWSOCKETS']) def test_noderawsockets_udp_ipv6(self): # Self-contained IPv6 UDP loopback echo over ::1 on AF_INET6 sockets. if not HAS_IPV6_LOOPBACK: self.skipTest('no IPv6 loopback available') - self.do_runf('sockets/test_udp_ipv6.c', 'UDP IPV6 PASS', cflags=['-sNODERAWSOCKETS']) + self.do_runf('sockets/test_udp_ipv6.c', 'done\n', cflags=['-sNODERAWSOCKETS']) def test_noderawsockets_epoll_socket_blocking(self): # A blocking epoll_wait() on a socket is woken by an incoming datagram # through the unified readiness wait-queue (the SOCKFS.emit bridge), with # main() proxied to a worker so the wait can suspend. - self.do_runf('sockets/test_epoll_socket_blocking.c', 'EPOLL SOCKET BLOCKING PASS', + self.do_runf('sockets/test_epoll_socket_blocking.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) - def test_noderawsockets_epoll_socket_blocking_jspi(self): - # Same, but the blocking epoll_wait() suspends the wasm stack under JSPI. - # NODERAWSOCKETS runs under node rather than the browser, so gate JSPI on - # node's own support (v24) instead of require_jspi's browser-test path. - if 'EMTEST_SKIP_JSPI' in os.environ: - self.skipTest('skipping JSPI (EMTEST_SKIP_JSPI is set)') - if not self.try_require_node_version(24): - self.skipTest('JSPI requires node v24') + def setup_jspi_node(self): + # These tests run on node via do_runf even though the class is a + # BrowserCore, so require_jspi()'s is_browser_test() early-return skips the + # node handling. The new JSPI API requires node >= 24, so skip below that. + if not common.check_node_version(24): + self.skipTest('JSPI requires node >= 24') if not common.check_node_version(26): self.node_args += ['--experimental-wasm-stack-switching'] self.cflags += ['-Wno-experimental'] self.set_setting('JSPI') - self.do_runf('sockets/test_epoll_socket_blocking.c', 'EPOLL SOCKET BLOCKING PASS', + + def test_noderawsockets_epoll_socket_blocking_jspi(self): + # Same, but the blocking epoll_wait() suspends the wasm stack under JSPI. + self.setup_jspi_node() + self.do_runf('sockets/test_epoll_socket_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. - self.do_runf('sockets/test_epoll_rdhup.c', 'EPOLL RDHUP PASS', + self.do_runf('sockets/test_epoll_rdhup.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) def test_noderawsockets_epoll_rdhup_jspi(self): - # Same, but the blocking calls suspend the wasm stack under JSPI. Gate on - # node's own JSPI support (v24) since NODERAWSOCKETS runs under node. - if 'EMTEST_SKIP_JSPI' in os.environ: - self.skipTest('skipping JSPI (EMTEST_SKIP_JSPI is set)') - if not self.try_require_node_version(24): - self.skipTest('JSPI requires node v24') - if not common.check_node_version(26): - self.node_args += ['--experimental-wasm-stack-switching'] - self.cflags += ['-Wno-experimental'] - self.set_setting('JSPI') - self.do_runf('sockets/test_epoll_rdhup.c', 'EPOLL RDHUP PASS', + # Same, but the blocking calls suspend the wasm stack under JSPI. + self.setup_jspi_node() + self.do_runf('sockets/test_epoll_rdhup.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) @also_with_proxy_to_pthread def test_noderawsockets_udp(self): # 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']) + self.do_runf('sockets/test_udp_echo.c', 'done\n', cflags=['-sNODERAWSOCKETS']) def test_noderawsockets_epoll_callback(self): # emscripten_epoll_set_callback woken repeatedly by arriving datagrams on a @@ -551,13 +545,13 @@ def test_noderawsockets_epoll_callback(self): # Not run under PROXY_TO_PTHREAD: the callback fires on the main-thread event # loop, which is not where the proxied application thread runs (use a blocking # epoll_wait from a pthread instead). - self.do_runf('sockets/test_epoll_callback.c', 'done', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + self.do_runf('sockets/test_epoll_callback.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) @also_with_proxy_to_pthread def test_noderawsockets_udp_connect(self): # 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']) + self.do_runf('sockets/test_udp_connect.c', 'done\n', cflags=['-sNODERAWSOCKETS']) @also_with_proxy_to_pthread def test_noderawsockets_udp_sockopts(self): From ad5001978c6f9f57929b8dff7a5a2c6627ff1077 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 8 Jul 2026 12:07:26 -0700 Subject: [PATCH 3/7] Deliver epoll callback on the registering thread under pthreads emscripten_epoll_set_callback is a sync-proxied syscall, so its body (and every readiness re-derivation) runs on the thread that owns the filesystem. Previously the callback also fired there, so under PROXY_TO_PTHREAD it never reached the application thread. Now the registering thread is captured and each delivery is back-proxied to it, while readiness stays tracked on the FS thread. - Track an `armed` count (registrations with a live listener, so a fired EPOLLONESHOT no longer counts) and key the keepalive on it: a set of only terminal registrations can never fire again and releases the runtime. - Hold the runtime keepalive on both threads while armed - the FS thread (runs the derivation) and the owner thread (runs the callback and must survive to receive it) - proxying push/pop to the owner via _emscripten_epoll_keepalive_on_thread. Only fires on empty<->populated threshold crossings, so no chatter under constant mutation. - Dispatch deliveries one-at-a-time via emscripten_proxy_callback: the owner drains inside the callback, so its completion (do_epoll_done -> _emscripten_epoll_delivery_done) paces the next derive, avoiding a level-triggered re-drain spin. A monotonic token re-finds the interest and drops stale completions whose interest was cleared mid-flight. - A replace from another thread hands ownership over cleanly (clear + reconcile before taking the new owner's keepalive). New system/lib/pthread/emscripten_epoll_callback.c carries the proxying helpers. test_noderawsockets_epoll_callback now also runs under PROXY_TO_PTHREAD. --- src/lib/libepoll.js | 162 +++++++++++++++--- src/lib/libsigs.js | 1 + system/include/emscripten/epoll.h | 6 +- system/lib/libc/emscripten_internal.h | 4 + .../lib/pthread/emscripten_epoll_callback.c | 100 +++++++++++ test/test_sockets.py | 6 +- tools/native_sigs.py | 2 + tools/system_libs.py | 1 + 8 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 system/lib/pthread/emscripten_epoll_callback.c diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 461600928a07a..e0498d439a91e 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -24,6 +24,12 @@ var EpollLibrary = { // (nesting) and carry the readiness wait-queue methods. node: new FS.FSNode(0, 'epoll', 0, 0), epoll: new Map(), + // Count of armed registrations: those with a live watched-node listener. A + // fired EPOLLONESHOT drops its listener (so it stops counting until a MOD + // re-arm), a plain registration counts until evicted. This - not the raw + // interest-map size - is what the callback keepalive keys on: a set with no + // armed registration can never fire again on its own, so it is terminal. + armed: 0, stream_ops: { // Readable when any listed registration is currently ready: this is what // lets an epoll fd be polled/nested. Walks only the ready list (O(ready)); @@ -68,18 +74,43 @@ var EpollLibrary = { ep.interest = null; it.listener.listeners.delete(it.listener.entry); _free(it.buf); +#if PTHREADS + // Retire its delivery token; a still-in-flight cross-thread delivery whose + // completion arrives after this finds nothing and is dropped. + if (it.token) delete epollDeliveries[it.token]; +#endif }, // A registered callback keeps the runtime alive only while it can still fire - - // i.e. while the epoll has at least one live registration. Once every watched - // fd is closed the set is terminal (it can never become ready again), so the + // i.e. while the epoll has at least one armed registration. Once every watched + // fd is closed (or every one-shot has fired) the set is terminal, so the // keepalive is dropped and the runtime may exit. Reconciled after any change to - // the callback or the registration count. + // the callback or the armed count. + // + // Two threads must be held while armed under pthread proxy: this (the FS/main + // thread) runs the readiness derivation and delivery scheduling, and the owner + // thread (ep.ownerThread) both runs the callback and must survive to receive + // it. So push/pop the local keepalive AND, for a distinct owner worker, proxy + // the same push/pop onto it. keepaliveThread records where the owner push + // landed so a later handover (a replace from another thread) pops the right one. $reconcileEpollKeepalive__internal: true, + $reconcileEpollKeepalive__deps: [ +#if PTHREADS + '_emscripten_epoll_keepalive_on_thread', +#endif + ], $reconcileEpollKeepalive: (ep) => { - var want = !!ep.interest && ep.epoll.size > 0; + var want = !!ep.interest && ep.armed > 0; if (want == !!ep.keepalive) return; ep.keepalive = want; +#if PTHREADS + // ownerThread is 0 when the FS thread itself registered - then the local + // push/pop below already covers it. + if (ep.ownerThread) { + if (want) __emscripten_epoll_keepalive_on_thread(ep.keepaliveThread = ep.ownerThread, 1); + else { __emscripten_epoll_keepalive_on_thread(ep.keepaliveThread, -1); ep.keepaliveThread = 0; } + } +#endif #if !MINIMAL_RUNTIME && (EXIT_RUNTIME || PTHREADS) if (want) { {{{ runtimeKeepalivePush() }}} } else { {{{ runtimeKeepalivePop() }}} } #endif @@ -121,7 +152,9 @@ var EpollLibrary = { $epollEvict__deps: ['$rdllistRemove', '$reconcileEpollKeepalive'], $epollEvict: (ep, reg) => { rdllistRemove(ep, reg); - reg.listener?.listeners.delete(reg.listener.entry); + // A fired EPOLLONESHOT already dropped its listener (and its armed count), so + // only decrement for a still-armed registration. + if (reg.listener) { reg.listener.listeners.delete(reg.listener.entry); ep.armed--; } ep.epoll.delete(reg.fd); reconcileEpollKeepalive(ep); }, @@ -201,10 +234,11 @@ var EpollLibrary = { reg.events = events; reg.dataLo = {{{ makeGetValue('ev', C_STRUCTS.epoll_event.data, 'i32') }}}; reg.dataHi = {{{ makeGetValue('ev', C_STRUCTS.epoll_event.data + 4, 'i32') }}}; - if (op == {{{ cDefs.EPOLL_CTL_ADD }}}) { ep.epoll.set(fd, reg); reconcileEpollKeepalive(ep); } + if (op == {{{ cDefs.EPOLL_CTL_ADD }}}) ep.epoll.set(fd, reg); // The registration's listener is its edge in the interest graph - present // only while armed, so a watched node fires nothing for a dead edge. ADD // installs it; a fired EPOLLONESHOT dropped it, so a MOD re-arm reinstalls it. + // Arming (re-)counts it toward the keepalive. // (ep_poll_callback: on an edge, list the reg and wake any waiter on this // epoll - and through ep.node any parent epoll nesting it.) if (!reg.listener) { @@ -214,6 +248,7 @@ var EpollLibrary = { // EPOLLEXCLUSIVE: when one fd is watched by several epolls, the watched // node wakes only one of them per edge (round-robin), not all. }, !!(events & {{{ cDefs.EPOLLEXCLUSIVE }}})); + ep.armed++; } // Arming is itself an event source (ep_insert/ep_modify): a source-based // model only learns readiness from edges, so sample the level now - the @@ -222,6 +257,8 @@ var EpollLibrary = { rdllistAdd(ep, reg); ep.node.notifyListeners({{{ cDefs.POLLIN }}}); } + // After arming (ADD or a MOD re-arm): the armed count may have changed. + reconcileEpollKeepalive(ep); return 0; }, @@ -233,8 +270,9 @@ var EpollLibrary = { // EPOLL_CTL_MOD; a no-longer-ready (spurious) edge is dropped; a closed/reused // fd is evicted. $doEpollWait__internal: true, - $doEpollWait__deps: ['$FS', '$pollOne', '$rdllistAdd', '$epollEvict'], + $doEpollWait__deps: ['$FS', '$pollOne', '$rdllistAdd', '$epollEvict', '$reconcileEpollKeepalive'], $doEpollWait: (ep, ev, maxevents) => { + var disarmed = false; // a fired EPOLLONESHOT dropped its armed count // Detach the list and drain from the head: re-armed level triggers and the // unprocessed remainder go back onto ep's now-empty list, so a single pass // never revisits an entry. O(delivered), not O(registered). @@ -262,9 +300,12 @@ var EpollLibrary = { n++; if (node.events & {{{ cDefs.EPOLLONESHOT }}}) { // Fired: a dead edge until EPOLL_CTL_MOD re-arms it, so drop its - // listener - the watched node stops poking it (no re-arm needed). + // listener - the watched node stops poking it (no re-arm needed) - and + // its armed count, so a set of only-fired one-shots reads as terminal. node.listener.listeners.delete(node.listener.entry); node.listener = null; + ep.armed--; + disarmed = true; } else if (!(node.events & {{{ cDefs.EPOLLET }}})) { rdllistAdd(ep, node); // level: re-list at tail } @@ -283,6 +324,9 @@ var EpollLibrary = { else ep.rdlTail = tail; ep.rdlHead = node; } + // A fired one-shot may have made the set terminal (no armed registration + // left); evictions above already reconciled themselves. + if (disarmed) reconcileEpollKeepalive(ep); return n; }, @@ -345,7 +389,11 @@ var EpollLibrary = { // The interest persists until replaced (call again), cleared (callback == NULL), // or the epoll fd is closed. It never suspends the stack, so it works without // ASYNCIFY/JSPI, and it keeps the runtime alive while armed. Returns 0 or -errno. - emscripten_epoll_set_callback__deps: ['$FS', '$doEpollWait', '$clearEpollInterest', '$reconcileEpollKeepalive', '$callUserCallback', 'malloc', 'free'], + emscripten_epoll_set_callback__deps: ['$FS', '$doEpollWait', '$clearEpollInterest', '$reconcileEpollKeepalive', '$callUserCallback', 'malloc', 'free', +#if PTHREADS + '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', +#endif + ], emscripten_epoll_set_callback__proxy: 'sync', emscripten_epoll_set_callback: (epfd, maxevents, callback, userdata) => { var ep = FS.getStream(epfd); @@ -354,17 +402,30 @@ var EpollLibrary = { // bad register call has no side effects (an unregister ignores it). if (callback && maxevents <= 0) return -{{{ cDefs.EINVAL }}}; +#if PTHREADS + // __proxy: 'sync' runs this (and every delivery) on the thread that owns the + // filesystem. When a worker registered the callback it must fire on that + // worker, not here, so capture the caller and back-proxy each delivery to it. + // Zero when the caller is the FS-owning thread itself - then deliver inline. + var callerThread = PThread.currentProxiedOperationCallerThread; +#endif + // Tear down any existing interest first - a second call replaces the - // callback, it does not stack. + // callback, it does not stack. Reconcile immediately: if a different thread + // owned the previous callback, this drops that thread's keepalive before we + // take ownership on the caller's thread below (a handover, not a no-op). clearEpollInterest(ep); - if (!callback) { - reconcileEpollKeepalive(ep); - return 0; - } + reconcileEpollKeepalive(ep); + if (!callback) return 0; // Runtime-owned output buffer reused across every delivery; freed at clear. var buf = _malloc(maxevents * {{{ C_STRUCTS.epoll_event.__size__ }}}); var it = ep.interest = {buf}; +#if PTHREADS + // The registering thread owns the callback and, once armed, its runtime + // keepalive. 0 is the FS thread itself (delivered inline, keepalive local). + ep.ownerThread = callerThread; +#endif // Producer notifies arrive synchronously (SOCKFS.emit, pipe writes); coalesce // them into one delivery on a microtask (the callback must not run in the // producer's/caller's stack), re-deriving the ready set at that tick. A @@ -373,31 +434,78 @@ var EpollLibrary = { // that waiter drains synchronously in the producer's stack, ahead of this // tick - but the tick may now run before vs after the waiter's async // resumption; that relative ordering is not guaranteed. + function deliver() { + if (ep.interest !== it) return; // cleared before the tick fired +#if PTHREADS + // One cross-thread delivery in flight at a time: the registering thread + // drains inside the callback, so re-deriving before it completes would just + // re-see (and re-deliver) the same still-ready level fd in a tight spin. + // The delivery's completion (do_epoll_done -> epoll_delivery_done) clears + // this and re-wakes. + if (it.inflight) return; +#endif + var c = doEpollWait(ep, buf, maxevents); + if (!c) return; +#if PTHREADS + if (callerThread) { + // The helper copies the events out synchronously, so buf is free to be + // reused; it also reports back on completion to pace the next delivery. + it.inflight = true; + __emscripten_epoll_run_callback_on_thread(callerThread, callback, epfd, buf, c, userdata, it.token); + return; + } +#endif + callUserCallback(() => {{{ makeDynCall('vipip', 'callback') }}}(epfd, buf, c, userdata)); + // Still ready (overflow past maxevents, or a still-ready level fd + // re-listed): keep draining on the next tick. Note this is NOT a + // blocking epoll_wait loop - there the app owns the loop and may block + // elsewhere. A level-triggered fd that is structurally always ready and + // never drained (e.g. EPOLLOUT on a writable socket) will re-schedule a + // microtask each tick and so starve the event loop; use EPOLLET or + // unregister for such fds. + if (ep.interest === it && ep.rdlHead) wake(); + } function wake() { if (it.scheduled) return; it.scheduled = true; queueMicrotask(() => { it.scheduled = false; - if (ep.interest !== it) return; // cleared before the tick fired - var c = doEpollWait(ep, buf, maxevents); - if (c) { - callUserCallback(() => {{{ makeDynCall('vipip', 'callback') }}}(epfd, buf, c, userdata)); - // Still ready (overflow past maxevents, or a still-ready level fd - // re-listed): keep draining on the next tick. Note this is NOT a - // blocking epoll_wait loop - there the app owns the loop and may block - // elsewhere. A level-triggered fd that is structurally always ready and - // never drained (e.g. EPOLLOUT on a writable socket) will re-schedule a - // microtask each tick and so starve the event loop; use EPOLLET or - // unregister for such fds. - if (ep.interest === it && ep.rdlHead) wake(); - } + deliver(); }); } +#if PTHREADS + // Resume point for a completed cross-thread delivery, keyed by token so the + // C completion can find this interest again. + if (callerThread) { + it.wake = wake; + it.token = epollDeliveries.nextToken++; + epollDeliveries[it.token] = it; + } +#endif it.listener = ep.node.addListener(wake); reconcileEpollKeepalive(ep); // hold the runtime only while there are live fds wake(); // deliver initial readiness if the set is already ready return 0; }, + +#if PTHREADS + // Token -> interest for cross-thread deliveries (numeric keys), plus nextToken: + // the next token to hand out. A monotonic token means a stale completion + // (interest cleared mid-flight) never resolves to a different interest - it + // simply finds nothing. + $epollDeliveries: {nextToken: 1}, + + // Called (on the FS-owning thread) by the C helper once a cross-thread delivery + // finishes on the registering thread: clear the in-flight gate and re-derive, + // so a still-ready set delivers its next batch. + _emscripten_epoll_delivery_done__deps: ['$epollDeliveries'], + _emscripten_epoll_delivery_done: (token) => { + var it = epollDeliveries[token]; + if (!it) return; // interest was cleared while the delivery was in flight + it.inflight = false; + it.wake(); + }, +#endif }; addToLibrary(EpollLibrary); diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index b4bd6bfae8e02..27ee067db4814 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -325,6 +325,7 @@ sigs = { _emscripten_create_wasm_worker__sig: 'iipip', _emscripten_dlopen_js__sig: 'vpppp', _emscripten_dlsync_threads__sig: 'v', + _emscripten_epoll_delivery_done__sig: 'vi', _emscripten_fetch_get_response_headers__sig: 'pipp', _emscripten_fetch_get_response_headers_length__sig: 'pi', _emscripten_fs_load_embedded_files__sig: 'vp', diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h index b87f5ce5cbd62..1af40ab884d5c 100644 --- a/system/include/emscripten/epoll.h +++ b/system/include/emscripten/epoll.h @@ -22,8 +22,10 @@ extern "C" { // ready events. An epoll is a long-lived readiness aggregator, so the interest is // armed once and reused across every delivery - no per-spin re-arming. Unlike // epoll_wait it never blocks the calling stack, so it works without ASYNCIFY/JSPI. -// The callback is delivered on the main thread's event loop; under -// PROXY_TO_PTHREAD use a blocking epoll_wait from the pthread instead. +// The callback is delivered on the calling thread's event loop: with pthreads the +// epoll readiness is tracked on the thread that owns the filesystem (the syscalls +// are proxied there), but each delivery is dispatched back to the thread that +// registered the callback. // // While armed it keeps the runtime alive only as long as it can still fire - i.e. // while the epoll has at least one open watched fd. Once every watched fd is diff --git a/system/lib/libc/emscripten_internal.h b/system/lib/libc/emscripten_internal.h index 55ca0fa09dc2a..a2f77c31a3c85 100644 --- a/system/lib/libc/emscripten_internal.h +++ b/system/lib/libc/emscripten_internal.h @@ -67,6 +67,10 @@ emscripten_stack_unwind_buffer(uintptr_t pc, uintptr_t* buffer, uint32_t depth); bool _emscripten_get_now_is_monotonic(void); +// Defined in library.js; called by emscripten_epoll_callback.c to report a +// completed cross-thread epoll callback delivery back to the FS-owning thread. +void _emscripten_epoll_delivery_done(int token); + void _emscripten_get_progname(char*, int); // Not defined in musl, but defined in library.js. Included here for diff --git a/system/lib/pthread/emscripten_epoll_callback.c b/system/lib/pthread/emscripten_epoll_callback.c new file mode 100644 index 0000000000000..56797dada9de4 --- /dev/null +++ b/system/lib/pthread/emscripten_epoll_callback.c @@ -0,0 +1,100 @@ +/* + * 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. + */ + +// Backs emscripten_epoll_set_callback under PTHREADS: the epoll readiness lives +// on the thread that owns the filesystem (the epoll syscalls are proxied +// there), but the user callback must run on the thread that registered it. This +// mirrors _emscripten_run_callback_on_thread in html5/callback.c, but carries +// the epoll callback's argument shape (epfd, events, nready, userdata) and, +// crucially, reports back to the registering thread when a delivery completes +// so it can pace the next one - a level-triggered fd stays ready until the +// callback drains it, so the FS thread must wait for that drain before +// re-deriving, or it would spin re-delivering the same readiness. + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "emscripten_internal.h" + +typedef void (*em_epoll_callback)(int epfd, + struct epoll_event* events, + int nready, + void* userdata); + +typedef struct epoll_callback_args_t { + em_epoll_callback callback; + int epfd; + int nready; + void* userdata; + int token; + struct epoll_event events[]; +} epoll_callback_args_t; + +// Runs on the registering thread: deliver the ready set to the user callback. +static void do_epoll_callback(void* arg) { + epoll_callback_args_t* args = (epoll_callback_args_t*)arg; + args->callback(args->epfd, args->events, args->nready, args->userdata); +} + +// Runs back on the FS-owning thread once the delivery above has finished (or +// was cancelled because the target thread went away): let the JS layer +// re-derive. +static void do_epoll_done(void* arg) { + epoll_callback_args_t* args = (epoll_callback_args_t*)arg; + _emscripten_epoll_delivery_done(args->token); + free(arg); +} + +void _emscripten_epoll_run_callback_on_thread(pthread_t t, + em_epoll_callback callback, + int epfd, + struct epoll_event* events, + int nready, + void* userdata, + int token) { + em_proxying_queue* q = emscripten_proxy_get_system_queue(); + // Copy the events out synchronously so the caller's (reused) buffer is free + // again the moment this returns; freed once the delivery completes. + size_t bytes = nready * sizeof(struct epoll_event); + epoll_callback_args_t* args = malloc(sizeof(epoll_callback_args_t) + bytes); + args->callback = callback; + args->epfd = epfd; + args->nready = nready; + args->userdata = userdata; + args->token = token; + memcpy(args->events, events, bytes); + + if (!emscripten_proxy_callback( + q, t, do_epoll_callback, do_epoll_done, do_epoll_done, args)) { + assert(false && "emscripten_proxy_callback failed"); + } +} + +// Runs on the owning thread: adjust its (thread-local) runtime keepalive so the +// epoll callback holds the thread it was registered on, not the FS thread. +static void do_epoll_keepalive(void* arg) { + if ((intptr_t)arg > 0) { + emscripten_runtime_keepalive_push(); + } else { + emscripten_runtime_keepalive_pop(); + } +} + +void _emscripten_epoll_keepalive_on_thread(pthread_t t, int delta) { + em_proxying_queue* q = emscripten_proxy_get_system_queue(); + if (!emscripten_proxy_async( + q, t, do_epoll_keepalive, (void*)(intptr_t)delta)) { + assert(false && "emscripten_proxy_async failed"); + } +} diff --git a/test/test_sockets.py b/test/test_sockets.py index f37639cc4d976..a044ca8db4be4 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -539,12 +539,12 @@ def test_noderawsockets_udp(self): # ephemeral port, the client sends a datagram, the server echoes it back. self.do_runf('sockets/test_udp_echo.c', 'done\n', cflags=['-sNODERAWSOCKETS']) + @also_with_proxy_to_pthread def test_noderawsockets_epoll_callback(self): # emscripten_epoll_set_callback woken repeatedly by arriving datagrams on a # real socket via the SOCKFS -> wait-queue bridge, with no ASYNCIFY/JSPI. - # Not run under PROXY_TO_PTHREAD: the callback fires on the main-thread event - # loop, which is not where the proxied application thread runs (use a blocking - # epoll_wait from a pthread instead). + # Under PROXY_TO_PTHREAD the readiness is tracked on the FS-owning main thread + # but each delivery is back-proxied to the registering pthread. self.do_runf('sockets/test_epoll_callback.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) @also_with_proxy_to_pthread diff --git a/tools/native_sigs.py b/tools/native_sigs.py index 31a3489887145..e934f04527b0c 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -532,6 +532,8 @@ '__year_to_secs': '__p', '_embind_register_bindings': '_p', '_emscripten_dlsync_self_async': '_p', + '_emscripten_epoll_keepalive_on_thread': '_p_', + '_emscripten_epoll_run_callback_on_thread': '_pp_p_p_', '_emscripten_find_dylib': 'ppppp', '_emscripten_memcpy_bulkmem': 'pppp', '_emscripten_memset_bulkmem': 'pp_p', diff --git a/tools/system_libs.py b/tools/system_libs.py index 037b01fab3f14..62401e55ebb70 100644 --- a/tools/system_libs.py +++ b/tools/system_libs.py @@ -1216,6 +1216,7 @@ def get_files(self): 'em_task_queue.c', 'proxying.c', 'proxying_legacy.c', + 'emscripten_epoll_callback.c', 'thread_mailbox.c', 'pthread_create.c', 'pthread_kill.c', From 0ce4df9438fde14f15a5ba9c801d3d0e9e9bde9c Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 8 Jul 2026 14:10:56 -0700 Subject: [PATCH 4/7] Share epoll instance state across dup'd fds dup(2) of an epoll fd must yield another reference to the same epoll instance (Linux eventpoll semantics): registrations, the ready list, and the persistent readiness callback are all shared across every fd. This is how tokio's single-threaded JSPI reactor drives the epoll - it arms emscripten_epoll_set_callback on one fd and routes epoll_ctl(ADD) through a dup of it. FS.dupStream shallow-copies the stream, so the reassigned scalars (rdlHead/rdlTail, armed, keepalive, interest) diverged between the two handles while the interest map and node stayed shared. A registration added via the dup appended to the dup's ready list, but the callback armed on the original drained the original's (always-empty) list, so it delivered 0 events forever and the reactor deadlocked. Hoist the instance state onto the stream's shared open-file-description object, which dup already propagates by reference, so every fd observes one instance. Add a refcounted dup/close so only the last close reclaims the instance. Self-watch and nesting checks now key on the instance rather than the fd. --- src/lib/libepoll.js | 153 ++++++++++++++++++++++--------------- src/lib/libsyscall.js | 12 +-- test/core/test_epoll_dup.c | 69 +++++++++++++++++ test/test_other.py | 6 ++ 4 files changed, 173 insertions(+), 67 deletions(-) create mode 100644 test/core/test_epoll_dup.c diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index e0498d439a91e..ce212ac7212ba 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -10,58 +10,82 @@ // derivation ($pollOne) defined in libsyscall.js. var EpollLibrary = { - // An epoll instance is a real FS fd whose stream carries an interest map - // `epoll` (fd -> reg) and a ready list (rdlHead/rdlTail). Each registration - // arms a persistent listener on the watched node's wait-queue at EPOLL_CTL_ADD - // (not per-wait), feeding the ready list on each edge so readiness can be - // tracked across waits and up a nesting chain. Being an fd, close(2) reclaims - // it (tearing every registration down) and it can itself be added to another - // epoll. + // An epoll instance's state lives on the stream's `shared` object - the open + // file description (Linux's struct file / eventpoll) that dup'd fds share. It + // carries an interest map `epoll` (fd -> reg) and a ready list + // (rdlHead/rdlTail). Each registration arms a persistent listener on the + // watched node's wait-queue at EPOLL_CTL_ADD (not per-wait), feeding the ready + // list on each edge so readiness can be tracked across waits and up a nesting + // chain. dup(2) yields another fd to the SAME instance (registrations, ready + // list, and callback all shared); close(2) drops one reference and only the + // last close reclaims it (tearing every registration down). An epoll fd can + // itself be added to another epoll. $newEpollInstance__internal: true, $newEpollInstance__deps: ['$FS', '$pollOne', '$clearEpollInterest', '$reconcileEpollKeepalive', '$epollEvict'], - $newEpollInstance: () => FS.createStream({ + $newEpollInstance: () => { // Its own (detached) node, so the epoll fd can be watched by a parent epoll - // (nesting) and carry the readiness wait-queue methods. - node: new FS.FSNode(0, 'epoll', 0, 0), - epoll: new Map(), - // Count of armed registrations: those with a live watched-node listener. A - // fired EPOLLONESHOT drops its listener (so it stops counting until a MOD - // re-arm), a plain registration counts until evicted. This - not the raw - // interest-map size - is what the callback keepalive keys on: a set with no - // armed registration can never fire again on its own, so it is terminal. - armed: 0, - stream_ops: { - // Readable when any listed registration is currently ready: this is what - // lets an epoll fd be polled/nested. Walks only the ready list (O(ready)); - // edge/oneshot/exclusive are reporting-time concerns, masked out here. A - // closed/reused fd is evicted here too, so a nested epoll that is only ever - // polled (never directly waited) does not accumulate dead registrations. - poll(stream) { - for (var reg = stream.rdlHead, next; reg; reg = next) { - next = reg.rdlNext; - if (FS.getStream(reg.fd)?.shared !== reg.shared) { epollEvict(stream, reg); continue; } - if (pollOne(reg.fd, reg.events & ~{{{ cDefs.EPOLLET | cDefs.EPOLLONESHOT | cDefs.EPOLLEXCLUSIVE }}})) { - return {{{ cDefs.POLLIN }}}; + // (nesting) and carry the readiness wait-queue methods. Shared across dups. + var node = new FS.FSNode(0, 'epoll', 0, 0); + var stream = FS.createStream({ + node, + stream_ops: { + // Readable when any listed registration is currently ready: this is what + // lets an epoll fd be polled/nested. Walks only the ready list (O(ready)); + // edge/oneshot/exclusive are reporting-time concerns, masked out here. A + // closed/reused fd is evicted here too, so a nested epoll that is only ever + // polled (never directly waited) does not accumulate dead registrations. + poll(stream) { + var ep = stream.shared; + for (var reg = ep.rdlHead, next; reg; reg = next) { + next = reg.rdlNext; + if (FS.getStream(reg.fd)?.shared !== reg.shared) { epollEvict(ep, reg); continue; } + if (pollOne(reg.fd, reg.events & ~{{{ cDefs.EPOLLET | cDefs.EPOLLONESHOT | cDefs.EPOLLEXCLUSIVE }}})) { + return {{{ cDefs.POLLIN }}}; + } } - } - return 0; - }, - // close(2): drop the readiness callback interest (if any), then every - // registration's listener (a fired EPOLLONESHOT has already dropped its - // own) from its watched node. - close(stream) { - // FS.close already fires POLLNVAL on this node, waking any parent epoll - // watching this epoll fd so it re-derives and drops the now-stale - // registration (via doEpollWait's shared check). - clearEpollInterest(stream); - reconcileEpollKeepalive(stream); // drop the keepalive if it was held - for (var reg of stream.epoll.values()) { - reg.listener?.listeners.delete(reg.listener.entry); - } - stream.epoll.clear(); + return 0; + }, + // dup(2): another fd to the same epoll instance (Linux: another reference + // to the eventpoll). The instance state lives on the shared open file + // description, already propagated by reference to the dup'd stream, so + // there is nothing to copy - just count the new reference. + dup(stream) { + stream.shared.refcount++; + }, + // close(2): drop one reference. Only the last close reclaims the + // instance: clear the readiness callback interest (if any), then drop + // every registration's listener (a fired EPOLLONESHOT has already dropped + // its own) from its watched node. A surviving dup keeps it all live. + close(stream) { + var ep = stream.shared; + // FS.close already fired POLLNVAL on the (shared) node, waking any + // parent epoll watching this fd so it re-derives and drops the + // now-stale registration (via doEpollWait's shared check). + if (--ep.refcount) return; + clearEpollInterest(ep); + reconcileEpollKeepalive(ep); // drop the keepalive if it was held + for (var reg of ep.epoll.values()) { + reg.listener?.listeners.delete(reg.listener.entry); + } + ep.epoll.clear(); + }, }, - }, - }), + }); + // Hoist the instance state onto `shared` so every dup observes one instance. + Object.assign(stream.shared, { + node, + epoll: new Map(), + // Count of armed registrations: those with a live watched-node listener. A + // fired EPOLLONESHOT drops its listener (so it stops counting until a MOD + // re-arm), a plain registration counts until evicted. This - not the raw + // interest-map size - is what the callback keepalive keys on: a set with no + // armed registration can never fire again on its own, so it is terminal. + armed: 0, + // Open references (fds) to this instance; the last close reclaims it. + refcount: 1, + }); + return stream; + }, // Drop an epoll's persistent readiness callback interest: remove its listener // on the epoll node and free the output buffer. Keepalive is managed by the @@ -170,8 +194,8 @@ var EpollLibrary = { if (op != {{{ cDefs.EPOLL_CTL_ADD }}} && op != {{{ cDefs.EPOLL_CTL_MOD }}} && op != {{{ cDefs.EPOLL_CTL_DEL }}}) { return -{{{ cDefs.EINVAL }}}; } - // An epoll cannot watch itself. - if (fd == ep.fd) return -{{{ cDefs.EINVAL }}}; + // An epoll cannot watch itself (via any fd referring to the same instance). + if (target.shared === ep) return -{{{ cDefs.EINVAL }}}; // A registration keys on the open file description (stream.shared) - the // struct-file analog that dup'd fds share. If this fd's number now resolves @@ -199,22 +223,26 @@ var EpollLibrary = { if (!target.stream_ops?.poll) return -{{{ cDefs.EPERM }}}; // Nesting another epoll: reject cycles, and chains deeper than 5 levels of // epoll (ELOOP) - the Linux cap is EP_MAX_NESTS (4) plus the leaf level. - if (target.epoll) { + if (target.shared.epoll) { + // Walk streams but key the graph on instances (stream.shared), so dup'd + // fds of one epoll count as a single node. var reaches = (from, goal, seen) => { - if (from === goal) return true; - if (!from?.epoll || seen.has(from)) return false; - seen.add(from); - for (var f of from.epoll.keys()) { + var inst = from?.shared; + if (inst === goal) return true; + if (!inst?.epoll || seen.has(inst)) return false; + seen.add(inst); + for (var f of inst.epoll.keys()) { if (reaches(FS.getStream(f), goal, seen)) return true; } return false; }; - var depth = (s, seen) => { - if (!s?.epoll || seen.has(s)) return 0; - seen.add(s); + var depth = (from, seen) => { + var inst = from?.shared; + if (!inst?.epoll || seen.has(inst)) return 0; + seen.add(inst); var max = 0; - for (var f of s.epoll.keys()) max = Math.max(max, depth(FS.getStream(f), seen)); - seen.delete(s); + for (var f of inst.epoll.keys()) max = Math.max(max, depth(FS.getStream(f), seen)); + seen.delete(inst); return 1 + max; }; if (reaches(target, ep, new Set()) || 1 + depth(target, new Set()) > 5) { @@ -396,8 +424,11 @@ var EpollLibrary = { ], emscripten_epoll_set_callback__proxy: 'sync', emscripten_epoll_set_callback: (epfd, maxevents, callback, userdata) => { - var ep = FS.getStream(epfd); - if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; + var stream = FS.getStream(epfd); + if (!stream?.shared.epoll) return -{{{ cDefs.EBADF }}}; + // Operate on the shared instance so a callback armed on one fd sees + // registrations made through any dup of it. + var ep = stream.shared; // maxevents only matters when (re-)arming; validate before any mutation so a // bad register call has no side effects (an unregister ignores it). if (callback && maxevents <= 0) return -{{{ cDefs.EINVAL }}}; diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index 403cb9bd990cf..f30d85ebfece8 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -706,17 +706,17 @@ var SyscallsLibrary = { __syscall_epoll_ctl__proxy: 'sync', __syscall_epoll_ctl: (epfd, op, fd, ev) => { var ep = FS.getStream(epfd); - if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; - return epollCtl(ep, op, fd, ev); + if (!ep?.shared.epoll) return -{{{ cDefs.EBADF }}}; + return epollCtl(ep.shared, op, fd, ev); }, __syscall_epoll_pwait__proxy: 'sync', __syscall_epoll_pwait__async: 'auto', __syscall_epoll_pwait__deps: ['$FS', '$epollPwait'], __syscall_epoll_pwait: (epfd, ev, maxevents, timeout, sigmask, sigsetsize) => { var ep = FS.getStream(epfd); - if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; + if (!ep?.shared.epoll) return -{{{ cDefs.EBADF }}}; if (maxevents <= 0) return -{{{ cDefs.EINVAL }}}; - return epollPwait(ep, ev, maxevents, timeout); + return epollPwait(ep.shared, ev, maxevents, timeout); }, // libc routes zero-timeout epoll_wait()/epoll_pwait() calls here: a plain // import that never suspends, so probes stay callable from any context (under @@ -727,9 +727,9 @@ var SyscallsLibrary = { __syscall_epoll_pwait_nonblocking__deps: ['$FS', '$doEpollWait'], __syscall_epoll_pwait_nonblocking: (epfd, ev, maxevents) => { var ep = FS.getStream(epfd); - if (!ep?.epoll) return -{{{ cDefs.EBADF }}}; + if (!ep?.shared.epoll) return -{{{ cDefs.EBADF }}}; if (maxevents <= 0) return -{{{ cDefs.EINVAL }}}; - return doEpollWait(ep, ev, maxevents); + return doEpollWait(ep.shared, ev, maxevents); }, __syscall_getcwd__deps: ['$lengthBytesUTF8', '$stringToUTF8'], __syscall_getcwd: (buf, size) => { diff --git a/test/core/test_epoll_dup.c b/test/core/test_epoll_dup.c new file mode 100644 index 0000000000000..4e98714356417 --- /dev/null +++ b/test/core/test_epoll_dup.c @@ -0,0 +1,69 @@ +/* + * 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. + * + * dup(2) of an epoll fd yields another reference to the SAME epoll instance + * (Linux eventpoll semantics): registrations, the ready list, and the persistent + * readiness callback are all shared across every fd. This mirrors tokio's + * single-threaded reactor, which arms emscripten_epoll_set_callback on one fd + * and drives epoll_ctl(ADD) through a dup of it. + * - A registration added via the dup must be delivered to a callback armed on + * the original fd. + * - Closing one dup must NOT tear the instance down while another fd is open; + * only the last close reclaims it. + */ + +#include +#include +#include +#include +#include +#include + +static int ep_a, ep_b, rfd, wfd; +static int fires; + +static void on_ready(int epfd, struct epoll_event* events, int nready, void* ud) { + assert(epfd == ep_a); + assert(nready == 1); + assert(events[0].events & EPOLLIN); + assert(events[0].data.u32 == 0x1234); + fires++; + + char b[1]; + assert(read(rfd, b, 1) == 1); + assert(emscripten_epoll_set_callback(ep_a, 4, NULL, NULL) == 0); + printf("done\n"); +} + +int main(void) { + ep_a = epoll_create1(0); + + // Arm the persistent callback on the original fd. + assert(emscripten_epoll_set_callback(ep_a, 4, on_ready, NULL) == 0); + + // dup: a second fd to the SAME epoll instance (like tokio's registry handle). + ep_b = dup(ep_a); + assert(ep_b >= 0 && ep_b != ep_a); + + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + + // Register through the dup. This must be visible to the callback armed on + // ep_a, since both fds share one epoll instance. + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.u32 = 0x1234; + assert(epoll_ctl(ep_b, EPOLL_CTL_ADD, rfd, &ev) == 0); + + // Closing one dup must not tear the instance down: the registration added via + // ep_b stays live and the callback on ep_a keeps working. + assert(close(ep_b) == 0); + + // Make rfd readable. The edge must reach ep_a's callback. + assert(write(wfd, "x", 1) == 1); + return 0; +} diff --git a/test/test_other.py b/test/test_other.py index 494e749a502cf..8f5ade4c7938b 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13320,6 +13320,12 @@ def test_epoll_callback(self): # persistent callback with no blocking and no ASYNCIFY/JSPI. self.do_runf('core/test_epoll_callback.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + def test_epoll_callback_dup(self): + # dup(2) of an epoll fd shares one instance: a registration added via the dup + # is delivered to a callback armed on the original fd, and closing one dup + # does not tear the instance down. + self.do_runf('core/test_epoll_dup.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + def test_epoll_callback_overflow(self): # maxevents < ready count: the callback re-triggers to drain the remainder # across ticks (no app loop to re-call it). From 3d7ef1b40aaef42823c90ef161b3ba9bfb1755fd Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 8 Jul 2026 14:12:38 -0700 Subject: [PATCH 5/7] dropped pr review --- ChangeLog.md | 20 ++++++++----------- src/lib/libepoll.js | 11 +++++++--- src/modules.mjs | 2 +- system/include/emscripten/epoll.h | 2 +- .../test_codesize_hello_dylink_all.json | 4 ++-- test/core/test_epoll_callback_replace.c | 4 ++-- 6 files changed, 22 insertions(+), 21 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index d50b5eaf40842..0b52cae32c115 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -34,6 +34,14 @@ See docs/process.md for more on how version tagging works. FS-backend handler signature changed from `poll(stream, timeout)` to `poll(stream)` returning the current readiness mask; out-of-tree custom FS backends with a `poll` handler must update. (#27226) +- Added support for `epoll` (`epoll_create1`/`epoll_ctl`/`epoll_wait`/ + `epoll_pwait`) on the legacy (non-WASMFS) JS filesystem, including + level- and edge-triggered modes, `EPOLLONESHOT`, `EPOLLEXCLUSIVE`, + `EPOLLRDHUP`, nesting, and blocking waits under `PROXY_TO_PTHREAD`, + `ASYNCIFY`, and `JSPI`. Also added `emscripten_epoll_set_callback` + (in the new ``, experimental), a non-blocking variant + that delivers an epoll set's readiness to a JS callback with no + `ASYNCIFY`/`JSPI`. (#27207) - compiler-rt and libunwind were updated to LLVM 22.1.8. (#27245, #27246) - `-fcoverage-mapping` is currently broken due to a mismatch between the version of LLVM used and the imported version of compiler-rt. We hope to fix this @@ -49,18 +57,6 @@ See docs/process.md for more on how version tagging works. feature will be used when available. Note that this only affects programs that are built with `ALLOW_MEMORY_GROWTH`, which is not enabled by default. (#27096, #27212) -- The `GROWABLE_ARRAYBUFFERS` setting now defaults to 1, which means it will be - used when available. Note that this only affects programs that are built with - `ALLOW_MEMORY_GROWTH`, which is not enabled by default. (#27212) - backends with a `poll` handler must update. (#27207) -- Added support for `epoll` (`epoll_create1`/`epoll_ctl`/`epoll_wait`/ - `epoll_pwait`) on the legacy (non-WASMFS) JS filesystem, including - level- and edge-triggered modes, `EPOLLONESHOT`, `EPOLLEXCLUSIVE`, - `EPOLLRDHUP`, nesting, and blocking waits under `PROXY_TO_PTHREAD`, - `ASYNCIFY`, and `JSPI`. Also added `emscripten_epoll_set_callback` - (in the new ``, experimental), a non-blocking variant - that delivers an epoll set's readiness to a JS callback with no - `ASYNCIFY`/`JSPI`. (#27207) - New `-sNODERAWSOCKETS` setting that backs the POSIX sockets API with real TCP (`node:net`) and UDP (`node:dgram`) sockets on Node.js, with no `ws`, proxy process, or pthreads required. Supports incoming and outgoing TCP, UDP, IPv6, diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index ce212ac7212ba..350d46ae60ac8 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -38,7 +38,10 @@ var EpollLibrary = { var ep = stream.shared; for (var reg = ep.rdlHead, next; reg; reg = next) { next = reg.rdlNext; - if (FS.getStream(reg.fd)?.shared !== reg.shared) { epollEvict(ep, reg); continue; } + if (FS.getStream(reg.fd)?.shared !== reg.shared) { + epollEvict(ep, reg); + continue; + } if (pollOne(reg.fd, reg.events & ~{{{ cDefs.EPOLLET | cDefs.EPOLLONESHOT | cDefs.EPOLLEXCLUSIVE }}})) { return {{{ cDefs.POLLIN }}}; } @@ -425,13 +428,15 @@ var EpollLibrary = { emscripten_epoll_set_callback__proxy: 'sync', emscripten_epoll_set_callback: (epfd, maxevents, callback, userdata) => { var stream = FS.getStream(epfd); - if (!stream?.shared.epoll) return -{{{ cDefs.EBADF }}}; + // This is a direct public API (not a syscall), so it returns a positive + // errno rather than the -errno syscall convention. + if (!stream?.shared.epoll) return {{{ cDefs.EBADF }}}; // Operate on the shared instance so a callback armed on one fd sees // registrations made through any dup of it. var ep = stream.shared; // maxevents only matters when (re-)arming; validate before any mutation so a // bad register call has no side effects (an unregister ignores it). - if (callback && maxevents <= 0) return -{{{ cDefs.EINVAL }}}; + if (callback && maxevents <= 0) return {{{ cDefs.EINVAL }}}; #if PTHREADS // __proxy: 'sync' runs this (and every delivery) on the thread that owns the diff --git a/src/modules.mjs b/src/modules.mjs index 83a7bde92f3a2..ec20f9016b355 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -88,7 +88,6 @@ function calculateLibraries() { if (!WASMFS) { libraries.push('libsyscall.js'); - libraries.push('libepoll.js'); } if (MAIN_MODULE) { @@ -114,6 +113,7 @@ function calculateLibraries() { 'libtty.js', 'libpipefs.js', // ok to include it by default since it's only used if the syscall is used 'libsockfs.js', // ok to include it by default since it's only used if the syscall is used + 'libepoll.js', // ok to include it by default since it's only used if the syscall is used ); if (NODERAWSOCKETS) { diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h index 1af40ab884d5c..da09af74609db 100644 --- a/system/include/emscripten/epoll.h +++ b/system/include/emscripten/epoll.h @@ -35,7 +35,7 @@ extern "C" { // `maxevents`) to unregister, or close the epoll fd. There is at most one // callback per epoll: calling again replaces it (it does not stack). `events` is // a runtime-owned buffer valid only for the duration of each callback. Returns 0, -// or -errno (-EBADF if `epfd` is not an epoll fd, -EINVAL). +// or a positive errno (EBADF if `epfd` is not an epoll fd, EINVAL). // // Each registration's trigger mode (set per-fd via epoll_ctl) controls how often // the callback fires for it - identically to epoll_wait, so one callback can mix diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 3d6ccd52124c5..d6cb263da95b6 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": 271668, + "a.out.js": 271951, "a.out.nodebug.wasm": 588038, - "total": 859706, + "total": 859989, "sent": [ "IMG_Init", "IMG_Load", diff --git a/test/core/test_epoll_callback_replace.c b/test/core/test_epoll_callback_replace.c index c60a7cdd0ad91..075c53bc48d80 100644 --- a/test/core/test_epoll_callback_replace.c +++ b/test/core/test_epoll_callback_replace.c @@ -45,8 +45,8 @@ int main(void) { ev.data.fd = rfd; assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd, &ev) == 0); - // A non-epoll fd is rejected with -EBADF. - assert(emscripten_epoll_set_callback(rfd, 4, cb1, 0) == -EBADF); + // A non-epoll fd is rejected with a positive EBADF. + assert(emscripten_epoll_set_callback(rfd, 4, cb1, 0) == EBADF); // Register then immediately replace, before any tick runs: only cb2 is armed. assert(emscripten_epoll_set_callback(ep, 4, cb1, 0) == 0); From 39ea34389385445aaeaa2c0c404673d2c17392e5 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 7 Jul 2026 18:26:51 -0700 Subject: [PATCH 6/7] Support blocking socket calls under pthreads and JSPI Implements a unified blocking layer via `$streamWaitOp` in libfs.js: it attempts a stream op once, and if it would-block, registers a listener on that inode's readiness wait-queue and suspends. The wake comes only from that readiness event - no polling or spin loop. The socket syscalls move from libsyscall.js into libsockfs.js. connect, accept4, recvfrom, sendto, sendmsg and recvmsg gain __async:'auto' and delegate to $streamWaitOp; accept4 also applies SOCK_NONBLOCK/SOCK_CLOEXEC to the new fd. fd_read and fd_write route through $streamWaitOp too, so a blocking read/write on a socket or pipe suspends until the inode signals readiness. This is gated on ASYNCIFY || PTHREADS: a purely-synchronous build keeps the direct doReadv/doWritev path byte-for-byte, so hello-world code size is unchanged. A blocking socket call may suspend the wasm stack, so a callback scheduled onto the JS event loop may itself need to suspend when it makes one. Add emscripten_async_call_promising, a variant of emscripten_async_call whose callback is dispatched through the existing promising dyncall path (makeDynCall's promising flag, as used by the pthread thread-entry) so it can suspend under JSPI. Without JSPI it is identical to emscripten_async_call. Tested via blocking-only loopback echo and epoll tests under PROXY_TO_PTHREAD and JSPI. --- ChangeLog.md | 9 + src/lib/libeventloop.js | 20 +- src/lib/libfs.js | 58 ++++ src/lib/libsockfs.js | 326 ++++++++++++++++++ src/lib/libsyscall.js | 241 +------------ src/lib/libwasi.js | 42 ++- src/struct_info.json | 1 + src/struct_info_generated.json | 1 + src/struct_info_generated_wasm64.json | 1 + system/include/emscripten/emscripten.h | 5 + test/codesize/test_codesize_hello_O0.json | 4 +- .../test_codesize_hello_dylink_all.json | 5 +- .../test_codesize_minimal_O0.expected.js | 4 +- test/codesize/test_codesize_minimal_O0.json | 4 +- test/codesize/test_unoptimized_code_size.json | 6 +- test/sockets/test_epoll_socket_blocking.c | 2 +- test/sockets/test_tcp_blocking_echo.c | 76 ++++ test/test_sockets.py | 15 + 18 files changed, 566 insertions(+), 254 deletions(-) create mode 100644 test/sockets/test_tcp_blocking_echo.c diff --git a/ChangeLog.md b/ChangeLog.md index 0b52cae32c115..02e85bd154db6 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -62,6 +62,15 @@ See docs/process.md for more on how version tagging works. process, or pthreads required. Supports incoming and outgoing TCP, UDP, IPv6, and `-pthread` with `PROXY_TO_PTHREAD`. Uses the public node APIs where available, falling back to `tcp_wrap`/`udp_wrap` on older Node.js. (#27080) +- The socket data syscalls (`connect`, `accept`, `recv*`, `send*`) on the legacy + (non-WASMFS) JS filesystem now block on the per-inode readiness wait-queue + rather than always returning `EAGAIN`, so blocking sockets work under + `PROXY_TO_PTHREAD`, `ASYNCIFY`, and `JSPI` (purely-synchronous builds keep the + old immediate-`EAGAIN` behaviour). `MSG_DONTWAIT` is honored for a single + non-blocking `recv`/`send`. (#NNNNN) +- Added `emscripten_async_call_promising`, a variant of `emscripten_async_call` + whose callback may suspend the wasm stack (e.g. perform blocking calls) under + `JSPI`. Without `JSPI` it is identical to `emscripten_async_call`. (#NNNNN) - The following symbols are no longer included in `INCOMING_MODULE_JS_API` by default: - GL_MAX_TEXTURE_IMAGE_UNITS diff --git a/src/lib/libeventloop.js b/src/lib/libeventloop.js index c024e755a101d..d6c65700ebc1d 100644 --- a/src/lib/libeventloop.js +++ b/src/lib/libeventloop.js @@ -170,10 +170,9 @@ LibraryJSEventLoop = { clearInterval(id); }, - emscripten_async_call__deps: ['$safeSetTimeout', '$safeRequestAnimationFrame'], - emscripten_async_call: (func, arg, millis) => { - var wrapper = () => {{{ makeDynCall('vp', 'func') }}}(arg); - + $scheduleAsyncCall__internal: true, + $scheduleAsyncCall__deps: ['$safeSetTimeout', '$safeRequestAnimationFrame'], + $scheduleAsyncCall: (wrapper, millis) => { if (millis >= 0 #if ENVIRONMENT_MAY_BE_NODE // node does not support requestAnimationFrame @@ -186,6 +185,19 @@ LibraryJSEventLoop = { } }, + emscripten_async_call__deps: ['$scheduleAsyncCall'], + emscripten_async_call: (func, arg, millis) => { + scheduleAsyncCall(() => {{{ makeDynCall('vp', 'func') }}}(arg), millis); + }, + + // Like emscripten_async_call, but the callback is dispatched so it may suspend + // the wasm stack (e.g. perform blocking calls) under JSPI. Without JSPI this + // is identical to emscripten_async_call. + emscripten_async_call_promising__deps: ['$scheduleAsyncCall'], + emscripten_async_call_promising: (func, arg, millis) => { + scheduleAsyncCall(() => {{{ makeDynCall('vp', 'func', ASYNCIFY == 2) }}}(arg), millis); + }, + $registerPostMainLoop: (f) => { // Does nothing unless $MainLoop is included/used. typeof MainLoop != 'undefined' && MainLoop.postMainLoop.push(f); diff --git a/src/lib/libfs.js b/src/lib/libfs.js index 7c2aa82c2d04f..02cc950efdeb3 100644 --- a/src/lib/libfs.js +++ b/src/lib/libfs.js @@ -5,6 +5,64 @@ */ var LibraryFS = { + // Retry a stream op until it stops reporting EAGAIN, blocking on the stream + // node's readiness wait-queue in between (the same queue poll()/epoll consume + // and that SOCKFS/PIPEFS emit into). `op` attempts the operation once and + // returns the syscall result, throwing FS.ErrnoError (EAGAIN = would-block) + // exactly as the underlying sock/stream op already does; the helper owns the + // blocking-vs-nonblocking decision from the stream's own O_NONBLOCK flag (or a + // per-call `nonblocking` hint for MSG_DONTWAIT). It is fd-generic: sockets, + // pipes and ttys all share it. Callers are __async:'auto', so a ready op + // resolves synchronously and only a genuine would-block yields a Promise. + // + // The first op() attempt runs to completion before we register the waiter, so + // (JS being single-threaded) no readiness event can slip in between - the same + // safety $doPollAsync relies on. On a purely-synchronous build there is no way + // to suspend, so it degrades to a single attempt (status quo EAGAIN). + $streamWaitOp__internal: true, + $streamWaitOp__deps: ['$FS'], + $streamWaitOp: (fd, op, nonblocking = 0) => { + function attempt() { + try { + return op(); + } catch (e) { + if (typeof FS == 'undefined' || e.name !== 'ErrnoError') throw e; + return -e.errno; + } + } + var r = attempt(); +#if ASYNCIFY || PTHREADS +#if !ASYNCIFY + // PTHREADS without ASYNCIFY/JSPI: the stack can only suspend when this runs + // as the target of a worker's sync-proxy, whose result is delivered by + // promise resolution. A direct main-thread call cannot block, so degrade to + // a single attempt (mirrors $doPollAsync's isAsyncContext gate). + if (!PThread.currentProxiedOperationCallerThread) return r; +#endif + var stream = FS.getStream(fd); + var wait = r == -{{{ cDefs.EAGAIN }}} && !nonblocking && !(stream.flags & {{{ cDefs.O_NONBLOCK }}}); +#if ASYNCIFY + // JSPI/Asyncify: handleAsync accepts a plain value or a Promise, so a ready + // op resolves synchronously without suspending. + if (!wait) return r; +#else + // A proxied result must be a thenable even when ready synchronously. + if (!wait) return Promise.resolve(r); +#endif + return new Promise((resolve) => { + var reg; + function wake() { + var r = attempt(); + if (r == -{{{ cDefs.EAGAIN }}}) return; // spurious wake: keep waiting + reg.listeners.delete(reg.entry); + resolve(r); + } + reg = stream.node.addListener(wake); + }); +#else + return r; +#endif + }, $FS__deps: ['$randomFill', '$PATH', '$PATH_FS', '$TTY', '$MEMFS', '$FS_modeStringToFlags', '$FS_fileDataToTypedArray', diff --git a/src/lib/libsockfs.js b/src/lib/libsockfs.js index c7ffe7520e975..67db9de6f9c6b 100644 --- a/src/lib/libsockfs.js +++ b/src/lib/libsockfs.js @@ -810,3 +810,329 @@ addToLibrary({ emscripten_set_socket_close_callback: (userData, callback) => _setNetworkCallback('close', userData, callback), }); + +// The socket syscalls. Relocated here from libsyscall.js because they are +// SOCKFS-coupled ($getSocketFromFD / SOCKFS) and the blocking data ops build on +// $streamWaitOp (libfs.js). Under PROXY_POSIX_SOCKETS the socket syscalls are +// native (libsockets.a); under WASMFS this file is not linked at all. +// +// __proxy: 'sync' consolidates socket ownership on the main thread (a node:net +// handle is a per-realm JS object; only linear memory is shared), matching the +// FS + epoll model - inert unless SHARED_MEMORY. The blocking data ops carry +// __async: 'auto' and delegate to $streamWaitOp, which suspends the wasm stack +// (JSPI/Asyncify) or, when proxied from a worker, resolves the sync-proxy across +// the main-thread event loop; on a purely-synchronous build they degrade to the +// old immediate-EAGAIN behaviour. +#if PROXY_POSIX_SOCKETS == 0 && WASMFS == 0 +var SockFSSyscalls = { + $getSocketFromFD__deps: ['$SOCKFS', '$FS'], + $getSocketFromFD: (fd) => { + var socket = SOCKFS.getSocket(fd); + if (!socket) throw new FS.ErrnoError({{{ cDefs.EBADF }}}); +#if SYSCALL_DEBUG + dbg(` (socket: "${socket.path}")`); +#endif + return socket; + }, + $getSocketAddress__deps: ['$readSockaddr', '$FS', '$DNS'], + $getSocketAddress: (addrp, addrlen) => { + var info = readSockaddr(addrp, addrlen); + if (info.errno) throw new FS.ErrnoError(info.errno); + info.addr = DNS.lookup_addr(info.addr) || info.addr; +#if SYSCALL_DEBUG + dbg(` (socketaddress: "${[info.addr, info.port]}")`); +#endif + return info; + }, + __syscall_socket__proxy: 'sync', + __syscall_socket__deps: ['$SOCKFS'], + __syscall_socket: (domain, type, protocol, u1, u2, u3) => { + var sock = SOCKFS.createSocket(domain, type, protocol); + return sock.stream.fd; + }, + __syscall_getsockname__proxy: 'sync', + __syscall_getsockname__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], + __syscall_getsockname: (fd, addr, len, u1, u2, u3) => { + var sock = getSocketFromFD(fd); + // TODO: sock.saddr should never be undefined, see TODO in websocket_sock_ops.getname + var errno = writeSockaddr(addr, sock.family, DNS.lookup_name(sock.saddr || '0.0.0.0'), sock.sport, len); +#if ASSERTIONS + assert(!errno); +#endif + return 0; + }, + __syscall_getpeername__proxy: 'sync', + __syscall_getpeername__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], + __syscall_getpeername: (fd, addr, len, u1, u2, u3) => { + var sock = getSocketFromFD(fd); + if (!sock.daddr) { + return -{{{ cDefs.ENOTCONN }}}; // The socket is not connected. + } + var errno = writeSockaddr(addr, sock.family, DNS.lookup_name(sock.daddr), sock.dport, len); +#if ASSERTIONS + assert(!errno); +#endif + return 0; + }, + // Blocking connect: the first attempt initiates the connect and reports it + // pending; the wait resolves once the socket becomes writable (or errors), + // reading SO_ERROR to distinguish success (0) from a failed connect (-err). + // The pending/completion signalling is backend-agnostic: it uses the same + // poll()/SO_ERROR the reactor uses, driven by the node backend's + // 'connect'/'error' (main: 'open'/'error') wait-queue emissions. + __syscall_connect__proxy: 'sync', + __syscall_connect__async: 'auto', + __syscall_connect__deps: ['$streamWaitOp', '$getSocketFromFD', '$getSocketAddress'], + __syscall_connect: (fd, addr, len, u1, u2, u3) => { + var sock = getSocketFromFD(fd); + var info = getSocketAddress(addr, len); + var started = false; + return streamWaitOp(fd, () => { + if (!started) { + started = true; + sock.sock_ops.connect(sock, info.addr, info.port); + // Datagram connect is synchronous. + if (sock.type == {{{ cDefs.SOCK_DGRAM }}}) return 0; +#if ASYNCIFY || PTHREADS + // Non-blocking: report the connect in progress; blocking: wait. + return (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}}) ? -{{{ cDefs.EINPROGRESS }}} : -{{{ cDefs.EAGAIN }}}; +#else + // No suspension available: non-blocking reports EINPROGRESS, blocking + // keeps the legacy fire-and-forget success. + return (sock.stream.flags & {{{ cDefs.O_NONBLOCK }}}) ? -{{{ cDefs.EINPROGRESS }}} : 0; +#endif + } + // Woken: writable/error means the connect resolved. Read (and clear) + // SO_ERROR to map success/failure onto the syscall result. + var mask = sock.sock_ops.poll(sock); + if (!(mask & ({{{ cDefs.POLLOUT }}} | {{{ cDefs.POLLERR }}} | {{{ cDefs.POLLHUP }}}))) { + return -{{{ cDefs.EAGAIN }}}; + } + var err = sock.error; + sock.error = null; + return err ? -err : 0; + }); + }, + __syscall_shutdown__proxy: 'sync', + __syscall_shutdown__deps: ['$getSocketFromFD'], + __syscall_shutdown: (fd, how, u1, u2, u3, u4) => { + var sock = getSocketFromFD(fd); +#if NODERAWSOCKETS + return sock.sock_ops.shutdown(sock, how); +#else + return -{{{ cDefs.ENOSYS }}}; // unsupported feature +#endif + }, + // Blocking accept: waits until a connection is pending (listener readable), + // then hands it out. `flags` (SOCK_NONBLOCK/SOCK_CLOEXEC) apply to the new fd + // only, never to the listener's own blocking behaviour. + __syscall_accept4__proxy: 'sync', + __syscall_accept4__async: 'auto', + __syscall_accept4__deps: ['$streamWaitOp', '$getSocketFromFD', '$writeSockaddr', '$DNS'], + __syscall_accept4: (fd, addr, len, flags, u1, u2) => { + var sock = getSocketFromFD(fd); + return streamWaitOp(fd, () => { + var newsock = sock.sock_ops.accept(sock); // throws EAGAIN until one is pending + if (addr) { + var errno = writeSockaddr(addr, newsock.family, DNS.lookup_name(newsock.daddr), newsock.dport, len); +#if ASSERTIONS + assert(!errno); +#endif + } + if (flags & {{{ cDefs.SOCK_NONBLOCK }}}) newsock.stream.flags |= {{{ cDefs.O_NONBLOCK }}}; + else newsock.stream.flags &= ~{{{ cDefs.O_NONBLOCK }}}; + if (flags & {{{ cDefs.SOCK_CLOEXEC }}}) newsock.stream.flags |= {{{ cDefs.O_CLOEXEC }}}; + return newsock.stream.fd; + }); + }, + __syscall_bind__proxy: 'sync', + __syscall_bind__deps: ['$getSocketFromFD', '$getSocketAddress'], + __syscall_bind: (fd, addr, len, u1, u2, u3) => { + var sock = getSocketFromFD(fd); + var info = getSocketAddress(addr, len); + sock.sock_ops.bind(sock, info.addr, info.port); + return 0; + }, + __syscall_listen__proxy: 'sync', + __syscall_listen__deps: ['$getSocketFromFD'], + __syscall_listen: (fd, backlog, u1, u2, u3, u4) => { + var sock = getSocketFromFD(fd); + sock.sock_ops.listen(sock, backlog); + return 0; + }, + // Blocking recv: waits until data is present (or EOF); MSG_DONTWAIT forces a + // single non-blocking attempt for that call. + __syscall_recvfrom__proxy: 'sync', + __syscall_recvfrom__async: 'auto', + __syscall_recvfrom__deps: ['$streamWaitOp', '$getSocketFromFD', '$writeSockaddr', '$DNS'], + __syscall_recvfrom: (fd, buf, len, flags, addr, alen) => { + var sock = getSocketFromFD(fd); + return streamWaitOp(fd, () => { + var msg = sock.sock_ops.recvmsg(sock, len, flags); // throws EAGAIN until data present + if (!msg) return 0; // socket is closed + if (addr) { + var errno = writeSockaddr(addr, sock.family, DNS.lookup_name(msg.addr), msg.port, alen); +#if ASSERTIONS + assert(!errno); +#endif + } + HEAPU8.set(msg.buffer, buf); + return msg.buffer.byteLength; + }, flags & {{{ cDefs.MSG_DONTWAIT }}}); + }, + // Blocking send: a blocking socket's backend never would-blocks (it buffers), + // so this normally resolves synchronously; the wait exists for symmetry with a + // backend that reports EAGAIN on a full send buffer (wakes on writable). + __syscall_sendto__proxy: 'sync', + __syscall_sendto__async: 'auto', + __syscall_sendto__deps: ['$streamWaitOp', '$getSocketFromFD', '$getSocketAddress'], + __syscall_sendto: (fd, buf, len, flags, addr, alen) => { + var sock = getSocketFromFD(fd); + return streamWaitOp(fd, () => { + if (!addr) { + // send, no address provided + return FS.write(sock.stream, HEAP8, buf, len); + } + var dest = getSocketAddress(addr, alen); + // sendto an address + return sock.sock_ops.sendmsg(sock, HEAP8, buf, len, dest.addr, dest.port); + }, flags & {{{ cDefs.MSG_DONTWAIT }}}); + }, + __syscall_getsockopt__proxy: 'sync', + __syscall_getsockopt__deps: ['$getSocketFromFD'], + __syscall_getsockopt: (fd, level, optname, optval, optlen, unused) => { + var sock = getSocketFromFD(fd); +#if NODERAWSOCKETS + // The node:net backend handles all socket options. + return sock.sock_ops.getsockopt(sock, level, optname, optval, optlen); +#else + // Minimal getsockopt aimed at resolving https://github.com/emscripten-core/emscripten/issues/2211 + // so only supports SOL_SOCKET with SO_ERROR. + if (level === {{{ cDefs.SOL_SOCKET }}}) { + if (optname === {{{ cDefs.SO_ERROR }}}) { + {{{ makeSetValue('optval', 0, 'sock.error', 'i32') }}}; + {{{ makeSetValue('optlen', 0, 4, 'i32') }}}; + sock.error = null; // Clear the error (The SO_ERROR option obtains and then clears this field). + return 0; + } + } + 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__proxy: 'sync', + __syscall_setsockopt__deps: ['$getSocketFromFD'], + __syscall_setsockopt: (fd, level, optname, optval, optlen, unused) => { +#if NODERAWSOCKETS + var sock = getSocketFromFD(fd); + return sock.sock_ops.setsockopt(sock, level, optname, optval, optlen); +#else + getSocketFromFD(fd); // validate the fd (and keep this syscall's catch reachable) + return -{{{ cDefs.ENOPROTOOPT }}}; // The option is unknown at the level indicated. +#endif + }, + __syscall_sendmsg__proxy: 'sync', + __syscall_sendmsg__async: 'auto', + __syscall_sendmsg__deps: ['$streamWaitOp', '$getSocketFromFD', '$getSocketAddress'], + __syscall_sendmsg: (fd, message, flags, u1, u2, u3) => { + var sock = getSocketFromFD(fd); + return streamWaitOp(fd, () => { + var iov = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_iov, '*') }}}; + var num = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_iovlen, 'i32') }}}; + // read the address and port to send to + var addr, port; + var name = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_name, '*') }}}; + var namelen = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_namelen, 'i32') }}}; + if (name) { + var info = getSocketAddress(name, namelen); + port = info.port; + addr = info.addr; + } + // concatenate scatter-gather arrays into one message buffer + var total = 0; + for (var i = 0; i < num; i++) { + total += {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; + } + var view = new Uint8Array(total); + var offset = 0; + for (var i = 0; i < num; i++) { + var iovbase = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_base}`, '*') }}}; + var iovlen = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; + for (var j = 0; j < iovlen; j++) { + view[offset++] = {{{ makeGetValue('iovbase', 'j', 'i8') }}}; + } + } + // write the buffer + return sock.sock_ops.sendmsg(sock, view, 0, total, addr, port); + }, flags & {{{ cDefs.MSG_DONTWAIT }}}); + }, + // Blocking recvmsg: like recvfrom, waits until data is present (or EOF). + __syscall_recvmsg__proxy: 'sync', + __syscall_recvmsg__async: 'auto', + __syscall_recvmsg__deps: ['$streamWaitOp', '$getSocketFromFD', '$writeSockaddr', '$DNS'], + __syscall_recvmsg: (fd, message, flags, u1, u2, u3) => { + var sock = getSocketFromFD(fd); + return streamWaitOp(fd, () => { + 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 + var total = 0; + for (var i = 0; i < num; i++) { + total += {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; + } + // try to read total data (MSG_PEEK, when set, leaves it buffered) + var msg = sock.sock_ops.recvmsg(sock, total, flags); // throws EAGAIN until data present + if (!msg) return 0; // socket is closed + + // TODO honor flags: + // MSG_OOB + // Requests out-of-band data. The significance and semantics of out-of-band data are protocol-specific. + // MSG_WAITALL + // Requests that the function block until the full amount of data requested can be returned. The function may return a smaller amount of data if a signal is caught, if the connection is terminated, if MSG_PEEK was specified, or if an error is pending for the socket. + + // write the source address out + var name = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_name, '*') }}}; + if (name) { + var errno = writeSockaddr(name, sock.family, DNS.lookup_name(msg.addr), msg.port); +#if ASSERTIONS + assert(!errno); +#endif + } + // write the buffer out to the scatter-gather arrays + var bytesRead = 0; + var bytesRemaining = msg.buffer.byteLength; + for (var i = 0; bytesRemaining > 0 && i < num; i++) { + var iovbase = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_base}`, '*') }}}; + var iovlen = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; + if (!iovlen) { + continue; + } + var length = Math.min(iovlen, bytesRemaining); + var buf = msg.buffer.subarray(bytesRead, bytesRead + length); + HEAPU8.set(buf, iovbase + bytesRead); + bytesRead += length; + bytesRemaining -= length; + } + + // TODO set msghdr.msg_flags + // MSG_EOR + // End of record was received (if supported by the protocol). + // MSG_OOB + // Out-of-band data was received. + // MSG_TRUNC + // Normal data was truncated. + // MSG_CTRUNC + + return bytesRead; + }, flags & {{{ cDefs.MSG_DONTWAIT }}}); + }, +}; + +for (const name of Object.keys(SockFSSyscalls)) { + wrapSyscallFunction(name, SockFSSyscalls, false); +} + +addToLibrary(SockFSSyscalls); +#endif // PROXY_POSIX_SOCKETS == 0 && WASMFS == 0 diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index f30d85ebfece8..4c46610809b1b 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -326,243 +326,10 @@ var SyscallsLibrary = { FS.fchmod(fd, mode); return 0; }, -// When building with PROXY_POSIX_SOCKETS the socket syscalls are implemented -// natively in libsockets.a. -// When building with WASMFS the socket syscalls are implemented natively in -// libwasmfs.a. -#if PROXY_POSIX_SOCKETS == 0 && WASMFS == 0 - $getSocketFromFD__deps: ['$SOCKFS', '$FS'], - $getSocketFromFD: (fd) => { - var socket = SOCKFS.getSocket(fd); - if (!socket) throw new FS.ErrnoError({{{ cDefs.EBADF }}}); -#if SYSCALL_DEBUG - dbg(` (socket: "${socket.path}")`); -#endif - return socket; - }, - $getSocketAddress__deps: ['$readSockaddr', '$FS', '$DNS'], - $getSocketAddress: (addrp, addrlen) => { - var info = readSockaddr(addrp, addrlen); - if (info.errno) throw new FS.ErrnoError(info.errno); - info.addr = DNS.lookup_addr(info.addr) || info.addr; -#if SYSCALL_DEBUG - dbg(` (socketaddress: "${[info.addr, info.port]}")`); -#endif - return info; - }, - __syscall_socket__deps: ['$SOCKFS'], - __syscall_socket: (domain, type, protocol, u1, u2, u3) => { - var sock = SOCKFS.createSocket(domain, type, protocol); - return sock.stream.fd; - }, - __syscall_getsockname__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], - __syscall_getsockname: (fd, addr, len, u1, u2, u3) => { - var sock = getSocketFromFD(fd); - // TODO: sock.saddr should never be undefined, see TODO in websocket_sock_ops.getname - var errno = writeSockaddr(addr, sock.family, DNS.lookup_name(sock.saddr || '0.0.0.0'), sock.sport, len); -#if ASSERTIONS - assert(!errno); -#endif - return 0; - }, - __syscall_getpeername__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], - __syscall_getpeername: (fd, addr, len, u1, u2, u3) => { - var sock = getSocketFromFD(fd); - if (!sock.daddr) { - return -{{{ cDefs.ENOTCONN }}}; // The socket is not connected. - } - var errno = writeSockaddr(addr, sock.family, DNS.lookup_name(sock.daddr), sock.dport, len); -#if ASSERTIONS - assert(!errno); -#endif - return 0; - }, - __syscall_connect__deps: ['$getSocketFromFD', '$getSocketAddress'], - __syscall_connect: (fd, addr, len, u1, u2, u3) => { - var sock = getSocketFromFD(fd); - var info = getSocketAddress(addr, len); - sock.sock_ops.connect(sock, info.addr, info.port); - return 0; - }, - __syscall_shutdown__deps: ['$getSocketFromFD'], - __syscall_shutdown: (fd, how, u1, u2, u3, u4) => { - var sock = getSocketFromFD(fd); -#if NODERAWSOCKETS - return sock.sock_ops.shutdown(sock, how); -#else - return -{{{ cDefs.ENOSYS }}}; // unsupported feature -#endif - }, - __syscall_accept4__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], - __syscall_accept4: (fd, addr, len, flags, u1, u2) => { - var sock = getSocketFromFD(fd); - var newsock = sock.sock_ops.accept(sock); - if (addr) { - var errno = writeSockaddr(addr, newsock.family, DNS.lookup_name(newsock.daddr), newsock.dport, len); -#if ASSERTIONS - assert(!errno); -#endif - } - return newsock.stream.fd; - }, - __syscall_bind__deps: ['$getSocketFromFD', '$getSocketAddress'], - __syscall_bind: (fd, addr, len, u1, u2, u3) => { - var sock = getSocketFromFD(fd); - var info = getSocketAddress(addr, len); - sock.sock_ops.bind(sock, info.addr, info.port); - return 0; - }, - __syscall_listen__deps: ['$getSocketFromFD'], - __syscall_listen: (fd, backlog, u1, u2, u3, u4) => { - var sock = getSocketFromFD(fd); - sock.sock_ops.listen(sock, backlog); - return 0; - }, - __syscall_recvfrom__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], - __syscall_recvfrom: (fd, buf, len, flags, addr, alen) => { - var sock = getSocketFromFD(fd); - var msg = sock.sock_ops.recvmsg(sock, len, flags); - if (!msg) return 0; // socket is closed - if (addr) { - var errno = writeSockaddr(addr, sock.family, DNS.lookup_name(msg.addr), msg.port, alen); -#if ASSERTIONS - assert(!errno); -#endif - } - HEAPU8.set(msg.buffer, buf); - return msg.buffer.byteLength; - }, - __syscall_sendto__deps: ['$getSocketFromFD', '$getSocketAddress'], - __syscall_sendto: (fd, buf, len, flags, addr, alen) => { - var sock = getSocketFromFD(fd); - if (!addr) { - // send, no address provided - return FS.write(sock.stream, HEAP8, buf, len); - } - var dest = getSocketAddress(addr, alen); - // sendto an address - return sock.sock_ops.sendmsg(sock, HEAP8, buf, len, dest.addr, dest.port); - }, - __syscall_getsockopt__deps: ['$getSocketFromFD'], - __syscall_getsockopt: (fd, level, optname, optval, optlen, unused) => { - var sock = getSocketFromFD(fd); -#if NODERAWSOCKETS - // The node:net backend handles all socket options. - return sock.sock_ops.getsockopt(sock, level, optname, optval, optlen); -#else - // Minimal getsockopt aimed at resolving https://github.com/emscripten-core/emscripten/issues/2211 - // so only supports SOL_SOCKET with SO_ERROR. - if (level === {{{ cDefs.SOL_SOCKET }}}) { - if (optname === {{{ cDefs.SO_ERROR }}}) { - {{{ makeSetValue('optval', 0, 'sock.error', 'i32') }}}; - {{{ makeSetValue('optlen', 0, 4, 'i32') }}}; - sock.error = null; // Clear the error (The SO_ERROR option obtains and then clears this field). - return 0; - } - } - return -{{{ cDefs.ENOPROTOOPT }}}; // The option is unknown at the level indicated. -#endif - }, - // Defined in JS rather than as a weak native stub so the node:net backend can - // provide it without a separate libstubs variation. Without that backend it - // just reports the option as unknown. - __syscall_setsockopt__deps: ['$getSocketFromFD'], - __syscall_setsockopt: (fd, level, optname, optval, optlen, unused) => { -#if NODERAWSOCKETS - var sock = getSocketFromFD(fd); - return sock.sock_ops.setsockopt(sock, level, optname, optval, optlen); -#else - getSocketFromFD(fd); // validate the fd (and keep this syscall's catch reachable) - return -{{{ cDefs.ENOPROTOOPT }}}; // The option is unknown at the level indicated. -#endif - }, - __syscall_sendmsg__deps: ['$getSocketFromFD', '$getSocketAddress'], - __syscall_sendmsg: (fd, message, flags, u1, u2, u3) => { - var sock = getSocketFromFD(fd); - var iov = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_iov, '*') }}}; - var num = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_iovlen, 'i32') }}}; - // read the address and port to send to - var addr, port; - var name = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_name, '*') }}}; - var namelen = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_namelen, 'i32') }}}; - if (name) { - var info = getSocketAddress(name, namelen); - port = info.port; - addr = info.addr; - } - // concatenate scatter-gather arrays into one message buffer - var total = 0; - for (var i = 0; i < num; i++) { - total += {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; - } - var view = new Uint8Array(total); - var offset = 0; - for (var i = 0; i < num; i++) { - var iovbase = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_base}`, '*') }}}; - var iovlen = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; - for (var j = 0; j < iovlen; j++) { - view[offset++] = {{{ makeGetValue('iovbase', 'j', 'i8') }}}; - } - } - // write the buffer - return sock.sock_ops.sendmsg(sock, view, 0, total, addr, port); - }, - __syscall_recvmsg__deps: ['$getSocketFromFD', '$writeSockaddr', '$DNS'], - __syscall_recvmsg: (fd, message, flags, u1, u2, u3) => { - var sock = getSocketFromFD(fd); - 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 - var total = 0; - for (var i = 0; i < num; i++) { - total += {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; - } - // try to read total data (MSG_PEEK, when set, leaves it buffered) - var msg = sock.sock_ops.recvmsg(sock, total, flags); - if (!msg) return 0; // socket is closed - - // TODO honor flags: - // MSG_OOB - // Requests out-of-band data. The significance and semantics of out-of-band data are protocol-specific. - // MSG_WAITALL - // Requests that the function block until the full amount of data requested can be returned. The function may return a smaller amount of data if a signal is caught, if the connection is terminated, if MSG_PEEK was specified, or if an error is pending for the socket. - - // write the source address out - var name = {{{ makeGetValue('message', C_STRUCTS.msghdr.msg_name, '*') }}}; - if (name) { - var errno = writeSockaddr(name, sock.family, DNS.lookup_name(msg.addr), msg.port); -#if ASSERTIONS - assert(!errno); -#endif - } - // write the buffer out to the scatter-gather arrays - var bytesRead = 0; - var bytesRemaining = msg.buffer.byteLength; - for (var i = 0; bytesRemaining > 0 && i < num; i++) { - var iovbase = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_base}`, '*') }}}; - var iovlen = {{{ makeGetValue('iov', `(${C_STRUCTS.iovec.__size__} * i) + ${C_STRUCTS.iovec.iov_len}`, 'i32') }}}; - if (!iovlen) { - continue; - } - var length = Math.min(iovlen, bytesRemaining); - var buf = msg.buffer.subarray(bytesRead, bytesRead + length); - HEAPU8.set(buf, iovbase + bytesRead); - bytesRead += length; - bytesRemaining -= length; - } - - // TODO set msghdr.msg_flags - // MSG_EOR - // End of record was received (if supported by the protocol). - // MSG_OOB - // Out-of-band data was received. - // MSG_TRUNC - // Normal data was truncated. - // MSG_CTRUNC - - return bytesRead; - }, -#endif // ~PROXY_POSIX_SOCKETS==0 +// The socket syscalls (socket/bind/connect/accept4/recv*/send*/... and the +// $getSocketFromFD / $getSocketAddress helpers) live in libsockfs.js: they are +// SOCKFS-coupled, and the blocking data ops build on $streamWaitOp there. +// (Under PROXY_POSIX_SOCKETS / WASMFS they are implemented natively instead.) __syscall_fchdir: (fd) => { var stream = SYSCALLS.getStreamFromFD(fd); FS.chdir(stream.path); diff --git a/src/lib/libwasi.js b/src/lib/libwasi.js index f95c513d0eab3..25a02d1d55d75 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -268,7 +268,12 @@ var WasiLibrary = { #endif // SYSCALLS_REQUIRE_FILESYSTEM #if SYSCALLS_REQUIRE_FILESYSTEM +#if ASYNCIFY || PTHREADS + fd_write__deps: ['$doWritev', '$streamWaitOp'], + fd_write__async: 'auto', +#else fd_write__deps: ['$doWritev'], +#endif #elif (!MINIMAL_RUNTIME || EXIT_RUNTIME) $flush_NO_FILESYSTEM__deps: ['$printChar', '$printCharBuffers'], $flush_NO_FILESYSTEM: () => { @@ -287,7 +292,23 @@ var WasiLibrary = { fd_write: (fd, iov, iovcnt, pnum) => { #if SYSCALLS_REQUIRE_FILESYSTEM var stream = SYSCALLS.getStreamFromFD(fd); +#if ASYNCIFY || PTHREADS + // A blocking write on a socket/pipe suspends via $streamWaitOp when the send + // buffer would-block, then retries once writable. + var r = streamWaitOp(fd, () => { + var num = doWritev(stream, iov, iovcnt); + {{{ makeSetValue('pnum', 0, 'num', SIZE_TYPE) }}}; + return 0; + }); + // $streamWaitOp yields 0 (success; pnum written) or a negative -errno; WASI + // wants a positive errno, so flip the sign. + var toWasi = (v) => v < 0 ? -v : 0; + return (typeof r?.then == 'function') ? r.then(toWasi) : toWasi(r); +#else var num = doWritev(stream, iov, iovcnt); + {{{ makeSetValue('pnum', 0, 'num', SIZE_TYPE) }}}; + return 0; +#endif #else // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0 var num = 0; @@ -300,9 +321,9 @@ var WasiLibrary = { } num += len; } -#endif // SYSCALLS_REQUIRE_FILESYSTEM {{{ makeSetValue('pnum', 0, 'num', SIZE_TYPE) }}}; return 0; +#endif // SYSCALLS_REQUIRE_FILESYSTEM }, #if SYSCALLS_REQUIRE_FILESYSTEM @@ -343,14 +364,33 @@ var WasiLibrary = { }, #if SYSCALLS_REQUIRE_FILESYSTEM +#if ASYNCIFY || PTHREADS + fd_read__deps: ['$doReadv', '$streamWaitOp'], + fd_read__async: 'auto', +#else fd_read__deps: ['$doReadv'], +#endif #endif fd_read: (fd, iov, iovcnt, pnum) => { #if SYSCALLS_REQUIRE_FILESYSTEM var stream = SYSCALLS.getStreamFromFD(fd); +#if ASYNCIFY || PTHREADS + // A blocking read on a socket or pipe suspends (JSPI/pthread proxy) via + // $streamWaitOp until the inode signals readiness, then retries. + var r = streamWaitOp(fd, () => { + var num = doReadv(stream, iov, iovcnt); + {{{ makeSetValue('pnum', 0, 'num', SIZE_TYPE) }}}; + return 0; + }); + // $streamWaitOp yields 0 (success; pnum written) or a negative -errno; WASI + // wants a positive errno, so flip the sign. + var toWasi = (v) => v < 0 ? -v : 0; + return (typeof r?.then == 'function') ? r.then(toWasi) : toWasi(r); +#else var num = doReadv(stream, iov, iovcnt); {{{ makeSetValue('pnum', 0, 'num', SIZE_TYPE) }}}; return 0; +#endif #elif ASSERTIONS abort('fd_read called without SYSCALLS_REQUIRE_FILESYSTEM'); #else diff --git a/src/struct_info.json b/src/struct_info.json index b4457c98e5e71..f368051157ebb 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -318,6 +318,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 0a30caf01a268..8d5b04c42465b 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -336,6 +336,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 c9abc70ca5f9c..3ea708c5a2a04 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -336,6 +336,7 @@ "KMOD_RCTRL": 128, "KMOD_RSHIFT": 2, "MAP_PRIVATE": 2, + "MSG_DONTWAIT": 64, "MSG_PEEK": 2, "NCCS": 32, "NI_NAMEREQD": 8, diff --git a/system/include/emscripten/emscripten.h b/system/include/emscripten/emscripten.h index 43d2f2899dd0e..2ace57ca34489 100644 --- a/system/include/emscripten/emscripten.h +++ b/system/include/emscripten/emscripten.h @@ -79,6 +79,11 @@ void emscripten_set_main_loop_expected_blockers(int num); void emscripten_async_call(em_arg_callback_func func, void *arg, int millis); +// Like emscripten_async_call, but the callback is dispatched so that it may +// suspend the wasm stack (e.g. perform blocking calls) under JSPI. Without JSPI +// this is identical to emscripten_async_call. +void emscripten_async_call_promising(em_arg_callback_func func, void *arg, int millis); + void emscripten_exit_with_live_runtime(void) __attribute__((__noreturn__)); void emscripten_force_exit(int status) __attribute__((__noreturn__)); diff --git a/test/codesize/test_codesize_hello_O0.json b/test/codesize/test_codesize_hello_O0.json index fa5e682019094..47fd719e69cec 100644 --- a/test/codesize/test_codesize_hello_O0.json +++ b/test/codesize/test_codesize_hello_O0.json @@ -1,10 +1,10 @@ { "a.out.js": 23586, - "a.out.js.gz": 8615, + "a.out.js.gz": 8616, "a.out.nodebug.wasm": 15115, "a.out.nodebug.wasm.gz": 7464, "total": 38701, - "total_gz": 16079, + "total_gz": 16080, "sent": [ "fd_write" ], diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index d6cb263da95b6..7b5b95e76181d 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": 271951, + "a.out.js": 272483, "a.out.nodebug.wasm": 588038, - "total": 859989, + "total": 860521, "sent": [ "IMG_Init", "IMG_Load", @@ -434,6 +434,7 @@ "emscripten_asm_const_ptr", "emscripten_asm_const_ptr_sync_on_main_thread", "emscripten_async_call", + "emscripten_async_call_promising", "emscripten_async_load_script", "emscripten_async_run_script", "emscripten_async_wget", diff --git a/test/codesize/test_codesize_minimal_O0.expected.js b/test/codesize/test_codesize_minimal_O0.expected.js index f768e93cd3692..346baa034c21d 100644 --- a/test/codesize/test_codesize_minimal_O0.expected.js +++ b/test/codesize/test_codesize_minimal_O0.expected.js @@ -1019,8 +1019,6 @@ Module['FS_createPreloadedFile'] = FS.createPreloadedFile; 'ydayFromDate', 'arraySum', 'addDays', - 'getSocketFromFD', - 'getSocketAddress', 'FS_createPreloadedFile', 'FS_preloadFile', 'FS_modeStringToFlags', @@ -1029,6 +1027,8 @@ Module['FS_createPreloadedFile'] = FS.createPreloadedFile; 'FS_stdin_getChar', 'FS_mkdirTree', '_setNetworkCallback', + 'getSocketFromFD', + 'getSocketAddress', ]; missingLibrarySymbols.forEach(missingLibrarySymbol) diff --git a/test/codesize/test_codesize_minimal_O0.json b/test/codesize/test_codesize_minimal_O0.json index 471feccf094c2..ccf3fe347c61a 100644 --- a/test/codesize/test_codesize_minimal_O0.json +++ b/test/codesize/test_codesize_minimal_O0.json @@ -1,10 +1,10 @@ { "a.out.js": 18790, - "a.out.js.gz": 6786, + "a.out.js.gz": 6787, "a.out.nodebug.wasm": 1015, "a.out.nodebug.wasm.gz": 602, "total": 19805, - "total_gz": 7388, + "total_gz": 7389, "sent": [], "imports": [], "exports": [ diff --git a/test/codesize/test_unoptimized_code_size.json b/test/codesize/test_unoptimized_code_size.json index 5e254e55d89b8..c521dd03a2e94 100644 --- a/test/codesize/test_unoptimized_code_size.json +++ b/test/codesize/test_unoptimized_code_size.json @@ -1,6 +1,6 @@ { "hello_world.js": 56153, - "hello_world.js.gz": 17678, + "hello_world.js.gz": 17680, "hello_world.wasm": 15115, "hello_world.wasm.gz": 7464, "no_asserts.js": 25585, @@ -8,9 +8,9 @@ "no_asserts.wasm": 12229, "no_asserts.wasm.gz": 6004, "strict.js": 53255, - "strict.js.gz": 16649, + "strict.js.gz": 16650, "strict.wasm": 15115, "strict.wasm.gz": 7461, "total": 177452, - "total_gz": 63944 + "total_gz": 63947 } diff --git a/test/sockets/test_epoll_socket_blocking.c b/test/sockets/test_epoll_socket_blocking.c index f71f157f6ab72..01d247019fa68 100644 --- a/test/sockets/test_epoll_socket_blocking.c +++ b/test/sockets/test_epoll_socket_blocking.c @@ -68,7 +68,7 @@ int main(void) { pthread_t t; assert(pthread_create(&t, NULL, sender, NULL) == 0); #else - emscripten_async_call(send_ping, NULL, 100); + emscripten_async_call_promising(send_ping, NULL, 100); #endif struct epoll_event out[4]; diff --git a/test/sockets/test_tcp_blocking_echo.c b/test/sockets/test_tcp_blocking_echo.c new file mode 100644 index 0000000000000..b9595a1e35b92 --- /dev/null +++ b/test/sockets/test_tcp_blocking_echo.c @@ -0,0 +1,76 @@ +/* + * 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 echo using only BLOCKING socket calls: no + * O_NONBLOCK, no select/poll, no emscripten_set_main_loop. A listener and a + * client live in one process; each blocking connect/accept/recv/send suspends + * the wasm stack (JSPI) - or, under PROXY_TO_PTHREAD, blocks the worker while + * the main thread's event loop services the sockets - until it can complete. + * This is plain POSIX and also builds and runs natively against the host stack. + */ + +#include +#include +#include +#include +#include +#include +#include + +int main(void) { + int listen_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(listen_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); + assert(bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == 0); + assert(listen(listen_fd, 4) == 0); + + socklen_t la_len = sizeof(addr); + assert(getsockname(listen_fd, (struct sockaddr*)&addr, &la_len) == 0); + assert(ntohs(addr.sin_port) != 0); + + // Blocking connect: suspends until the loopback handshake completes. + int client_fd = socket(AF_INET, SOCK_STREAM, 0); + assert(client_fd >= 0); + assert(connect(client_fd, (struct sockaddr*)&addr, sizeof(addr)) == 0); + + // Blocking accept: the connection queued during connect(), so this returns + // the server-side peer (blocking until one is pending in the general case). + struct sockaddr_in ca; + socklen_t ca_len = sizeof(ca); + int peer_fd = accept(listen_fd, (struct sockaddr*)&ca, &ca_len); + assert(peer_fd >= 0); + + // Blocking send then blocking recv, in both directions. + assert(send(client_fd, "ping", 4, 0) == 4); + + char buf[4]; + assert(recv(peer_fd, buf, sizeof(buf), 0) == 4); + assert(memcmp(buf, "ping", 4) == 0); + assert(send(peer_fd, "pong", 4, 0) == 4); + + assert(recv(client_fd, buf, sizeof(buf), 0) == 4); + assert(memcmp(buf, "pong", 4) == 0); + + // Same again over read()/write() (fd_read/fd_write), which block on the + // socket via the same suspend path as recv/send. + assert(write(client_fd, "ping", 4) == 4); + assert(read(peer_fd, buf, sizeof(buf)) == 4); + assert(memcmp(buf, "ping", 4) == 0); + assert(write(peer_fd, "pong", 4) == 4); + assert(read(client_fd, buf, sizeof(buf)) == 4); + assert(memcmp(buf, "pong", 4) == 0); + + close(client_fd); + close(peer_fd); + close(listen_fd); + printf("done\n"); + return 0; +} diff --git a/test/test_sockets.py b/test/test_sockets.py index a044ca8db4be4..804627e11b19c 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -521,6 +521,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_blocking_echo(self): + # A self-contained loopback echo using only blocking socket calls (no + # O_NONBLOCK, no poll/select, no main loop): blocking connect, accept, recv + # and send. Each suspends the worker while the main thread's event loop + # services the sockets, with main() proxied to a worker (Part B topology). + self.do_runf('sockets/test_tcp_blocking_echo.c', 'done\n', + cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) + + def test_noderawsockets_blocking_echo_jspi(self): + # Same, but the blocking socket calls suspend the wasm stack under JSPI on a + # single thread - no threads at all. + self.setup_jspi_node() + self.do_runf('sockets/test_tcp_blocking_echo.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 c673692ae8fe114b0230e1ab607c7f265c55a5b5 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 8 Jul 2026 18:26:55 -0700 Subject: [PATCH 7/7] Add emscripten_async_call_promising to libsigs.js --- src/lib/libsigs.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index 27ee067db4814..e68774ba7c971 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -590,6 +590,7 @@ sigs = { emscripten_asm_const_ptr__sig: 'pppp', emscripten_asm_const_ptr_sync_on_main_thread__sig: 'pppp', emscripten_async_call__sig: 'vppi', + emscripten_async_call_promising__sig: 'vppi', emscripten_async_load_script__sig: 'vppp', emscripten_async_run_script__sig: 'vpi', emscripten_async_wget__sig: 'vpppp',