Skip to content

Implement epoll APIs in the JS filesystem - #27207

Open
guybedford wants to merge 5 commits into
emscripten-core:mainfrom
guybedford:epoll
Open

Implement epoll APIs in the JS filesystem#27207
guybedford wants to merge 5 commits into
emscripten-core:mainfrom
guybedford:epoll

Conversation

@guybedford

@guybedford guybedford commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Implements epoll, based on previously landed work #27226 and #27206.

Resolves #5033, #10556.

Adds epoll_create1, epoll_ctl, epoll_wait and epoll_pwait on a single fd readiness model shared with poll().

Builds off of the existing event-driven readiness model in the JS FS system with the integration point as the per-inode wait-queue, having each FS node carrying a listeners set and producers calling notifyNodeListeners(node, flags) on ready transitions. There is no separate or parallel readiness machinery - it integrates directly with the existing model. pollOne(fd, events) is reused on the same readiness definition.

Per standard epoll semantics - epoll_ctl ADD installs a new listener on the watched node. If items are already ready they are added to the ready list. That listener then appends the registration to the epoll's ready list for waking. The epoll_wait consumes the ready list, re-checking each item against its current mask via pollOne.

  • EPOLLONESHOT clears listeners to avoid unnecessary callback firing. EPOLL_CTL_MOD can then re-arm them again.
  • EPOLLET is implemented correctly to avoid refiring items that remain ready
  • EPOLLEXCLUSIVE is passed for listeners allowing only one wake for multiple epoll listeners to avoid the "thundering herd".
  • When exceeding maxevents, draining follows Linux-like semantics in supporting round-robin ready calling. To achieve this without losing performance, a doubly-linked list is used for the registrations. A simpler set / array with copying could be used alternatively if we don't want to use this approach.
  • Registrations key on the open file description (the dup-shared stream state): closing a watched fd and reusing its number for a different open does not resurrect the registration onto the new fd (matching Linux).
  • dup(2) of an epoll fd yields another reference to the same epoll instance (registrations and ready list shared, per Linux eventpoll semantics); only the last close reclaims it.

Most of the diff is tests, covering these semantics in depth including error handling, level versus edge reporting, nesting and ELOOP, fd-close auto-removal, dup instance sharing, JSPI and pthreads, real sockets, deregistration, deterministic round-robin fairness, and multi-threaded waits on a shared epoll fd (per-edge exactly-once wakeup under EPOLLET, herd wakeup under level triggering, and EPOLLONESHOT disarm/re-arm and exactly-once delivery against racing waiter threads).

Minor semantic divergences to note:

  • epoll_pwait ignores sigmask
  • epoll_create1 accepts and ignores EPOLL_CLOEXEC (unknown flags are rejected with EINVAL)
  • closing a watched fd evicts its registration even when a dup of the open description survives; Linux keeps the registration alive through the file and continues delivering events (the classic epoll footgun)
  • nesting is capped at 5 levels
  • epoll_event under Wasm in Musl is laid out as aligned 16 rather than x86-64's packed 12 bytes.

This PR originally also included emscripten_epoll_set_callback, a non-blocking JS-callback readiness variant (usable without ASYNCIFY/JSPI), which was split out into the follow-on PR #27547.

PR made with AI assistance, under my review

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this like this direction.

I've not had time to look at all the details yet, but it seems like a great idea to unify the node events like this.

Comment thread system/include/emscripten/emscripten.h Outdated
Comment thread ChangeLog.md Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libsockfs.js Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libsockfs_node.js Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libpipefs.js
Comment thread src/lib/libpipefs.js Outdated
Comment thread system/include/emscripten/emscripten.h Outdated
Comment thread src/lib/libsyscall.js
@sbc100 sbc100 changed the title epoll implementation for the JS filesystem Implement epoll APIs in the JS filesystem Jun 27, 2026
@guybedford
guybedford force-pushed the epoll branch 2 times, most recently from 0e64f2b to 9b46f69 Compare June 29, 2026 23:46
Comment thread src/modules.mjs Outdated
Comment thread system/include/emscripten/epoll.h Outdated
Comment thread src/lib/libsyscall.js Outdated
Comment thread src/lib/libepoll.js Outdated
@guybedford
guybedford force-pushed the epoll branch 3 times, most recently from 97a4580 to d33958b Compare June 30, 2026 23:04
@sbc100

sbc100 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

I'm liking the direction of this commit but still a little overwhelmed by the size of it.

Can you think of any more ways to split it up? I'm not sure myself... for example, could we land the internal refactoring of the poll/select to use the new notification system before we land the rest of epoll? Maybe not practically separable?

Comment thread src/lib/libepoll.js Outdated
Comment thread test/codesize/test_codesize_cxx_ctors1.json Outdated
Comment thread test/core/test_epoll_noderawfs.out Outdated
Comment thread test/test_core.py Outdated
Comment thread test/test_core.py Outdated
@guybedford

Copy link
Copy Markdown
Collaborator Author

Can you think of any more ways to split it up? I'm not sure myself... for example, could we land the internal refactoring of the poll/select to use the new notification system before we land the rest of epoll? Maybe not practically separable?

I've refactored out the JS notification changes into #27226 if that helps?

Comment thread ChangeLog.md Outdated
@guybedford
guybedford force-pushed the epoll branch 4 times, most recently from 3d7ef1b to aaa31e8 Compare July 10, 2026 20:47
guybedford added a commit to guybedford/mio that referenced this pull request Jul 11, 2026
Adds `wasm32-unknown-emscripten` as a target for mio, plus a CI job that runs
the suite under Node. Resolves tokio-rs#642.

Emscripten exposes a real epoll backed by its runtime event loop, so the
existing Linux epoll selector is reused rather than adding a new backend. The
wasm `compile_error!` guard is relaxed to let emscripten through, and the
`epoll`/`eventfd`/pipe-waker cfg lists gain emscripten.

Because emscripten cannot block in `epoll_wait` without JSPI/ASYNCIFY, two
non-blocking readiness paths are added alongside the normal `Poll::poll`:

* `Poll::new_with_callback` arms a persistent callback on the epoll fd via the
  runtime's `emscripten_epoll_set_callback`, delivering ready events on a fresh
  host tick whenever the set makes progress. The callback state is boxed, owned
  by the arming selector, and disarmed on drop; it may freely re-enter mio to
  (de)register sources.
* `Registry::poll_ready` does a zero-timeout drain of the same epoll set as a
  readiness probe where a blocking wait is impossible.

AF_UNIX support is stream-only: emscripten's node-backed sockets have no
datagram primitive, so `UnixDatagram` and the `socketpair`-based helpers are
not compiled there. Sockets set `O_NONBLOCK` via `fcntl` since emscripten's
`socket(2)` silently strips `SOCK_NONBLOCK`/`SOCK_CLOEXEC`.

The suite spawns OS threads (socket-peer test harnesses), so std is rebuilt
with atomics via -Zbuild-std and linked -pthread with -sPROXY_TO_PTHREAD so the
main thread can block; JSPI (-sJSPI) provides the return-to-host suspension for
blocking reads/writes, and NODERAWFS/NODERAWSOCKETS back the filesystem and
sockets with node's. This needs nightly + rust-src and a JSPI-capable Node
(26+, or 22 with --experimental-wasm-jspi). No custom target spec is required:
nightly now emits the __main_argc_argv entry point (rust-lang/rust#158937).
Doctests are skipped on this target: rustdoc does not apply the emcc link args,
so the examples cannot be linked with the socket/thread runtime.

Temporary, until the dependencies land upstream:

* Cargo.toml patches libc to guybedford/libc#emscripten for the emscripten
  epoll/pthread externs (rust-lang/libc#5270).
* the CI job builds against the guybedford/emscripten `cf` fork, which carries
  the epoll callback (emscripten-core/emscripten#27207), AF_UNIX pathname
  stream sockets, and multicast getsockopt patches this target depends on.

Suite result: 148 passed, 0 failed, 3 ignored under Node - green on CI.
guybedford added a commit to guybedford/mio that referenced this pull request Jul 11, 2026
Adds `wasm32-unknown-emscripten` as a target for mio, plus a CI job that runs
the suite under Node. Resolves tokio-rs#642.

Emscripten exposes a real epoll backed by its runtime event loop, so the
existing Linux epoll selector is reused rather than adding a new backend. The
wasm `compile_error!` guard is relaxed to let emscripten through, and the
`epoll`/`eventfd`/pipe-waker cfg lists gain emscripten.

Because emscripten cannot block in `epoll_wait` without JSPI/ASYNCIFY, two
non-blocking readiness paths are added alongside the normal `Poll::poll`:

* `Poll::new_with_callback` arms a persistent callback on the epoll fd via the
  runtime's `emscripten_epoll_set_callback`, delivering ready events on a fresh
  host tick whenever the set makes progress. The callback state is boxed, owned
  by the arming selector, and disarmed on drop; it may freely re-enter mio to
  (de)register sources.
* `Registry::poll_ready` does a zero-timeout drain of the same epoll set as a
  readiness probe where a blocking wait is impossible.

AF_UNIX support is stream-only: emscripten's node-backed sockets have no
datagram primitive, so `UnixDatagram` and the `socketpair`-based helpers are
not compiled there. Sockets set `O_NONBLOCK` via `fcntl` since emscripten's
`socket(2)` silently strips `SOCK_NONBLOCK`/`SOCK_CLOEXEC`.

The suite spawns OS threads (socket-peer test harnesses), so std is rebuilt
with atomics via -Zbuild-std and linked -pthread with -sPROXY_TO_PTHREAD so the
main thread can block; JSPI (-sJSPI) provides the return-to-host suspension for
blocking reads/writes, and NODERAWFS/NODERAWSOCKETS back the filesystem and
sockets with node's. This needs nightly + rust-src and a JSPI-capable Node
(26+, or 22 with --experimental-wasm-jspi). No custom target spec is required:
nightly now emits the __main_argc_argv entry point (rust-lang/rust#158937).
Doctests are skipped on this target: rustdoc does not apply the emcc link args,
so the examples cannot be linked with the socket/thread runtime.

Temporary, until the dependencies land upstream:

* Cargo.toml patches libc to guybedford/libc#emscripten for the emscripten
  epoll/pthread externs (rust-lang/libc#5270).
* the CI job builds against the guybedford/emscripten `cf` fork, which carries
  the epoll callback (emscripten-core/emscripten#27207), AF_UNIX pathname
  stream sockets, and multicast getsockopt patches this target depends on.

Suite result: 148 passed, 0 failed, 3 ignored under Node - green on CI.
@guybedford
guybedford force-pushed the epoll branch 2 times, most recently from 01ddd22 to d8e24d7 Compare July 14, 2026 02:01
@guybedford

Copy link
Copy Markdown
Collaborator Author

I've gone ahead and simplified the epoll callback design here so that the callback no longer consumes and provides ready events, but instead only reports that there are non-zero events ready to be consumed by a 0-timeout poll_wait. This way, the callback system is notification-only, separate from event consumption.

@sbc100

sbc100 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Thanks guy. Will try to find some time to review this this week.

@kripken @juj @dschuff FYI. I think this kind of nice change since it unifies all the polling into a single salable epoll interface.

@guybedford
guybedford force-pushed the epoll branch 3 times, most recently from 57dfba2 to 8b5f74d Compare July 21, 2026 01:29
guybedford added a commit to guybedford/emscripten that referenced this pull request Jul 24, 2026
guybedford added a commit to guybedford/emscripten that referenced this pull request Jul 28, 2026

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry if we've already discussed this possibility before, but do you think we could split out the emscripten-specific callback stuff from the this PR, and just the epoll implementation first?

It seems like the emscripten-specific-callback API adds quite a bit of extra complexity regarding proxying and runtimekeealive.

Comment thread src/lib/libepoll.js Outdated
Comment thread src/lib/libepoll.js Outdated
$newEpollInstance: () => {
// 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.
var node = new FS.FSNode(0, 'epoll', 0, 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the name "epoll" here every visible to the user? i.e is it just a placeholder?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's just a placeholder - changed to the empty string to save space.

Comment thread src/lib/libepoll.js
});
// Hoist the instance state onto `shared` so every dup observes one instance.
Object.assign(stream.shared, {
node,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is node needed in stream.shared even though its already part of stream?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because stream.shared is what the syscall entry points pass as the instance data, from which we extract the node.

Comment thread src/lib/libepoll.js
// armed registration can never fire again on its own, so it is terminal.
armed: 0,
// Open references (fds) to this instance; the last close reclaims it.
refcount: 1,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nodefs also adds refcount here for the same purpose.. i wonder if we share this code, or make this some kind of mixin?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be done, although would affect every other backend including pipefs and nodefs, so would be a larger refactoring. Perhaps a possible follow-on rather?

Comment thread src/lib/libepoll.js Outdated
Comment thread ChangeLog.md Outdated
Comment thread src/lib/libepoll.js Outdated
Comment thread src/lib/libepoll.js Outdated
Comment thread src/lib/libepoll.js Outdated
Comment thread src/lib/libepoll.js Outdated
Add epoll_create1/epoll_ctl/epoll_wait/epoll_pwait on the legacy (non-WASMFS)
JS syscall layer, built on the per-inode readiness wait-queue: level- and
edge-triggered modes, EPOLLONESHOT, EPOLLEXCLUSIVE, EPOLLRDHUP, nesting,
dup-shared epoll instances, and blocking waits under PROXY_TO_PTHREAD,
ASYNCIFY, and JSPI.
@guybedford

Copy link
Copy Markdown
Collaborator Author

Thanks @sbc100 for the review! The latest PR feedback has now been addressed. I've also added a proper multi-threading test and posted the follow-on with its own review feedback integrated per the comments here in turn.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

epoll support

2 participants