From baa70dfb77730117a7d8bcdb75bb314b58163616 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 30 Jun 2026 16:58:02 -0700 Subject: [PATCH 1/3] 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 | 9 + 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, 1941 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 e97b4adad87f5..6654a14aa0b07 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -40,6 +40,15 @@ See docs/process.md for more on how version tagging works. - 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 e2add292a2a63..5aca9628a2eca 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -594,9 +594,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', @@ -702,13 +700,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 e4852a0b44b92..30d6c271c2d83 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 54c755a8dd05c..791ec049ac6f3 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, @@ -1018,6 +1031,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 22786bca5b884..6f93190cc19df 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, @@ -1018,6 +1031,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 66bfb0e2fd9ba..619930635143d 100644 --- a/system/lib/wasmfs/syscalls.cpp +++ b/system/lib/wasmfs/syscalls.cpp @@ -1862,4 +1862,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 ed5c3055fd6f3..c2ae6d3ac6ac2 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": 268311, - "a.out.nodebug.wasm": 587978, - "total": 856289, + "a.out.js": 271378, + "a.out.nodebug.wasm": 588038, + "total": 859416, "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 c3199bdcf0f16..0f34c84ab1753 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -399,6 +399,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) @@ -5755,6 +5768,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') @@ -9695,6 +9777,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 53f5bf9d90c29..aae93b9bc3b8c 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -475,12 +475,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 2a091c645570b98d835366613e9a1e100e42e516 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 1 Jul 2026 12:15:38 -0700 Subject: [PATCH 2/3] pr feedback --- .../test_codesize_hello_dylink_all.json | 4 +- 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 +++++++++---------- 16 files changed, 93 insertions(+), 102 deletions(-) diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index c2ae6d3ac6ac2..ff5e1a998fc98 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": 271378, + "a.out.js": 271358, "a.out.nodebug.wasm": 588038, - "total": 859416, + "total": 859396, "sent": [ "IMG_Init", "IMG_Load", 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 62a6d0edb7b8e..9a46f858736b7 100644 --- a/test/sockets/test_tcp_client_semantics.c +++ b/test/sockets/test_tcp_client_semantics.c @@ -37,7 +37,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 1fbcda3f33519..1f46f454c5792 100644 --- a/test/sockets/test_tcp_refused.c +++ b/test/sockets/test_tcp_refused.c @@ -28,7 +28,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 0f34c84ab1753..ee720347125d6 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -5776,12 +5776,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): @@ -5789,54 +5783,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 85d3ac9eaf7f1..711bceb978f8d 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13284,6 +13284,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 aae93b9bc3b8c..33e44bb847979 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -394,7 +394,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(), @@ -411,7 +411,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() @@ -421,11 +421,11 @@ def test_noderawsockets_client_bind(self): def test_noderawsockets_client_semantics(self): # EISCONN on a second connect, shutdown(SHUT_WR) leaving reads working, and # EPIPE on a write after that. - self._run_against_echo_server('sockets/test_tcp_client_semantics.c', 'CLIENT SEMANTICS PASS') + 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 @@ -441,7 +441,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() @@ -454,12 +454,12 @@ 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']) 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): @@ -467,61 +467,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 @@ -529,13 +523,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']) @requires_native_clang @requires_python_dev_packages From cbebf40849a94fcee4e12d59bfbea75c4e0bc566 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 23 Jun 2026 19:47:56 -0700 Subject: [PATCH 3/3] Add asynchronous DNS resolution (emscripten_dns_lookup_async) Under -sNODERAWSOCKETS, getaddrinfo() previously fabricated fake addresses via DNS.lookup_name. This adds real resolution backed by node:dns, plus an async getaddrinfo so names can be resolved without blocking. getaddrinfo() now resolves numeric addresses and /etc/hosts entries (read fresh through emscripten's FS) synchronously, and returns a full addrinfo list (one node per address). For a real hostname: - without JSPI it returns EAI_AGAIN (no synchronous DNS); resolve it via the async API below. - under JSPI it suspends the wasm stack on the node:dns lookup and returns the addresses directly (gated on ASYNCIFY == 2). The async API (available in all builds): - emscripten_dns_lookup_async(node, service, hint) takes the same inputs as getaddrinfo() and returns a pollable fd that becomes readable when resolution completes. - emscripten_dns_lookup_result(fd, struct addrinfo **res) reads the outcome: 0 on success (writing the addrinfo list to *res, freed with freeaddrinfo), or an EAI_* code on failure. The completion fd is a plain pollable descriptor: wait on it with epoll/poll/select. freeaddrinfo now frees the whole ai_next chain; EAI_AGAIN is added to the generated struct info. Tested with test_dns_async, test_dns_callback (completion via emscripten_poll_with_callback), test_dns_async_net, test_dns_async_default, and test_dns_jspi, including PROXY_TO_PTHREAD variants. --- .../docs/api_reference/emscripten.h.rst | 63 ++++++++ src/lib/libcore.js | 153 ++++++++++++------ src/lib/libsigs.js | 2 + src/lib/libsockfs.js | 81 ++++++++++ src/lib/libsockfs_node.js | 87 +++++++++- src/struct_info.json | 3 +- src/struct_info_generated.json | 1 + src/struct_info_generated_wasm64.json | 1 + system/include/emscripten/emscripten.h | 17 ++ .../lib/libc/musl/src/network/freeaddrinfo.c | 14 +- test/codesize/test_codesize_hello_O0.json | 8 +- .../test_codesize_hello_dylink_all.json | 8 +- .../test_codesize_minimal_O0.expected.js | 2 + test/codesize/test_codesize_minimal_O0.json | 8 +- test/codesize/test_unoptimized_code_size.json | 12 +- test/sockets/test_dns_async.c | 124 ++++++++++++++ test/sockets/test_dns_async_default.c | 62 +++++++ test/sockets/test_dns_async_net.c | 77 +++++++++ test/sockets/test_dns_callback.c | 65 ++++++++ test/sockets/test_dns_jspi.c | 38 +++++ test/test_sockets.py | 48 ++++++ 21 files changed, 804 insertions(+), 70 deletions(-) create mode 100644 test/sockets/test_dns_async.c create mode 100644 test/sockets/test_dns_async_default.c create mode 100644 test/sockets/test_dns_async_net.c create mode 100644 test/sockets/test_dns_callback.c create mode 100644 test/sockets/test_dns_jspi.c diff --git a/site/source/docs/api_reference/emscripten.h.rst b/site/source/docs/api_reference/emscripten.h.rst index fdb9567ffcd86..6d98e7a732e8a 100644 --- a/site/source/docs/api_reference/emscripten.h.rst +++ b/site/source/docs/api_reference/emscripten.h.rst @@ -1280,6 +1280,69 @@ Functions :param em_socket_callback callback: Pointer to a callback function. The callback returns a file descriptor and the arbitrary ``userData`` passed to this function. +.. c:function:: int emscripten_dns_lookup_async(const char *node, const char *service, const struct addrinfo *hints) + + Asynchronous :c:func:`getaddrinfo`. Takes the same ``node``/``service``/``hints`` + inputs and returns a file descriptor that becomes readable once resolution + completes. Wait on it with ``poll``/``select``/``epoll`` or, without blocking, by + adding it to an epoll set and using :c:func:`emscripten_epoll_set_callback`, then read + the result with :c:func:`emscripten_dns_lookup_result`. The caller owns the fd + and should ``close()`` it. + + With ``-sNODERAWSOCKETS`` a hostname is resolved asynchronously via ``node:dns``; + otherwise (and for numeric or ``/etc/hosts`` names) resolution is synchronous, + as :c:func:`getaddrinfo`, and the fd is simply readable on the next turn. + + :param node: The hostname or numeric address to resolve. + :param service: The service name or port string (may be ``NULL``). + :param hints: ``addrinfo`` filter (``ai_family``/``ai_socktype``/etc.; may be ``NULL``). + :returns: A pollable file descriptor, or ``-1`` on failure to start the lookup. + + +.. c:function:: int emscripten_dns_lookup_result(int fd, struct addrinfo **res) + + Reads the outcome of a lookup started by :c:func:`emscripten_dns_lookup_async`, + once its ``fd`` is readable. + + :param int fd: The file descriptor returned by :c:func:`emscripten_dns_lookup_async`. + :param res: On success, receives the head of the resulting ``addrinfo`` list (free it with :c:func:`freeaddrinfo`, as for :c:func:`getaddrinfo`). + :returns: ``0`` on success, or an ``EAI_*`` error code on failure (``EAI_AGAIN`` if the lookup has not completed yet). + + +.. c:type:: em_epoll_callback + + Function pointer type for the :c:func:`emscripten_epoll_set_callback` callback, + defined as: :: + + typedef void (*em_epoll_callback)(int epfd, struct epoll_event *events, int nready, void *userdata); + + ``events`` is a runtime-owned buffer of ``nready`` ready entries, valid only for + the duration of the call. + + +.. c:function:: int emscripten_epoll_set_callback(int epfd, int maxevents, em_epoll_callback callback, void *userdata) + + Register a persistent readiness callback on an existing epoll fd (built with + :c:func:`epoll_create1`/:c:func:`epoll_ctl`): instead of blocking in + :c:func:`epoll_wait`, the runtime calls ``callback`` every time the set makes + progress, delivering up to ``maxevents`` ready events. The interest is armed once + and reused across every delivery. Unlike :c:func:`epoll_wait` it never blocks the + calling stack, so it works without ASYNCIFY/JSPI, and it keeps the runtime alive + while armed. + + There is at most one callback per epoll: calling again replaces it. A ``NULL`` + ``callback`` unregisters; the interest also ends when the epoll fd is closed. + Per-fd ``EPOLLET``/``EPOLLONESHOT`` behave exactly as with the blocking + :c:func:`epoll_wait`. + + :param epfd: An epoll fd from :c:func:`epoll_create1`. + :param maxevents: Maximum number of ready events to deliver per callback. + :param callback: Invoked with ``epfd``, the runtime-owned ``events`` buffer, the ready count ``nready``, and ``userdata``. ``NULL`` unregisters. + :param userdata: Opaque pointer passed through to the callback. + :returns: ``0`` on success, or ``-errno`` (``-EBADF``/``-EINVAL``). + + + Unaligned types =============== diff --git a/src/lib/libcore.js b/src/lib/libcore.js index e7269f5803da8..3cfc52d7a92b9 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -947,53 +947,61 @@ addToLibrary({ return inetPton4(DNS.lookup_name(nameString)); }, - getaddrinfo__deps: ['$DNS', '$inetPton4', '$inetNtop4', '$inetPton6', '$inetNtop6', '$writeSockaddr', 'malloc', 'htonl'], - getaddrinfo__proxy: 'sync', - getaddrinfo: (node, service, hint, out) => { - // Note getaddrinfo currently only returns a single addrinfo with ai_next defaulting to NULL. When NULL - // hints are specified or ai_family set to AF_UNSPEC or ai_socktype or ai_protocol set to 0 then we - // really should provide a linked list of suitable addrinfo values. - var addrs = []; - var canon = null; - var addr = 0; - var port = 0; - var flags = 0; - var family = {{{ cDefs.AF_UNSPEC }}}; - var type = 0; - var proto = 0; - var ai, last; - - function allocaddrinfo(family, type, proto, canon, addr, port) { - var sa, salen, ai; - var errno; - - salen = family === {{{ cDefs.AF_INET6 }}} ? + // The encode/mint stage: turn a resolved descriptor ({entries, type, proto, + // port}, addr in parsed inetPton form) into an addrinfo linked list and return + // the head (0 for an empty list). This is the sole point that mints C memory, + // and the whole chain is freed uniformly by freeaddrinfo - one ownership rule. + // (A future ring/aio backend would add a sibling encoder here, e.g. one that + // writes into a caller buffer, without touching parse/resolve.) + $writeAddrInfoList__deps: ['$inetNtop4', '$inetNtop6', '$writeSockaddr', 'malloc'], + $writeAddrInfoList: (desc) => { + var head = 0, prev = 0; + for (var entry of desc.entries) { + var family = entry.family; + var salen = family === {{{ cDefs.AF_INET6 }}} ? {{{ C_STRUCTS.sockaddr_in6.__size__ }}} : {{{ C_STRUCTS.sockaddr_in.__size__ }}}; - addr = family === {{{ cDefs.AF_INET6 }}} ? - inetNtop6(addr) : - inetNtop4(addr); - sa = _malloc(salen); - errno = writeSockaddr(sa, family, addr, port); + var sa = _malloc(salen); + var errno = writeSockaddr(sa, family, family === {{{ cDefs.AF_INET6 }}} ? inetNtop6(entry.addr) : inetNtop4(entry.addr), desc.port); #if ASSERTIONS assert(!errno); #endif - - ai = _malloc({{{ C_STRUCTS.addrinfo.__size__ }}}); + var ai = _malloc({{{ C_STRUCTS.addrinfo.__size__ }}}); {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_family, 'family', 'i32') }}}; - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_socktype, 'type', 'i32') }}}; - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_protocol, 'proto', 'i32') }}}; - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_canonname, 'canon', '*') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_socktype, 'desc.type', 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_protocol, 'desc.proto', 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_canonname, '0', '*') }}}; {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addr, 'sa', '*') }}}; - if (family === {{{ cDefs.AF_INET6 }}}) { - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addrlen, C_STRUCTS.sockaddr_in6.__size__, 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addrlen, 'salen', 'i32') }}}; + {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_next, '0', 'i32') }}}; + if (prev) { + {{{ makeSetValue('prev', C_STRUCTS.addrinfo.ai_next, 'ai', '*') }}}; } else { - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_addrlen, C_STRUCTS.sockaddr_in.__size__, 'i32') }}}; + head = ai; } - {{{ makeSetValue('ai', C_STRUCTS.addrinfo.ai_next, '0', 'i32') }}}; - - return ai; + prev = ai; } + return head; + }, + + // Shared getaddrinfo core. Allocates nothing: returns a resolved descriptor + // {entries, type, proto, port} (entries are {family, addr} with addr in parsed + // inetPton form), a negative EAI_* code on failure, or - under NODERAWSOCKETS, + // for a hostname needing DNS - a {node, family, type, proto, port} descriptor + // (no entries) for the caller to resolve. The result is minted from a + // descriptor by writeAddrInfoList at the point ownership passes to the caller. + $getAddrInfo__deps: ['$DNS', '$inetPton4', '$inetPton6', 'htonl', '$UTF8ToString', +#if NODERAWSOCKETS + '$nodeSockHelpers', +#endif + ], + $getAddrInfo: (node, service, hint) => { + var addr = 0; + var port = 0; + var flags = 0; + var family = {{{ cDefs.AF_UNSPEC }}}; + var type = 0; + var proto = 0; if (hint) { flags = {{{ makeGetValue('hint', C_STRUCTS.addrinfo.ai_flags, 'i32') }}}; @@ -1063,9 +1071,7 @@ addToLibrary({ addr = [0, 0, 0, _htonl(1)]; } } - ai = allocaddrinfo(family, type, proto, null, addr, port); - {{{ makeSetValue('out', '0', 'ai', '*') }}}; - return 0; + return { entries: [{ family, addr }], type, proto, port }; } // @@ -1096,9 +1102,7 @@ addToLibrary({ } } if (addr != null) { - ai = allocaddrinfo(family, type, proto, node, addr, port); - {{{ makeSetValue('out', '0', 'ai', '*') }}}; - return 0; + return { entries: [{ family, addr }], type, proto, port }; } if (flags & {{{ cDefs.AI_NUMERICHOST }}}) { return {{{ cDefs.EAI_NONAME }}}; @@ -1107,6 +1111,22 @@ addToLibrary({ // // try as a hostname // +#if NODERAWSOCKETS + // /etc/hosts resolves synchronously (read fresh through emscripten's FS). + var hosts = nodeSockHelpers.readHosts(node).filter((e) => + family === {{{ cDefs.AF_UNSPEC }}} || e.family === family); + if (hosts.length) { + var entries = hosts.map((e) => ({ + family: e.family, + addr: e.family === {{{ cDefs.AF_INET6 }}} ? inetPton6(e.addr) : inetPton4(e.addr), + })); + return { entries, type, proto, port }; + } + // A real hostname needs a DNS lookup; hand the request back to the caller to + // resolve asynchronously (getaddrinfo suspends under JSPI / returns + // EAI_AGAIN otherwise; emscripten_dns_lookup_async drives the poll-fd flow). + return { node, family, type, proto, port }; +#else // resolve the hostname to a temporary fake address node = DNS.lookup_name(node); addr = inetPton4(node); @@ -1115,9 +1135,50 @@ addToLibrary({ } else if (family === {{{ cDefs.AF_INET6 }}}) { addr = [0, 0, _htonl(0xffff), addr]; } - ai = allocaddrinfo(family, type, proto, null, addr, port); - {{{ makeSetValue('out', '0', 'ai', '*') }}}; - return 0; + return { entries: [{ family, addr }], type, proto, port }; +#endif + }, + + getaddrinfo__deps: ['$getAddrInfo', '$writeAddrInfoList', +#if NODERAWSOCKETS + '$nodeSockHelpers', +#endif + ], + getaddrinfo__proxy: 'sync', +#if NODERAWSOCKETS && ASYNCIFY == 2 + // Under JSPI a hostname miss suspends the wasm stack on the real node:dns + // lookup (returning a promise) rather than reporting EAI_AGAIN. A resolved + // descriptor (numeric/hosts) or error does not suspend. + getaddrinfo__async: true, +#endif + getaddrinfo: (node, service, hint, out) => { + // parse -> (resolve) -> mint. One descriptor threads through all three. + var desc = getAddrInfo(node, service, hint); + if (typeof desc === 'object') { + if (desc.entries) { + {{{ makeSetValue('out', '0', 'writeAddrInfoList(desc)', '*') }}}; + return 0; + } +#if NODERAWSOCKETS && ASYNCIFY == 2 + // JSPI: suspend on the real node:dns lookup, which fills desc.entries, then + // mint from the same descriptor. + return nodeSockHelpers.resolveAddrInfo(desc).then((eai) => { + if (eai) return eai; + {{{ makeSetValue('out', '0', 'writeAddrInfoList(desc)', '*') }}}; + return 0; + }); +#elif NODERAWSOCKETS + // No synchronous DNS available: numeric and /etc/hosts names resolve above. + // A name pre-warmed by emscripten_dns_lookup_async() answers from the shared + // cache; otherwise it must be resolved out-of-band via that async syscall. + if (nodeSockHelpers.dnsCacheGet(desc)) { + {{{ makeSetValue('out', '0', 'writeAddrInfoList(desc)', '*') }}}; + return 0; + } + return {{{ cDefs.EAI_AGAIN }}}; +#endif + } + return desc; }, getnameinfo__deps: ['$DNS', '$readSockaddr', '$stringToUTF8'], diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index b4bd6bfae8e02..9c1dad21178e3 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -637,6 +637,8 @@ sigs = { emscripten_destroy_audio_context__sig: 'vi', emscripten_destroy_web_audio_node__sig: 'vi', emscripten_destroy_worker__sig: 'vi', + emscripten_dns_lookup_async__sig: 'ippp', + emscripten_dns_lookup_result__sig: 'iip', emscripten_enter_soft_fullscreen__sig: 'ipp', emscripten_epoll_set_callback__sig: 'iiipp', emscripten_err__sig: 'vp', diff --git a/src/lib/libsockfs.js b/src/lib/libsockfs.js index 99851e542dd23..8a0627d460932 100644 --- a/src/lib/libsockfs.js +++ b/src/lib/libsockfs.js @@ -36,6 +36,13 @@ addToLibrary({ // 'listen' has no readiness mapping; skip it. if (flags) FS.getStream(fd)?.node.notifyListeners(flags); }, + // Mark an async-completion pseudo-socket ready: flip it readable and wake + // its waiters through the generic wait-queue. A future ring/aio completion + // fd would reuse the same mechanism rather than re-adding one. + finishDns(sock) { + sock.dnsDone = true; + sock.stream.node?.notifyListeners({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}}); + }, mount(mount) { #if expectToReceiveOnModule('websocket') // The incoming Module['websocket'] can be used for configuring @@ -128,6 +135,11 @@ addToLibrary({ stream_ops: { poll(stream) { var sock = stream.node.sock; + // A DNS request fd (emscripten_dns_lookup_async) is readable once the + // lookup completes; read the result with emscripten_dns_lookup_result. + if (sock.dns) { + return sock.dnsDone ? ({{{ cDefs.POLLRDNORM }}} | {{{ cDefs.POLLIN }}}) : 0; + } return sock.sock_ops.poll(sock); }, ioctl(stream, request, varargs) { @@ -150,6 +162,8 @@ addToLibrary({ }, close(stream) { var sock = stream.node.sock; + // A DNS request fd is a pseudo-socket with no backend resources. + if (sock.dns) return; sock.sock_ops.close(sock); } }, @@ -803,4 +817,71 @@ addToLibrary({ emscripten_set_socket_close_callback__deps: ['$_setNetworkCallback'], emscripten_set_socket_close_callback: (userData, callback) => _setNetworkCallback('close', userData, callback), + + // Asynchronous getaddrinfo: same (node, service, hint) inputs as the sync call. + // Returns a pollable fd that becomes readable when resolution completes (wait + // on it with poll/select or emscripten_epoll_set_callback); read the result + // with emscripten_dns_lookup_result. Returns -1 on failure to allocate the fd. + // Without -sNODERAWSOCKETS this resolves synchronously (like getaddrinfo) and + // the fd is simply readable on the next turn. + emscripten_dns_lookup_async__deps: ['$SOCKFS', '$getAddrInfo', '$safeSetTimeout', +#if NODERAWSOCKETS + '$nodeSockHelpers', +#endif + ], + emscripten_dns_lookup_async__proxy: 'sync', + emscripten_dns_lookup_async: (node, service, hint) => { + var sock; + try { + sock = SOCKFS.createSocket({{{ cDefs.AF_INET }}}, {{{ cDefs.SOCK_STREAM }}}, 0); + } catch (e) { + return -1; + } + sock.dns = true; + // Read the request synchronously (the input pointers are only valid now). No + // C memory is allocated here; the resolved descriptor is stashed on the sock + // and minted into an addrinfo only when the caller takes it via + // emscripten_dns_lookup_result. + var desc = getAddrInfo(node, service, hint); +#if NODERAWSOCKETS + if (typeof desc === 'object' && !desc.entries) { + // A real hostname: resolve via node:dns (fills desc.entries), then stash + // the same descriptor for the caller to mint from. + nodeSockHelpers.resolveAddrInfo(desc).then((eai) => { + sock.dnsResult = eai; + sock.dnsDesc = desc; + SOCKFS.finishDns(sock); + }); + return sock.stream.fd; + } +#endif + // Resolved synchronously (numeric/`/etc/hosts`/fake/null-node success, or a + // validation error). Deliver readiness on a later turn regardless, so the + // contract is uniformly async (the caller can poll or attach a listener + // first); safeSetTimeout keeps the runtime alive until it fires. + if (typeof desc === 'object') { + sock.dnsDesc = desc; + sock.dnsResult = 0; + } else { + sock.dnsResult = desc; + } + safeSetTimeout(() => SOCKFS.finishDns(sock), 0); + return sock.stream.fd; + }, + + // Read the outcome of a completed async lookup: 0 on success - minting the + // addrinfo list and writing its head to *res (freed with freeaddrinfo, as for + // getaddrinfo) - or an EAI_* code on failure (EAI_AGAIN if not yet complete). + // The memory is allocated here, so a caller that closes the fd without reading + // leaks nothing. The caller owns the fd and should close() it. + emscripten_dns_lookup_result__deps: ['$SOCKFS', '$writeAddrInfoList'], + emscripten_dns_lookup_result__proxy: 'sync', + emscripten_dns_lookup_result: (fd, res) => { + var sock = SOCKFS.getSocket(fd); + if (!sock || !sock.dns || !sock.dnsDone) return {{{ cDefs.EAI_AGAIN }}}; + if (sock.dnsResult === 0) { + {{{ makeSetValue('res', '0', 'writeAddrInfoList(sock.dnsDesc)', '*') }}}; + } + return sock.dnsResult; + }, }); diff --git a/src/lib/libsockfs_node.js b/src/lib/libsockfs_node.js index 8c284b53e596d..e7f0c2dd9a354 100644 --- a/src/lib/libsockfs_node.js +++ b/src/lib/libsockfs_node.js @@ -54,7 +54,7 @@ null; var NodeSockFSLibrary = { // Node plumbing shared by the interface methods below. - $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES'], + $nodeSockHelpers__deps: ['$SOCKFS', '$ERRNO_CODES', '$FS', '$inetPton4', '$inetPton6'], $nodeSockHelpers: { // node builtins, resolved once each. getBuiltinModule works in both // CommonJS and ESM output, with require as the fallback. @@ -67,6 +67,91 @@ var NodeSockFSLibrary = { getDgram() { return nodeSockHelpers.dgramModule ??= (process.getBuiltinModule || require)('dgram'); }, + getDns() { + return nodeSockHelpers.dnsModule ??= (process.getBuiltinModule || require)('dns'); + }, + // Look up `name` in /etc/hosts, read fresh on each call through emscripten's + // FS so live edits (MEMFS or a mounted real fs) are honored. Returns a list + // of {family, addr}; a missing or unreadable file is just empty. + readHosts(name) { + var out = []; + var text; + try { + text = FS.readFile('/etc/hosts', { encoding: 'utf8' }); + } catch (e) { + return out; + } + for (var line of text.split('\n')) { + var hash = line.indexOf('#'); + if (hash !== -1) line = line.slice(0, hash); + var parts = line.split(/\s+/).filter((p) => p.length); + if (parts.length < 2 || !parts.slice(1).includes(name)) continue; + var addr = parts[0]; + out.push({ family: addr.includes(':') ? {{{ cDefs.AF_INET6 }}} : {{{ cDefs.AF_INET }}}, addr }); + } + return out; + }, + // Map a node:dns error to an EAI_* code. node:dns surfaces either getaddrinfo + // EAI_* names or libuv/system codes; the transient ones become EAI_AGAIN and + // everything else a hard "name not found". + eaiForDns(e) { + switch (e && e.code) { + case 'EAI_AGAIN': + case 'ETIMEDOUT': + case 'ESERVFAIL': + case 'EREFUSED': + return {{{ cDefs.EAI_AGAIN }}}; + default: + return {{{ cDefs.EAI_NONAME }}}; + } + }, + // Resolved hostnames keyed by family + name, shared by the sync and async + // getaddrinfo paths: an async resolution pre-warms this so a later sync + // getaddrinfo() (which has no synchronous DNS of its own) can answer from it + // instead of returning EAI_AGAIN. + dnsCache: {}, + dnsCacheKey(desc) { + return desc.family + ':' + desc.node; + }, + // Fill desc.entries from the cache if present; true on a hit. + dnsCacheGet(desc) { + var entries = nodeSockHelpers.dnsCache[nodeSockHelpers.dnsCacheKey(desc)]; + if (entries) { + desc.entries = entries; + return true; + } + return false; + }, + // The resolve stage: take a needs-DNS descriptor (from getAddrInfo) and fill + // in desc.entries via node:dns, populating the shared cache on success. + // Returns a promise of the EAI_* code (0 on success). Pure: no fd, no C + // allocation - just name -> addresses, so a future ring/aio backend can reuse + // it verbatim. + resolveAddrInfo(desc) { + if (nodeSockHelpers.dnsCacheGet(desc)) { + return Promise.resolve(0); + } + var opts = { all: true }; + if (desc.family === {{{ cDefs.AF_INET }}}) opts.family = 4; + else if (desc.family === {{{ cDefs.AF_INET6 }}}) opts.family = 6; + return new Promise((resolve) => { + nodeSockHelpers.getDns().lookup(desc.node, opts, (err, addresses) => { + if (err) { + resolve(nodeSockHelpers.eaiForDns(err)); + } else { + desc.entries = addresses.map((a) => { + var fam = a.family === 6 ? {{{ cDefs.AF_INET6 }}} : {{{ cDefs.AF_INET }}}; + return { family: fam, addr: fam === {{{ cDefs.AF_INET6 }}} ? inetPton6(a.address) : inetPton4(a.address) }; + }); + if (addresses.length) { + nodeSockHelpers.dnsCache[nodeSockHelpers.dnsCacheKey(desc)] = desc.entries; + } + resolve(addresses.length ? 0 : {{{ cDefs.EAI_NONAME }}}); + } + }); + }); + }, + // True when node:dgram exposes both synchronous bindSync and connectSync // (a recent addition), letting UDP run entirely on the public API. A runtime // missing either falls back to the private udp_wrap handle, which provides diff --git a/src/struct_info.json b/src/struct_info.json index 30d6c271c2d83..a396a4645580f 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -206,7 +206,8 @@ "NI_NAMEREQD", "EAI_NONAME", "EAI_SOCKTYPE", - "EAI_BADFLAGS" + "EAI_BADFLAGS", + "EAI_AGAIN" ], "structs": { "addrinfo": [ diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index 791ec049ac6f3..d367f40d77ec8 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -63,6 +63,7 @@ "EADV": 122, "EAFNOSUPPORT": 5, "EAGAIN": 6, + "EAI_AGAIN": -3, "EAI_BADFLAGS": -1, "EAI_FAMILY": -6, "EAI_NONAME": -2, diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index 6f93190cc19df..c6d4f9b9e1359 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -63,6 +63,7 @@ "EADV": 122, "EAFNOSUPPORT": 5, "EAGAIN": 6, + "EAI_AGAIN": -3, "EAI_BADFLAGS": -1, "EAI_FAMILY": -6, "EAI_NONAME": -2, diff --git a/system/include/emscripten/emscripten.h b/system/include/emscripten/emscripten.h index 43d2f2899dd0e..e13150396526e 100644 --- a/system/include/emscripten/emscripten.h +++ b/system/include/emscripten/emscripten.h @@ -68,6 +68,23 @@ void emscripten_set_socket_connection_callback(void *userData, em_socket_callbac void emscripten_set_socket_message_callback(void *userData, em_socket_callback callback); void emscripten_set_socket_close_callback(void *userData, em_socket_callback callback); +// Asynchronous getaddrinfo. emscripten_dns_lookup_async() takes the same +// node/service/hints inputs as getaddrinfo() and returns a file descriptor that +// becomes readable once resolution completes, or -1 on error. Wait on it with +// poll/select/epoll, or without blocking by adding it to an epoll set and using +// emscripten_epoll_set_callback(). Once readable, call emscripten_dns_lookup_result() +// to read the outcome: 0 on success - writing the addrinfo list head to *res +// (free it with freeaddrinfo, as for getaddrinfo) - or an EAI_* code on failure. +// The caller owns the fd and should close() it. +// With -sNODERAWSOCKETS a hostname is resolved asynchronously via node:dns; +// otherwise resolution is synchronous (as getaddrinfo) and the fd is simply +// readable on the next turn. A successful resolution pre-warms getaddrinfo()'s +// cache, so a subsequent synchronous getaddrinfo() of the same name resolves +// from it rather than returning EAI_AGAIN. +struct addrinfo; +int emscripten_dns_lookup_async(const char *node, const char *service, const struct addrinfo *hints); +int emscripten_dns_lookup_result(int fd, struct addrinfo **res); + void _emscripten_push_main_loop_blocker(em_arg_callback_func func, void *arg, const char *name); void _emscripten_push_uncounted_main_loop_blocker(em_arg_callback_func func, void *arg, const char *name); #define emscripten_push_main_loop_blocker(func, arg) \ diff --git a/system/lib/libc/musl/src/network/freeaddrinfo.c b/system/lib/libc/musl/src/network/freeaddrinfo.c index c4016d9f7c246..6e075a261adea 100644 --- a/system/lib/libc/musl/src/network/freeaddrinfo.c +++ b/system/lib/libc/musl/src/network/freeaddrinfo.c @@ -7,11 +7,15 @@ void freeaddrinfo(struct addrinfo *p) { #if __EMSCRIPTEN__ - // Emscripten's usage of this structure is very simple: we always allocate - // ai_addr, and do not use the linked list aspect at all. There is also no - // aliasing with aibuf. - free(p->ai_addr); - free(p); + // Emscripten allocates each node and its ai_addr separately (no aibuf block, + // no aliasing), and getaddrinfo may return a linked list, so walk it freeing + // each node and its address. + while (p) { + struct addrinfo *next = p->ai_next; + free(p->ai_addr); + free(p); + p = next; + } #else size_t cnt; for (cnt=1; p->ai_next; cnt++, p=p->ai_next); diff --git a/test/codesize/test_codesize_hello_O0.json b/test/codesize/test_codesize_hello_O0.json index b5a2cffa258f4..1e82db34c1a4f 100644 --- a/test/codesize/test_codesize_hello_O0.json +++ b/test/codesize/test_codesize_hello_O0.json @@ -1,10 +1,10 @@ { - "a.out.js": 23488, - "a.out.js.gz": 8594, + "a.out.js": 23518, + "a.out.js.gz": 8606, "a.out.nodebug.wasm": 15115, "a.out.nodebug.wasm.gz": 7464, - "total": 38603, - "total_gz": 16058, + "total": 38633, + "total_gz": 16070, "sent": [ "fd_write" ], diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index ff5e1a998fc98..04c873e99e2a7 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": 271358, - "a.out.nodebug.wasm": 588038, - "total": 859396, + "a.out.js": 271977, + "a.out.nodebug.wasm": 588061, + "total": 860038, "sent": [ "IMG_Init", "IMG_Load", @@ -455,6 +455,8 @@ "emscripten_date_now", "emscripten_debugger", "emscripten_destroy_worker", + "emscripten_dns_lookup_async", + "emscripten_dns_lookup_result", "emscripten_enter_soft_fullscreen", "emscripten_epoll_set_callback", "emscripten_err", diff --git a/test/codesize/test_codesize_minimal_O0.expected.js b/test/codesize/test_codesize_minimal_O0.expected.js index 67d29ed7e93b1..fef2ed5d67f94 100644 --- a/test/codesize/test_codesize_minimal_O0.expected.js +++ b/test/codesize/test_codesize_minimal_O0.expected.js @@ -890,6 +890,8 @@ Module['FS_createPreloadedFile'] = FS.createPreloadedFile; 'inetNtop6', 'readSockaddr', 'writeSockaddr', + 'writeAddrInfoList', + 'getAddrInfo', 'readEmAsmArgs', 'jstoi_q', 'getExecutableName', diff --git a/test/codesize/test_codesize_minimal_O0.json b/test/codesize/test_codesize_minimal_O0.json index a18e3a0cab40b..e2ed00c06c81c 100644 --- a/test/codesize/test_codesize_minimal_O0.json +++ b/test/codesize/test_codesize_minimal_O0.json @@ -1,10 +1,10 @@ { - "a.out.js": 18692, - "a.out.js.gz": 6766, + "a.out.js": 18722, + "a.out.js.gz": 6777, "a.out.nodebug.wasm": 1015, "a.out.nodebug.wasm.gz": 602, - "total": 19707, - "total_gz": 7368, + "total": 19737, + "total_gz": 7379, "sent": [], "imports": [], "exports": [ diff --git a/test/codesize/test_unoptimized_code_size.json b/test/codesize/test_unoptimized_code_size.json index 640fc29761fc3..65c011982fbbb 100644 --- a/test/codesize/test_unoptimized_code_size.json +++ b/test/codesize/test_unoptimized_code_size.json @@ -1,16 +1,16 @@ { - "hello_world.js": 55916, - "hello_world.js.gz": 17589, + "hello_world.js": 55956, + "hello_world.js.gz": 17601, "hello_world.wasm": 15115, "hello_world.wasm.gz": 7464, "no_asserts.js": 25585, "no_asserts.js.gz": 8688, "no_asserts.wasm": 12229, "no_asserts.wasm.gz": 6004, - "strict.js": 53018, - "strict.js.gz": 16561, + "strict.js": 53058, + "strict.js.gz": 16575, "strict.wasm": 15115, "strict.wasm.gz": 7461, - "total": 176978, - "total_gz": 63767 + "total": 177058, + "total_gz": 63793 } diff --git a/test/sockets/test_dns_async.c b/test/sockets/test_dns_async.c new file mode 100644 index 0000000000000..21c2a2cdbe6ee --- /dev/null +++ b/test/sockets/test_dns_async.c @@ -0,0 +1,124 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Exercises the -sNODERAWSOCKETS DNS path. getaddrinfo() resolves numeric and + * /etc/hosts names synchronously (the latter read through emscripten's FS) and + * returns EAI_AGAIN for a real hostname. emscripten_dns_lookup_async() is the + * asynchronous getaddrinfo: it takes the same node/service/hints and returns a + * pollable fd; emscripten_dns_lookup_result() then yields the addrinfo payload. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int lookup_fd = -1; + +static void fail(const char* why) { + printf("DNS ASYNC FAIL: %s\n", why); + abort(); +} + +// getaddrinfo() of an AF_INET hostname, returning its first address (or *err). +static unsigned ipv4_of(const char* name, int* err_out) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* res = NULL; + int err = getaddrinfo(name, NULL, &hints, &res); + if (err_out) *err_out = err; + if (err != 0) return 0; + unsigned addr = ((struct sockaddr_in*)res->ai_addr)->sin_addr.s_addr; + freeaddrinfo(res); + return addr; +} + +static void main_loop(void) { + fd_set fdr; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_SET(lookup_fd, &fdr); + select(lookup_fd + 1, &fdr, NULL, NULL, &tv); + if (!FD_ISSET(lookup_fd, &fdr)) { + return; // resolution still pending + } + + // The result is delivered directly as an addrinfo payload, in the same format + // getaddrinfo() produces (and freed the same way). + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(lookup_fd, &res); + close(lookup_fd); + if (result != 0) fail("async lookup failed"); + assert(res); + if (res->ai_socktype != SOCK_STREAM) fail("async result lost the requested socktype"); + unsigned addr = ((struct sockaddr_in*)res->ai_addr)->sin_addr.s_addr; + freeaddrinfo(res); + if (addr != htonl(INADDR_LOOPBACK)) fail("localhost did not resolve to 127.0.0.1"); + + printf("DNS ASYNC PASS\n"); + emscripten_cancel_main_loop(); +} + +int main(void) { + // Seed /etc/hosts (through emscripten's FS) with names node:dns could never + // resolve, including one mapped to multiple addresses. + mkdir("/etc", 0777); + FILE* f = fopen("/etc/hosts", "w"); + assert(f); + fputs("# test hosts\n" + "10.1.2.3 statichost.test\n" + "192.0.2.1 multi.test\n" + "192.0.2.2 multi.test\n", + f); + fclose(f); + + // /etc/hosts resolves synchronously through getaddrinfo. + int err = 0; + unsigned static_addr = ipv4_of("statichost.test", &err); + if (err != 0) fail("static host not resolved from /etc/hosts"); + if (static_addr != inet_addr("10.1.2.3")) fail("static host wrong address"); + + // A name with several addresses comes back as an addrinfo linked list, freed + // as a whole by freeaddrinfo. + struct addrinfo mhints = {0}; + mhints.ai_family = AF_INET; + mhints.ai_socktype = SOCK_STREAM; + struct addrinfo* mres = NULL; + if (getaddrinfo("multi.test", NULL, &mhints, &mres) != 0) fail("multi host not resolved"); + int count = 0, seen1 = 0, seen2 = 0; + for (struct addrinfo* ai = mres; ai; ai = ai->ai_next) { + unsigned a = ((struct sockaddr_in*)ai->ai_addr)->sin_addr.s_addr; + if (a == inet_addr("192.0.2.1")) seen1 = 1; + if (a == inet_addr("192.0.2.2")) seen2 = 1; + count++; + } + freeaddrinfo(mres); + if (count != 2 || !seen1 || !seen2) fail("multi host did not return both addresses"); + + // A real hostname not in /etc/hosts has no synchronous resolution. + ipv4_of("localhost", &err); + if (err != EAI_AGAIN) fail("real hostname should be EAI_AGAIN"); + + // Resolve it asynchronously; the result arrives via the pollable fd. + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + lookup_fd = emscripten_dns_lookup_async("localhost", NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + emscripten_set_main_loop(main_loop, 0, 0); + return 0; +} diff --git a/test/sockets/test_dns_async_default.c b/test/sockets/test_dns_async_default.c new file mode 100644 index 0000000000000..543d3ae033dbb --- /dev/null +++ b/test/sockets/test_dns_async_default.c @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * The async getaddrinfo API is available without -sNODERAWSOCKETS too: there it + * resolves synchronously (the same fake address getaddrinfo() returns) and the + * fd is simply readable on the next turn, so integration code need not branch on + * the backend. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int lookup_fd = -1; + +static void fail(const char* why) { + printf("DNS ASYNC DEFAULT FAIL: %s\n", why); + abort(); +} + +static void main_loop(void) { + fd_set fdr; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_SET(lookup_fd, &fdr); + select(lookup_fd + 1, &fdr, NULL, NULL, &tv); + if (!FD_ISSET(lookup_fd, &fdr)) { + return; + } + + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(lookup_fd, &res); + close(lookup_fd); + if (result != 0) fail("async lookup failed"); + assert(res && res->ai_addr); + if (res->ai_socktype != SOCK_STREAM) fail("async result lost the requested socktype"); + freeaddrinfo(res); + + printf("DNS ASYNC DEFAULT PASS\n"); + emscripten_cancel_main_loop(); +} + +int main(void) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + lookup_fd = emscripten_dns_lookup_async("example.com", NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + emscripten_set_main_loop(main_loop, 0, 0); + return 0; +} diff --git a/test/sockets/test_dns_async_net.c b/test/sockets/test_dns_async_net.c new file mode 100644 index 0000000000000..9d3069b8a8566 --- /dev/null +++ b/test/sockets/test_dns_async_net.c @@ -0,0 +1,77 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Asynchronous getaddrinfo over the real network with -sNODERAWSOCKETS: a real + * public hostname has no synchronous resolution (EAI_AGAIN), then resolves via + * emscripten_dns_lookup_async(), whose result is delivered as an addrinfo + * payload. This hits the real network (like test_getaddrinfo). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char* HOST = "google.com"; + +static int lookup_fd = -1; + +static void fail(const char* why) { + printf("DNS ASYNC NET FAIL: %s\n", why); + abort(); +} + +static void main_loop(void) { + fd_set fdr; + struct timeval tv = {0}; + FD_ZERO(&fdr); + FD_SET(lookup_fd, &fdr); + select(lookup_fd + 1, &fdr, NULL, NULL, &tv); + if (!FD_ISSET(lookup_fd, &fdr)) { + return; // resolution still in flight + } + + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(lookup_fd, &res); + close(lookup_fd); + if (result != 0) fail("async lookup failed"); + assert(res && res->ai_addr); + freeaddrinfo(res); + + // The async resolution pre-warms getaddrinfo's cache: the same name that was + // EAI_AGAIN up front now resolves synchronously. + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* sync_res = NULL; + if (getaddrinfo(HOST, NULL, &hints, &sync_res) != 0) fail("host should be cached"); + assert(sync_res && sync_res->ai_addr); + freeaddrinfo(sync_res); + + printf("DNS ASYNC NET PASS\n"); + emscripten_cancel_main_loop(); +} + +int main(void) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + // No synchronous DNS without JSPI: the name is EAI_AGAIN up front. + struct addrinfo* res = NULL; + if (getaddrinfo(HOST, NULL, &hints, &res) != EAI_AGAIN) fail("host should be EAI_AGAIN"); + + lookup_fd = emscripten_dns_lookup_async(HOST, NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + emscripten_set_main_loop(main_loop, 0, 0); + return 0; +} diff --git a/test/sockets/test_dns_callback.c b/test/sockets/test_dns_callback.c new file mode 100644 index 0000000000000..b444aa04916dc --- /dev/null +++ b/test/sockets/test_dns_callback.c @@ -0,0 +1,65 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * The fd returned by emscripten_dns_lookup_async() becomes readable once the + * lookup completes. It is added to an epoll set and emscripten_epoll_set_callback() + * awaits it without blocking, driving the lookup purely through that callback + * with no main loop or blocking poll/select. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int ep; + +static void fail(const char* why) { + printf("DNS CALLBACK FAIL: %s\n", why); + abort(); +} + +static void on_ready(int epfd, struct epoll_event* events, int nready, void* ud) { + if (nready != 1 || !(events[0].events & EPOLLIN)) fail("lookup fd did not become readable"); + int fd = events[0].data.fd; + + struct addrinfo* res = NULL; + int result = emscripten_dns_lookup_result(fd, &res); + close(fd); + close(ep); + if (result != 0) fail("async lookup failed"); + assert(res); + unsigned addr = ((struct sockaddr_in*)res->ai_addr)->sin_addr.s_addr; + freeaddrinfo(res); + if (addr != htonl(INADDR_LOOPBACK)) fail("localhost did not resolve to 127.0.0.1"); + + printf("DNS CALLBACK PASS\n"); +} + +int main(void) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + int lookup_fd = emscripten_dns_lookup_async("localhost", NULL, &hints); + if (lookup_fd < 0) fail("async lookup did not return an fd"); + + ep = epoll_create1(0); + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = lookup_fd; + if (epoll_ctl(ep, EPOLL_CTL_ADD, lookup_fd, &ev) != 0) fail("epoll_ctl add"); + + // The armed callback keeps the runtime alive; on_ready fires when the lookup + // fd becomes readable. Closing the epoll fd in the callback ends the interest. + emscripten_epoll_set_callback(ep, 1, on_ready, NULL); + return 0; +} diff --git a/test/sockets/test_dns_jspi.c b/test/sockets/test_dns_jspi.c new file mode 100644 index 0000000000000..f88bd6f0ab994 --- /dev/null +++ b/test/sockets/test_dns_jspi.c @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * With -sNODERAWSOCKETS under JSPI, getaddrinfo() of a real public hostname + * blocks on the node:dns lookup by suspending the wasm stack, and resolves + * directly - no EAI_AGAIN + async prewarm + retry needed. This resolves over + * the real network. + */ + +#include +#include +#include +#include +#include +#include + +static const char* HOST = "google.com"; + +int main(void) { + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + struct addrinfo* res = NULL; + int err = getaddrinfo(HOST, NULL, &hints, &res); + if (err != 0) { + printf("DNS JSPI FAIL: getaddrinfo err=%d\n", err); + return 1; + } + assert(res); + freeaddrinfo(res); + + printf("DNS JSPI PASS\n"); + return 0; +} diff --git a/test/test_sockets.py b/test/test_sockets.py index 33e44bb847979..756cf46805f3d 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -511,6 +511,54 @@ def test_noderawsockets_epoll_rdhup_jspi(self): self.do_runf('sockets/test_epoll_rdhup.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) + @also_with_proxy_to_pthread + def test_noderawsockets_dns_async(self): + # getaddrinfo() resolves numeric and /etc/hosts names (read via emscripten's + # FS) synchronously and returns EAI_AGAIN for a real hostname. + # emscripten_dns_lookup_async() is the async getaddrinfo: a pollable fd whose + # emscripten_dns_lookup_result() yields the addrinfo payload directly. + self.do_runf('sockets/test_dns_async.c', 'DNS ASYNC PASS', cflags=['-sNODERAWSOCKETS']) + + @also_with_proxy_to_pthread + def test_noderawsockets_dns_callback(self): + # The async lookup fd becomes readable on completion; it is added to an epoll + # set and emscripten_epoll_set_callback() awaits it without blocking, driving the + # lookup purely via its callback, with no main loop or blocking poll/select. + # EXIT_RUNTIME so the proxy_to_pthread variant tears down cleanly once the + # callback's keepalive is released (otherwise the worker lingers). + self.do_runf('sockets/test_dns_callback.c', 'DNS CALLBACK PASS', cflags=['-sNODERAWSOCKETS', '-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_noderawsockets_dns_async_net(self): + # A real public hostname is EAI_AGAIN synchronously, then resolves via the + # async getaddrinfo, whose result is delivered as an addrinfo payload. The + # async resolution pre-warms the shared cache, so a following synchronous + # getaddrinfo() of the same name then succeeds. This hits the real network + # (like test_getaddrinfo). + self.do_runf('sockets/test_dns_async_net.c', 'DNS ASYNC NET PASS', cflags=['-sNODERAWSOCKETS']) + + def test_dns_async_default(self): + # The async getaddrinfo API is available without -sNODERAWSOCKETS, resolving + # synchronously (the same fake address getaddrinfo() returns) and delivering + # it via the pollable fd. + self.do_runf('sockets/test_dns_async_default.c', 'DNS ASYNC DEFAULT PASS') + + @also_with_proxy_to_pthread + def test_noderawsockets_dns_jspi(self): + # Under JSPI, getaddrinfo() of a real public hostname blocks on the + # node:dns lookup (suspending the wasm stack) and resolves directly, + # without the EAI_AGAIN + async retry needed in non-JSPI builds. This + # hits the real network (like test_getaddrinfo). 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_dns_jspi.c', 'DNS JSPI PASS', cflags=['-sNODERAWSOCKETS']) + @also_with_proxy_to_pthread def test_noderawsockets_udp(self): # Self-contained loopback UDP echo: the server binds(:0)+getsockname for its