diff --git a/ChangeLog.md b/ChangeLog.md index adaa69fdd9f6f..bc44741374af1 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -27,6 +27,11 @@ See docs/process.md for more on how version tagging works. level- and edge-triggered modes, `EPOLLONESHOT`, `EPOLLEXCLUSIVE`, `EPOLLRDHUP`, nesting, and blocking waits under `PROXY_TO_PTHREAD`, `ASYNCIFY`, and `JSPI`. (#27207) +- Added `emscripten_epoll_add_listener`/`emscripten_epoll_remove_listener` (in + the new ``, experimental), a non-blocking variant of + `epoll_wait` that signals an epoll set's readiness to listener callbacks + (which collect the events themselves via a zero-timeout `epoll_wait`) with no + `ASYNCIFY`/`JSPI` requirement. 6.0.7 - 08/17/26 ---------------- diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index dffeb7d269c86..d88f84aec08fd 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -4,9 +4,10 @@ * SPDX-License-Identifier: MIT */ -// epoll(7) for the JS filesystem. The epoll syscalls build on the per-inode -// readiness wait-queue (FSNode.addListener/notifyListeners) and the synchronous -// readiness derivation ($pollOne) defined in libsyscall.js. +// epoll(7) for the JS filesystem. The epoll syscalls and the +// emscripten_epoll_add_listener 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 @@ -15,19 +16,19 @@ var EpollLibrary = { // (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 and - // ready list 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. + // chain. dup(2) yields another fd to the SAME instance (registrations, ready + // list, and listeners 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. // Would a wait on this epoll block - i.e. does no listed registration have a // genuine ready event? Walks the ready list (O(ready)), masking out the // reporting-time flags (edge/oneshot/exclusive), and evicts a closed/reused fd // as it goes (so a set only ever probed, never drained, does not accumulate - // dead registrations). This is the readiness derivation behind the epoll fd's - // own poll handler (nesting): a stale ready-list entry (a spurious edge, or - // one left after its fd was drained then closed) is not a ready event, so it - // never reports one. + // dead registrations). This is the shared readiness derivation behind the + // epoll fd's own poll handler (nesting) and the listeners' fire gate: a stale + // ready-list entry (a spurious edge, or one left after its fd was drained then + // closed) is not a ready event, so neither fires on it. $epollWouldBlock__internal: true, $epollWouldBlock__deps: ['$FS', '$pollOne', '$epollEvict'], $epollWouldBlock: (ep) => { @@ -45,7 +46,7 @@ var EpollLibrary = { }, $epollNewInstance__internal: true, - $epollNewInstance__deps: ['$FS', '$epollWouldBlock'], + $epollNewInstance__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive'], $epollNewInstance: () => { // 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. @@ -66,15 +67,17 @@ var EpollLibrary = { stream.shared.refcount++; }, // close(2): drop one reference. Only the last close reclaims the - // instance: drop every registration's listener (a fired EPOLLONESHOT has - // already dropped its own) from its watched node. A surviving dup keeps - // it all live. + // instance: remove any readiness listeners, 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; + for (var it of ep.interests.values()) epollClearListener(ep, it); + epollReconcileKeepalive(ep); for (var reg of ep.epoll.values()) { reg.listener?.listeners.delete(reg.listener.entry); } @@ -86,12 +89,78 @@ var EpollLibrary = { Object.assign(stream.shared, { node, epoll: new Map(), + // Readiness listeners (emscripten_epoll_add_listener), keyed by + // (registering thread, callback). + interests: new Map(), + // Registrations with a live watched-node listener; keys the listener + // keepalive (0 means the set is terminal - it can never fire again). + armed: 0, // Open references (fds) to this instance; the last close reclaims it. refcount: 1, }); return stream; }, + // Drop one readiness listener: remove its wait-queue entry on the epoll node + // and release its holds. The caller reconciles the main keepalive. + $epollClearListener__internal: true, + $epollClearListener__deps: [ +#if PTHREADS + '$epollDeliveries', '_emscripten_epoll_keepalive_on_thread', +#endif + ], + $epollClearListener: (ep, it) => { + ep.interests.delete(it.key); + it.cleared = true; + it.listener.listeners.delete(it.listener.entry); +#if PTHREADS + if (it.keptAlive && it.ownerThread) { + __emscripten_epoll_keepalive_on_thread(it.ownerThread, -1); + } + it.keptAlive = false; + // 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 + }, + + // Listeners hold the runtime alive only while the epoll can still fire: at + // least one listener and one armed registration (Node.js-style, registered + // I/O interest holds the loop open; a terminal set releases it). With + // pthreads each listener's owner thread (which runs its deliveries) is held + // too. + $epollReconcileKeepalive__internal: true, + $epollReconcileKeepalive__deps: [ +#if PTHREADS + '_emscripten_epoll_keepalive_on_thread', +#endif + ], + $epollReconcileKeepalive: (ep) => { + var armed = ep.armed > 0; +#if PTHREADS + for (var it of ep.interests.values()) { + if (armed != !!it.keptAlive) { + it.keptAlive = armed; + // ownerThread is 0 when the main thread registered; the main keepalive + // below covers it. + if (it.ownerThread) { + __emscripten_epoll_keepalive_on_thread(it.ownerThread, armed ? 1 : -1); + } + } + } +#endif + var want = armed && ep.interests.size > 0; + if (want == !!ep.keepalive) return; + ep.keepalive = want; +#if useRuntimeKeepaliveStack() + 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 @@ -125,19 +194,24 @@ var EpollLibrary = { // entry at ctl time, and a closed/reused fd seen at derive time (doEpollWait // or the nesting poll). $epollEvict__internal: true, - $epollEvict__deps: ['$readyListRemove'], + $epollEvict__deps: ['$readyListRemove', '$epollReconcileKeepalive'], $epollEvict: (ep, reg) => { readyListRemove(ep, reg); - reg.listener?.listeners.delete(reg.listener.entry); - reg.listener = null; + // A fired EPOLLONESHOT already dropped its listener and armed count. + if (reg.listener) { + reg.listener.listeners.delete(reg.listener.entry); + reg.listener = null; + ep.armed--; + } ep.epoll.delete(reg.fd); + epollReconcileKeepalive(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', '$readyListAdd', '$epollEvict'], + $epollCtl__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict', '$epollReconcileKeepalive'], $epollCtl: (ep, op, fd, ev) => { var target = FS.getStream(fd); if (!target) return -{{{ cDefs.EBADF }}}; @@ -227,6 +301,7 @@ var EpollLibrary = { // EPOLLEXCLUSIVE: when one fd is watched by several epolls, the watched // node wakes only one of them per edge (round-robin), not all. }, !!(events & {{{ cDefs.EPOLLEXCLUSIVE }}})); + ep.armed++; } // Arming is itself an event source (ep_insert/ep_modify): a source-based // model only learns readiness from edges, so sample the level now - the @@ -235,6 +310,7 @@ var EpollLibrary = { readyListAdd(ep, reg); ep.node.notifyListeners({{{ cDefs.POLLIN }}}); } + epollReconcileKeepalive(ep); return 0; }, @@ -246,8 +322,9 @@ var EpollLibrary = { // EPOLL_CTL_MOD; a no-longer-ready (spurious) edge is dropped; a closed/reused // fd is evicted. $doEpollWait__internal: true, - $doEpollWait__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict'], + $doEpollWait__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict', '$epollReconcileKeepalive'], $doEpollWait: (ep, ev, maxevents) => { + var disarmed = false; // 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). @@ -278,6 +355,8 @@ var EpollLibrary = { // listener - the watched node stops poking it (no re-arm needed). node.listener.listeners.delete(node.listener.entry); node.listener = null; + ep.armed--; + disarmed = true; } else if (!(node.events & {{{ cDefs.EPOLLET }}})) { readyListAdd(ep, node); // level: re-list at tail } @@ -296,6 +375,8 @@ var EpollLibrary = { else ep.rdlTail = tail; ep.rdlHead = node; } + // Evictions above reconciled themselves. + if (disarmed) epollReconcileKeepalive(ep); return n; }, @@ -345,6 +426,147 @@ var EpollLibrary = { #endif return count; }, + + // Register a persistent readiness listener on an existing epoll fd: instead of + // blocking in epoll_wait, the runtime invokes `callback` on the event loop + // whenever the epoll set has ready events waiting to be collected. The callback + // receives only `userdata` and does NOT drain the set - to collect the events + // it calls epoll_wait(epfd, ..., 0) (a non-blocking, zero-timeout wait) itself. + // + // Any number of listeners may be added, keyed by (registering thread, + // callback). Every listener is signalled while uncollected ready events remain + // (broadcast); collectors race, so per-fd EPOLLET/EPOLLONESHOT items are + // collected by exactly one of them - the same load balancing as multiple + // blocking epoll_wait callers on one epoll. A level fd left undrained + // re-signals every tick, an edge fd once per edge. + emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive', '$callUserCallback', +#if PTHREADS + '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', +#endif + ], + emscripten_epoll_add_listener__proxy: 'sync', + emscripten_epoll_add_listener: (epfd, 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 listener added on one fd sees + // registrations made through any dup of it. + var ep = stream.shared; + +#if PTHREADS + // __proxy: 'sync' runs this (and every derivation) on the main thread; each + // delivery is back-proxied to the registering thread (0 = the main thread + // itself, delivered inline). + var callerThread = PThread.currentProxiedOperationCallerThread; + var key = callerThread + ':' + callback; +#else + var key = callback; +#endif + // Re-adding the same (thread, callback) identity replaces the registration, + // just updating userdata. + var prev = ep.interests.get(key); + if (prev) epollClearListener(ep, prev); + + var it = {key}; +#if PTHREADS + it.ownerThread = callerThread; +#endif + ep.interests.set(key, it); + // Producer notifies arrive synchronously (SOCKFS.emit, pipe writes); coalesce + // them into one delivery per listener on a microtask (the callback must not + // run in the producer's/caller's stack; a microtask avoids the setTimeout + // clamp). Fire whenever the set is readable, and re-fire while it stays + // readable (whether the callback left a level fd undrained, or a drain + // re-listed a still-ready level fd). + function deliver() { + if (it.cleared) return; +#if PTHREADS + // One cross-thread delivery in flight at a time: the registering thread + // collects (drains) inside the callback via a proxied epoll_wait, so firing + // again before it completes would just re-see 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 + if (epollWouldBlock(ep)) return; // no genuine uncollected ready event +#if PTHREADS + if (callerThread) { + it.inflight = true; + __emscripten_epoll_run_callback_on_thread(callerThread, callback, userdata, it.token); + return; + } +#endif + callUserCallback(() => {{{ makeDynCall('vp', 'callback') }}}(userdata)); + // Still readable (this callback didn't drain, or a still-ready level fd + // re-listed): fire again on the next tick. Note this is NOT a blocking + // epoll_wait loop - a level-triggered fd that is structurally always ready + // (e.g. EPOLLOUT on a writable socket) will re-schedule a microtask each + // tick and so starve the event loop; use EPOLLET or remove the listener + // for such fds. + if (!it.cleared && !epollWouldBlock(ep)) 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 listener again. + if (callerThread) { + it.wake = wake; + it.token = epollDeliveries.nextToken++; + epollDeliveries[it.token] = it; + } +#endif + it.listener = ep.node.addListener(wake); + epollReconcileKeepalive(ep); + wake(); // deliver initial readiness if the set is already ready + return 0; + }, + + // Remove the calling thread's listener for `callback`. All listeners are also + // removed when the last fd to the instance closes. + emscripten_epoll_remove_listener__deps: ['$FS', '$epollClearListener', '$epollReconcileKeepalive'], + emscripten_epoll_remove_listener__proxy: 'sync', + emscripten_epoll_remove_listener: (epfd, callback) => { + var stream = FS.getStream(epfd); + if (!stream?.shared.epoll) return {{{ cDefs.EBADF }}}; + var ep = stream.shared; +#if PTHREADS + var key = PThread.currentProxiedOperationCallerThread + ':' + callback; +#else + var key = callback; +#endif + var it = ep.interests.get(key); + if (!it) return {{{ cDefs.ENOENT }}}; + epollClearListener(ep, it); + epollReconcileKeepalive(ep); + return 0; + }, + +#if PTHREADS + // Token -> listener for cross-thread deliveries (numeric keys), plus nextToken: + // the next token to hand out. A monotonic token means a stale completion + // (listener removed mid-flight) never resolves to a different listener - it + // simply finds nothing. + $epollDeliveries: {nextToken: 1}, + + // Called (on the main 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; // listener was removed 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 89e5cba52aa9b..3f980d93909db 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -330,6 +330,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', @@ -643,6 +644,8 @@ sigs = { emscripten_destroy_web_audio_node__sig: 'vi', emscripten_destroy_worker__sig: 'vi', emscripten_enter_soft_fullscreen__sig: 'ipp', + emscripten_epoll_add_listener__sig: 'iipp', + emscripten_epoll_remove_listener__sig: 'iip', emscripten_err__sig: 'vp', emscripten_errn__sig: 'vpp', emscripten_exit_fullscreen__sig: 'i', diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h new file mode 100644 index 0000000000000..709331638b3cd --- /dev/null +++ b/system/include/emscripten/epoll.h @@ -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. + */ + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// EXPERIMENTAL. This API is new and may change (signature or semantics) over the +// next few releases. +// +// Register a persistent readiness listener on an existing epoll fd (built with +// epoll_create1/epoll_ctl): instead of blocking in epoll_wait, the runtime +// invokes `callback` on the event loop whenever the epoll set has ready events +// waiting to be collected. The callback receives only `userdata`; it does not +// receive the events. To collect them it calls epoll_wait(epfd, ..., 0) itself +// - a non-blocking, zero-timeout wait - from within the callback (or later). +// Unlike epoll_wait it never blocks the calling stack, so it works without +// ASYNCIFY/JSPI. The callback is delivered on the registering thread's event +// loop: with pthreads the epoll readiness is tracked on the main thread (the +// syscalls are proxied there), but each delivery is dispatched back to the +// thread that added the listener. +// +// Any number of listeners may be added, from any threads, identified by the +// (callback, registering thread) pair; re-adding the same identity just updates +// `userdata`. Every listener is signalled while uncollected ready events remain +// (broadcast), and listeners race to collect: per-fd trigger modes distribute +// events across collectors exactly as between multiple blocking epoll_wait +// callers on one epoll, so an EPOLLET edge or an EPOLLONESHOT firing is +// collected by exactly one listener (load balancing), while a level fd keeps +// signalling every listener until drained. +// +// A listener fires on the next event-loop tick while the set has ready events +// that have not yet been collected, and keeps firing while any remain - it only +// signals that events are pending, so a callback that does not drain them (via +// epoll_wait) leaves them pending and re-fires. Whether a given fd is +// re-reported follows its per-fd trigger mode (set via epoll_ctl) exactly as +// epoll_wait does, so one epoll can mix modes: +// - Level-triggered (the default): the fd is reported on the next tick whenever +// it is ready, and keeps re-firing while it stays ready. The runtime - not +// the application - drives the loop, so an fd that is structurally always +// ready (notably EPOLLOUT on a writable socket) will spin the event loop. +// Use one of the modes below for such fds. +// - EPOLLET (edge-triggered): reported once per readiness edge and not again +// until a fresh edge; usually preferable in this model. +// - EPOLLONESHOT: reported once, then the registration is disabled until you +// re-arm it with epoll_ctl(EPOLL_CTL_MOD). +// +// Listeners keep the runtime alive as long as the set can still fire - i.e. +// while the epoll has at least one open watched fd. This follows the Node.js +// model, where registered I/O interest holds the event loop open. Once every +// watched fd is closed the set is terminal (it can never become ready again) +// and its listeners stop holding the runtime, so no explicit disposal is +// required in that case. +// +// Listeners are shared instance state: they see registrations made through any +// dup'd fd, and closing the last fd to the instance removes them all. Returns +// 0, or a positive errno (EBADF if `epfd` is not an epoll fd). +typedef void (*em_epoll_callback)(void *userdata); +int emscripten_epoll_add_listener(int epfd, em_epoll_callback callback, void *userdata); + +// Remove the calling thread's listener for `callback`. Returns 0, EBADF if +// `epfd` is not an epoll fd, or ENOENT if no such listener is registered. +int emscripten_epoll_remove_listener(int epfd, em_epoll_callback callback); + +#ifdef __cplusplus +} +#endif diff --git a/system/lib/libc/emscripten_internal.h b/system/lib/libc/emscripten_internal.h index 55ca0fa09dc2a..8052c612cde02 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 main thread. +void _emscripten_epoll_delivery_done(int token); + void _emscripten_get_progname(char*, int); // Not defined in musl, but defined in library.js. Included here for diff --git a/system/lib/pthread/emscripten_epoll_callback.c b/system/lib/pthread/emscripten_epoll_callback.c new file mode 100644 index 0000000000000..3cf067049619b --- /dev/null +++ b/system/lib/pthread/emscripten_epoll_callback.c @@ -0,0 +1,82 @@ +/* + * 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_add_listener under PTHREADS: the epoll readiness lives +// on the main thread (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 reports back to +// the main thread when a delivery completes so it can pace the next one - the +// callback collects the ready events (via a proxied epoll_wait) itself, so the +// main thread must wait for that before firing again, or it would spin +// re-signalling the same still-ready level fd. + +#include +#include +#include +#include + +#include +#include + +#include "emscripten_internal.h" + +typedef void (*em_epoll_callback)(void* userdata); + +typedef struct epoll_callback_args_t { + em_epoll_callback callback; + void* userdata; + int token; +} epoll_callback_args_t; + +// Runs on the registering thread: signal the user callback that events are +// pending (it collects them itself via epoll_wait). +static void do_epoll_callback(void* arg) { + epoll_callback_args_t* args = (epoll_callback_args_t*)arg; + args->callback(args->userdata); +} + +// Runs back on the main 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, + void* userdata, + int token) { + em_proxying_queue* q = emscripten_proxy_get_system_queue(); + epoll_callback_args_t* args = malloc(sizeof(epoll_callback_args_t)); + args->callback = callback; + args->userdata = userdata; + args->token = token; + + if (!emscripten_proxy_callback( + q, t, do_epoll_callback, do_epoll_done, do_epoll_done, args)) { + assert(false && "emscripten_proxy_callback failed"); + } +} + +// Adjust the owner thread's (thread-local) runtime keepalive so the epoll +// callback holds the thread it was registered on, not the main thread. +static void do_epoll_keepalive(void* arg) { + if ((intptr_t)arg > 0) { + emscripten_runtime_keepalive_push(); + } else { + emscripten_runtime_keepalive_pop(); + } +} + +void _emscripten_epoll_keepalive_on_thread(pthread_t t, int delta) { + em_proxying_queue* q = emscripten_proxy_get_system_queue(); + if (!emscripten_proxy_async( + q, t, do_epoll_keepalive, (void*)(intptr_t)delta)) { + assert(false && "emscripten_proxy_async failed"); + } +} diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 7542916f9e7ee..cafd12f58a651 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": 270568, + "a.out.js": 271350, "a.out.nodebug.wasm": 588359, - "total": 858927, + "total": 859709, "sent": [ "IMG_Init", "IMG_Load", @@ -468,6 +468,8 @@ "emscripten_debugger", "emscripten_destroy_worker", "emscripten_enter_soft_fullscreen", + "emscripten_epoll_add_listener", + "emscripten_epoll_remove_listener", "emscripten_err", "emscripten_errn", "emscripten_exit_fullscreen", 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..3f9c24f345384 --- /dev/null +++ b/test/core/test_epoll_wait_and_callback.c @@ -0,0 +1,104 @@ +/* + * 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_add_listener 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; +} + +static void on_ready(void* ud); + +// 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_remove_listener(ep, on_ready) == 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(void* ud) { + struct epoll_event ev[8]; + int n = epoll_wait(ep, ev, 8, 0); // collect our slice off the shared list + 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_add_listener(ep, 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/other/test_epoll_callback.c b/test/other/test_epoll_callback.c new file mode 100644 index 0000000000000..81c5c437d17f3 --- /dev/null +++ b/test/other/test_epoll_callback.c @@ -0,0 +1,76 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * emscripten_epoll_add_listener: a persistent, non-blocking, non-suspending epoll + * readiness callback (no ASYNCIFY/JSPI). The callback receives only its userdata + * and collects the ready events itself with a zero-timeout epoll_wait. 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 signals it. + * - EPOLL_CTL_MOD re-arming a still-readable EPOLLONESHOT fd signals 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(void* ud) { + assert((long)ud == 42); + struct epoll_event events[4]; + int nready = epoll_wait(ep, events, 4, 0); + assert(nready == 1); + assert(events[0].events & EPOLLIN); + assert(events[0].data.u32 == 0x1234); + 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_remove_listener(ep, on_ready) == 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_add_listener(ep, 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/other/test_epoll_callback_close.c b/test/other/test_epoll_callback_close.c new file mode 100644 index 0000000000000..b1247db282833 --- /dev/null +++ b/test/other/test_epoll_callback_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. + * + * 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(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 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_add_listener(ep, on_ready, 0) == 0); + assert(write(wfd, "x", 1) == 1); + return 0; +} diff --git a/test/other/test_epoll_callback_dup.c b/test/other/test_epoll_callback_dup.c new file mode 100644 index 0000000000000..24ebb37c1c396 --- /dev/null +++ b/test/other/test_epoll_callback_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 an epoll listener 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(void* ud) { + struct epoll_event events[4]; + assert(epoll_wait(ep_a, events, 4, 0) == 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_remove_listener(ep_a, on_ready) == 0); + printf("done\n"); +} + +int main(void) { + ep_a = epoll_create1(0); + + // Arm the persistent callback on the original fd. + assert(emscripten_epoll_add_listener(ep_a, 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/other/test_epoll_callback_edge.c b/test/other/test_epoll_callback_edge.c new file mode 100644 index 0000000000000..f5f9d357838ad --- /dev/null +++ b/test/other/test_epoll_callback_edge.c @@ -0,0 +1,63 @@ +/* + * 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(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 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_remove_listener(ep, on_ready) == 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_add_listener(ep, on_ready, 0) == 0); + assert(write(wfd, "x", 1) == 1); // first edge + return 0; +} diff --git a/test/other/test_epoll_callback_level.c b/test/other/test_epoll_callback_level.c new file mode 100644 index 0000000000000..2f4237595bbf3 --- /dev/null +++ b/test/other/test_epoll_callback_level.c @@ -0,0 +1,43 @@ +/* + * 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(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 1); + assert(ev[0].events & EPOLLOUT); + if (++fires == 3) { // re-fired every tick despite no new event and no drain + assert(emscripten_epoll_remove_listener(ep, on_ready) == 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_add_listener(ep, on_ready, 0) == 0); + return 0; +} diff --git a/test/other/test_epoll_callback_multi.c b/test/other/test_epoll_callback_multi.c new file mode 100644 index 0000000000000..96cd36f1ec457 --- /dev/null +++ b/test/other/test_epoll_callback_multi.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. + * + * Multiple listeners on one epoll: every listener is signalled while + * uncollected ready events remain (broadcast), and collectors race over the + * shared ready list, so each event is collected exactly once (load balancing). + * Two listeners each collecting one event per fire split two ready fds one + * each: A's first tick takes one, B's tick takes the other, and A's re-fire + * finds nothing left so it stays silent. + */ + +#include +#include +#include +#include +#include +#include + +static int ep, rfd[2]; +static int seen[2]; +static int fires_a, fires_b, collected; + +static int idx(int fd) { + for (int i = 0; i < 2; i++) if (rfd[i] == fd) return i; + return -1; +} + +static void collect(void) { + struct epoll_event ev[1]; + int n = epoll_wait(ep, ev, 1, 0); // collect at most one per fire + if (n == 1) { + int i = idx(ev[0].data.fd); + assert(i >= 0 && !seen[i]); // disjoint: each fd collected exactly once + seen[i] = 1; + char b[1]; + assert(read(rfd[i], b, 1) == 1); // drain so it is no longer ready + collected++; + } +} + +static void listener_a(void* ud) { fires_a++; collect(); } +static void listener_b(void* ud) { fires_b++; collect(); } + +static void check(void* ud) { + // Both listeners were woken by the same readiness (broadcast) and the split + // was one event each (load balancing). + assert(collected == 2 && seen[0] && seen[1]); + assert(fires_a == 1 && fires_b == 1); + assert(emscripten_epoll_remove_listener(ep, listener_a) == 0); + assert(emscripten_epoll_remove_listener(ep, listener_b) == 0); + printf("done\n"); +} + +int main(void) { + ep = epoll_create1(0); + for (int i = 0; i < 2; 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); + } + + assert(emscripten_epoll_add_listener(ep, listener_a, 0) == 0); + assert(emscripten_epoll_add_listener(ep, listener_b, 0) == 0); + // Both fds are already ready: A's tick collects one, B's collects the other, + // then a macrotask verifies the exact one-each split before removing both. + emscripten_async_call(check, NULL, 0); + return 0; +} diff --git a/test/other/test_epoll_callback_nested.c b/test/other/test_epoll_callback_nested.c new file mode 100644 index 0000000000000..8c9b7b42ca018 --- /dev/null +++ b/test/other/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(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(epA, ev, 4, 0) == 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_remove_listener(epA, on_ready) == 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_add_listener(epA, on_ready, 0) == 0); + emscripten_async_call(writer, NULL, 0); + return 0; +} diff --git a/test/other/test_epoll_callback_nested_close.c b/test/other/test_epoll_callback_nested_close.c new file mode 100644 index 0000000000000..bc5d2f661cb69 --- /dev/null +++ b/test/other/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(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(epA, ev, 4, 0) == 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_add_listener(epA, on_ready, 0) == 0); + assert(write(wfd, "x", 1) == 1); // leaf ready -> propagates up to epA's callback + return 0; +} diff --git a/test/other/test_epoll_callback_overflow.c b/test/other/test_epoll_callback_overflow.c new file mode 100644 index 0000000000000..81819a18a0984 --- /dev/null +++ b/test/other/test_epoll_callback_overflow.c @@ -0,0 +1,63 @@ +/* + * 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_add_listener drain across ticks: the listener fires while the + * poll queue has ready events, so a callback that collects only one per tick + * (epoll_wait maxevents=1) is re-triggered until the queue drains - there is no + * app loop to re-call it. Three always-readable fds 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(void* ud) { + struct epoll_event ev[1]; + assert(epoll_wait(ep, ev, 1, 0) == 1); // collect one per tick + 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_remove_listener(ep, on_ready) == 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, three ready fds, and a callback that collects one per tick: it must + // be re-triggered to deliver all three (one per tick), not just the first. + assert(emscripten_epoll_add_listener(ep, on_ready, 0) == 0); + return 0; +} diff --git a/test/other/test_epoll_callback_replace.c b/test/other/test_epoll_callback_replace.c new file mode 100644 index 0000000000000..8e18a77288021 --- /dev/null +++ b/test/other/test_epoll_callback_replace.c @@ -0,0 +1,64 @@ +/* + * 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. + * + * Listener registration identity: a listener is keyed by (callback, thread), so + * re-adding the same callback replaces it (just updating userdata, no + * stacking), and emscripten_epoll_remove_listener removes by callback identity + * (ENOENT when absent, EBADF on a non-epoll fd). + */ + +#include +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd; +static int fires; + +static void on_ready(void* ud) { + // Re-added with updated userdata: only the second registration's userdata is + // ever delivered, exactly once per collected batch. + assert((long)ud == 2); + fires++; + assert(fires == 1); + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 1); + char b[1]; + assert(read(rfd, b, 1) == 1); // drain + + // Remove, then make the set ready again to prove no further delivery happens. + assert(emscripten_epoll_remove_listener(ep, on_ready) == 0); + assert(emscripten_epoll_remove_listener(ep, on_ready) == ENOENT); + 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_add_listener(rfd, on_ready, 0) == EBADF); + assert(emscripten_epoll_remove_listener(rfd, on_ready) == EBADF); + // Removing a never-added listener is ENOENT. + assert(emscripten_epoll_remove_listener(ep, on_ready) == ENOENT); + + // Add then immediately re-add the same identity, before any tick runs: one + // registration, carrying the updated userdata. + assert(emscripten_epoll_add_listener(ep, on_ready, (void*)1) == 0); + assert(emscripten_epoll_add_listener(ep, on_ready, (void*)2) == 0); + assert(write(wfd, "x", 1) == 1); // delivered on the next tick, once + return 0; +} diff --git a/test/sockets/test_epoll_callback.c b/test/sockets/test_epoll_callback.c new file mode 100644 index 0000000000000..126be41fadff2 --- /dev/null +++ b/test/sockets/test_epoll_callback.c @@ -0,0 +1,76 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * An epoll listener 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(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 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_add_listener(ep, on_ready, 0) == 0); + send_one("one"); + return 0; +} diff --git a/test/test_core.py b/test/test_core.py index 19bd5633fff4e..116c9aa2006d6 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9776,6 +9776,15 @@ def test_epoll_blocking_asyncify(self): 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 38ea43819d40f..6b9e782be5ddd 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13520,6 +13520,56 @@ def test_epoll_dup(self): # the instance down. self.do_runf('other/test_epoll_dup.c', 'done\n') + def test_epoll_callback(self): + # emscripten_epoll_add_listener delivers an epoll set's readiness by a + # persistent callback with no blocking and no ASYNCIFY/JSPI. + self.do_runf('other/test_epoll_callback.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_multi(self): + # Multiple listeners on one epoll: broadcast wake, racing collectors take + # disjoint slices of the shared ready list (load balancing). + self.do_runf('other/test_epoll_callback_multi.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_dup(self): + # A registration added via a dup'd epoll fd is delivered to a callback armed + # on the original fd, since both fds share one epoll instance. + self.do_runf('other/test_epoll_callback_dup.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_overflow(self): + # A callback that collects one event per tick (epoll_wait maxevents=1) is + # re-triggered to drain the remainder across ticks (no app loop to re-call it). + self.do_runf('other/test_epoll_callback_overflow.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + + def test_epoll_callback_replace(self): + # Listener identity is (callback, thread): re-adding replaces (updating + # userdata, no stacking); removal is by identity (ENOENT/EBADF errors). + self.do_runf('other/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('other/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('other/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('other/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('other/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('other/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_node.py b/test/test_sockets_node.py index 97750c862641c..3d2451d52ad27 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -246,6 +246,15 @@ def test_noderawsockets_mmsg(self): # call, updating msg_len per message. self.do_runf('sockets/test_udp_mmsg.c', 'done\n', cflags=['-sNODERAWSOCKETS']) + @also_with_proxy_to_pthread + def test_noderawsockets_epoll_callback(self): + # An epoll listener callback woken repeatedly by arriving datagrams on a + # real socket via the SOCKFS -> wait-queue bridge, with no ASYNCIFY/JSPI. + # With pthreads the readiness is tracked on the main thread (where the epoll + # syscalls are proxied) but each delivery is back-proxied to the thread that + # registered the callback. + 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 diff --git a/tools/maint/gen_sig_info.py b/tools/maint/gen_sig_info.py index eb730c768dfc1..a65c4b4a45b68 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 f8ccb9ff2829d..cf215353a8fef 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -530,6 +530,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': '_ppp_', '_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 e1270eb7dab65..9c4285dbbbcee 100644 --- a/tools/system_libs.py +++ b/tools/system_libs.py @@ -1223,6 +1223,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',