diff --git a/ChangeLog.md b/ChangeLog.md index a3fe632c528d1..4201bd72c1ad3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -68,6 +68,12 @@ 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), 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/libepoll.js b/src/lib/libepoll.js index dffeb7d269c86..a68b2347b41ba 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -45,7 +45,7 @@ var EpollLibrary = { }, $epollNewInstance__internal: true, - $epollNewInstance__deps: ['$FS', '$epollWouldBlock'], + $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. @@ -75,6 +75,7 @@ var EpollLibrary = { // 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); for (var reg of ep.epoll.values()) { reg.listener?.listeners.delete(reg.listener.entry); } @@ -86,6 +87,7 @@ var EpollLibrary = { Object.assign(stream.shared, { node, epoll: new Map(), + interests: new Map(), // emscripten_epoll_add_listener listeners // Open references (fds) to this instance; the last close reclaims it. refcount: 1, }); @@ -221,9 +223,10 @@ 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 }}}); + // 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. }, !!(events & {{{ cDefs.EPOLLEXCLUSIVE }}})); @@ -345,6 +348,162 @@ var EpollLibrary = { #endif return count; }, + + $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 + }, + + // 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', +#endif +#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); + // A public API, not a syscall: positive errno. + if (!stream?.shared.epoll) return {{{ cDefs.EBADF }}}; + var ep = stream.shared; +#if PTHREADS + // 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 + 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; 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; +#if PTHREADS + if (callerThread) { + it.inflight = true; + // 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); + } + return; + } +#endif + callUserCallback(() => { + {{{ makeDynCall('vp', 'callback') }}}(userdata); + // 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); + }); + } + // 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; + {{{ runtimeKeepalivePush() }}} + } + if (it.scheduled) return; + it.scheduled = true; + emSetImmediate(() => { + it.scheduled = false; + if (it.held) { + it.held = false; + {{{ runtimeKeepalivePop() }}} + } + // 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(); +#endif + return; + } +#if PTHREADS + if (callerThread) { + deliver(); +#if !MINIMAL_RUNTIME + maybeExit(); +#endif + return; + } +#endif + deliver(); + }); + } +#if PTHREADS + 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 }}}))); + wake(!epollWouldBlock(ep)); + return 0; + }, + + emscripten_epoll_remove_listener__deps: ['$FS', '$epollClearListener'], + 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); + return 0; + }, + +#if PTHREADS + // Token -> listener for in-flight cross-thread deliveries. Tokens are + // monotonic so a stale completion finds nothing rather than another listener. + $epollDeliveries: {nextToken: 1}, + + _emscripten_epoll_delivery_done__deps: ['$epollDeliveries', '$epollWouldBlock'], + _emscripten_epoll_delivery_done: (token) => { + var it = epollDeliveries[token]; + if (!it) return; + it.inflight = false; + it.wake(!epollWouldBlock(it.ep)); + }, +#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..d5c3ff7bcb85e --- /dev/null +++ b/system/include/emscripten/epoll.h @@ -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. + */ + +#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 (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. 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. +// +// 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 +// 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..509169ad7f80e 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 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); + 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..61fe6d0fda6d2 --- /dev/null +++ b/system/lib/pthread/emscripten_epoll_callback.c @@ -0,0 +1,58 @@ +/* + * 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: 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 +#include +#include + +#include +#include + +#include "emscripten_internal.h" + +typedef struct epoll_callback_args_t { + em_epoll_callback callback; + void* userdata; + int token; +} epoll_callback_args_t; + +static void do_epoll_callback(void* arg) { + epoll_callback_args_t* args = (epoll_callback_args_t*)arg; + args->callback(args->userdata); +} + +// 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); + free(arg); +} + +// 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) { + 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)) { + free(args); + return false; + } + return true; +} diff --git a/test/codesize/test_codesize_hello_dylink_all.json b/test/codesize/test_codesize_hello_dylink_all.json index 2599da95e6573..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": 270695, + "a.out.js": 271402, "a.out.nodebug.wasm": 588289, - "total": 858984, + "total": 859691, "sent": [ "IMG_Init", "IMG_Load", @@ -468,6 +468,8 @@ "emscripten_debugger", "emscripten_destroy_worker", "emscripten_enter_soft_fullscreen", + "emscripten_epoll_add_listener", + "emscripten_epoll_remove_listener", "emscripten_err", "emscripten_errn", "emscripten_exit_fullscreen", diff --git a/test/core/test_epoll_wait_and_callback.c b/test/core/test_epoll_wait_and_callback.c new file mode 100644 index 0000000000000..bf0971f61d3d5 --- /dev/null +++ b/test/core/test_epoll_wait_and_callback.c @@ -0,0 +1,105 @@ +/* + * 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'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.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..220ded8666a7f --- /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. + * + * 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 +#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: nothing is held, so the callback returning exits the runtime + // with the (now fd-less) listener still registered. + 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_drain_exit.c b/test/other/test_epoll_callback_drain_exit.c new file mode 100644 index 0000000000000..7a5795abd944d --- /dev/null +++ b/test/other/test_epoll_callback_drain_exit.c @@ -0,0 +1,73 @@ +/* + * 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. + * + * 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). + */ + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __EMSCRIPTEN_PTHREADS__ +#define EXIT(rc) exit(rc) +#else +#define EXIT(rc) return rc +#endif + +static int ep, rfd, wfd; + +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 at_exit(void) { + 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); + 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 + EXIT(7); +} 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..379c3361a11f8 --- /dev/null +++ b/test/other/test_epoll_callback_edge.c @@ -0,0 +1,65 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * 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 +#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. 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; + } + + 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_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 new file mode 100644 index 0000000000000..9ce626dbdefe6 --- /dev/null +++ b/test/other/test_epoll_callback_multi.c @@ -0,0 +1,77 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * 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 +#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 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/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..096192ab6210b --- /dev/null +++ b/test/other/test_epoll_callback_nested_close.c @@ -0,0 +1,46 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + * + * Closing a nested (inner) epoll wakes the outer epoll watching it, which + * 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 +#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 evicts its only registration on the wake +} + +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/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/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 new file mode 100644 index 0000000000000..85562880f1938 --- /dev/null +++ b/test/sockets/test_epoll_callback.c @@ -0,0 +1,93 @@ +/* + * 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 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 + +int ep, rx, tx; +struct sockaddr_in addr; +int fires; + +void send_one(const char* msg) { + assert(sendto(tx, msg, 4, 0, (struct sockaddr*)&addr, sizeof addr) == 4); +} + +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); + 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); + 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"); +#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 new file mode 100644 index 0000000000000..13eacb8fb7596 --- /dev/null +++ b/test/sockets/test_epoll_callback_force_exit.c @@ -0,0 +1,52 @@ +/* + * 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 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 +#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); + // 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_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..304a060515a69 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13611,6 +13611,100 @@ 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 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): + # 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 + # 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): + # 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']) + + @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: + # 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 + # 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'],), + '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): + # 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=7) + @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..bf93f50fd2069 100644 --- a/test/test_sockets_node.py +++ b/test/test_sockets_node.py @@ -282,6 +282,29 @@ 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. + # 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 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 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..28a89c7b676f5 100644 --- a/tools/native_sigs.py +++ b/tools/native_sigs.py @@ -529,6 +529,7 @@ '__year_to_secs': '__p', '_embind_register_bindings': '_p', '_emscripten_dlsync_self_async': '_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',