From c8ddffcc66cb98bff800b36eea41d362c5b62f95 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 14 Aug 2026 21:47:33 -0700 Subject: [PATCH 01/12] Add emscripten_epoll_add_listener / emscripten_epoll_remove_listener A non-blocking readiness delivery mechanism for epoll: instead of blocking in epoll_wait, the runtime invokes registered listener callbacks on the event loop whenever the epoll set has ready events waiting to be collected (new experimental ), working without ASYNCIFY/JSPI. A callback takes only its userdata and collects events itself via a zero-timeout epoll_wait(epfd, ..., 0). Firing is gated on the shared readiness derivation ($epollWouldBlock) also used by the epoll fd's own poll handler, so a stale ready-list entry never spuriously fires. Per-fd trigger modes apply exactly as in epoll_wait: a level fd left undrained re-fires every tick, an edge fd once per edge, a fired EPOLLONESHOT not until re-armed. Any number of listeners may be added, keyed by (callback, registering thread); re-adding the same identity updates userdata. Every listener is signalled while uncollected ready events remain (broadcast) and collectors race over the single shared ready list, so EPOLLET/EPOLLONESHOT items are collected by exactly one listener - the same load balancing as between multiple blocking epoll_wait callers on one epoll. Listeners hold a runtime keepalive while the set can still fire, keyed on the armed-registration count (a fired EPOLLONESHOT no longer counts): registered I/O interest holds the event loop open, following the Node.js model, and a terminal set (every watched fd closed) releases the runtime with no explicit disposal needed. Listeners are instance state shared across dup'd fds; the last close removes them all. Under pthreads the registration body runs sync-proxied on the main thread, so the registering thread is captured and each delivery is back-proxied to it via emscripten_proxy_callback (new system/lib/pthread/emscripten_epoll_callback.c), one delivery in flight at a time, paced by its completion (_emscripten_epoll_delivery_done) with a monotonic token dropping stale completions. While armed, each listener also holds its owner thread's keepalive so it survives to receive deliveries. --- ChangeLog.md | 5 + src/lib/libepoll.js | 262 ++++++++++++++++-- src/lib/libsigs.js | 3 + system/include/emscripten/epoll.h | 75 +++++ system/lib/libc/emscripten_internal.h | 4 + .../lib/pthread/emscripten_epoll_callback.c | 82 ++++++ test/core/test_epoll_wait_and_callback.c | 104 +++++++ test/other/test_epoll_callback.c | 76 +++++ test/other/test_epoll_callback_close.c | 47 ++++ test/other/test_epoll_callback_dup.c | 69 +++++ test/other/test_epoll_callback_edge.c | 63 +++++ test/other/test_epoll_callback_level.c | 43 +++ test/other/test_epoll_callback_multi.c | 75 +++++ test/other/test_epoll_callback_nested.c | 54 ++++ test/other/test_epoll_callback_nested_close.c | 47 ++++ test/other/test_epoll_callback_overflow.c | 63 +++++ test/other/test_epoll_callback_replace.c | 64 +++++ test/sockets/test_epoll_callback.c | 76 +++++ test/test_core.py | 9 + test/test_other.py | 50 ++++ test/test_sockets_node.py | 9 + tools/maint/gen_sig_info.py | 2 + tools/native_sigs.py | 2 + tools/system_libs.py | 1 + 24 files changed, 1265 insertions(+), 20 deletions(-) create mode 100644 system/include/emscripten/epoll.h create mode 100644 system/lib/pthread/emscripten_epoll_callback.c create mode 100644 test/core/test_epoll_wait_and_callback.c create mode 100644 test/other/test_epoll_callback.c create mode 100644 test/other/test_epoll_callback_close.c create mode 100644 test/other/test_epoll_callback_dup.c create mode 100644 test/other/test_epoll_callback_edge.c create mode 100644 test/other/test_epoll_callback_level.c create mode 100644 test/other/test_epoll_callback_multi.c create mode 100644 test/other/test_epoll_callback_nested.c create mode 100644 test/other/test_epoll_callback_nested_close.c create mode 100644 test/other/test_epoll_callback_overflow.c create mode 100644 test/other/test_epoll_callback_replace.c create mode 100644 test/sockets/test_epoll_callback.c diff --git a/ChangeLog.md b/ChangeLog.md index a3fe632c528d1..748217fa84dff 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -68,6 +68,11 @@ See docs/process.md for more on how version tagging works. - `WASM_BIGINT` was deprecated. BigInt integration is standard and enabled by default across all supported engines; it should now only ever be disabled implicitly when targeting JavaScript via `-sWASM=0`. (#27558) +- 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 601f26850933a..89621041ff6b8 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 12d0b300fb21d..9ce43710a60b3 100644 --- a/system/lib/libc/emscripten_internal.h +++ b/system/lib/libc/emscripten_internal.h @@ -62,6 +62,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/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 2d58ff20cdca6..3d66c8f38b34a 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9756,6 +9756,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 bd63374a7c13b..1b484a845a6d6 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13611,6 +13611,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 414d27cd37d63..35e47715cc0c1 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -282,6 +282,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 85e9611f03920..3d219caf3876d 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 9878ea4ef2b9e..5ddaf39206d70 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -529,6 +529,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_proxy_dlsync': '_p', '_emscripten_proxy_dlsync_async': '_pp', diff --git a/tools/system_libs.py b/tools/system_libs.py index f73efe18d6eaf..77534ff00241f 100644 --- a/tools/system_libs.py +++ b/tools/system_libs.py @@ -1220,6 +1220,7 @@ def get_files(self): 'em_task_queue.c', 'proxying.c', 'proxying_legacy.c', + 'emscripten_epoll_callback.c', 'thread_mailbox.c', 'pthread_create.c', 'pthread_kill.c', From 3a7299aeb5bd73d4f361af5c4883e6cf855c1fb7 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 14 Sep 2026 10:37:52 -0700 Subject: [PATCH 02/12] Listener keepalive: host-backed fds only, pending deliveries, force_exit guard Only armed registrations on host-backed fds (sockets; nested epolls counted conservatively) hold the runtime alive. A pipe can only be written by wasm, which is already running and held when it does, so a net-enabled runtime whose waker pipe stayed armed would otherwise never exit under EXIT_RUNTIME. A scheduled or in-flight delivery holds the runtime separately until it runs (as safeSetTimeout does), so a pipe write from live work still delivers; the post-callback re-wake moves inside the callUserCallback wrapper so that hold precedes maybeExit. emscripten_force_exit forfeits every hold before exitRuntime, whose FS.quit then closes the epoll fd and released the listener's hold, underflowing the counter. All epoll holds now go through one helper that treats a release on a zero counter as forfeited. --- src/lib/libepoll.js | 104 ++++++++++++------ system/include/emscripten/epoll.h | 14 ++- test/other/test_epoll_callback_pipe_exit.c | 54 +++++++++ test/sockets/test_epoll_callback_force_exit.c | 53 +++++++++ test/test_other.py | 6 + test/test_sockets_node.py | 7 ++ 6 files changed, 199 insertions(+), 39 deletions(-) create mode 100644 test/other/test_epoll_callback_pipe_exit.c create mode 100644 test/sockets/test_epoll_callback_force_exit.c diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index d88f84aec08fd..30cb89d4f7849 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -92,9 +92,9 @@ var EpollLibrary = { // 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, + // Armed registrations on host-backed fds (epollHostBacked); keys the + // listener keepalive (0 means nothing the host can do makes the set ready). + hostArmed: 0, // Open references (fds) to this instance; the last close reclaims it. refcount: 1, }); @@ -106,7 +106,7 @@ var EpollLibrary = { $epollClearListener__internal: true, $epollClearListener__deps: [ #if PTHREADS - '$epollDeliveries', '_emscripten_epoll_keepalive_on_thread', + '$epollDeliveries', '$epollKeepalive', '_emscripten_epoll_keepalive_on_thread', #endif ], $epollClearListener: (ep, it) => { @@ -119,24 +119,57 @@ var EpollLibrary = { } it.keptAlive = false; // Retire its delivery token; a still-in-flight cross-thread delivery whose - // completion arrives after this finds nothing and is dropped. + // completion arrives after this finds nothing and is dropped - so release + // its hold here. if (it.token) delete epollDeliveries[it.token]; + if (it.inflight) { + it.inflight = false; + epollKeepalive(-1); + } #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. + // Every main-runtime keepalive hold taken by this library goes through here. + // exitRuntime only runs once the counter is 0 - naturally, or forfeited by + // emscripten_force_exit - and its FS.quit closes the epoll fds; that release + // (or a delivery landing after exit) is of an already-forfeited hold, not an + // underflow. + $epollKeepalive__internal: true, + $epollKeepalive__deps: [ +#if useRuntimeKeepaliveStack() + '$runtimeKeepaliveCounter', '$runtimeKeepalivePush', '$runtimeKeepalivePop', +#endif + ], + $epollKeepalive: (delta) => { +#if useRuntimeKeepaliveStack() + if (delta > 0) runtimeKeepalivePush(); + else if (runtimeKeepaliveCounter > 0) runtimeKeepalivePop(); +#endif + }, + + // Can readiness on this watched fd arrive from the host (the JS event loop), + // with no wasm running? A socket's can. A pipe is only ever written by wasm, + // so whatever runs that write already holds the runtime - the registration + // itself need not. A nested epoll is counted conservatively (it may hold + // sockets). + $epollHostBacked__internal: true, + $epollHostBacked__deps: ['$FS'], + $epollHostBacked: (target) => FS.isSocket(target.node.mode) || !!target.shared.epoll, + + // Listeners hold the runtime alive only while the host can still make the + // epoll ready: at least one listener and one armed host-backed registration + // (Node.js-style, registered I/O interest holds the loop open; a set the host + // cannot fire releases it). A pending delivery holds it separately (see + // wake()). With pthreads each listener's owner thread (which runs its + // deliveries) is held too. $epollReconcileKeepalive__internal: true, - $epollReconcileKeepalive__deps: [ + $epollReconcileKeepalive__deps: ['$epollKeepalive', #if PTHREADS '_emscripten_epoll_keepalive_on_thread', #endif ], $epollReconcileKeepalive: (ep) => { - var armed = ep.armed > 0; + var armed = ep.hostArmed > 0; #if PTHREADS for (var it of ep.interests.values()) { if (armed != !!it.keptAlive) { @@ -152,13 +185,7 @@ var EpollLibrary = { var want = armed && ep.interests.size > 0; if (want == !!ep.keepalive) return; ep.keepalive = want; -#if useRuntimeKeepaliveStack() - if (want) { - {{{ runtimeKeepalivePush() }}} - } else { - {{{ runtimeKeepalivePop() }}} - } -#endif + epollKeepalive(want ? 1 : -1); }, // The ready list (Linux's rdllist): registrations whose readiness edge has @@ -201,7 +228,7 @@ var EpollLibrary = { if (reg.listener) { reg.listener.listeners.delete(reg.listener.entry); reg.listener = null; - ep.armed--; + ep.hostArmed -= reg.host; } ep.epoll.delete(reg.fd); epollReconcileKeepalive(ep); @@ -211,7 +238,7 @@ var EpollLibrary = { // 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', '$epollReconcileKeepalive'], + $epollCtl__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict', '$epollReconcileKeepalive', '$epollHostBacked'], $epollCtl: (ep, op, fd, ev) => { var target = FS.getStream(fd); if (!target) return -{{{ cDefs.EBADF }}}; @@ -301,7 +328,8 @@ 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++; + reg.host = +epollHostBacked(target); + ep.hostArmed += reg.host; } // 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 @@ -355,7 +383,7 @@ 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--; + ep.hostArmed -= node.host; disarmed = true; } else if (!(node.events & {{{ cDefs.EPOLLET }}})) { readyListAdd(ep, node); // level: re-list at tail @@ -439,7 +467,7 @@ var EpollLibrary = { // 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', + emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive', '$epollKeepalive', '$callUserCallback', #if PTHREADS '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', #endif @@ -493,24 +521,33 @@ var EpollLibrary = { #if PTHREADS if (callerThread) { it.inflight = true; + epollKeepalive(1); // held until the completion lands back here __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(); + 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. Inside the wrapper so the re-wake's + // hold is taken before callUserCallback's maybeExit. + if (!it.cleared && !epollWouldBlock(ep)) wake(); + }); } + // A scheduled delivery is pending work and holds the runtime until it runs + // (like safeSetTimeout), independent of what the set watches: a pipe write + // from a callback's last act must still deliver. function wake() { if (it.scheduled) return; it.scheduled = true; + epollKeepalive(1); queueMicrotask(() => { it.scheduled = false; + epollKeepalive(-1); deliver(); }); } @@ -559,12 +596,13 @@ var EpollLibrary = { // 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__deps: ['$epollDeliveries', '$epollKeepalive'], _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(); + epollKeepalive(-1); }, #endif }; diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h index 709331638b3cd..673f8a13104a7 100644 --- a/system/include/emscripten/epoll.h +++ b/system/include/emscripten/epoll.h @@ -53,12 +53,14 @@ extern "C" { // - 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 keep the runtime alive as long as the host can still make the set +// ready - i.e. while the epoll has at least one armed registration on a +// host-backed fd (a socket). This follows the Node.js model, where registered +// I/O interest holds the event loop open. Once every such fd is closed (or +// disarmed) the listeners stop holding the runtime, so no explicit disposal is +// required in that case. A pipe does not count: it can only be written by wasm +// code, which is already running (and so already held) when it does; a +// delivery that write schedules is itself held until it runs. // // 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 diff --git a/test/other/test_epoll_callback_pipe_exit.c b/test/other/test_epoll_callback_pipe_exit.c new file mode 100644 index 0000000000000..01440dec444a3 --- /dev/null +++ b/test/other/test_epoll_callback_pipe_exit.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. + * + * Only host-backed registrations (sockets) hold the runtime alive. A pipe can + * only be written by wasm, so it can never fire from the host: a listener over + * an armed pipe alone must not keep the runtime alive once main returns (the + * process exits, running atexit), even though a pipe write scheduled by other + * live work (a timer) still delivers. + */ + +#include +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd, fires; + +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); + fires++; +} + +static void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } + +static void at_exit(void) { + // Delivered once (the timer-driven write), then exited with the pipe still + // armed and the listener still registered. + assert(fires == 1); + printf("done\n"); +} + +int main(void) { + atexit(at_exit); + 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); + emscripten_async_call(writer, NULL, 0); + return 0; +} diff --git a/test/sockets/test_epoll_callback_force_exit.c b/test/sockets/test_epoll_callback_force_exit.c new file mode 100644 index 0000000000000..23350adcc9a11 --- /dev/null +++ b/test/sockets/test_epoll_callback_force_exit.c @@ -0,0 +1,53 @@ +/* + * 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_force_exit while a listener holds the runtime: the forced exit + * forfeits every keepalive hold before exitRuntime, whose FS.quit then closes the + * epoll fd and releases the listener's (already forfeited) hold. That release + * must not underflow the keepalive counter (which asserts). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int ep, rx; + +static void on_ready(void* ud) { + assert(0 && "nothing ever connects"); +} + +static void quit(void* arg) { + printf("done\n"); + emscripten_force_exit(0); +} + +int main(void) { + ep = epoll_create1(0); + rx = socket(AF_INET, SOCK_STREAM, 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(rx, (struct sockaddr*)&addr, sizeof addr) == 0); + assert(listen(rx, 1) == 0); // readable only on a pending connection + + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rx; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rx, &ev) == 0); + // A socket registration holds the runtime open; the listener is armed and the + // socket left open when the forced exit runs. + assert(emscripten_epoll_add_listener(ep, on_ready, 0) == 0); + emscripten_async_call(quit, NULL, 0); + return 0; +} diff --git a/test/test_other.py b/test/test_other.py index 1b484a845a6d6..8860e8437df63 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13661,6 +13661,12 @@ def test_epoll_callback_level(self): # 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']) + def test_epoll_callback_pipe_exit(self): + # Only host-backed (socket) registrations hold the runtime: a listener over + # an armed pipe alone lets the process exit when main returns, while a pipe + # write from other live work still delivers. + self.do_runf('other/test_epoll_callback_pipe_exit.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 35e47715cc0c1..603830d1ed383 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -291,6 +291,13 @@ def test_noderawsockets_epoll_callback(self): # 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_epoll_callback_force_exit(self): + # emscripten_force_exit with a listener still holding the runtime (an armed + # socket): the forfeited hold released by FS.quit at exit must not underflow + # the keepalive counter. + self.do_runf('sockets/test_epoll_callback_force_exit.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 From 1fb7deca6b14cc7bc162d307eccf1946a20fd37f Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 14 Sep 2026 12:01:35 -0700 Subject: [PATCH 03/12] epoll listeners: a teardown wake holds nothing A scheduled delivery holds the runtime until it runs, but a wake raised by teardown can never deliver, and a hold taken there outlives the exit: exitRuntime's FS.quit closes every open fd in fd order, and each close wakes the listener - the fd's own POLLNVAL, and for a pipe the peer end's close reporting POLLHUP on the still-armed registration. The hold then leaves keepRuntimeAlive() set when _proc_exit runs, so Module.onExit is skipped and the exit is left to the host loop draining. A registration now forwards the cause of its wake to the epoll node (POLLNVAL for a closing fd, POLLIN otherwise), and a listener wake holds only for a readiness wake while FS.initialized, which FS.quit clears before its first close. Under pthreads the FS.quit wakes were also what pushed a keepalive to an owner thread that had already exited. Test: a pipe registered and quiet at main's return, created before its epoll so its ends close first; the runtime exits (atexit) and the exit completes (onExit). --- src/lib/libepoll.js | 37 +++++++++---- .../other/test_epoll_callback_teardown_wake.c | 55 +++++++++++++++++++ test/test_other.py | 6 ++ 3 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 test/other/test_epoll_callback_teardown_wake.c diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 30cb89d4f7849..aa65a687e1660 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -322,9 +322,11 @@ var EpollLibrary = { // (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(() => { + reg.listener = target.node.addListener((flags) => { readyListAdd(ep, reg); - ep.node.notifyListeners({{{ cDefs.POLLIN }}}); + // Readiness wakes the epoll as POLLIN; a closing fd (POLLNVAL) wakes + // it as a teardown, so listeners can tell an eviction from an event. + ep.node.notifyListeners(flags & {{{ cDefs.POLLNVAL }}} ? {{{ cDefs.POLLNVAL }}} : {{{ 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 }}})); @@ -496,7 +498,7 @@ var EpollLibrary = { var prev = ep.interests.get(key); if (prev) epollClearListener(ep, prev); - var it = {key}; + var it = {key, ep}; #if PTHREADS it.ownerThread = callerThread; #endif @@ -535,19 +537,30 @@ var EpollLibrary = { // microtask each tick and so starve the event loop; use EPOLLET or // remove the listener for such fds. Inside the wrapper so the re-wake's // hold is taken before callUserCallback's maybeExit. - if (!it.cleared && !epollWouldBlock(ep)) wake(); + if (!it.cleared && !epollWouldBlock(ep)) wake(true); }); } // A scheduled delivery is pending work and holds the runtime until it runs // (like safeSetTimeout), independent of what the set watches: a pipe write - // from a callback's last act must still deliver. - function wake() { + // from a callback's last act must still deliver. A teardown wake holds + // nothing: a watched fd closing (POLLNVAL) only evicts, and once FS.quit + // has begun (exitRuntime: FS.initialized cleared, every open fd closed, + // pipe peers reporting POLLHUP on the way) no delivery can run, while a + // hold taken there would outlive the exit, leaving keepRuntimeAlive() set + // at _proc_exit and onExit skipped. + function wake(held) { + if (held && FS.initialized && !it.held) { + it.held = true; + epollKeepalive(1); + } if (it.scheduled) return; it.scheduled = true; - epollKeepalive(1); queueMicrotask(() => { it.scheduled = false; - epollKeepalive(-1); + if (it.held) { + it.held = false; + epollKeepalive(-1); + } deliver(); }); } @@ -560,9 +573,9 @@ var EpollLibrary = { epollDeliveries[it.token] = it; } #endif - it.listener = ep.node.addListener(wake); + it.listener = ep.node.addListener((flags) => wake(!(flags & {{{ cDefs.POLLNVAL }}}))); epollReconcileKeepalive(ep); - wake(); // deliver initial readiness if the set is already ready + wake(!epollWouldBlock(ep)); // deliver initial readiness if the set is already ready return 0; }, @@ -596,12 +609,12 @@ var EpollLibrary = { // 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', '$epollKeepalive'], + _emscripten_epoll_delivery_done__deps: ['$epollDeliveries', '$epollKeepalive', '$epollWouldBlock'], _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(); + it.wake(!epollWouldBlock(it.ep)); epollKeepalive(-1); }, #endif diff --git a/test/other/test_epoll_callback_teardown_wake.c b/test/other/test_epoll_callback_teardown_wake.c new file mode 100644 index 0000000000000..be14048f87bf1 --- /dev/null +++ b/test/other/test_epoll_callback_teardown_wake.c @@ -0,0 +1,55 @@ +/* + * 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. + * + * Wakes raised while the runtime is exiting must hold nothing: exitRuntime's + * FS.quit closes every fd still open, in fd order, and a hold taken there + * outlives the exit, leaving keepRuntimeAlive() set when _proc_exit runs, so + * Module.onExit is skipped. The pipe is created before the epoll so its ends + * close first: the read end's POLLNVAL, then the write end's close reporting + * POLLHUP on the still-armed registration - a readiness-shaped wake. The + * runtime exits (atexit prints "done") and the exit completes (onExit prints + * "exited"). + */ + +#include +#include +#include +#include +#include +#include +#include + +static int ep, rfd; + +static void on_ready(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 1 && ev[0].data.fd == rfd); + char b; + assert(read(rfd, &b, 1) == 1); +} + +static void at_exit(void) { + printf("done\n"); +} + +int main(void) { + EM_ASM({ Module['onExit'] = () => out('exited'); }); + atexit(at_exit); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + ep = epoll_create1(0); + + 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); + // One pending delivery is held and drained; then, with the pipe and epoll + // still open, nothing holds the runtime and main's return exits it. + // Neither end is closed here: FS.quit closes them, pipe ends first. + assert(write(p[1], "x", 1) == 1); + return 0; +} diff --git a/test/test_other.py b/test/test_other.py index 8860e8437df63..366e29e94c45a 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13667,6 +13667,12 @@ def test_epoll_callback_pipe_exit(self): # write from other live work still delivers. self.do_runf('other/test_epoll_callback_pipe_exit.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + def test_epoll_callback_teardown_wake(self): + # A closing watched fd wakes the listener only to evict and holds nothing; + # exitRuntime's FS.quit closes every open fd, and a hold taken there would + # leave keepRuntimeAlive() set at _proc_exit and skip Module.onExit. + self.do_runf('other/test_epoll_callback_teardown_wake.c', 'done\nexited\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + @requires_pthreads @no_bun('https://github.com/emscripten-core/emscripten/issues/26197') def test_pthread_trap(self): From fec1d761455202c8acb5d30488764c5990bcc082 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 14 Sep 2026 19:34:04 -0700 Subject: [PATCH 04/12] epoll listeners: deliver as a macrotask A delivery was queued as a microtask. Hosts may drain the microtask queue synchronously inside unrelated calls (workerd does on a Node builtin load, which its connect() path performs), so a listener ran re-entrantly under the frames of the wasm call that had just made the set ready. Schedule deliveries with emSetImmediate instead; the two tests that ordered a check after deliveries by timeout now queue it as a later immediate. --- src/lib/libepoll.js | 19 +++++--- system/include/emscripten/epoll.h | 5 +- test/other/test_epoll_callback_edge.c | 6 ++- test/other/test_epoll_callback_macrotask.c | 54 ++++++++++++++++++++++ test/other/test_epoll_callback_multi.c | 8 ++-- test/test_other.py | 7 +++ 6 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 test/other/test_epoll_callback_macrotask.c diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index aa65a687e1660..1c91f8d285f84 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -469,7 +469,7 @@ var EpollLibrary = { // 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', '$epollKeepalive', '$callUserCallback', + emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive', '$epollKeepalive', '$callUserCallback', '$emSetImmediate', #if PTHREADS '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', #endif @@ -531,12 +531,12 @@ var EpollLibrary = { 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 + // re-listed): fire again on the next turn. 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. Inside the wrapper so the re-wake's - // hold is taken before callUserCallback's maybeExit. + // ready (e.g. EPOLLOUT on a writable socket) will re-schedule every + // turn and so starve the event loop; use EPOLLET or remove the + // listener for such fds. Inside the wrapper so the re-wake's hold is + // taken before callUserCallback's maybeExit. if (!it.cleared && !epollWouldBlock(ep)) wake(true); }); } @@ -548,6 +548,11 @@ var EpollLibrary = { // pipe peers reporting POLLHUP on the way) no delivery can run, while a // hold taken there would outlive the exit, leaving keepRuntimeAlive() set // at _proc_exit and onExit skipped. + // + // Delivery is a macrotask, not a microtask: hosts drain microtasks + // synchronously inside other calls (Node's module loader does so on a + // first builtin load, e.g. from connect()), which would run the callback + // re-entrantly under the caller's frames. function wake(held) { if (held && FS.initialized && !it.held) { it.held = true; @@ -555,7 +560,7 @@ var EpollLibrary = { } if (it.scheduled) return; it.scheduled = true; - queueMicrotask(() => { + emSetImmediate(() => { it.scheduled = false; if (it.held) { it.held = false; diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h index 673f8a13104a7..2bbbefcd5be4f 100644 --- a/system/include/emscripten/epoll.h +++ b/system/include/emscripten/epoll.h @@ -37,8 +37,9 @@ extern "C" { // 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 +// A listener fires on the next event-loop tick (as a macrotask, never from +// within a running wasm call) 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 diff --git a/test/other/test_epoll_callback_edge.c b/test/other/test_epoll_callback_edge.c index f5f9d357838ad..379c3361a11f8 100644 --- a/test/other/test_epoll_callback_edge.c +++ b/test/other/test_epoll_callback_edge.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -35,8 +36,9 @@ static void on_ready(void* ud) { 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); + // fresh edge. A re-delivery, were one wrongly scheduled, is an immediate + // queued before this one and would run first. + emscripten_set_immediate(second_edge, NULL); return; } diff --git a/test/other/test_epoll_callback_macrotask.c b/test/other/test_epoll_callback_macrotask.c new file mode 100644 index 0000000000000..3b834b7551940 --- /dev/null +++ b/test/other/test_epoll_callback_macrotask.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 listener delivery is a macrotask, ordered after every microtask queued + * before it runs, however late. Some hosts drain the microtask queue + * synchronously inside unrelated calls (a builtin module load), so a microtask + * delivery could run the callback re-entrantly under the frames of whatever + * wasm call happened to be executing; a macrotask never can. + */ + +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd; +static int microtask_ran; + +EM_JS(void, queue_microtask_marker, (int* flag), { + queueMicrotask(() => { HEAP32[flag >> 2] = 1; }); +}); + +static void on_ready(void* ud) { + // Queued after the set became ready, from the frame that made it ready. + assert(microtask_ran && "delivery ran before an earlier-queued microtask"); + struct epoll_event events[1]; + assert(epoll_wait(ep, events, 1, 0) == 1); + char b[1]; + assert(read(rfd, b, 1) == 1); + 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 }; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd, &ev) == 0); + assert(emscripten_epoll_add_listener(ep, on_ready, NULL) == 0); + + // Readiness schedules the delivery; a microtask queued afterwards must still + // run first. + assert(write(wfd, "x", 1) == 1); + queue_microtask_marker(µtask_ran); + return 0; +} diff --git a/test/other/test_epoll_callback_multi.c b/test/other/test_epoll_callback_multi.c index 96cd36f1ec457..9ce626dbdefe6 100644 --- a/test/other/test_epoll_callback_multi.c +++ b/test/other/test_epoll_callback_multi.c @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -68,8 +69,9 @@ int main(void) { 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); + // Both fds are already ready: A's delivery collects one, B's the other. The + // deliveries are immediates queued by add_listener, so an immediate queued + // after them runs once both have, and verifies the exact one-each split. + emscripten_set_immediate(check, NULL); return 0; } diff --git a/test/test_other.py b/test/test_other.py index 366e29e94c45a..b5be4c22d08e6 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13667,6 +13667,13 @@ def test_epoll_callback_pipe_exit(self): # write from other live work still delivers. self.do_runf('other/test_epoll_callback_pipe_exit.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + def test_epoll_callback_macrotask(self): + # A delivery is a macrotask, ordered after microtasks queued before it runs: + # hosts that drain microtasks synchronously inside unrelated calls would + # otherwise run the callback under the frames of the call that made the set + # ready. + self.do_runf('other/test_epoll_callback_macrotask.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + def test_epoll_callback_teardown_wake(self): # A closing watched fd wakes the listener only to evict and holds nothing; # exitRuntime's FS.quit closes every open fd, and a hold taken there would From a704ff2380b348437d3f1c1de3385c1a019d4fcf Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 00:18:21 -0700 Subject: [PATCH 05/12] epoll listeners: a no-deliver wake still runs maybeExit --- src/lib/libepoll.js | 9 ++- test/other/test_epoll_callback_drain_exit.c | 84 +++++++++++++++++++++ test/test_other.py | 16 ++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 test/other/test_epoll_callback_drain_exit.c diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 1c91f8d285f84..57e3cdd1d1403 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -469,7 +469,7 @@ var EpollLibrary = { // 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', '$epollKeepalive', '$callUserCallback', '$emSetImmediate', + emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive', '$epollKeepalive', '$callUserCallback', '$emSetImmediate', '$maybeExit', #if PTHREADS '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', #endif @@ -566,6 +566,13 @@ var EpollLibrary = { it.held = false; epollKeepalive(-1); } + // Nothing to deliver (cleared, or drained synchronously meanwhile): + // callUserCallback's maybeExit will not run, and the hold just + // released may have been what deferred main's exit. + if (it.cleared || epollWouldBlock(ep)) { + maybeExit(); + return; + } deliver(); }); } diff --git a/test/other/test_epoll_callback_drain_exit.c b/test/other/test_epoll_callback_drain_exit.c new file mode 100644 index 0000000000000..9f842442cc025 --- /dev/null +++ b/test/other/test_epoll_callback_drain_exit.c @@ -0,0 +1,84 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * A scheduled delivery holds the runtime until it runs. If the set is drained + * synchronously before then (MODE_DRAIN: epoll_wait(..., 0) from main) or the + * listener is removed (MODE_REMOVE), the delivery has nothing to do - but + * releasing its hold may be what lets main's deferred exit proceed, so the + * runtime must still exit: atexit prints "done", Module.onExit "exited", and + * the process exits with main's status. MODE_LATER is the regression guard: a + * set made ready after main returns still delivers, then exits. + * + * A listener registered from a non-main thread (PROXY_TO_PTHREAD) may see one + * spurious wakeup: the main thread's delivery can be dispatched between the + * proxied write and the proxied drain. Its epoll_wait(0) then collects nothing. + */ + +#include +#include +#include +#include +#include +#include +#include + +static int ep, rfd, wfd, fires; + +static void nothing_to_collect(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 0); +#ifndef __EMSCRIPTEN_PTHREADS__ + printf("delivered after drain\n"); + abort(); +#endif +} + +static void on_ready(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 1 && ev[0].data.fd == rfd); + char b; + assert(read(rfd, &b, 1) == 1); + fires++; +} + +static void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } + +static void at_exit(void) { +#if MODE_LATER + assert(fires == 1); +#endif + printf("done\n"); +} + +int main(void) { + MAIN_THREAD_EM_ASM({ Module['onExit'] = () => out('exited'); }); + atexit(at_exit); + int p[2]; + assert(pipe(p) == 0); + rfd = p[0]; + wfd = p[1]; + ep = epoll_create1(0); + struct epoll_event ev = { .events = EPOLLIN }; + ev.data.fd = rfd; + assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd, &ev) == 0); +#if MODE_LATER + assert(emscripten_epoll_add_listener(ep, on_ready, 0) == 0); + emscripten_async_call(writer, NULL, 0); + return 0; +#else + assert(emscripten_epoll_add_listener(ep, nothing_to_collect, 0) == 0); + // Ready: a delivery is now scheduled and holds the runtime. + assert(write(wfd, "x", 1) == 1); +#if MODE_REMOVE + assert(emscripten_epoll_remove_listener(ep, nothing_to_collect) == 0); +#else + assert(epoll_wait(ep, &ev, 1, 0) == 1 && ev.data.fd == rfd); + char b; + assert(read(rfd, &b, 1) == 1); +#endif + return 7; +#endif +} diff --git a/test/test_other.py b/test/test_other.py index b5be4c22d08e6..c2c010eae1cb7 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13680,6 +13680,22 @@ def test_epoll_callback_teardown_wake(self): # leave keepRuntimeAlive() set at _proc_exit and skip Module.onExit. self.do_runf('other/test_epoll_callback_teardown_wake.c', 'done\nexited\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + @parameterized({ + 'drain': (['-DMODE_DRAIN'], 7), + 'remove': (['-DMODE_REMOVE'], 7), + 'later': (['-DMODE_LATER'], 0), + 'drain_pthread': (['-DMODE_DRAIN', '-pthread', '-sPROXY_TO_PTHREAD'], 7), + 'remove_pthread': (['-DMODE_REMOVE', '-pthread', '-sPROXY_TO_PTHREAD'], 7), + }) + def test_epoll_callback_drain_exit(self, cflags, returncode): + # A scheduled delivery whose set was drained (or listener removed) before it + # ran has nothing to deliver, but releasing its hold must still let main's + # deferred exit complete (Module.onExit fires, main's status is returned). + if '-pthread' in cflags: + self.require_pthreads() + self.do_runf('other/test_epoll_callback_drain_exit.c', 'done\nexited\n', + cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME'] + cflags, assert_returncode=returncode) + @requires_pthreads @no_bun('https://github.com/emscripten-core/emscripten/issues/26197') def test_pthread_trap(self): From 45018d2efee539e88a03218ce2ccd2ccd3cd2d58 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 01:04:56 -0700 Subject: [PATCH 06/12] epoll listeners: hold the owner thread through a shared-memory keepalive A listener registered from a pthread runs its callbacks there, so the holds epoll takes for it (host-armed interest, a pending or in-flight delivery) must hold that thread's runtime too. Proxying a push to the owner lands only on its next event-loop turn, after it may already have decided to exit (its return from main, or a callback's end), so acquires were racy. Add struct pthread.keepalive_holds, an atomic count of holds other threads place on a thread's runtime, read by keepRuntimeAlive() alongside the JS-side counter, with _emscripten_thread_keepalive(t, delta) to adjust it; a release also queues a no-op task so the target re-evaluates. epoll mirrors its listener-scoped holds onto the owner with this, acquiring before releasing where one hold hands over to another so the owner never sees a gap. A delivery to an owner that has exited is dropped and clears the listener instead of asserting. --- src/lib/libcore.js | 36 +++++++- src/lib/libepoll.js | 91 ++++++++++++++----- src/struct_info_generated.json | 3 +- src/struct_info_generated_wasm64.json | 3 +- src/struct_info_internal.json | 1 + system/include/emscripten/epoll.h | 6 +- .../lib/libc/musl/src/internal/pthread_impl.h | 5 + .../lib/pthread/emscripten_epoll_callback.c | 27 ++---- system/lib/pthread/library_pthread.c | 15 +++ test/other/test_epoll_callback_drain_exit.c | 22 ++++- test/test_other.py | 1 + tools/native_sigs.py | 2 +- 12 files changed, 158 insertions(+), 54 deletions(-) diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 928777d174877..59f6546e2e6b9 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -2094,12 +2094,32 @@ addToLibrary({ $runtimeKeepaliveCounter__internal: true, $runtimeKeepaliveCounter: 0, +#if PTHREADS + // Holds other threads placed on this thread's runtime, in shared memory + // (struct pthread.keepalive_holds, via _emscripten_thread_keepalive): a + // thread cannot reach another's runtimeKeepaliveCounter synchronously. + $keepaliveHeldByOthers__internal: true, + $keepaliveHeldByOthers__deps: ['pthread_self'], + $keepaliveHeldByOthers: () => { + var self = _pthread_self(); + return self && Atomics.load(HEAP32, {{{ getHeapOffset('self + ' + C_STRUCTS.pthread.keepalive_holds, 'i32') }}}) > 0; + }, +#endif + #if isSymbolNeeded('$noExitRuntime') // If the `noExitRuntime` symbol is included in the build then // keepRuntimeAlive is always conditional since its state can change // at runtime. - $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter'], - $keepRuntimeAlive: () => noExitRuntime || runtimeKeepaliveCounter > 0, + $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter', +#if PTHREADS + '$keepaliveHeldByOthers', +#endif + ], + $keepRuntimeAlive: () => noExitRuntime || runtimeKeepaliveCounter > 0 +#if PTHREADS + || keepaliveHeldByOthers() +#endif + , #elif !EXIT_RUNTIME && !PTHREADS // When `noExitRuntime` is not included and EXIT_RUNTIME=0 then we know the // runtime can never exit (i.e. should always be kept alive). @@ -2107,8 +2127,16 @@ addToLibrary({ // have to track `runtimeKeepaliveCounter` in that case. $keepRuntimeAlive: () => true, #else - $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter'], - $keepRuntimeAlive: () => runtimeKeepaliveCounter > 0, + $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter', +#if PTHREADS + '$keepaliveHeldByOthers', +#endif + ], + $keepRuntimeAlive: () => runtimeKeepaliveCounter > 0 +#if PTHREADS + || keepaliveHeldByOthers() +#endif + , #endif // Callable in pthread without __proxy needed. diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 57e3cdd1d1403..44bd1f87d2651 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -106,7 +106,7 @@ var EpollLibrary = { $epollClearListener__internal: true, $epollClearListener__deps: [ #if PTHREADS - '$epollDeliveries', '$epollKeepalive', '_emscripten_epoll_keepalive_on_thread', + '$epollDeliveries', '$epollHoldOwner', #endif ], $epollClearListener: (ep, it) => { @@ -114,9 +114,7 @@ var EpollLibrary = { 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); - } + if (it.keptAlive) epollHoldOwner(it, -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 - so release @@ -124,7 +122,7 @@ var EpollLibrary = { if (it.token) delete epollDeliveries[it.token]; if (it.inflight) { it.inflight = false; - epollKeepalive(-1); + epollHoldOwner(it, -1); } #endif }, @@ -147,6 +145,36 @@ var EpollLibrary = { #endif }, +#if PTHREADS + // Hold or release a listener's owner thread (where its callbacks run) in + // step with the main thread: the atomic add lands immediately, so the owner + // can never decide to exit ahead of a hold taken for it. ownerThread is 0 + // for a main-thread listener, covered by epollKeepalive. An owner that has + // exited (its pthread struct is being reclaimed) has nothing to hold. + $epollHoldOwner__internal: true, + $epollHoldOwner__deps: ['_emscripten_thread_keepalive'], + $epollHoldOwner: (it, delta) => { + if (it.ownerThread && PThread.pthreads[it.ownerThread]) { + __emscripten_thread_keepalive(it.ownerThread, delta); + } + }, +#endif + + // A hold scoped to one listener (a pending or in-flight delivery): on the + // main thread and, with pthreads, mirrored on the owner. + $epollHold__internal: true, + $epollHold__deps: ['$epollKeepalive', +#if PTHREADS + '$epollHoldOwner', +#endif + ], + $epollHold: (it, delta) => { + epollKeepalive(delta); +#if PTHREADS + epollHoldOwner(it, delta); +#endif + }, + // Can readiness on this watched fd arrive from the host (the JS event loop), // with no wasm running? A socket's can. A pipe is only ever written by wasm, // so whatever runs that write already holds the runtime - the registration @@ -161,11 +189,11 @@ var EpollLibrary = { // (Node.js-style, registered I/O interest holds the loop open; a set the host // cannot fire releases it). A pending delivery holds it separately (see // wake()). With pthreads each listener's owner thread (which runs its - // deliveries) is held too. + // deliveries) is held by the same rule. $epollReconcileKeepalive__internal: true, $epollReconcileKeepalive__deps: ['$epollKeepalive', #if PTHREADS - '_emscripten_epoll_keepalive_on_thread', + '$epollHoldOwner', #endif ], $epollReconcileKeepalive: (ep) => { @@ -174,11 +202,7 @@ var EpollLibrary = { 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); - } + epollHoldOwner(it, armed ? 1 : -1); } } #endif @@ -469,7 +493,7 @@ var EpollLibrary = { // 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', '$epollKeepalive', '$callUserCallback', '$emSetImmediate', '$maybeExit', + emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive', '$epollHold', '$callUserCallback', '$emSetImmediate', '$maybeExit', #if PTHREADS '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', #endif @@ -523,8 +547,16 @@ var EpollLibrary = { #if PTHREADS if (callerThread) { it.inflight = true; - epollKeepalive(1); // held until the completion lands back here - __emscripten_epoll_run_callback_on_thread(callerThread, callback, userdata, it.token); + // Held until the completion lands back here - on the owner only: an + // exit() from inside the callback unwinds past the completion, and a + // hold on the main thread would then defer the exit it asks for. + epollHoldOwner(it, 1); + if (!__emscripten_epoll_run_callback_on_thread(callerThread, callback, userdata, it.token)) { + // The owner thread is gone (exited under an exit() elsewhere): the + // listener dies with it. + epollClearListener(ep, it); + epollReconcileKeepalive(ep); + } return; } #endif @@ -556,23 +588,40 @@ var EpollLibrary = { function wake(held) { if (held && FS.initialized && !it.held) { it.held = true; - epollKeepalive(1); + epollHold(it, 1); } if (it.scheduled) return; it.scheduled = true; emSetImmediate(() => { it.scheduled = false; - if (it.held) { - it.held = false; - epollKeepalive(-1); + function release() { + if (it.held) { + it.held = false; + epollHold(it, -1); + } } // Nothing to deliver (cleared, or drained synchronously meanwhile): // callUserCallback's maybeExit will not run, and the hold just // released may have been what deferred main's exit. if (it.cleared || epollWouldBlock(ep)) { + release(); maybeExit(); return; } +#if PTHREADS + // Cross-thread: take the in-flight hold before releasing this one, so + // the owner (woken at once by the release) never sees a gap between + // the two and exits under the callback on its way. + if (callerThread) { + deliver(); + release(); + maybeExit(); + return; + } +#endif + // Inline: release first, so callUserCallback's maybeExit sees the + // true state (the callback's own re-wake takes a fresh hold inside). + release(); deliver(); }); } @@ -621,13 +670,13 @@ var EpollLibrary = { // 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', '$epollKeepalive', '$epollWouldBlock'], + _emscripten_epoll_delivery_done__deps: ['$epollDeliveries', '$epollHoldOwner', '$epollWouldBlock'], _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(!epollWouldBlock(it.ep)); - epollKeepalive(-1); + epollHoldOwner(it, -1); }, #endif }; diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index e8cad551d543c..63a6b15b6e435 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -1071,7 +1071,8 @@ "p_proto": 8 }, "pthread": { - "__size__": 120, + "__size__": 124, + "keepalive_holds": 120, "profilerBlock": 96, "stack": 48, "stack_size": 52, diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index c08719f390c1f..63fa7fc680001 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -1071,7 +1071,8 @@ "p_proto": 16 }, "pthread": { - "__size__": 216, + "__size__": 224, + "keepalive_holds": 216, "profilerBlock": 176, "stack": 80, "stack_size": 88, diff --git a/src/struct_info_internal.json b/src/struct_info_internal.json index de28e7a0b6c4c..6253c62b80d8d 100644 --- a/src/struct_info_internal.json +++ b/src/struct_info_internal.json @@ -6,6 +6,7 @@ "file": "pthread_impl.h", "structs": { "pthread": [ + "keepalive_holds", "profilerBlock", "stack", "stack_size", diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h index 2bbbefcd5be4f..c575ed487f604 100644 --- a/system/include/emscripten/epoll.h +++ b/system/include/emscripten/epoll.h @@ -61,7 +61,11 @@ extern "C" { // disarmed) the listeners stop holding the runtime, so no explicit disposal is // required in that case. A pipe does not count: it can only be written by wasm // code, which is already running (and so already held) when it does; a -// delivery that write schedules is itself held until it runs. +// delivery that write schedules is itself held until it runs. With pthreads the +// same holds apply to the thread that registered the listener, where its +// callbacks run. Such a callback may see a spurious wakeup (epoll_wait +// collects nothing) if the set was drained from that thread while the signal +// was in flight. // // 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 diff --git a/system/lib/libc/musl/src/internal/pthread_impl.h b/system/lib/libc/musl/src/internal/pthread_impl.h index c9a687cddbad8..21193d472c0f1 100644 --- a/system/lib/libc/musl/src/internal/pthread_impl.h +++ b/system/lib/libc/musl/src/internal/pthread_impl.h @@ -125,6 +125,11 @@ struct pthread { // // Since futex addresses must be 4-byte aligned, the low bit is safe to use. _Atomic uintptr_t wait_addr; + // Runtime keepalive holds placed on this thread by other threads (see + // _emscripten_thread_keepalive). Read by this thread's keepRuntimeAlive() + // alongside its own JS-side counter, which other threads cannot reach + // synchronously. + _Atomic int keepalive_holds; #endif }; diff --git a/system/lib/pthread/emscripten_epoll_callback.c b/system/lib/pthread/emscripten_epoll_callback.c index 3cf067049619b..6747279bdf1bf 100644 --- a/system/lib/pthread/emscripten_epoll_callback.c +++ b/system/lib/pthread/emscripten_epoll_callback.c @@ -14,8 +14,8 @@ // 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 @@ -47,7 +47,8 @@ static void do_epoll_done(void* arg) { free(arg); } -void _emscripten_epoll_run_callback_on_thread(pthread_t t, +// Returns false if the target thread no longer exists. +bool _emscripten_epoll_run_callback_on_thread(pthread_t t, em_epoll_callback callback, void* userdata, int token) { @@ -59,24 +60,8 @@ void _emscripten_epoll_run_callback_on_thread(pthread_t t, 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"); + free(args); + return false; } + return true; } diff --git a/system/lib/pthread/library_pthread.c b/system/lib/pthread/library_pthread.c index b8a48cb91f1c9..3b20f12cb358b 100644 --- a/system/lib/pthread/library_pthread.c +++ b/system/lib/pthread/library_pthread.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -133,3 +134,17 @@ void _emscripten_init_main_thread(void) { _emscripten_thread_mailbox_init(&__main_pthread); _emscripten_thread_mailbox_await(&__main_pthread); } + +static void keepalive_noop(void* arg) {} + +// Hold (delta > 0) or release another thread's runtime. The add is immediate, +// so an acquire is never late; a release also queues a no-op task so the +// target re-evaluates keepRuntimeAlive on its next event-loop turn (a thread +// that already exited has nothing to re-evaluate, so a failed proxy is fine). +void _emscripten_thread_keepalive(pthread_t t, int delta) { + atomic_fetch_add(&t->keepalive_holds, delta); + if (delta < 0) { + emscripten_proxy_async( + emscripten_proxy_get_system_queue(), t, keepalive_noop, NULL); + } +} diff --git a/test/other/test_epoll_callback_drain_exit.c b/test/other/test_epoll_callback_drain_exit.c index 9f842442cc025..53c420675f561 100644 --- a/test/other/test_epoll_callback_drain_exit.c +++ b/test/other/test_epoll_callback_drain_exit.c @@ -12,9 +12,14 @@ * the process exits with main's status. MODE_LATER is the regression guard: a * set made ready after main returns still delivers, then exits. * - * A listener registered from a non-main thread (PROXY_TO_PTHREAD) may see one - * spurious wakeup: the main thread's delivery can be dispatched between the - * proxied write and the proxied drain. Its epoll_wait(0) then collects nothing. + * Under PROXY_TO_PTHREAD the listener is owned by the proxied main thread, + * whose holds mirror the main thread's: in MODE_LATER it survives its return + * from main to take the delivery scheduled by its own pipe write. It may see + * one spurious wakeup: the main thread's delivery can be dispatched between + * the proxied write and the proxied drain, and its epoll_wait(0) then collects + * nothing. Exits are explicit there: a proxied main whose keepalive later + * reaches zero does not run exit() + * (https://github.com/emscripten-core/emscripten/issues/ISSUE_TODO). */ #include @@ -25,6 +30,12 @@ #include #include +#ifdef __EMSCRIPTEN_PTHREADS__ +#define EXIT(rc) exit(rc) +#else +#define EXIT(rc) return rc +#endif + static int ep, rfd, wfd, fires; static void nothing_to_collect(void* ud) { @@ -42,6 +53,9 @@ static void on_ready(void* ud) { char b; assert(read(rfd, &b, 1) == 1); fires++; +#ifdef __EMSCRIPTEN_PTHREADS__ + exit(0); +#endif } static void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } @@ -79,6 +93,6 @@ int main(void) { char b; assert(read(rfd, &b, 1) == 1); #endif - return 7; + EXIT(7); #endif } diff --git a/test/test_other.py b/test/test_other.py index c2c010eae1cb7..52f1bbbb80fd0 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13686,6 +13686,7 @@ def test_epoll_callback_teardown_wake(self): 'later': (['-DMODE_LATER'], 0), 'drain_pthread': (['-DMODE_DRAIN', '-pthread', '-sPROXY_TO_PTHREAD'], 7), 'remove_pthread': (['-DMODE_REMOVE', '-pthread', '-sPROXY_TO_PTHREAD'], 7), + 'later_pthread': (['-DMODE_LATER', '-pthread', '-sPROXY_TO_PTHREAD'], 0), }) def test_epoll_callback_drain_exit(self, cflags, returncode): # A scheduled delivery whose set was drained (or listener removed) before it diff --git a/tools/native_sigs.py b/tools/native_sigs.py index 5ddaf39206d70..240f10f32ba6c 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -529,7 +529,6 @@ '__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_proxy_dlsync': '_p', @@ -543,6 +542,7 @@ '_emscripten_thread_exit': '_p', '_emscripten_thread_free_data': '_p', '_emscripten_thread_init': '_p_____', + '_emscripten_thread_keepalive': '_p_', '_emscripten_thread_is_valid': '_p', '_emscripten_thread_mailbox_init': '_p', '_emscripten_thread_mailbox_shutdown': '_p', From 5cc5ebc23b7b0e97a0328e05386ddc49b3bf4d2d Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 01:07:03 -0700 Subject: [PATCH 07/12] epoll listeners: link the proxied-main exit issue in the drain test --- test/other/test_epoll_callback_drain_exit.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/other/test_epoll_callback_drain_exit.c b/test/other/test_epoll_callback_drain_exit.c index 53c420675f561..9801b1d9d7469 100644 --- a/test/other/test_epoll_callback_drain_exit.c +++ b/test/other/test_epoll_callback_drain_exit.c @@ -19,7 +19,7 @@ * the proxied write and the proxied drain, and its epoll_wait(0) then collects * nothing. Exits are explicit there: a proxied main whose keepalive later * reaches zero does not run exit() - * (https://github.com/emscripten-core/emscripten/issues/ISSUE_TODO). + * (https://github.com/emscripten-core/emscripten/issues/27721). */ #include From 7501f5891d5061447212f114550aed57f95d7bb2 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 01:38:46 -0700 Subject: [PATCH 08/12] epoll listeners: maybeExit is not available under MINIMAL_RUNTIME --- src/lib/libepoll.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 44bd1f87d2651..1a0cc6df107fa 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -493,7 +493,10 @@ var EpollLibrary = { // 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', '$epollHold', '$callUserCallback', '$emSetImmediate', '$maybeExit', + emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive', '$epollHold', '$callUserCallback', '$emSetImmediate', +#if !MINIMAL_RUNTIME + '$maybeExit', +#endif #if PTHREADS '$epollDeliveries', '_emscripten_epoll_run_callback_on_thread', #endif @@ -605,7 +608,9 @@ var EpollLibrary = { // released may have been what deferred main's exit. if (it.cleared || epollWouldBlock(ep)) { release(); +#if !MINIMAL_RUNTIME maybeExit(); +#endif return; } #if PTHREADS @@ -615,7 +620,9 @@ var EpollLibrary = { if (callerThread) { deliver(); release(); +#if !MINIMAL_RUNTIME maybeExit(); +#endif return; } #endif From 205391bf8cd0bb527ef3f22bb374be7de3c8995a Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 01:56:15 -0700 Subject: [PATCH 09/12] Automatic rebaseline of codesize expectations. NFC This is an automatic change generated by tools/maint/rebaseline_tests.py. The following (12) test expectation files were updated by running the tests with `--rebaseline`: ``` codesize/test_codesize_cxx_ctors2.json: 152604 => 152604 [+0 bytes / +0.00%] codesize/test_codesize_cxx_except_wasm.json: 168663 => 168663 [+0 bytes / +0.00%] codesize/test_codesize_cxx_except_wasm_legacy.json: 166523 => 166523 [+0 bytes / +0.00%] codesize/test_codesize_cxx_lto.json: 119632 => 119632 [+0 bytes / +0.00%] codesize/test_codesize_hello_dylink.json: 43250 => 43250 [+0 bytes / +0.00%] codesize/test_codesize_hello_dylink_all.json: 859704 => 859814 [+110 bytes / +0.01%] codesize/test_codesize_mem_O3_grow_standalone.json: 9306 => 9306 [+0 bytes / +0.00%] codesize/test_codesize_mem_O3_standalone.json: 9140 => 9140 [+0 bytes / +0.00%] codesize/test_codesize_mem_O3_standalone_narg.json: 8455 => 8455 [+0 bytes / +0.00%] codesize/test_codesize_mem_O3_standalone_narg_flto.json: 7386 => 7386 [+0 bytes / +0.00%] codesize/test_codesize_minimal_pthreads.json: 26030 => 26109 [+79 bytes / +0.30%] codesize/test_codesize_minimal_pthreads_memgrowth.json: 26489 => 26573 [+84 bytes / +0.32%] Average change: +0.05% (+0.00% - +0.32%) ``` --- test/codesize/test_codesize_cxx_ctors2.json | 4 ++-- test/codesize/test_codesize_cxx_except_wasm.json | 4 ++-- test/codesize/test_codesize_cxx_except_wasm_legacy.json | 4 ++-- test/codesize/test_codesize_cxx_lto.json | 4 ++-- test/codesize/test_codesize_mem_O3_grow_standalone.json | 4 ++-- test/codesize/test_codesize_mem_O3_standalone.json | 4 ++-- test/codesize/test_codesize_mem_O3_standalone_narg.json | 4 ++-- test/codesize/test_codesize_mem_O3_standalone_narg_flto.json | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/test/codesize/test_codesize_cxx_ctors2.json b/test/codesize/test_codesize_cxx_ctors2.json index 9745a6316785d..8ef61cbbe1eb2 100644 --- a/test/codesize/test_codesize_cxx_ctors2.json +++ b/test/codesize/test_codesize_cxx_ctors2.json @@ -2,9 +2,9 @@ "a.out.js": 19201, "a.out.js.gz": 8107, "a.out.nodebug.wasm": 133403, - "a.out.nodebug.wasm.gz": 50959, + "a.out.nodebug.wasm.gz": 50960, "total": 152604, - "total_gz": 59066, + "total_gz": 59067, "sent": [ "__cxa_throw", "_abort_js", diff --git a/test/codesize/test_codesize_cxx_except_wasm.json b/test/codesize/test_codesize_cxx_except_wasm.json index acce5eb08a6f9..cd1f66d282f79 100644 --- a/test/codesize/test_codesize_cxx_except_wasm.json +++ b/test/codesize/test_codesize_cxx_except_wasm.json @@ -2,9 +2,9 @@ "a.out.js": 19023, "a.out.js.gz": 8039, "a.out.nodebug.wasm": 149640, - "a.out.nodebug.wasm.gz": 56311, + "a.out.nodebug.wasm.gz": 56306, "total": 168663, - "total_gz": 64350, + "total_gz": 64345, "sent": [ "_abort_js", "_tzset_js", diff --git a/test/codesize/test_codesize_cxx_except_wasm_legacy.json b/test/codesize/test_codesize_cxx_except_wasm_legacy.json index 7d2e3ac6023e4..10bd156ac3b3b 100644 --- a/test/codesize/test_codesize_cxx_except_wasm_legacy.json +++ b/test/codesize/test_codesize_cxx_except_wasm_legacy.json @@ -2,9 +2,9 @@ "a.out.js": 19101, "a.out.js.gz": 8065, "a.out.nodebug.wasm": 147422, - "a.out.nodebug.wasm.gz": 55982, + "a.out.nodebug.wasm.gz": 55975, "total": 166523, - "total_gz": 64047, + "total_gz": 64040, "sent": [ "_abort_js", "_tzset_js", diff --git a/test/codesize/test_codesize_cxx_lto.json b/test/codesize/test_codesize_cxx_lto.json index 5bf114b165af0..90f007af237fc 100644 --- a/test/codesize/test_codesize_cxx_lto.json +++ b/test/codesize/test_codesize_cxx_lto.json @@ -2,9 +2,9 @@ "a.out.js": 18568, "a.out.js.gz": 7818, "a.out.nodebug.wasm": 101064, - "a.out.nodebug.wasm.gz": 38292, + "a.out.nodebug.wasm.gz": 38291, "total": 119632, - "total_gz": 46110, + "total_gz": 46109, "sent": [ "a (emscripten_resize_heap)", "b (_setitimer_js)", diff --git a/test/codesize/test_codesize_mem_O3_grow_standalone.json b/test/codesize/test_codesize_mem_O3_grow_standalone.json index bbf629f321167..82e82d16e9331 100644 --- a/test/codesize/test_codesize_mem_O3_grow_standalone.json +++ b/test/codesize/test_codesize_mem_O3_grow_standalone.json @@ -2,9 +2,9 @@ "a.out.js": 3657, "a.out.js.gz": 1834, "a.out.nodebug.wasm": 5649, - "a.out.nodebug.wasm.gz": 2669, + "a.out.nodebug.wasm.gz": 2673, "total": 9306, - "total_gz": 4503, + "total_gz": 4507, "sent": [ "args_get", "args_sizes_get", diff --git a/test/codesize/test_codesize_mem_O3_standalone.json b/test/codesize/test_codesize_mem_O3_standalone.json index 61f177858fad1..703340ef85380 100644 --- a/test/codesize/test_codesize_mem_O3_standalone.json +++ b/test/codesize/test_codesize_mem_O3_standalone.json @@ -2,9 +2,9 @@ "a.out.js": 3567, "a.out.js.gz": 1784, "a.out.nodebug.wasm": 5573, - "a.out.nodebug.wasm.gz": 2608, + "a.out.nodebug.wasm.gz": 2610, "total": 9140, - "total_gz": 4392, + "total_gz": 4394, "sent": [ "args_get", "args_sizes_get", diff --git a/test/codesize/test_codesize_mem_O3_standalone_narg.json b/test/codesize/test_codesize_mem_O3_standalone_narg.json index 34167a05ea85c..2e0eb64bdef08 100644 --- a/test/codesize/test_codesize_mem_O3_standalone_narg.json +++ b/test/codesize/test_codesize_mem_O3_standalone_narg.json @@ -2,9 +2,9 @@ "a.out.js": 3093, "a.out.js.gz": 1530, "a.out.nodebug.wasm": 5362, - "a.out.nodebug.wasm.gz": 2451, + "a.out.nodebug.wasm.gz": 2453, "total": 8455, - "total_gz": 3981, + "total_gz": 3983, "sent": [ "proc_exit" ], diff --git a/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json b/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json index d1c92228a1005..65e5cb8d2ae44 100644 --- a/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json +++ b/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json @@ -2,9 +2,9 @@ "a.out.js": 3093, "a.out.js.gz": 1530, "a.out.nodebug.wasm": 4293, - "a.out.nodebug.wasm.gz": 2147, + "a.out.nodebug.wasm.gz": 2144, "total": 7386, - "total_gz": 3677, + "total_gz": 3674, "sent": [ "proc_exit" ], From dbcf0471b60f26f4acef7e0f51415eac01eb0c53 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 02:17:14 -0700 Subject: [PATCH 10/12] epoll listeners: keep native_sigs.py sorted --- test/codesize/test_codesize_hello_dylink.json | 4 ++-- test/codesize/test_codesize_hello_dylink_all.json | 6 ++++-- test/codesize/test_codesize_minimal_pthreads.json | 10 +++++----- .../test_codesize_minimal_pthreads_memgrowth.json | 10 +++++----- tools/native_sigs.py | 2 +- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/test/codesize/test_codesize_hello_dylink.json b/test/codesize/test_codesize_hello_dylink.json index 27c28e6b09a79..7638a16f8551e 100644 --- a/test/codesize/test_codesize_hello_dylink.json +++ b/test/codesize/test_codesize_hello_dylink.json @@ -2,9 +2,9 @@ "a.out.js": 26188, "a.out.js.gz": 11168, "a.out.nodebug.wasm": 16992, - "a.out.nodebug.wasm.gz": 8679, + "a.out.nodebug.wasm.gz": 8678, "total": 43180, - "total_gz": 19847, + "total_gz": 19846, "sent": [ "__syscall_stat64", "emscripten_resize_heap", diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 2599da95e6573..df354917d2abd 100644 --- a/test/codesize/test_codesize_hello_dylink_all.json +++ b/test/codesize/test_codesize_hello_dylink_all.json @@ -1,7 +1,7 @@ { - "a.out.js": 270695, + "a.out.js": 271659, "a.out.nodebug.wasm": 588289, - "total": 858984, + "total": 859948, "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/codesize/test_codesize_minimal_pthreads.json b/test/codesize/test_codesize_minimal_pthreads.json index f897ee3214106..ea2dc8e3d33dd 100644 --- a/test/codesize/test_codesize_minimal_pthreads.json +++ b/test/codesize/test_codesize_minimal_pthreads.json @@ -1,10 +1,10 @@ { - "a.out.js": 6900, - "a.out.js.gz": 3432, + "a.out.js": 6979, + "a.out.js.gz": 3457, "a.out.nodebug.wasm": 19147, - "a.out.nodebug.wasm.gz": 8834, - "total": 26047, - "total_gz": 12266, + "a.out.nodebug.wasm.gz": 8837, + "total": 26126, + "total_gz": 12294, "sent": [ "a (memory)", "b (exit)", diff --git a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json index 5124575dc3ca5..eb525f65bc687 100644 --- a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json +++ b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json @@ -1,10 +1,10 @@ { - "a.out.js": 7358, - "a.out.js.gz": 3648, + "a.out.js": 7442, + "a.out.js.gz": 3675, "a.out.nodebug.wasm": 19148, - "a.out.nodebug.wasm.gz": 8835, - "total": 26506, - "total_gz": 12483, + "a.out.nodebug.wasm.gz": 8838, + "total": 26590, + "total_gz": 12513, "sent": [ "a (memory)", "b (exit)", diff --git a/tools/native_sigs.py b/tools/native_sigs.py index 240f10f32ba6c..aa6bf0822574f 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -542,8 +542,8 @@ '_emscripten_thread_exit': '_p', '_emscripten_thread_free_data': '_p', '_emscripten_thread_init': '_p_____', - '_emscripten_thread_keepalive': '_p_', '_emscripten_thread_is_valid': '_p', + '_emscripten_thread_keepalive': '_p_', '_emscripten_thread_mailbox_init': '_p', '_emscripten_thread_mailbox_shutdown': '_p', '_emscripten_thread_notify': '_p', From 41dbb9161936228b36a35b11f661a8195454be47 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 18:22:45 -0700 Subject: [PATCH 11/12] epoll listeners: unref'd handles A listener never keeps the runtime or its registering thread alive; a program holds the runtime itself with emscripten_runtime_keepalive_push/pop. Drops the host-armed keepalive model, the cross-thread keepalive_holds in struct pthread and the keepRuntimeAlive() change. The pending-delivery hold stays. Tests rewritten to the new contract. --- ChangeLog.md | 9 +- src/lib/libcore.js | 36 +-- src/lib/libepoll.js | 246 +++++------------- src/struct_info_generated.json | 3 +- src/struct_info_generated_wasm64.json | 3 +- src/struct_info_internal.json | 1 - system/include/emscripten/epoll.h | 46 ++-- system/lib/libc/emscripten_internal.h | 2 +- .../lib/libc/musl/src/internal/pthread_impl.h | 5 - .../lib/pthread/emscripten_epoll_callback.c | 4 +- system/lib/pthread/library_pthread.c | 15 -- test/codesize/test_codesize_cxx_ctors2.json | 4 +- .../test_codesize_cxx_except_wasm.json | 4 +- .../test_codesize_cxx_except_wasm_legacy.json | 4 +- test/codesize/test_codesize_cxx_lto.json | 4 +- test/codesize/test_codesize_hello_dylink.json | 4 +- .../test_codesize_hello_dylink_all.json | 4 +- .../test_codesize_mem_O3_grow_standalone.json | 4 +- .../test_codesize_mem_O3_standalone.json | 4 +- .../test_codesize_mem_O3_standalone_narg.json | 4 +- ..._codesize_mem_O3_standalone_narg_flto.json | 4 +- .../test_codesize_minimal_pthreads.json | 10 +- ...t_codesize_minimal_pthreads_memgrowth.json | 10 +- test/core/test_epoll_wait_and_callback.c | 5 +- test/other/test_epoll_callback_close.c | 12 +- test/other/test_epoll_callback_drain_exit.c | 39 +-- test/other/test_epoll_callback_nested_close.c | 9 +- test/other/test_epoll_callback_pipe_exit.c | 54 ---- test/other/test_epoll_callback_unref.c | 72 +++++ test/sockets/test_epoll_callback.c | 49 ++-- test/sockets/test_epoll_callback_force_exit.c | 11 +- test/test_other.py | 42 +-- test/test_sockets_node.py | 19 +- tools/native_sigs.py | 1 - 34 files changed, 291 insertions(+), 452 deletions(-) delete mode 100644 test/other/test_epoll_callback_pipe_exit.c create mode 100644 test/other/test_epoll_callback_unref.c diff --git a/ChangeLog.md b/ChangeLog.md index 748217fa84dff..4201bd72c1ad3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -69,10 +69,11 @@ See docs/process.md for more on how version tagging works. default across all supported engines; it should now only ever be disabled implicitly when targeting JavaScript via `-sWASM=0`. (#27558) - 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. + the new ``, experimental), which deliver an epoll set's + readiness to a callback on the host event loop (the callback collects the + events itself via a zero-timeout `epoll_wait`), with no `ASYNCIFY`/`JSPI` + requirement. The listener does not keep the runtime alive; use + `emscripten_runtime_keepalive_push`/`pop` for that. (#27547) 6.0.7 - 08/17/26 ---------------- diff --git a/src/lib/libcore.js b/src/lib/libcore.js index 59f6546e2e6b9..928777d174877 100644 --- a/src/lib/libcore.js +++ b/src/lib/libcore.js @@ -2094,32 +2094,12 @@ addToLibrary({ $runtimeKeepaliveCounter__internal: true, $runtimeKeepaliveCounter: 0, -#if PTHREADS - // Holds other threads placed on this thread's runtime, in shared memory - // (struct pthread.keepalive_holds, via _emscripten_thread_keepalive): a - // thread cannot reach another's runtimeKeepaliveCounter synchronously. - $keepaliveHeldByOthers__internal: true, - $keepaliveHeldByOthers__deps: ['pthread_self'], - $keepaliveHeldByOthers: () => { - var self = _pthread_self(); - return self && Atomics.load(HEAP32, {{{ getHeapOffset('self + ' + C_STRUCTS.pthread.keepalive_holds, 'i32') }}}) > 0; - }, -#endif - #if isSymbolNeeded('$noExitRuntime') // If the `noExitRuntime` symbol is included in the build then // keepRuntimeAlive is always conditional since its state can change // at runtime. - $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter', -#if PTHREADS - '$keepaliveHeldByOthers', -#endif - ], - $keepRuntimeAlive: () => noExitRuntime || runtimeKeepaliveCounter > 0 -#if PTHREADS - || keepaliveHeldByOthers() -#endif - , + $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter'], + $keepRuntimeAlive: () => noExitRuntime || runtimeKeepaliveCounter > 0, #elif !EXIT_RUNTIME && !PTHREADS // When `noExitRuntime` is not included and EXIT_RUNTIME=0 then we know the // runtime can never exit (i.e. should always be kept alive). @@ -2127,16 +2107,8 @@ addToLibrary({ // have to track `runtimeKeepaliveCounter` in that case. $keepRuntimeAlive: () => true, #else - $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter', -#if PTHREADS - '$keepaliveHeldByOthers', -#endif - ], - $keepRuntimeAlive: () => runtimeKeepaliveCounter > 0 -#if PTHREADS - || keepaliveHeldByOthers() -#endif - , + $keepRuntimeAlive__deps: ['$runtimeKeepaliveCounter'], + $keepRuntimeAlive: () => runtimeKeepaliveCounter > 0, #endif // Callable in pthread without __proxy needed. diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 1a0cc6df107fa..778255c8402b3 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -46,7 +46,7 @@ var EpollLibrary = { }, $epollNewInstance__internal: true, - $epollNewInstance__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$epollReconcileKeepalive'], + $epollNewInstance__deps: ['$FS', '$epollWouldBlock', '$epollClearListener'], $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. @@ -77,7 +77,6 @@ var EpollLibrary = { // 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); } @@ -92,126 +91,12 @@ var EpollLibrary = { // Readiness listeners (emscripten_epoll_add_listener), keyed by // (registering thread, callback). interests: new Map(), - // Armed registrations on host-backed fds (epollHostBacked); keys the - // listener keepalive (0 means nothing the host can do makes the set ready). - hostArmed: 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', '$epollHoldOwner', -#endif - ], - $epollClearListener: (ep, it) => { - ep.interests.delete(it.key); - it.cleared = true; - it.listener.listeners.delete(it.listener.entry); -#if PTHREADS - if (it.keptAlive) epollHoldOwner(it, -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 - so release - // its hold here. - if (it.token) delete epollDeliveries[it.token]; - if (it.inflight) { - it.inflight = false; - epollHoldOwner(it, -1); - } -#endif - }, - - // Every main-runtime keepalive hold taken by this library goes through here. - // exitRuntime only runs once the counter is 0 - naturally, or forfeited by - // emscripten_force_exit - and its FS.quit closes the epoll fds; that release - // (or a delivery landing after exit) is of an already-forfeited hold, not an - // underflow. - $epollKeepalive__internal: true, - $epollKeepalive__deps: [ -#if useRuntimeKeepaliveStack() - '$runtimeKeepaliveCounter', '$runtimeKeepalivePush', '$runtimeKeepalivePop', -#endif - ], - $epollKeepalive: (delta) => { -#if useRuntimeKeepaliveStack() - if (delta > 0) runtimeKeepalivePush(); - else if (runtimeKeepaliveCounter > 0) runtimeKeepalivePop(); -#endif - }, - -#if PTHREADS - // Hold or release a listener's owner thread (where its callbacks run) in - // step with the main thread: the atomic add lands immediately, so the owner - // can never decide to exit ahead of a hold taken for it. ownerThread is 0 - // for a main-thread listener, covered by epollKeepalive. An owner that has - // exited (its pthread struct is being reclaimed) has nothing to hold. - $epollHoldOwner__internal: true, - $epollHoldOwner__deps: ['_emscripten_thread_keepalive'], - $epollHoldOwner: (it, delta) => { - if (it.ownerThread && PThread.pthreads[it.ownerThread]) { - __emscripten_thread_keepalive(it.ownerThread, delta); - } - }, -#endif - - // A hold scoped to one listener (a pending or in-flight delivery): on the - // main thread and, with pthreads, mirrored on the owner. - $epollHold__internal: true, - $epollHold__deps: ['$epollKeepalive', -#if PTHREADS - '$epollHoldOwner', -#endif - ], - $epollHold: (it, delta) => { - epollKeepalive(delta); -#if PTHREADS - epollHoldOwner(it, delta); -#endif - }, - - // Can readiness on this watched fd arrive from the host (the JS event loop), - // with no wasm running? A socket's can. A pipe is only ever written by wasm, - // so whatever runs that write already holds the runtime - the registration - // itself need not. A nested epoll is counted conservatively (it may hold - // sockets). - $epollHostBacked__internal: true, - $epollHostBacked__deps: ['$FS'], - $epollHostBacked: (target) => FS.isSocket(target.node.mode) || !!target.shared.epoll, - - // Listeners hold the runtime alive only while the host can still make the - // epoll ready: at least one listener and one armed host-backed registration - // (Node.js-style, registered I/O interest holds the loop open; a set the host - // cannot fire releases it). A pending delivery holds it separately (see - // wake()). With pthreads each listener's owner thread (which runs its - // deliveries) is held by the same rule. - $epollReconcileKeepalive__internal: true, - $epollReconcileKeepalive__deps: ['$epollKeepalive', -#if PTHREADS - '$epollHoldOwner', -#endif - ], - $epollReconcileKeepalive: (ep) => { - var armed = ep.hostArmed > 0; -#if PTHREADS - for (var it of ep.interests.values()) { - if (armed != !!it.keptAlive) { - it.keptAlive = armed; - epollHoldOwner(it, armed ? 1 : -1); - } - } -#endif - var want = armed && ep.interests.size > 0; - if (want == !!ep.keepalive) return; - ep.keepalive = want; - epollKeepalive(want ? 1 : -1); - }, - // 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 @@ -245,24 +130,19 @@ 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', '$epollReconcileKeepalive'], + $epollEvict__deps: ['$readyListRemove'], $epollEvict: (ep, reg) => { readyListRemove(ep, reg); - // A fired EPOLLONESHOT already dropped its listener and armed count. - if (reg.listener) { - reg.listener.listeners.delete(reg.listener.entry); - reg.listener = null; - ep.hostArmed -= reg.host; - } + reg.listener?.listeners.delete(reg.listener.entry); + reg.listener = null; 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', '$epollReconcileKeepalive', '$epollHostBacked'], + $epollCtl__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict'], $epollCtl: (ep, op, fd, ev) => { var target = FS.getStream(fd); if (!target) return -{{{ cDefs.EBADF }}}; @@ -354,8 +234,6 @@ 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 }}})); - reg.host = +epollHostBacked(target); - ep.hostArmed += reg.host; } // 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 @@ -364,7 +242,6 @@ var EpollLibrary = { readyListAdd(ep, reg); ep.node.notifyListeners({{{ cDefs.POLLIN }}}); } - epollReconcileKeepalive(ep); return 0; }, @@ -376,9 +253,8 @@ 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', '$epollReconcileKeepalive'], + $doEpollWait__deps: ['$FS', '$pollOne', '$readyListAdd', '$epollEvict'], $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). @@ -409,8 +285,6 @@ var EpollLibrary = { // listener - the watched node stops poking it (no re-arm needed). node.listener.listeners.delete(node.listener.entry); node.listener = null; - ep.hostArmed -= node.host; - disarmed = true; } else if (!(node.events & {{{ cDefs.EPOLLET }}})) { readyListAdd(ep, node); // level: re-list at tail } @@ -429,8 +303,6 @@ var EpollLibrary = { else ep.rdlTail = tail; ep.rdlHead = node; } - // Evictions above reconciled themselves. - if (disarmed) epollReconcileKeepalive(ep); return n; }, @@ -481,6 +353,24 @@ var EpollLibrary = { return count; }, + // Drop one readiness listener: remove its wait-queue entry on the epoll node + // and retire its cross-thread delivery token, so a completion that lands + // after this finds nothing. + $epollClearListener__internal: true, + $epollClearListener__deps: [ +#if PTHREADS + '$epollDeliveries', +#endif + ], + $epollClearListener: (ep, it) => { + ep.interests.delete(it.key); + it.cleared = true; + it.listener.listeners.delete(it.listener.entry); +#if PTHREADS + if (it.token) delete epollDeliveries[it.token]; +#endif + }, + // 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 @@ -493,7 +383,13 @@ var EpollLibrary = { // 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', '$epollHold', '$callUserCallback', '$emSetImmediate', + // + // A listener is an unref'd handle (Node's handle.unref()): it never keeps the + // runtime, or with pthreads its registering thread, alive. A program that + // only stays alive for its listeners holds the runtime itself with + // emscripten_runtime_keepalive_push/pop. The one hold taken here is for a + // scheduled delivery, which is pending work like a safeSetTimeout callback. + emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$callUserCallback', '$emSetImmediate', #if !MINIMAL_RUNTIME '$maybeExit', #endif @@ -525,17 +421,9 @@ var EpollLibrary = { var prev = ep.interests.get(key); if (prev) epollClearListener(ep, prev); - var it = {key, ep}; -#if PTHREADS - it.ownerThread = callerThread; -#endif + var it = {key}; 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 @@ -550,15 +438,10 @@ var EpollLibrary = { #if PTHREADS if (callerThread) { it.inflight = true; - // Held until the completion lands back here - on the owner only: an - // exit() from inside the callback unwinds past the completion, and a - // hold on the main thread would then defer the exit it asks for. - epollHoldOwner(it, 1); + // The owner thread is gone (exited under an exit() elsewhere): the + // listener dies with it. if (!__emscripten_epoll_run_callback_on_thread(callerThread, callback, userdata, it.token)) { - // The owner thread is gone (exited under an exit() elsewhere): the - // listener dies with it. epollClearListener(ep, it); - epollReconcileKeepalive(ep); } return; } @@ -575,60 +458,53 @@ var EpollLibrary = { if (!it.cleared && !epollWouldBlock(ep)) wake(true); }); } - // A scheduled delivery is pending work and holds the runtime until it runs - // (like safeSetTimeout), independent of what the set watches: a pipe write - // from a callback's last act must still deliver. A teardown wake holds - // nothing: a watched fd closing (POLLNVAL) only evicts, and once FS.quit - // has begun (exitRuntime: FS.initialized cleared, every open fd closed, - // pipe peers reporting POLLHUP on the way) no delivery can run, while a - // hold taken there would outlive the exit, leaving keepRuntimeAlive() set - // at _proc_exit and onExit skipped. + // Producer notifies arrive synchronously (SOCKFS.emit, pipe writes); coalesce + // them into one delivery per listener on a macrotask - not a microtask, + // since hosts drain microtasks synchronously inside other calls (Node's + // module loader does so on a first builtin load, e.g. from connect()), + // which would run the callback re-entrantly under the caller's frames. // - // Delivery is a macrotask, not a microtask: hosts drain microtasks - // synchronously inside other calls (Node's module loader does so on a - // first builtin load, e.g. from connect()), which would run the callback - // re-entrantly under the caller's frames. + // A scheduled delivery is pending work and holds the runtime until it runs + // (like safeSetTimeout): a pipe write from a callback's last act must still + // deliver. A teardown wake holds nothing: a watched fd closing (POLLNVAL) + // only evicts, and once FS.quit has begun (exitRuntime: FS.initialized + // cleared, every open fd closed, pipe peers reporting POLLHUP on the way) + // no delivery can run, while a hold taken there would outlive the exit, + // leaving keepRuntimeAlive() set at _proc_exit and onExit skipped. function wake(held) { if (held && FS.initialized && !it.held) { it.held = true; - epollHold(it, 1); + {{{ runtimeKeepalivePush() }}} } if (it.scheduled) return; it.scheduled = true; emSetImmediate(() => { it.scheduled = false; - function release() { - if (it.held) { - it.held = false; - epollHold(it, -1); - } + // Release first, so callUserCallback's maybeExit sees the true state + // (the callback's own re-wake takes a fresh hold inside). + if (it.held) { + it.held = false; + {{{ runtimeKeepalivePop() }}} } - // Nothing to deliver (cleared, or drained synchronously meanwhile): - // callUserCallback's maybeExit will not run, and the hold just - // released may have been what deferred main's exit. + // Nothing to deliver (cleared, or drained synchronously meanwhile), or + // a cross-thread dispatch that runs elsewhere: callUserCallback's + // maybeExit will not run here, and the hold just released may have + // been what deferred main's exit. if (it.cleared || epollWouldBlock(ep)) { - release(); #if !MINIMAL_RUNTIME maybeExit(); #endif return; } #if PTHREADS - // Cross-thread: take the in-flight hold before releasing this one, so - // the owner (woken at once by the release) never sees a gap between - // the two and exits under the callback on its way. if (callerThread) { deliver(); - release(); #if !MINIMAL_RUNTIME maybeExit(); #endif return; } #endif - // Inline: release first, so callUserCallback's maybeExit sees the - // true state (the callback's own re-wake takes a fresh hold inside). - release(); deliver(); }); } @@ -636,20 +512,20 @@ var EpollLibrary = { // Resume point for a completed cross-thread delivery, keyed by token so the // C completion can find this listener again. if (callerThread) { + it.ep = ep; it.wake = wake; it.token = epollDeliveries.nextToken++; epollDeliveries[it.token] = it; } #endif it.listener = ep.node.addListener((flags) => wake(!(flags & {{{ cDefs.POLLNVAL }}}))); - epollReconcileKeepalive(ep); wake(!epollWouldBlock(ep)); // 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__deps: ['$FS', '$epollClearListener'], emscripten_epoll_remove_listener__proxy: 'sync', emscripten_epoll_remove_listener: (epfd, callback) => { var stream = FS.getStream(epfd); @@ -663,7 +539,6 @@ var EpollLibrary = { var it = ep.interests.get(key); if (!it) return {{{ cDefs.ENOENT }}}; epollClearListener(ep, it); - epollReconcileKeepalive(ep); return 0; }, @@ -677,13 +552,12 @@ var EpollLibrary = { // 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', '$epollHoldOwner', '$epollWouldBlock'], + _emscripten_epoll_delivery_done__deps: ['$epollDeliveries', '$epollWouldBlock'], _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(!epollWouldBlock(it.ep)); - epollHoldOwner(it, -1); }, #endif }; diff --git a/src/struct_info_generated.json b/src/struct_info_generated.json index 63a6b15b6e435..e8cad551d543c 100644 --- a/src/struct_info_generated.json +++ b/src/struct_info_generated.json @@ -1071,8 +1071,7 @@ "p_proto": 8 }, "pthread": { - "__size__": 124, - "keepalive_holds": 120, + "__size__": 120, "profilerBlock": 96, "stack": 48, "stack_size": 52, diff --git a/src/struct_info_generated_wasm64.json b/src/struct_info_generated_wasm64.json index 63fa7fc680001..c08719f390c1f 100644 --- a/src/struct_info_generated_wasm64.json +++ b/src/struct_info_generated_wasm64.json @@ -1071,8 +1071,7 @@ "p_proto": 16 }, "pthread": { - "__size__": 224, - "keepalive_holds": 216, + "__size__": 216, "profilerBlock": 176, "stack": 80, "stack_size": 88, diff --git a/src/struct_info_internal.json b/src/struct_info_internal.json index 6253c62b80d8d..de28e7a0b6c4c 100644 --- a/src/struct_info_internal.json +++ b/src/struct_info_internal.json @@ -6,7 +6,6 @@ "file": "pthread_impl.h", "structs": { "pthread": [ - "keepalive_holds", "profilerBlock", "stack", "stack_size", diff --git a/system/include/emscripten/epoll.h b/system/include/emscripten/epoll.h index c575ed487f604..d5c3ff7bcb85e 100644 --- a/system/include/emscripten/epoll.h +++ b/system/include/emscripten/epoll.h @@ -39,33 +39,27 @@ extern "C" { // // A listener fires on the next event-loop tick (as a macrotask, never from // within a running wasm call) 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). +// 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. Note +// that for a level-triggered fd 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 EPOLLET or EPOLLONESHOT for +// such fds. // -// Listeners keep the runtime alive as long as the host can still make the set -// ready - i.e. while the epoll has at least one armed registration on a -// host-backed fd (a socket). This follows the Node.js model, where registered -// I/O interest holds the event loop open. Once every such fd is closed (or -// disarmed) the listeners stop holding the runtime, so no explicit disposal is -// required in that case. A pipe does not count: it can only be written by wasm -// code, which is already running (and so already held) when it does; a -// delivery that write schedules is itself held until it runs. With pthreads the -// same holds apply to the thread that registered the listener, where its -// callbacks run. Such a callback may see a spurious wakeup (epoll_wait -// collects nothing) if the set was drained from that thread while the signal -// was in flight. +// A listener is an unref'd handle (like Node's handle.unref()): while the +// runtime is alive, readiness is delivered to it, but it never keeps the +// runtime - or, with pthreads, the registering thread - alive by itself. A +// program whose only reason to stay alive is a listener holds the runtime +// itself, on the registering thread: +// +// emscripten_runtime_keepalive_push(); // e.g. before main() returns +// ... +// emscripten_runtime_keepalive_pop(); // e.g. from the callback, when done +// +// Likewise emscripten_epoll_remove_listener and the last close of the epoll fd +// release nothing, since nothing was held. // // 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 diff --git a/system/lib/libc/emscripten_internal.h b/system/lib/libc/emscripten_internal.h index 9ce43710a60b3..509169ad7f80e 100644 --- a/system/lib/libc/emscripten_internal.h +++ b/system/lib/libc/emscripten_internal.h @@ -62,7 +62,7 @@ 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 +// Defined in libepoll.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); diff --git a/system/lib/libc/musl/src/internal/pthread_impl.h b/system/lib/libc/musl/src/internal/pthread_impl.h index 21193d472c0f1..c9a687cddbad8 100644 --- a/system/lib/libc/musl/src/internal/pthread_impl.h +++ b/system/lib/libc/musl/src/internal/pthread_impl.h @@ -125,11 +125,6 @@ struct pthread { // // Since futex addresses must be 4-byte aligned, the low bit is safe to use. _Atomic uintptr_t wait_addr; - // Runtime keepalive holds placed on this thread by other threads (see - // _emscripten_thread_keepalive). Read by this thread's keepRuntimeAlive() - // alongside its own JS-side counter, which other threads cannot reach - // synchronously. - _Atomic int keepalive_holds; #endif }; diff --git a/system/lib/pthread/emscripten_epoll_callback.c b/system/lib/pthread/emscripten_epoll_callback.c index 6747279bdf1bf..af907b31aa880 100644 --- a/system/lib/pthread/emscripten_epoll_callback.c +++ b/system/lib/pthread/emscripten_epoll_callback.c @@ -19,13 +19,11 @@ #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; diff --git a/system/lib/pthread/library_pthread.c b/system/lib/pthread/library_pthread.c index 3b20f12cb358b..b8a48cb91f1c9 100644 --- a/system/lib/pthread/library_pthread.c +++ b/system/lib/pthread/library_pthread.c @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -134,17 +133,3 @@ void _emscripten_init_main_thread(void) { _emscripten_thread_mailbox_init(&__main_pthread); _emscripten_thread_mailbox_await(&__main_pthread); } - -static void keepalive_noop(void* arg) {} - -// Hold (delta > 0) or release another thread's runtime. The add is immediate, -// so an acquire is never late; a release also queues a no-op task so the -// target re-evaluates keepRuntimeAlive on its next event-loop turn (a thread -// that already exited has nothing to re-evaluate, so a failed proxy is fine). -void _emscripten_thread_keepalive(pthread_t t, int delta) { - atomic_fetch_add(&t->keepalive_holds, delta); - if (delta < 0) { - emscripten_proxy_async( - emscripten_proxy_get_system_queue(), t, keepalive_noop, NULL); - } -} diff --git a/test/codesize/test_codesize_cxx_ctors2.json b/test/codesize/test_codesize_cxx_ctors2.json index 8ef61cbbe1eb2..9745a6316785d 100644 --- a/test/codesize/test_codesize_cxx_ctors2.json +++ b/test/codesize/test_codesize_cxx_ctors2.json @@ -2,9 +2,9 @@ "a.out.js": 19201, "a.out.js.gz": 8107, "a.out.nodebug.wasm": 133403, - "a.out.nodebug.wasm.gz": 50960, + "a.out.nodebug.wasm.gz": 50959, "total": 152604, - "total_gz": 59067, + "total_gz": 59066, "sent": [ "__cxa_throw", "_abort_js", diff --git a/test/codesize/test_codesize_cxx_except_wasm.json b/test/codesize/test_codesize_cxx_except_wasm.json index cd1f66d282f79..acce5eb08a6f9 100644 --- a/test/codesize/test_codesize_cxx_except_wasm.json +++ b/test/codesize/test_codesize_cxx_except_wasm.json @@ -2,9 +2,9 @@ "a.out.js": 19023, "a.out.js.gz": 8039, "a.out.nodebug.wasm": 149640, - "a.out.nodebug.wasm.gz": 56306, + "a.out.nodebug.wasm.gz": 56311, "total": 168663, - "total_gz": 64345, + "total_gz": 64350, "sent": [ "_abort_js", "_tzset_js", diff --git a/test/codesize/test_codesize_cxx_except_wasm_legacy.json b/test/codesize/test_codesize_cxx_except_wasm_legacy.json index 10bd156ac3b3b..7d2e3ac6023e4 100644 --- a/test/codesize/test_codesize_cxx_except_wasm_legacy.json +++ b/test/codesize/test_codesize_cxx_except_wasm_legacy.json @@ -2,9 +2,9 @@ "a.out.js": 19101, "a.out.js.gz": 8065, "a.out.nodebug.wasm": 147422, - "a.out.nodebug.wasm.gz": 55975, + "a.out.nodebug.wasm.gz": 55982, "total": 166523, - "total_gz": 64040, + "total_gz": 64047, "sent": [ "_abort_js", "_tzset_js", diff --git a/test/codesize/test_codesize_cxx_lto.json b/test/codesize/test_codesize_cxx_lto.json index 90f007af237fc..5bf114b165af0 100644 --- a/test/codesize/test_codesize_cxx_lto.json +++ b/test/codesize/test_codesize_cxx_lto.json @@ -2,9 +2,9 @@ "a.out.js": 18568, "a.out.js.gz": 7818, "a.out.nodebug.wasm": 101064, - "a.out.nodebug.wasm.gz": 38291, + "a.out.nodebug.wasm.gz": 38292, "total": 119632, - "total_gz": 46109, + "total_gz": 46110, "sent": [ "a (emscripten_resize_heap)", "b (_setitimer_js)", diff --git a/test/codesize/test_codesize_hello_dylink.json b/test/codesize/test_codesize_hello_dylink.json index 7638a16f8551e..27c28e6b09a79 100644 --- a/test/codesize/test_codesize_hello_dylink.json +++ b/test/codesize/test_codesize_hello_dylink.json @@ -2,9 +2,9 @@ "a.out.js": 26188, "a.out.js.gz": 11168, "a.out.nodebug.wasm": 16992, - "a.out.nodebug.wasm.gz": 8678, + "a.out.nodebug.wasm.gz": 8679, "total": 43180, - "total_gz": 19846, + "total_gz": 19847, "sent": [ "__syscall_stat64", "emscripten_resize_heap", diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index df354917d2abd..e9f3f9bda09bf 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": 271659, + "a.out.js": 271402, "a.out.nodebug.wasm": 588289, - "total": 859948, + "total": 859691, "sent": [ "IMG_Init", "IMG_Load", diff --git a/test/codesize/test_codesize_mem_O3_grow_standalone.json b/test/codesize/test_codesize_mem_O3_grow_standalone.json index 82e82d16e9331..bbf629f321167 100644 --- a/test/codesize/test_codesize_mem_O3_grow_standalone.json +++ b/test/codesize/test_codesize_mem_O3_grow_standalone.json @@ -2,9 +2,9 @@ "a.out.js": 3657, "a.out.js.gz": 1834, "a.out.nodebug.wasm": 5649, - "a.out.nodebug.wasm.gz": 2673, + "a.out.nodebug.wasm.gz": 2669, "total": 9306, - "total_gz": 4507, + "total_gz": 4503, "sent": [ "args_get", "args_sizes_get", diff --git a/test/codesize/test_codesize_mem_O3_standalone.json b/test/codesize/test_codesize_mem_O3_standalone.json index 703340ef85380..61f177858fad1 100644 --- a/test/codesize/test_codesize_mem_O3_standalone.json +++ b/test/codesize/test_codesize_mem_O3_standalone.json @@ -2,9 +2,9 @@ "a.out.js": 3567, "a.out.js.gz": 1784, "a.out.nodebug.wasm": 5573, - "a.out.nodebug.wasm.gz": 2610, + "a.out.nodebug.wasm.gz": 2608, "total": 9140, - "total_gz": 4394, + "total_gz": 4392, "sent": [ "args_get", "args_sizes_get", diff --git a/test/codesize/test_codesize_mem_O3_standalone_narg.json b/test/codesize/test_codesize_mem_O3_standalone_narg.json index 2e0eb64bdef08..34167a05ea85c 100644 --- a/test/codesize/test_codesize_mem_O3_standalone_narg.json +++ b/test/codesize/test_codesize_mem_O3_standalone_narg.json @@ -2,9 +2,9 @@ "a.out.js": 3093, "a.out.js.gz": 1530, "a.out.nodebug.wasm": 5362, - "a.out.nodebug.wasm.gz": 2453, + "a.out.nodebug.wasm.gz": 2451, "total": 8455, - "total_gz": 3983, + "total_gz": 3981, "sent": [ "proc_exit" ], diff --git a/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json b/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json index 65e5cb8d2ae44..d1c92228a1005 100644 --- a/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json +++ b/test/codesize/test_codesize_mem_O3_standalone_narg_flto.json @@ -2,9 +2,9 @@ "a.out.js": 3093, "a.out.js.gz": 1530, "a.out.nodebug.wasm": 4293, - "a.out.nodebug.wasm.gz": 2144, + "a.out.nodebug.wasm.gz": 2147, "total": 7386, - "total_gz": 3674, + "total_gz": 3677, "sent": [ "proc_exit" ], diff --git a/test/codesize/test_codesize_minimal_pthreads.json b/test/codesize/test_codesize_minimal_pthreads.json index ea2dc8e3d33dd..f897ee3214106 100644 --- a/test/codesize/test_codesize_minimal_pthreads.json +++ b/test/codesize/test_codesize_minimal_pthreads.json @@ -1,10 +1,10 @@ { - "a.out.js": 6979, - "a.out.js.gz": 3457, + "a.out.js": 6900, + "a.out.js.gz": 3432, "a.out.nodebug.wasm": 19147, - "a.out.nodebug.wasm.gz": 8837, - "total": 26126, - "total_gz": 12294, + "a.out.nodebug.wasm.gz": 8834, + "total": 26047, + "total_gz": 12266, "sent": [ "a (memory)", "b (exit)", diff --git a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json index eb525f65bc687..5124575dc3ca5 100644 --- a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json +++ b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json @@ -1,10 +1,10 @@ { - "a.out.js": 7442, - "a.out.js.gz": 3675, + "a.out.js": 7358, + "a.out.js.gz": 3648, "a.out.nodebug.wasm": 19148, - "a.out.nodebug.wasm.gz": 8838, - "total": 26590, - "total_gz": 12513, + "a.out.nodebug.wasm.gz": 8835, + "total": 26506, + "total_gz": 12483, "sent": [ "a (memory)", "b (exit)", diff --git a/test/core/test_epoll_wait_and_callback.c b/test/core/test_epoll_wait_and_callback.c index 3f9c24f345384..bf0971f61d3d5 100644 --- a/test/core/test_epoll_wait_and_callback.c +++ b/test/core/test_epoll_wait_and_callback.c @@ -97,8 +97,9 @@ int main(void) { 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. + // The callback's delivery (scheduled by those edges, and held until it runs) + // collects 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_close.c b/test/other/test_epoll_callback_close.c index b1247db282833..220ded8666a7f 100644 --- a/test/other/test_epoll_callback_close.c +++ b/test/other/test_epoll_callback_close.c @@ -4,11 +4,10 @@ * 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). + * Closing the watched fd from inside the callback: the PIPEFS close wakes the + * epoll (POLLNVAL), which evicts the now-stale registration rather than + * delivering, and with nothing held the process exits with the listener still + * registered and no explicit unregister. */ #include @@ -26,7 +25,8 @@ static void on_ready(void* ud) { char b[1]; assert(read(rfd, b, 1) == 1); printf("done\n"); - // No unregister: closing the watched fd alone must let the runtime exit. + // No unregister: nothing is held, so the callback returning exits the runtime + // with the (now fd-less) listener still registered. close(rfd); close(wfd); } diff --git a/test/other/test_epoll_callback_drain_exit.c b/test/other/test_epoll_callback_drain_exit.c index 9801b1d9d7469..7a5795abd944d 100644 --- a/test/other/test_epoll_callback_drain_exit.c +++ b/test/other/test_epoll_callback_drain_exit.c @@ -9,16 +9,13 @@ * listener is removed (MODE_REMOVE), the delivery has nothing to do - but * releasing its hold may be what lets main's deferred exit proceed, so the * runtime must still exit: atexit prints "done", Module.onExit "exited", and - * the process exits with main's status. MODE_LATER is the regression guard: a - * set made ready after main returns still delivers, then exits. + * the process exits with main's status. * - * Under PROXY_TO_PTHREAD the listener is owned by the proxied main thread, - * whose holds mirror the main thread's: in MODE_LATER it survives its return - * from main to take the delivery scheduled by its own pipe write. It may see - * one spurious wakeup: the main thread's delivery can be dispatched between - * the proxied write and the proxied drain, and its epoll_wait(0) then collects - * nothing. Exits are explicit there: a proxied main whose keepalive later - * reaches zero does not run exit() + * Under PROXY_TO_PTHREAD the listener is owned by the proxied main thread. It + * may see one spurious wakeup: the main thread's delivery can be dispatched + * between the proxied write and the proxied drain, and its epoll_wait(0) then + * collects nothing. Exits are explicit there: a proxied main whose keepalive + * later reaches zero does not run exit() * (https://github.com/emscripten-core/emscripten/issues/27721). */ @@ -36,7 +33,7 @@ #define EXIT(rc) return rc #endif -static int ep, rfd, wfd, fires; +static int ep, rfd, wfd; static void nothing_to_collect(void* ud) { struct epoll_event ev[4]; @@ -47,23 +44,7 @@ static void nothing_to_collect(void* ud) { #endif } -static void on_ready(void* ud) { - struct epoll_event ev[4]; - assert(epoll_wait(ep, ev, 4, 0) == 1 && ev[0].data.fd == rfd); - char b; - assert(read(rfd, &b, 1) == 1); - fires++; -#ifdef __EMSCRIPTEN_PTHREADS__ - exit(0); -#endif -} - -static void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } - static void at_exit(void) { -#if MODE_LATER - assert(fires == 1); -#endif printf("done\n"); } @@ -78,11 +59,6 @@ int main(void) { struct epoll_event ev = { .events = EPOLLIN }; ev.data.fd = rfd; assert(epoll_ctl(ep, EPOLL_CTL_ADD, rfd, &ev) == 0); -#if MODE_LATER - assert(emscripten_epoll_add_listener(ep, on_ready, 0) == 0); - emscripten_async_call(writer, NULL, 0); - return 0; -#else assert(emscripten_epoll_add_listener(ep, nothing_to_collect, 0) == 0); // Ready: a delivery is now scheduled and holds the runtime. assert(write(wfd, "x", 1) == 1); @@ -94,5 +70,4 @@ int main(void) { assert(read(rfd, &b, 1) == 1); #endif EXIT(7); -#endif } diff --git a/test/other/test_epoll_callback_nested_close.c b/test/other/test_epoll_callback_nested_close.c index bc5d2f661cb69..096192ab6210b 100644 --- a/test/other/test_epoll_callback_nested_close.c +++ b/test/other/test_epoll_callback_nested_close.c @@ -5,10 +5,9 @@ * 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. + * re-derives and drops the now-stale registration instead of delivering; the + * process then exits with the outer listener still registered - the same + * close -> wake -> evict path as a leaf fd, one level up. */ #include @@ -24,7 +23,7 @@ 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 + close(epB); // inner epoll gone -> outer evicts its only registration on the wake } int main(void) { diff --git a/test/other/test_epoll_callback_pipe_exit.c b/test/other/test_epoll_callback_pipe_exit.c deleted file mode 100644 index 01440dec444a3..0000000000000 --- a/test/other/test_epoll_callback_pipe_exit.c +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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. - * - * Only host-backed registrations (sockets) hold the runtime alive. A pipe can - * only be written by wasm, so it can never fire from the host: a listener over - * an armed pipe alone must not keep the runtime alive once main returns (the - * process exits, running atexit), even though a pipe write scheduled by other - * live work (a timer) still delivers. - */ - -#include -#include -#include -#include -#include -#include -#include - -static int ep, rfd, wfd, fires; - -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); - fires++; -} - -static void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } - -static void at_exit(void) { - // Delivered once (the timer-driven write), then exited with the pipe still - // armed and the listener still registered. - assert(fires == 1); - printf("done\n"); -} - -int main(void) { - atexit(at_exit); - 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); - emscripten_async_call(writer, NULL, 0); - return 0; -} diff --git a/test/other/test_epoll_callback_unref.c b/test/other/test_epoll_callback_unref.c new file mode 100644 index 0000000000000..f1452adb7346a --- /dev/null +++ b/test/other/test_epoll_callback_unref.c @@ -0,0 +1,72 @@ +/* + * 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 listener is an unref'd handle: it never keeps the runtime (or, with + * pthreads, its registering thread) alive. Without a hold, main returning + * exits the runtime at once with main's status and the callback never runs. + * With MODE_HOLD the program holds the runtime itself with + * emscripten_runtime_keepalive_push() before returning; the delivery then runs + * on the registering thread, and the pop from the callback lets the runtime + * exit, with atexit and onExit both firing. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +int ep, rfd, wfd, fires; + +void on_ready(void* ud) { + struct epoll_event ev[4]; + assert(epoll_wait(ep, ev, 4, 0) == 1 && (ev[0].events & EPOLLIN)); + char b; + assert(read(rfd, &b, 1) == 1); + fires++; + emscripten_runtime_keepalive_pop(); +#ifdef __EMSCRIPTEN_PTHREADS__ + // Under PROXY_TO_PTHREAD releasing the last hold on the worker exits only the + // thread, not the process; exit explicitly. + exit(0); +#endif +} + +void writer(void* arg) { assert(write(wfd, "x", 1) == 1); } + +void at_exit(void) { +#ifdef MODE_HOLD + assert(fires == 1); +#else + assert(fires == 0); +#endif + printf("done\n"); +} + +int main(void) { + MAIN_THREAD_EM_ASM({ Module['onExit'] = (status) => out('exited ' + status); }); + atexit(at_exit); + 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); +#ifdef MODE_HOLD + emscripten_runtime_keepalive_push(); + emscripten_set_timeout(writer, 0, NULL); + return 0; +#else + // Armed and listening, nothing held: exit now, callback never runs. + return 3; +#endif +} diff --git a/test/sockets/test_epoll_callback.c b/test/sockets/test_epoll_callback.c index 126be41fadff2..85562880f1938 100644 --- a/test/sockets/test_epoll_callback.c +++ b/test/sockets/test_epoll_callback.c @@ -4,32 +4,36 @@ * 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. + * An epoll listener callback woken by datagrams arriving on a real socket, with + * no ASYNCIFY/JSPI. The datagram lands from the host after main returns, so + * the program holds the runtime itself with emscripten_runtime_keepalive_push() + * and pops from the callback once done. With MODE_UNREF nothing is held: main + * returning exits the runtime at once and the callback never runs, even though + * a datagram is in flight to an armed socket. */ #include #include #include #include +#include #include #include #include #include #include #include +#include -static int ep, rx, tx; -static struct sockaddr_in addr; -static int fires; +int ep, rx, tx; +struct sockaddr_in addr; +int fires; -static void send_one(const char* msg) { +void send_one(const char* msg) { assert(sendto(tx, msg, 4, 0, (struct sockaddr*)&addr, sizeof addr) == 4); } -static void on_ready(void* ud) { +void on_ready(void* ud) { struct epoll_event ev[4]; assert(epoll_wait(ep, ev, 4, 0) == 1); assert(ev[0].events & EPOLLIN); @@ -37,7 +41,6 @@ static void on_ready(void* ud) { 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 @@ -45,15 +48,28 @@ static void on_ready(void* ud) { } 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); + // Done: release the hold taken in main so the runtime exits. + emscripten_runtime_keepalive_pop(); +#ifdef __EMSCRIPTEN_PTHREADS__ + // Under PROXY_TO_PTHREAD releasing the last hold on the worker exits only the + // thread, not the process; exit explicitly. + exit(0); +#endif +} + +void at_exit(void) { +#ifdef MODE_UNREF + assert(fires == 0); +#else + assert(fires == 2); +#endif + printf("done\n"); } int main(void) { + atexit(at_exit); ep = epoll_create1(0); rx = socket(AF_INET, SOCK_DGRAM, 0); tx = socket(AF_INET, SOCK_DGRAM, 0); @@ -63,14 +79,15 @@ int main(void) { 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"); +#ifndef MODE_UNREF + emscripten_runtime_keepalive_push(); +#endif return 0; } diff --git a/test/sockets/test_epoll_callback_force_exit.c b/test/sockets/test_epoll_callback_force_exit.c index 23350adcc9a11..13eacb8fb7596 100644 --- a/test/sockets/test_epoll_callback_force_exit.c +++ b/test/sockets/test_epoll_callback_force_exit.c @@ -4,10 +4,9 @@ * University of Illinois/NCSA Open Source License. Both these licenses can be * found in the LICENSE file. * - * emscripten_force_exit while a listener holds the runtime: the forced exit - * forfeits every keepalive hold before exitRuntime, whose FS.quit then closes the - * epoll fd and releases the listener's (already forfeited) hold. That release - * must not underflow the keepalive counter (which asserts). + * emscripten_force_exit with a listener registered on an armed socket: FS.quit + * closes the epoll fd on the way out, removing the listener, and the teardown + * wake that raises delivers nothing and holds nothing. */ #include @@ -45,8 +44,8 @@ int main(void) { struct epoll_event ev = { .events = EPOLLIN }; ev.data.fd = rx; assert(epoll_ctl(ep, EPOLL_CTL_ADD, rx, &ev) == 0); - // A socket registration holds the runtime open; the listener is armed and the - // socket left open when the forced exit runs. + // The listener is armed and the socket left open when the forced exit runs: + // FS.quit closes the epoll and removes the listener on the way out. assert(emscripten_epoll_add_listener(ep, on_ready, 0) == 0); emscripten_async_call(quit, NULL, 0); return 0; diff --git a/test/test_other.py b/test/test_other.py index 52f1bbbb80fd0..304a060515a69 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13637,8 +13637,8 @@ def test_epoll_callback_replace(self): 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). + # Closing the watched fd from the callback wakes the epoll only to evict the + # stale registration; the process exits with the listener still registered. self.do_runf('other/test_epoll_callback_close.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) def test_epoll_callback_nested(self): @@ -13647,8 +13647,8 @@ def test_epoll_callback_nested(self): 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. + # Closing the inner epoll wakes the outer to drop its stale registration + # rather than deliver; the same close -> wake -> evict path one level up. self.do_runf('other/test_epoll_callback_nested_close.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) def test_epoll_callback_edge(self): @@ -13661,11 +13661,21 @@ def test_epoll_callback_level(self): # 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']) - def test_epoll_callback_pipe_exit(self): - # Only host-backed (socket) registrations hold the runtime: a listener over - # an armed pipe alone lets the process exit when main returns, while a pipe - # write from other live work still delivers. - self.do_runf('other/test_epoll_callback_pipe_exit.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + @parameterized({ + '': ([], 3), + 'hold': (['-DMODE_HOLD'], 0), + 'pthread': (['-pthread', '-sPROXY_TO_PTHREAD'], 3), + 'hold_pthread': (['-DMODE_HOLD', '-pthread', '-sPROXY_TO_PTHREAD'], 0), + }) + def test_epoll_callback_unref(self, cflags, returncode): + # A listener is an unref'd handle: with nothing held, main returning exits + # at once with its status and the callback never runs. With a + # emscripten_runtime_keepalive_push() the delivery runs (on the registering + # thread under PROXY_TO_PTHREAD) and the pop from the callback exits. + if '-pthread' in cflags: + self.require_pthreads() + self.do_runf('other/test_epoll_callback_unref.c', 'done\nexited %d\n' % returncode, + cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME'] + cflags, assert_returncode=returncode) def test_epoll_callback_macrotask(self): # A delivery is a macrotask, ordered after microtasks queued before it runs: @@ -13681,21 +13691,19 @@ def test_epoll_callback_teardown_wake(self): self.do_runf('other/test_epoll_callback_teardown_wake.c', 'done\nexited\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) @parameterized({ - 'drain': (['-DMODE_DRAIN'], 7), - 'remove': (['-DMODE_REMOVE'], 7), - 'later': (['-DMODE_LATER'], 0), - 'drain_pthread': (['-DMODE_DRAIN', '-pthread', '-sPROXY_TO_PTHREAD'], 7), - 'remove_pthread': (['-DMODE_REMOVE', '-pthread', '-sPROXY_TO_PTHREAD'], 7), - 'later_pthread': (['-DMODE_LATER', '-pthread', '-sPROXY_TO_PTHREAD'], 0), + 'drain': (['-DMODE_DRAIN'],), + 'remove': (['-DMODE_REMOVE'],), + 'drain_pthread': (['-DMODE_DRAIN', '-pthread', '-sPROXY_TO_PTHREAD'],), + 'remove_pthread': (['-DMODE_REMOVE', '-pthread', '-sPROXY_TO_PTHREAD'],), }) - def test_epoll_callback_drain_exit(self, cflags, returncode): + def test_epoll_callback_drain_exit(self, cflags): # A scheduled delivery whose set was drained (or listener removed) before it # ran has nothing to deliver, but releasing its hold must still let main's # deferred exit complete (Module.onExit fires, main's status is returned). if '-pthread' in cflags: self.require_pthreads() self.do_runf('other/test_epoll_callback_drain_exit.c', 'done\nexited\n', - cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME'] + cflags, assert_returncode=returncode) + cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME'] + cflags, assert_returncode=7) @requires_pthreads @no_bun('https://github.com/emscripten-core/emscripten/issues/26197') diff --git a/test/test_sockets_node.py b/test/test_sockets_node.py index 603830d1ed383..bf93f50fd2069 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -286,16 +286,23 @@ def test_noderawsockets_mmsg(self): 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. + # The program holds the runtime with emscripten_runtime_keepalive_push() + # across main's return and pops from the callback. 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_epoll_callback_unref(self): + # Same, holding nothing: the listener is unref'd, so main returning exits at + # once and the in-flight datagram never reaches the callback. + self.do_runf('sockets/test_epoll_callback.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME', '-DMODE_UNREF']) + @also_with_proxy_to_pthread def test_noderawsockets_epoll_callback_force_exit(self): - # emscripten_force_exit with a listener still holding the runtime (an armed - # socket): the forfeited hold released by FS.quit at exit must not underflow - # the keepalive counter. + # emscripten_force_exit with a listener registered on an armed socket: + # FS.quit closes the epoll on the way out, removing the listener. self.do_runf('sockets/test_epoll_callback_force_exit.c', 'done\n', cflags=['-sNODERAWSOCKETS', '-sEXIT_RUNTIME']) @also_with_proxy_to_pthread diff --git a/tools/native_sigs.py b/tools/native_sigs.py index aa6bf0822574f..28a89c7b676f5 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -543,7 +543,6 @@ '_emscripten_thread_free_data': '_p', '_emscripten_thread_init': '_p_____', '_emscripten_thread_is_valid': '_p', - '_emscripten_thread_keepalive': '_p_', '_emscripten_thread_mailbox_init': '_p', '_emscripten_thread_mailbox_shutdown': '_p', '_emscripten_thread_notify': '_p', From 25f46ad34f842ed63802ebd85ad6ccf121c565ae Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 16 Sep 2026 18:31:44 -0700 Subject: [PATCH 12/12] epoll listeners: trim comments --- src/lib/libepoll.js | 142 ++++++------------ .../lib/pthread/emscripten_epoll_callback.c | 17 +-- 2 files changed, 48 insertions(+), 111 deletions(-) diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 778255c8402b3..a68b2347b41ba 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -4,10 +4,9 @@ * SPDX-License-Identifier: MIT */ -// 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. +// 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. var EpollLibrary = { // An epoll instance's state lives on the stream's `shared` object - the open @@ -16,19 +15,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, 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. + // 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. // 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 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. + // 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. $epollWouldBlock__internal: true, $epollWouldBlock__deps: ['$FS', '$pollOne', '$epollEvict'], $epollWouldBlock: (ep) => { @@ -67,9 +66,9 @@ var EpollLibrary = { stream.shared.refcount++; }, // close(2): drop one reference. Only the last close reclaims the - // 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. + // 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. close(stream) { var ep = stream.shared; // FS.close already fired POLLNVAL on the (shared) node, waking any @@ -88,9 +87,7 @@ var EpollLibrary = { Object.assign(stream.shared, { node, epoll: new Map(), - // Readiness listeners (emscripten_epoll_add_listener), keyed by - // (registering thread, callback). - interests: new Map(), + interests: new Map(), // emscripten_epoll_add_listener listeners // Open references (fds) to this instance; the last close reclaims it. refcount: 1, }); @@ -228,8 +225,7 @@ var EpollLibrary = { if (!reg.listener) { reg.listener = target.node.addListener((flags) => { readyListAdd(ep, reg); - // Readiness wakes the epoll as POLLIN; a closing fd (POLLNVAL) wakes - // it as a teardown, so listeners can tell an eviction from an event. + // A closing fd (POLLNVAL) wakes the epoll as a teardown, not readiness. ep.node.notifyListeners(flags & {{{ cDefs.POLLNVAL }}} ? {{{ cDefs.POLLNVAL }}} : {{{ 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. @@ -353,9 +349,6 @@ var EpollLibrary = { return count; }, - // Drop one readiness listener: remove its wait-queue entry on the epoll node - // and retire its cross-thread delivery token, so a completion that lands - // after this finds nothing. $epollClearListener__internal: true, $epollClearListener__deps: [ #if PTHREADS @@ -371,24 +364,10 @@ var EpollLibrary = { #endif }, - // 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. - // - // A listener is an unref'd handle (Node's handle.unref()): it never keeps the - // runtime, or with pthreads its registering thread, alive. A program that - // only stays alive for its listeners holds the runtime itself with - // emscripten_runtime_keepalive_push/pop. The one hold taken here is for a - // scheduled delivery, which is pending work like a safeSetTimeout callback. + // See . A listener is keyed by (registering thread, + // callback), signals the callback while the set has uncollected ready events, + // and holds nothing itself: the only keepalive taken is for a scheduled + // delivery, which is pending work like a safeSetTimeout callback. emscripten_epoll_add_listener__deps: ['$FS', '$epollWouldBlock', '$epollClearListener', '$callUserCallback', '$emSetImmediate', #if !MINIMAL_RUNTIME '$maybeExit', @@ -400,46 +379,35 @@ var EpollLibrary = { 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. + // A public API, not a syscall: positive errno. 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). + // Runs on the main thread; deliveries are back-proxied to the registering + // thread (0 = the main thread itself). 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}; ep.interests.set(key, it); 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. + // One cross-thread delivery in flight at a time; its completion + // (_emscripten_epoll_delivery_done) re-wakes, else a still-ready level fd + // would be re-signalled in a tight spin while the owner drains it. if (it.inflight) return; #endif - if (epollWouldBlock(ep)) return; // no genuine uncollected ready event + if (epollWouldBlock(ep)) return; #if PTHREADS if (callerThread) { it.inflight = true; - // The owner thread is gone (exited under an exit() elsewhere): the - // listener dies with it. + // The owner thread is gone: the listener dies with it. if (!__emscripten_epoll_run_callback_on_thread(callerThread, callback, userdata, it.token)) { epollClearListener(ep, it); } @@ -448,29 +416,17 @@ var EpollLibrary = { #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 turn. 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 every - // turn and so starve the event loop; use EPOLLET or remove the - // listener for such fds. Inside the wrapper so the re-wake's hold is - // taken before callUserCallback's maybeExit. + // Still ready (undrained, or a re-listed level fd): fire again next + // turn, taking the hold before callUserCallback's maybeExit. if (!it.cleared && !epollWouldBlock(ep)) wake(true); }); } - // Producer notifies arrive synchronously (SOCKFS.emit, pipe writes); coalesce - // them into one delivery per listener on a macrotask - not a microtask, - // since hosts drain microtasks synchronously inside other calls (Node's - // module loader does so on a first builtin load, e.g. from connect()), - // which would run the callback re-entrantly under the caller's frames. - // - // A scheduled delivery is pending work and holds the runtime until it runs - // (like safeSetTimeout): a pipe write from a callback's last act must still - // deliver. A teardown wake holds nothing: a watched fd closing (POLLNVAL) - // only evicts, and once FS.quit has begun (exitRuntime: FS.initialized - // cleared, every open fd closed, pipe peers reporting POLLHUP on the way) - // no delivery can run, while a hold taken there would outlive the exit, - // leaving keepRuntimeAlive() set at _proc_exit and onExit skipped. + // Coalesce synchronous producer notifies into one macrotask delivery (a + // microtask could run re-entrantly: hosts drain microtasks inside other + // calls, e.g. Node's module loader on a first builtin load). A scheduled + // delivery holds the runtime until it runs; a teardown wake (POLLNVAL, or + // once FS.quit has begun) holds nothing, since no delivery can follow and + // the hold would outlive the exit. function wake(held) { if (held && FS.initialized && !it.held) { it.held = true; @@ -480,16 +436,13 @@ var EpollLibrary = { it.scheduled = true; emSetImmediate(() => { it.scheduled = false; - // Release first, so callUserCallback's maybeExit sees the true state - // (the callback's own re-wake takes a fresh hold inside). if (it.held) { it.held = false; {{{ runtimeKeepalivePop() }}} } - // Nothing to deliver (cleared, or drained synchronously meanwhile), or - // a cross-thread dispatch that runs elsewhere: callUserCallback's - // maybeExit will not run here, and the hold just released may have - // been what deferred main's exit. + // Not delivering here (nothing to collect, or dispatched to another + // thread): callUserCallback's maybeExit will not run, and the hold just + // released may have been what deferred main's exit. if (it.cleared || epollWouldBlock(ep)) { #if !MINIMAL_RUNTIME maybeExit(); @@ -509,8 +462,6 @@ var EpollLibrary = { }); } #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.ep = ep; it.wake = wake; @@ -519,12 +470,10 @@ var EpollLibrary = { } #endif it.listener = ep.node.addListener((flags) => wake(!(flags & {{{ cDefs.POLLNVAL }}}))); - wake(!epollWouldBlock(ep)); // deliver initial readiness if the set is already ready + wake(!epollWouldBlock(ep)); 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'], emscripten_epoll_remove_listener__proxy: 'sync', emscripten_epoll_remove_listener: (epfd, callback) => { @@ -543,19 +492,14 @@ var EpollLibrary = { }, #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. + // Token -> listener for in-flight cross-thread deliveries. Tokens are + // monotonic so a stale completion finds nothing rather than another listener. $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', '$epollWouldBlock'], _emscripten_epoll_delivery_done: (token) => { var it = epollDeliveries[token]; - if (!it) return; // listener was removed while the delivery was in flight + if (!it) return; it.inflight = false; it.wake(!epollWouldBlock(it.ep)); }, diff --git a/system/lib/pthread/emscripten_epoll_callback.c b/system/lib/pthread/emscripten_epoll_callback.c index af907b31aa880..61fe6d0fda6d2 100644 --- a/system/lib/pthread/emscripten_epoll_callback.c +++ b/system/lib/pthread/emscripten_epoll_callback.c @@ -5,14 +5,10 @@ * 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. +// Backs emscripten_epoll_add_listener under PTHREADS: readiness lives on the +// main thread, the callback runs on the registering thread. Like +// _emscripten_run_callback_on_thread (html5/callback.c), but reports completion +// back to the main thread so it can pace the next delivery. #include #include @@ -30,15 +26,12 @@ typedef struct epoll_callback_args_t { 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. +// On the main thread, after the delivery ran or its target thread went away. static void do_epoll_done(void* arg) { epoll_callback_args_t* args = (epoll_callback_args_t*)arg; _emscripten_epoll_delivery_done(args->token);