diff --git a/ChangeLog.md b/ChangeLog.md index d95ba68965b5a..36f653c926fef 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -37,6 +37,14 @@ See docs/process.md for more on how version tagging works. FS-backend handler signature changed from `poll(stream, timeout)` to `poll(stream)` returning the current readiness mask; out-of-tree custom FS backends with a `poll` handler must update. (#27226) +- Added support for `epoll` (`epoll_create1`/`epoll_ctl`/`epoll_wait`/ + `epoll_pwait`) on the legacy (non-WASMFS) JS filesystem, including + level- and edge-triggered modes, `EPOLLONESHOT`, `EPOLLEXCLUSIVE`, + `EPOLLRDHUP`, nesting, and blocking waits under `PROXY_TO_PTHREAD`, + `ASYNCIFY`, and `JSPI`. Also added `emscripten_epoll_set_callback` + (in the new ``, experimental), a non-blocking variant + that delivers an epoll set's readiness to a JS callback with no + `ASYNCIFY`/`JSPI`. (#27207) - compiler-rt and libunwind were updated to LLVM 22.1.8. (#27245, #27246) - `-fcoverage-mapping` is currently broken due to a mismatch between the version of LLVM used and the imported version of compiler-rt. We hope to fix this diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js new file mode 100644 index 0000000000000..350d46ae60ac8 --- /dev/null +++ b/src/lib/libepoll.js @@ -0,0 +1,547 @@ +/** + * @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's state lives on the stream's `shared` object - the open + // file description (Linux's struct file / eventpoll) that dup'd fds share. It + // carries an interest map `epoll` (fd -> reg) and a ready list + // (rdlHead/rdlTail). Each registration arms a persistent listener on the + // watched node's wait-queue at EPOLL_CTL_ADD (not per-wait), feeding the ready + // list on each edge so readiness can be tracked across waits and up a nesting + // chain. dup(2) yields another fd to the SAME instance (registrations, ready + // list, and callback all shared); close(2) drops one reference and only the + // last close reclaims it (tearing every registration down). An epoll fd can + // itself be added to another epoll. + $newEpollInstance__internal: true, + $newEpollInstance__deps: ['$FS', '$pollOne', '$clearEpollInterest', '$reconcileEpollKeepalive', '$epollEvict'], + $newEpollInstance: () => { + // Its own (detached) node, so the epoll fd can be watched by a parent epoll + // (nesting) and carry the readiness wait-queue methods. Shared across dups. + var node = new FS.FSNode(0, 'epoll', 0, 0); + var stream = FS.createStream({ + node, + stream_ops: { + // Readable when any listed registration is currently ready: this is what + // lets an epoll fd be polled/nested. Walks only the ready list (O(ready)); + // edge/oneshot/exclusive are reporting-time concerns, masked out here. A + // closed/reused fd is evicted here too, so a nested epoll that is only ever + // polled (never directly waited) does not accumulate dead registrations. + poll(stream) { + var ep = stream.shared; + for (var reg = ep.rdlHead, next; reg; reg = next) { + next = reg.rdlNext; + if (FS.getStream(reg.fd)?.shared !== reg.shared) { + epollEvict(ep, reg); + continue; + } + if (pollOne(reg.fd, reg.events & ~{{{ cDefs.EPOLLET | cDefs.EPOLLONESHOT | cDefs.EPOLLEXCLUSIVE }}})) { + return {{{ cDefs.POLLIN }}}; + } + } + return 0; + }, + // dup(2): another fd to the same epoll instance (Linux: another reference + // to the eventpoll). The instance state lives on the shared open file + // description, already propagated by reference to the dup'd stream, so + // there is nothing to copy - just count the new reference. + dup(stream) { + stream.shared.refcount++; + }, + // close(2): drop one reference. Only the last close reclaims the + // instance: clear the readiness callback interest (if any), then drop + // every registration's listener (a fired EPOLLONESHOT has already dropped + // its own) from its watched node. A surviving dup keeps it all live. + close(stream) { + var ep = stream.shared; + // FS.close already fired POLLNVAL on the (shared) node, waking any + // parent epoll watching this fd so it re-derives and drops the + // now-stale registration (via doEpollWait's shared check). + if (--ep.refcount) return; + clearEpollInterest(ep); + reconcileEpollKeepalive(ep); // drop the keepalive if it was held + for (var reg of ep.epoll.values()) { + reg.listener?.listeners.delete(reg.listener.entry); + } + ep.epoll.clear(); + }, + }, + }); + // Hoist the instance state onto `shared` so every dup observes one instance. + Object.assign(stream.shared, { + node, + epoll: new Map(), + // Count of armed registrations: those with a live watched-node listener. A + // fired EPOLLONESHOT drops its listener (so it stops counting until a MOD + // re-arm), a plain registration counts until evicted. This - not the raw + // interest-map size - is what the callback keepalive keys on: a set with no + // armed registration can never fire again on its own, so it is terminal. + armed: 0, + // Open references (fds) to this instance; the last close reclaims it. + refcount: 1, + }); + return stream; + }, + + // Drop an epoll's persistent readiness callback interest: remove its listener + // on the epoll node and free the output buffer. Keepalive is managed by the + // 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); +#if PTHREADS + // Retire its delivery token; a still-in-flight cross-thread delivery whose + // completion arrives after this finds nothing and is dropped. + if (it.token) delete epollDeliveries[it.token]; +#endif + }, + + // A registered callback keeps the runtime alive only while it can still fire - + // i.e. while the epoll has at least one armed registration. Once every watched + // fd is closed (or every one-shot has fired) the set is terminal, so the + // keepalive is dropped and the runtime may exit. Reconciled after any change to + // the callback or the armed count. + // + // Two threads must be held while armed under pthread proxy: this (the FS/main + // thread) runs the readiness derivation and delivery scheduling, and the owner + // thread (ep.ownerThread) both runs the callback and must survive to receive + // it. So push/pop the local keepalive AND, for a distinct owner worker, proxy + // the same push/pop onto it. keepaliveThread records where the owner push + // landed so a later handover (a replace from another thread) pops the right one. + $reconcileEpollKeepalive__internal: true, + $reconcileEpollKeepalive__deps: [ +#if PTHREADS + '_emscripten_epoll_keepalive_on_thread', +#endif + ], + $reconcileEpollKeepalive: (ep) => { + var want = !!ep.interest && ep.armed > 0; + if (want == !!ep.keepalive) return; + ep.keepalive = want; +#if PTHREADS + // ownerThread is 0 when the FS thread itself registered - then the local + // push/pop below already covers it. + if (ep.ownerThread) { + if (want) __emscripten_epoll_keepalive_on_thread(ep.keepaliveThread = ep.ownerThread, 1); + else { __emscripten_epoll_keepalive_on_thread(ep.keepaliveThread, -1); ep.keepaliveThread = 0; } + } +#endif +#if !MINIMAL_RUNTIME && (EXIT_RUNTIME || PTHREADS) + if (want) { {{{ runtimeKeepalivePush() }}} } else { {{{ runtimeKeepalivePop() }}} } +#endif + }, + + // 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); + // A fired EPOLLONESHOT already dropped its listener (and its armed count), so + // only decrement for a still-armed registration. + if (reg.listener) { reg.listener.listeners.delete(reg.listener.entry); ep.armed--; } + ep.epoll.delete(reg.fd); + reconcileEpollKeepalive(ep); + }, + + // 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 (via any fd referring to the same instance). + if (target.shared === ep) return -{{{ cDefs.EINVAL }}}; + + // A registration keys on the open file description (stream.shared) - the + // struct-file analog that dup'd fds share. If this fd's number now resolves + // 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.shared.epoll) { + // Walk streams but key the graph on instances (stream.shared), so dup'd + // fds of one epoll count as a single node. + var reaches = (from, goal, seen) => { + var inst = from?.shared; + if (inst === goal) return true; + if (!inst?.epoll || seen.has(inst)) return false; + seen.add(inst); + for (var f of inst.epoll.keys()) { + if (reaches(FS.getStream(f), goal, seen)) return true; + } + return false; + }; + var depth = (from, seen) => { + var inst = from?.shared; + if (!inst?.epoll || seen.has(inst)) return 0; + seen.add(inst); + var max = 0; + for (var f of inst.epoll.keys()) max = Math.max(max, depth(FS.getStream(f), seen)); + seen.delete(inst); + return 1 + max; + }; + if (reaches(target, ep, new Set()) || 1 + depth(target, new Set()) > 5) { + 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); + // The registration's listener is its edge in the interest graph - present + // only while armed, so a watched node fires nothing for a dead edge. ADD + // installs it; a fired EPOLLONESHOT dropped it, so a MOD re-arm reinstalls it. + // Arming (re-)counts it toward the keepalive. + // (ep_poll_callback: on an edge, list the reg and wake any waiter on this + // epoll - and through ep.node any parent epoll nesting it.) + if (!reg.listener) { + 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 }}})); + ep.armed++; + } + // Arming is itself an event source (ep_insert/ep_modify): a source-based + // model only learns readiness from edges, so sample the level now - the + // (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 }}}); + } + // After arming (ADD or a MOD re-arm): the armed count may have changed. + reconcileEpollKeepalive(ep); + 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', '$reconcileEpollKeepalive'], + $doEpollWait: (ep, ev, maxevents) => { + var disarmed = false; // a fired EPOLLONESHOT dropped its armed count + // Detach the list and drain from the head: re-armed level triggers and the + // unprocessed remainder go back onto ep's now-empty list, so a single pass + // never revisits an entry. O(delivered), not O(registered). + 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) - and + // its armed count, so a set of only-fired one-shots reads as terminal. + node.listener.listeners.delete(node.listener.entry); + node.listener = null; + ep.armed--; + disarmed = true; + } else if (!(node.events & {{{ cDefs.EPOLLET }}})) { + rdllistAdd(ep, node); // level: re-list at tail + } + } + // 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; + } + // A fired one-shot may have made the set terminal (no armed registration + // left); evictions above already reconciled themselves. + if (disarmed) reconcileEpollKeepalive(ep); + return n; + }, + + // 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', +#if PTHREADS + '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', +#endif + ], + emscripten_epoll_set_callback__proxy: 'sync', + emscripten_epoll_set_callback: (epfd, maxevents, callback, userdata) => { + var stream = FS.getStream(epfd); + // This is a direct public API (not a syscall), so it returns a positive + // errno rather than the -errno syscall convention. + if (!stream?.shared.epoll) return {{{ cDefs.EBADF }}}; + // Operate on the shared instance so a callback armed on one fd sees + // registrations made through any dup of it. + var ep = stream.shared; + // maxevents only matters when (re-)arming; validate before any mutation so a + // bad register call has no side effects (an unregister ignores it). + if (callback && maxevents <= 0) return {{{ cDefs.EINVAL }}}; + +#if PTHREADS + // __proxy: 'sync' runs this (and every delivery) on the thread that owns the + // filesystem. When a worker registered the callback it must fire on that + // worker, not here, so capture the caller and back-proxy each delivery to it. + // Zero when the caller is the FS-owning thread itself - then deliver inline. + var callerThread = PThread.currentProxiedOperationCallerThread; +#endif + + // Tear down any existing interest first - a second call replaces the + // callback, it does not stack. Reconcile immediately: if a different thread + // owned the previous callback, this drops that thread's keepalive before we + // take ownership on the caller's thread below (a handover, not a no-op). + clearEpollInterest(ep); + reconcileEpollKeepalive(ep); + if (!callback) return 0; + + // Runtime-owned output buffer reused across every delivery; freed at clear. + var buf = _malloc(maxevents * {{{ C_STRUCTS.epoll_event.__size__ }}}); + var it = ep.interest = {buf}; +#if PTHREADS + // The registering thread owns the callback and, once armed, its runtime + // keepalive. 0 is the FS thread itself (delivered inline, keepalive local). + ep.ownerThread = callerThread; +#endif + // Producer notifies arrive synchronously (SOCKFS.emit, pipe writes); coalesce + // them into one delivery on a microtask (the callback must not run in the + // producer's/caller's stack), re-deriving the ready set at that tick. A + // 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 deliver() { + if (ep.interest !== it) return; // cleared before the tick fired +#if PTHREADS + // One cross-thread delivery in flight at a time: the registering thread + // drains inside the callback, so re-deriving before it completes would just + // re-see (and re-deliver) the same still-ready level fd in a tight spin. + // The delivery's completion (do_epoll_done -> epoll_delivery_done) clears + // this and re-wakes. + if (it.inflight) return; +#endif + var c = doEpollWait(ep, buf, maxevents); + if (!c) return; +#if PTHREADS + if (callerThread) { + // The helper copies the events out synchronously, so buf is free to be + // reused; it also reports back on completion to pace the next delivery. + it.inflight = true; + __emscripten_epoll_run_callback_on_thread(callerThread, callback, epfd, buf, c, userdata, it.token); + return; + } +#endif + callUserCallback(() => {{{ makeDynCall('vipip', 'callback') }}}(epfd, buf, c, userdata)); + // Still ready (overflow past maxevents, or a still-ready level fd + // re-listed): keep draining on the next tick. Note this is NOT a + // blocking epoll_wait loop - there the app owns the loop and may block + // elsewhere. A level-triggered fd that is structurally always ready and + // never drained (e.g. EPOLLOUT on a writable socket) will re-schedule a + // microtask each tick and so starve the event loop; use EPOLLET or + // unregister for such fds. + if (ep.interest === it && ep.rdlHead) wake(); + } + function wake() { + if (it.scheduled) return; + it.scheduled = true; + queueMicrotask(() => { + it.scheduled = false; + deliver(); + }); + } +#if PTHREADS + // Resume point for a completed cross-thread delivery, keyed by token so the + // C completion can find this interest again. + if (callerThread) { + it.wake = wake; + it.token = epollDeliveries.nextToken++; + epollDeliveries[it.token] = it; + } +#endif + it.listener = ep.node.addListener(wake); + reconcileEpollKeepalive(ep); // hold the runtime only while there are live fds + wake(); // deliver initial readiness if the set is already ready + return 0; + }, + +#if PTHREADS + // Token -> interest for cross-thread deliveries (numeric keys), plus nextToken: + // the next token to hand out. A monotonic token means a stale completion + // (interest cleared mid-flight) never resolves to a different interest - it + // simply finds nothing. + $epollDeliveries: {nextToken: 1}, + + // Called (on the FS-owning thread) by the C helper once a cross-thread delivery + // finishes on the registering thread: clear the in-flight gate and re-derive, + // so a still-ready set delivers its next batch. + _emscripten_epoll_delivery_done__deps: ['$epollDeliveries'], + _emscripten_epoll_delivery_done: (token) => { + var it = epollDeliveries[token]; + if (!it) return; // interest was cleared while the delivery was in flight + it.inflight = false; + it.wake(); + }, +#endif +}; + +addToLibrary(EpollLibrary); diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index 538f80c9b7c2b..757767240d11b 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_fadvise64__sig: 'iijji', __syscall_fallocate__sig: 'iiijj', @@ -326,6 +327,7 @@ sigs = { _emscripten_create_wasm_worker__sig: 'iipip', _emscripten_dlopen_js__sig: 'vpppp', _emscripten_dlsync_threads__sig: 'v', + _emscripten_epoll_delivery_done__sig: 'vi', _emscripten_fetch_get_response_headers__sig: 'pipp', _emscripten_fetch_get_response_headers_length__sig: 'pi', _emscripten_fs_load_embedded_files__sig: 'vp', @@ -639,6 +641,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 603fdae2a50f3..5b6f280f62a12 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -589,9 +589,7 @@ var SyscallsLibrary = { if (!stream) return {{{ cDefs.POLLNVAL }}}; // Streams without a poll handler (regular files, incl. NODERAWFS/NODEFS // which leave stream_ops unset) are treated as always readable+writable. - var flags = stream.stream_ops?.poll - ? stream.stream_ops.poll(stream) - : {{{ cDefs.POLLIN | cDefs.POLLOUT }}}; + var flags = stream.stream_ops?.poll?.(stream) ?? {{{ cDefs.POLLIN | cDefs.POLLOUT }}}; return flags & (events | {{{ cDefs.POLLERR }}} | {{{ cDefs.POLLHUP }}} | {{{ cDefs.POLLNVAL }}}); }, __syscall_poll__proxy: 'sync', @@ -697,13 +695,42 @@ var SyscallsLibrary = { __syscall_poll_nonblocking: (fds, nfds) => { return doPollSync(fds, nfds); }, - // epoll is not yet implemented in the legacy (non-WASMFS) JS syscall layer. - __syscall_epoll_create1__nothrow: true, - __syscall_epoll_create1: (flags) => -{{{ cDefs.ENOSYS }}}, - __syscall_epoll_ctl__nothrow: true, - __syscall_epoll_ctl: (epfd, op, fd, ev) => -{{{ cDefs.ENOSYS }}}, - __syscall_epoll_pwait__nothrow: true, - __syscall_epoll_pwait: (epfd, ev, maxevents, timeout, sigmask, sigsetsize) => -{{{ cDefs.ENOSYS }}}, + // epoll: the entry points live here (like every other syscall); the heavy + // lifting is in libepoll.js, which they call after resolving the epoll stream. + __syscall_epoll_create1__deps: ['$newEpollInstance'], + __syscall_epoll_create1__proxy: 'sync', + __syscall_epoll_create1: (flags) => { + return newEpollInstance().fd; + }, + __syscall_epoll_ctl__deps: ['$FS', '$epollCtl'], + __syscall_epoll_ctl__proxy: 'sync', + __syscall_epoll_ctl: (epfd, op, fd, ev) => { + var ep = FS.getStream(epfd); + if (!ep?.shared.epoll) return -{{{ cDefs.EBADF }}}; + return epollCtl(ep.shared, op, fd, ev); + }, + __syscall_epoll_pwait__proxy: 'sync', + __syscall_epoll_pwait__async: 'auto', + __syscall_epoll_pwait__deps: ['$FS', '$epollPwait'], + __syscall_epoll_pwait: (epfd, ev, maxevents, timeout, sigmask, sigsetsize) => { + var ep = FS.getStream(epfd); + if (!ep?.shared.epoll) return -{{{ cDefs.EBADF }}}; + if (maxevents <= 0) return -{{{ cDefs.EINVAL }}}; + return epollPwait(ep.shared, ev, maxevents, timeout); + }, + // libc routes zero-timeout epoll_wait()/epoll_pwait() calls here: a plain + // import that never suspends, so probes stay callable from any context (under + // 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?.shared.epoll) return -{{{ cDefs.EBADF }}}; + if (maxevents <= 0) return -{{{ cDefs.EINVAL }}}; + return doEpollWait(ep.shared, 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..ec20f9016b355 100644 --- a/src/modules.mjs +++ b/src/modules.mjs @@ -113,6 +113,7 @@ function calculateLibraries() { 'libtty.js', 'libpipefs.js', // ok to include it by default since it's only used if the syscall is used 'libsockfs.js', // ok to include it by default since it's only used if the syscall is used + 'libepoll.js', // ok to include it by default since it's only used if the syscall is used ); if (NODERAWSOCKETS) { diff --git a/src/struct_info.json b/src/struct_info.json index 86b32cc0d3aee..b4457c98e5e71 100644 --- a/src/struct_info.json +++ b/src/struct_info.json @@ -141,6 +141,30 @@ ] } }, + { + "file": "sys/epoll.h", + "defines": [ + "EPOLLIN", + "EPOLLOUT", + "EPOLLERR", + "EPOLLHUP", + "EPOLLRDNORM", + "EPOLLWRNORM", + "EPOLLET", + "EPOLLONESHOT", + "EPOLLEXCLUSIVE", + "EPOLL_CTL_ADD", + "EPOLL_CTL_DEL", + "EPOLL_CTL_MOD", + "EPOLL_CLOEXEC" + ], + "structs": { + "epoll_event": [ + "events", + "data" + ] + } + }, { "file": "time.h", "defines": [ diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index 89ea8ef20bf49..0a30caf01a268 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -260,6 +260,19 @@ "EPERM": 63, "EPFNOSUPPORT": 139, "EPIPE": 64, + "EPOLLERR": 8, + "EPOLLET": -2147483648, + "EPOLLEXCLUSIVE": 268435456, + "EPOLLHUP": 16, + "EPOLLIN": 1, + "EPOLLONESHOT": 1073741824, + "EPOLLOUT": 4, + "EPOLLRDNORM": 64, + "EPOLLWRNORM": 256, + "EPOLL_CLOEXEC": 524288, + "EPOLL_CTL_ADD": 1, + "EPOLL_CTL_DEL": 2, + "EPOLL_CTL_MOD": 3, "EPROTO": 65, "EPROTONOSUPPORT": 66, "EPROTOTYPE": 67, @@ -1019,6 +1032,11 @@ "stack_ptr": 8, "user_data": 16 }, + "epoll_event": { + "__size__": 16, + "data": 8, + "events": 0 + }, "flock": { "__size__": 32, "l_type": 0 diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index 9605a53d336a9..c9abc70ca5f9c 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -260,6 +260,19 @@ "EPERM": 63, "EPFNOSUPPORT": 139, "EPIPE": 64, + "EPOLLERR": 8, + "EPOLLET": -2147483648, + "EPOLLEXCLUSIVE": 268435456, + "EPOLLHUP": 16, + "EPOLLIN": 1, + "EPOLLONESHOT": 1073741824, + "EPOLLOUT": 4, + "EPOLLRDNORM": 64, + "EPOLLWRNORM": 256, + "EPOLL_CLOEXEC": 524288, + "EPOLL_CTL_ADD": 1, + "EPOLL_CTL_DEL": 2, + "EPOLL_CTL_MOD": 3, "EPROTO": 65, "EPROTONOSUPPORT": 66, "EPROTOTYPE": 67, @@ -1019,6 +1032,11 @@ "stack_ptr": 16, "user_data": 32 }, + "epoll_event": { + "__size__": 16, + "data": 8, + "events": 0 + }, "flock": { "__size__": 32, "l_type": 0 diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h new file mode 100644 index 0000000000000..da09af74609db --- /dev/null +++ b/system/include/emscripten/epoll.h @@ -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. + */ + +#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 calling thread's event loop: with pthreads the +// epoll readiness is tracked on the thread that owns the filesystem (the syscalls +// are proxied there), but each delivery is dispatched back to the thread that +// registered the callback. +// +// While armed it keeps the runtime alive only as long as it can still fire - i.e. +// while the epoll has at least one open watched fd. Once every watched fd is +// 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 a positive errno (EBADF if `epfd` is not an epoll fd, EINVAL). +// +// Each registration's trigger mode (set per-fd via epoll_ctl) controls how often +// the callback fires for it - identically to epoll_wait, so one callback can mix +// 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/emscripten_internal.h b/system/lib/libc/emscripten_internal.h index 55ca0fa09dc2a..a2f77c31a3c85 100644 --- a/system/lib/libc/emscripten_internal.h +++ b/system/lib/libc/emscripten_internal.h @@ -67,6 +67,10 @@ emscripten_stack_unwind_buffer(uintptr_t pc, uintptr_t* buffer, uint32_t depth); bool _emscripten_get_now_is_monotonic(void); +// Defined in library.js; called by emscripten_epoll_callback.c to report a +// completed cross-thread epoll callback delivery back to the FS-owning thread. +void _emscripten_epoll_delivery_done(int token); + void _emscripten_get_progname(char*, int); // Not defined in musl, but defined in library.js. Included here for diff --git a/system/lib/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/pthread/emscripten_epoll_callback.c b/system/lib/pthread/emscripten_epoll_callback.c new file mode 100644 index 0000000000000..56797dada9de4 --- /dev/null +++ b/system/lib/pthread/emscripten_epoll_callback.c @@ -0,0 +1,100 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + */ + +// Backs emscripten_epoll_set_callback under PTHREADS: the epoll readiness lives +// on the thread that owns the filesystem (the epoll syscalls are proxied +// there), but the user callback must run on the thread that registered it. This +// mirrors _emscripten_run_callback_on_thread in html5/callback.c, but carries +// the epoll callback's argument shape (epfd, events, nready, userdata) and, +// crucially, reports back to the registering thread when a delivery completes +// so it can pace the next one - a level-triggered fd stays ready until the +// callback drains it, so the FS thread must wait for that drain before +// re-deriving, or it would spin re-delivering the same readiness. + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "emscripten_internal.h" + +typedef void (*em_epoll_callback)(int epfd, + struct epoll_event* events, + int nready, + void* userdata); + +typedef struct epoll_callback_args_t { + em_epoll_callback callback; + int epfd; + int nready; + void* userdata; + int token; + struct epoll_event events[]; +} epoll_callback_args_t; + +// Runs on the registering thread: deliver the ready set to the user callback. +static void do_epoll_callback(void* arg) { + epoll_callback_args_t* args = (epoll_callback_args_t*)arg; + args->callback(args->epfd, args->events, args->nready, args->userdata); +} + +// Runs back on the FS-owning thread once the delivery above has finished (or +// was cancelled because the target thread went away): let the JS layer +// re-derive. +static void do_epoll_done(void* arg) { + epoll_callback_args_t* args = (epoll_callback_args_t*)arg; + _emscripten_epoll_delivery_done(args->token); + free(arg); +} + +void _emscripten_epoll_run_callback_on_thread(pthread_t t, + em_epoll_callback callback, + int epfd, + struct epoll_event* events, + int nready, + void* userdata, + int token) { + em_proxying_queue* q = emscripten_proxy_get_system_queue(); + // Copy the events out synchronously so the caller's (reused) buffer is free + // again the moment this returns; freed once the delivery completes. + size_t bytes = nready * sizeof(struct epoll_event); + epoll_callback_args_t* args = malloc(sizeof(epoll_callback_args_t) + bytes); + args->callback = callback; + args->epfd = epfd; + args->nready = nready; + args->userdata = userdata; + args->token = token; + memcpy(args->events, events, bytes); + + if (!emscripten_proxy_callback( + q, t, do_epoll_callback, do_epoll_done, do_epoll_done, args)) { + assert(false && "emscripten_proxy_callback failed"); + } +} + +// Runs on the owning thread: adjust its (thread-local) runtime keepalive so the +// epoll callback holds the thread it was registered on, not the FS thread. +static void do_epoll_keepalive(void* arg) { + if ((intptr_t)arg > 0) { + emscripten_runtime_keepalive_push(); + } else { + emscripten_runtime_keepalive_pop(); + } +} + +void _emscripten_epoll_keepalive_on_thread(pthread_t t, int delta) { + em_proxying_queue* q = emscripten_proxy_get_system_queue(); + if (!emscripten_proxy_async( + q, t, do_epoll_keepalive, (void*)(intptr_t)delta)) { + assert(false && "emscripten_proxy_async failed"); + } +} diff --git a/system/lib/wasmfs/syscalls.cpp b/system/lib/wasmfs/syscalls.cpp index 757a2d7f21c46..4e271b1167e3a 100644 --- a/system/lib/wasmfs/syscalls.cpp +++ b/system/lib/wasmfs/syscalls.cpp @@ -1880,4 +1880,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 1dca8f07ac0cb..efed26ba969fb 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": 269157, - "a.out.nodebug.wasm": 588047, - "total": 857204, + "a.out.js": 272487, + "a.out.nodebug.wasm": 588107, + "total": 860594, "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_fadvise64", "__syscall_fallocate", @@ -457,6 +458,7 @@ "emscripten_debugger", "emscripten_destroy_worker", "emscripten_enter_soft_fullscreen", + "emscripten_epoll_set_callback", "emscripten_err", "emscripten_errn", "emscripten_exit_fullscreen", @@ -1752,6 +1754,7 @@ "env.__syscall_epoll_create1", "env.__syscall_epoll_ctl", "env.__syscall_epoll_pwait", + "env.__syscall_epoll_pwait_nonblocking", "env.__syscall_faccessat", "env.__syscall_fadvise64", "env.__syscall_fallocate", 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..075c53bc48d80 --- /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 a positive EBADF. + assert(emscripten_epoll_set_callback(rfd, 4, cb1, 0) == EBADF); + + // Register then immediately replace, before any tick runs: only cb2 is armed. + assert(emscripten_epoll_set_callback(ep, 4, cb1, 0) == 0); + 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_dup.c b/test/core/test_epoll_dup.c new file mode 100644 index 0000000000000..4e98714356417 --- /dev/null +++ b/test/core/test_epoll_dup.c @@ -0,0 +1,69 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * dup(2) of an epoll fd yields another reference to the SAME epoll instance + * (Linux eventpoll semantics): registrations, the ready list, and the persistent + * readiness callback are all shared across every fd. This mirrors tokio's + * single-threaded reactor, which arms emscripten_epoll_set_callback on one fd + * and drives epoll_ctl(ADD) through a dup of it. + * - A registration added via the dup must be delivered to a callback armed on + * the original fd. + * - Closing one dup must NOT tear the instance down while another fd is open; + * only the last close reclaims it. + */ + +#include +#include +#include +#include +#include +#include + +static int ep_a, ep_b, rfd, wfd; +static int fires; + +static void on_ready(int epfd, struct epoll_event* events, int nready, void* ud) { + assert(epfd == ep_a); + assert(nready == 1); + assert(events[0].events & EPOLLIN); + assert(events[0].data.u32 == 0x1234); + fires++; + + char b[1]; + assert(read(rfd, b, 1) == 1); + assert(emscripten_epoll_set_callback(ep_a, 4, NULL, NULL) == 0); + printf("done\n"); +} + +int main(void) { + ep_a = epoll_create1(0); + + // Arm the persistent callback on the original fd. + assert(emscripten_epoll_set_callback(ep_a, 4, on_ready, NULL) == 0); + + // dup: a second fd to the SAME epoll instance (like tokio's registry handle). + ep_b = dup(ep_a); + assert(ep_b >= 0 && ep_b != ep_a); + + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + + // Register through the dup. This must be visible to the callback armed on + // ep_a, since both fds share one epoll instance. + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.u32 = 0x1234; + assert(epoll_ctl(ep_b, EPOLL_CTL_ADD, rfd, &ev) == 0); + + // Closing one dup must not tear the instance down: the registration added via + // ep_b stays live and the callback on ep_a keeps working. + assert(close(ep_b) == 0); + + // Make rfd readable. The edge must reach ep_a's callback. + assert(write(wfd, "x", 1) == 1); + return 0; +} diff --git a/test/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..7fcf78a70e84f --- /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("done\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..f71f157f6ab72 --- /dev/null +++ b/test/sockets/test_epoll_socket_blocking.c @@ -0,0 +1,87 @@ +/* + * 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__ + // 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 + 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("done\n"); + return 0; +} diff --git a/test/sockets/test_tcp_backpressure.c b/test/sockets/test_tcp_backpressure.c index 993515526fad7..a10174ed0d6e6 100644 --- a/test/sockets/test_tcp_backpressure.c +++ b/test/sockets/test_tcp_backpressure.c @@ -36,7 +36,7 @@ long long sent_total = 0; const long long CAP = 512LL * 1024 * 1024; void test_success(void) { - printf("BACKPRESSURE PASS\n"); + printf("done\n"); if (fd >= 0) close(fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_client_bind.c b/test/sockets/test_tcp_client_bind.c index 59e5b1d188668..c9fea5d3f4dec 100644 --- a/test/sockets/test_tcp_client_bind.c +++ b/test/sockets/test_tcp_client_bind.c @@ -36,7 +36,7 @@ bool connected = false; bool ping_sent = false; void test_success(void) { - printf("CLIENT BIND PASS\n"); + printf("done\n"); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_client_semantics.c b/test/sockets/test_tcp_client_semantics.c index 7cc83680cbaad..588b3a4dabb6f 100644 --- a/test/sockets/test_tcp_client_semantics.c +++ b/test/sockets/test_tcp_client_semantics.c @@ -39,7 +39,7 @@ bool ping_sent = false; bool echoed = false; void test_success(void) { - printf("CLIENT SEMANTICS PASS\n"); + printf("done\n"); if (fd >= 0) close(fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_echo.c b/test/sockets/test_tcp_echo.c index b5204a49b1287..a7c0457b0bd0c 100644 --- a/test/sockets/test_tcp_echo.c +++ b/test/sockets/test_tcp_echo.c @@ -36,7 +36,7 @@ bool connected = false; bool ping_sent = false; void test_success(void) { - printf("TCP ECHO PASS\n"); + printf("done\n"); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ // The socket is closed and the main loop cancelled, so node's event loop diff --git a/test/sockets/test_tcp_ipv6.c b/test/sockets/test_tcp_ipv6.c index 4e4a20330c14d..b9cdd80f34991 100644 --- a/test/sockets/test_tcp_ipv6.c +++ b/test/sockets/test_tcp_ipv6.c @@ -39,7 +39,7 @@ bool pong_sent = false; void set_nonblocking(int fd) { fcntl(fd, F_SETFL, O_NONBLOCK); } void test_success(void) { - printf("TCP IPV6 PASS\n"); + printf("done\n"); if (listen_fd >= 0) close(listen_fd); if (client_fd >= 0) close(client_fd); if (peer_fd >= 0) close(peer_fd); diff --git a/test/sockets/test_tcp_refused.c b/test/sockets/test_tcp_refused.c index 29cd9e3ca6a23..b4e1664c0776b 100644 --- a/test/sockets/test_tcp_refused.c +++ b/test/sockets/test_tcp_refused.c @@ -29,7 +29,7 @@ int fd = -1; void test_success(void) { - printf("REFUSED PASS\n"); + printf("done\n"); if (fd >= 0) close(fd); #ifdef __EMSCRIPTEN__ emscripten_cancel_main_loop(); diff --git a/test/sockets/test_tcp_server.c b/test/sockets/test_tcp_server.c index 677cd8d2c925d..7b3921a7617c0 100644 --- a/test/sockets/test_tcp_server.c +++ b/test/sockets/test_tcp_server.c @@ -42,7 +42,7 @@ void set_nonblocking(int fd) { } void test_success(void) { - printf("TCP SERVER PASS\n"); + printf("done\n"); if (listen_fd >= 0) close(listen_fd); if (client_fd >= 0) close(client_fd); if (peer_fd >= 0) close(peer_fd); diff --git a/test/sockets/test_udp_connect.c b/test/sockets/test_udp_connect.c index 4af53a3cf1908..403bcbf1c18c2 100644 --- a/test/sockets/test_udp_connect.c +++ b/test/sockets/test_udp_connect.c @@ -40,7 +40,7 @@ void set_nonblocking(int fd) { } void test_success(void) { - printf("UDP CONNECT PASS\n"); + printf("done\n"); if (server_fd >= 0) close(server_fd); if (client_fd >= 0) close(client_fd); if (other_fd >= 0) close(other_fd); diff --git a/test/sockets/test_udp_echo.c b/test/sockets/test_udp_echo.c index c8f3085ee3a6f..bed4b286c17aa 100644 --- a/test/sockets/test_udp_echo.c +++ b/test/sockets/test_udp_echo.c @@ -40,7 +40,7 @@ void set_nonblocking(int fd) { } void test_success(void) { - printf("UDP ECHO PASS\n"); + printf("done\n"); if (server_fd >= 0) close(server_fd); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ diff --git a/test/sockets/test_udp_ipv6.c b/test/sockets/test_udp_ipv6.c index adf66df62e2e4..5fa48ee4a9d89 100644 --- a/test/sockets/test_udp_ipv6.c +++ b/test/sockets/test_udp_ipv6.c @@ -37,7 +37,7 @@ bool pong_sent = false; void set_nonblocking(int fd) { fcntl(fd, F_SETFL, O_NONBLOCK); } void test_success(void) { - printf("UDP IPV6 PASS\n"); + printf("done\n"); if (server_fd >= 0) close(server_fd); if (client_fd >= 0) close(client_fd); #ifdef __EMSCRIPTEN__ diff --git a/test/test_core.py b/test/test_core.py index fe18964eaae1d..84a90357ddb54 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -400,6 +400,19 @@ def decorated(self, *args, **kwargs): return decorator +def needs_epoll(func): + # epoll is implemented in the JS (non-WASMFS) syscall layer and needs the FS. + assert callable(func) + + @wraps(func) + def decorated(self, *args, **kwargs): + if self.get_setting('WASMFS'): + self.skipTest('epoll is implemented in the JS (non-WASMFS) syscall layer') + self.set_setting('FORCE_FILESYSTEM') + return func(self, *args, **kwargs) + return decorated + + def make_no_decorator_for_setting(name): def outer_decorator(note): assert not callable(note) @@ -5783,6 +5796,21 @@ 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 + @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']) + @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') @@ -9750,6 +9778,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_other.py b/test/test_other.py index 6682ff94529c8..cd6a5fb547759 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13307,6 +13307,60 @@ 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_dup(self): + # dup(2) of an epoll fd shares one instance: a registration added via the dup + # is delivered to a callback armed on the original fd, and closing one dup + # does not tear the instance down. + self.do_runf('core/test_epoll_dup.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_overflow(self): + # maxevents < ready count: the callback re-triggers to drain the remainder + # across ticks (no app loop to re-call it). + 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 ed4e5f2e72034..a044ca8db4be4 100644 --- a/test/test_sockets.py +++ b/test/test_sockets.py @@ -410,7 +410,7 @@ def _run_against_echo_server(self, src, expected): def test_noderawsockets_echo(self): # With -sNODERAWSOCKETS the client does a non-blocking connect, send and # recv over a real OS socket against a loopback echo server we run here. - self._run_against_echo_server('sockets/test_tcp_echo.c', 'TCP ECHO PASS') + self._run_against_echo_server('sockets/test_tcp_echo.c', 'done\n') def test_noderawsockets_client_bind(self): # A client that bind()s an explicit source port has it honored by connect(), @@ -427,7 +427,7 @@ def test_noderawsockets_client_bind(self): thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - self.do_runf('sockets/test_tcp_client_bind.c', 'CLIENT BIND PASS', + self.do_runf('sockets/test_tcp_client_bind.c', 'done\n', cflags=['-sNODERAWSOCKETS'], args=[str(port), str(src_port)]) finally: server.shutdown() @@ -437,11 +437,11 @@ def test_noderawsockets_client_bind(self): def test_noderawsockets_client_semantics(self): # EISCONN on a second connect, shutdown(SHUT_WR) leaving reads working, # EPIPE on a write after that, and POLLHUP after a full shutdown(SHUT_RDWR). - self._run_against_echo_server('sockets/test_tcp_client_semantics.c', 'CLIENT SEMANTICS PASS') + self._run_against_echo_server('sockets/test_tcp_client_semantics.c', 'done\n') def test_noderawsockets_refused(self): # A connect to a loopback port with nothing listening reports ECONNREFUSED. - self.do_runf('sockets/test_tcp_refused.c', 'REFUSED PASS', cflags=['-sNODERAWSOCKETS']) + self.do_runf('sockets/test_tcp_refused.c', 'done\n', cflags=['-sNODERAWSOCKETS']) def test_noderawsockets_backpressure(self): # A sink server that accepts but never reads, so the client's writes fill @@ -457,7 +457,7 @@ def handle(self): thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - self.do_runf('sockets/test_tcp_backpressure.c', 'BACKPRESSURE PASS', + self.do_runf('sockets/test_tcp_backpressure.c', 'done\n', cflags=['-sNODERAWSOCKETS'], args=[str(port)]) finally: done.set() @@ -470,7 +470,7 @@ def test_noderawsockets_server(self): # Self-contained loopback accept+echo, exercising bind(:0)+getsockname # (synchronous ephemeral port), listen, accept, non-blocking connect, send # and recv over real OS sockets via the tcp_wrap server path. - self.do_runf('sockets/test_tcp_server.c', 'TCP SERVER PASS', cflags=['-sNODERAWSOCKETS']) + self.do_runf('sockets/test_tcp_server.c', 'done\n', cflags=['-sNODERAWSOCKETS']) @also_with_proxy_to_pthread def test_noderawsockets_peek(self): @@ -481,7 +481,7 @@ def test_noderawsockets_peek(self): def test_noderawsockets_server_autobind(self): # listen() without a prior bind() must auto-bind an ephemeral port and # getsockname() must report it (POSIX), then accept+echo as usual. - self.do_runf('sockets/test_tcp_server.c', 'TCP SERVER PASS', + self.do_runf('sockets/test_tcp_server.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-DNO_EXPLICIT_BIND']) def test_noderawsockets_tcp_ipv6(self): @@ -489,25 +489,69 @@ 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', 'done\n', + cflags=['-sNODERAWSOCKETS', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME']) + + 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') + + 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', '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. + 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']) + + @also_with_proxy_to_pthread + def test_noderawsockets_epoll_callback(self): + # emscripten_epoll_set_callback woken repeatedly by arriving datagrams on a + # real socket via the SOCKFS -> wait-queue bridge, with no ASYNCIFY/JSPI. + # Under PROXY_TO_PTHREAD the readiness is tracked on the FS-owning main thread + # but each delivery is back-proxied to the registering pthread. + self.do_runf('sockets/test_epoll_callback.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) @also_with_proxy_to_pthread def test_noderawsockets_udp_connect(self): # Connected UDP: sendto() with an address gives EISCONN, send() reaches the # peer, and datagrams from a non-peer socket are filtered out. - self.do_runf('sockets/test_udp_connect.c', 'UDP CONNECT PASS', cflags=['-sNODERAWSOCKETS']) + self.do_runf('sockets/test_udp_connect.c', 'done\n', cflags=['-sNODERAWSOCKETS']) @also_with_proxy_to_pthread def test_noderawsockets_udp_sockopts(self): 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..e934f04527b0c 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___', @@ -531,6 +532,8 @@ '__year_to_secs': '__p', '_embind_register_bindings': '_p', '_emscripten_dlsync_self_async': '_p', + '_emscripten_epoll_keepalive_on_thread': '_p_', + '_emscripten_epoll_run_callback_on_thread': '_pp_p_p_', '_emscripten_find_dylib': 'ppppp', '_emscripten_memcpy_bulkmem': 'pppp', '_emscripten_memset_bulkmem': 'pp_p', diff --git a/tools/system_libs.py b/tools/system_libs.py index 037b01fab3f14..62401e55ebb70 100644 --- a/tools/system_libs.py +++ b/tools/system_libs.py @@ -1216,6 +1216,7 @@ def get_files(self): 'em_task_queue.c', 'proxying.c', 'proxying_legacy.c', + 'emscripten_epoll_callback.c', 'thread_mailbox.c', 'pthread_create.c', 'pthread_kill.c',