Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<emscripten/epoll.h>`, 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
----------------
Expand Down
165 changes: 162 additions & 3 deletions src/lib/libepoll.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand All @@ -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,
});
Expand Down Expand Up @@ -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 }}}));
Expand Down Expand Up @@ -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 <emscripten/epoll.h>. 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);
3 changes: 3 additions & 0 deletions src/lib/libsigs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
76 changes: 76 additions & 0 deletions system/include/emscripten/epoll.h
Original file line number Diff line number Diff line change
@@ -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 <sys/epoll.h>

#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
4 changes: 4 additions & 0 deletions system/lib/libc/emscripten_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions system/lib/pthread/emscripten_epoll_callback.c
Original file line number Diff line number Diff line change
@@ -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 <pthread.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>

#include <emscripten/epoll.h>
#include <emscripten/proxying.h>

#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;
}
6 changes: 4 additions & 2 deletions test/codesize/test_codesize_hello_dylink_all.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading