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
5 changes: 5 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ See docs/process.md for more on how version tagging works.
level- and edge-triggered modes, `EPOLLONESHOT`, `EPOLLEXCLUSIVE`,
`EPOLLRDHUP`, nesting, and blocking waits under `PROXY_TO_PTHREAD`,
`ASYNCIFY`, and `JSPI`. (#27207)
- Added `emscripten_epoll_add_listener`/`emscripten_epoll_remove_listener` (in
the new `<emscripten/epoll.h>`, 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
----------------
Expand Down
262 changes: 242 additions & 20 deletions src/lib/libepoll.js

Large diffs are not rendered by default.

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
75 changes: 75 additions & 0 deletions system/include/emscripten/epoll.h
Original file line number Diff line number Diff line change
@@ -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 <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 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
4 changes: 4 additions & 0 deletions system/lib/libc/emscripten_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ emscripten_stack_unwind_buffer(uintptr_t pc, uintptr_t* buffer, uint32_t depth);

bool _emscripten_get_now_is_monotonic(void);

// Defined in library.js; called by emscripten_epoll_callback.c to report a
// completed cross-thread epoll callback delivery back to the main thread.
void _emscripten_epoll_delivery_done(int token);

void _emscripten_get_progname(char*, int);

// Not defined in musl, but defined in library.js. Included here for
Expand Down
82 changes: 82 additions & 0 deletions system/lib/pthread/emscripten_epoll_callback.c
Original file line number Diff line number Diff line change
@@ -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 <assert.h>
#include <pthread.h>
#include <stdint.h>
#include <stdlib.h>

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

#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");
}
}
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": 270568,
"a.out.js": 271350,
"a.out.nodebug.wasm": 588359,
"total": 858927,
"total": 859709,
"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
104 changes: 104 additions & 0 deletions test/core/test_epoll_wait_and_callback.c
Original file line number Diff line number Diff line change
@@ -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 <sys/epoll.h>
#include <emscripten.h>
#include <emscripten/epoll.h>
#include <unistd.h>
#include <assert.h>
#include <stdio.h>

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;
}
76 changes: 76 additions & 0 deletions test/other/test_epoll_callback.c
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.
*
* 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 <sys/epoll.h>
#include <emscripten.h>
#include <emscripten/epoll.h>
#include <unistd.h>
#include <assert.h>
#include <stdio.h>

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;
}
Loading
Loading