Skip to content

Add emscripten_epoll_add_listener for epoll readiness callbacks - #27547

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

Add emscripten_epoll_add_listener for epoll readiness callbacks#27547
guybedford wants to merge 7 commits into
emscripten-core:mainfrom
guybedford:epoll-callback

Conversation

@guybedford

Copy link
Copy Markdown
Collaborator

Follow-on to #27207 implementing the non-blocking readiness callback model for epoll, split out of that PR per review feedback. (Includes #27207 as its base; the callback model is the final commit.)

emscripten_epoll_add_listener (new experimental <emscripten/epoll.h>) registers a persistent readiness listener on an epoll fd: the runtime invokes the callback on the event loop whenever the set has uncollected ready events, and the callback collects them itself via a zero-timeout epoll_wait(epfd, ..., 0). It never suspends the stack, so event-loop reactors can drive epoll without ASYNCIFY/JSPI.

  • Any number of listeners, identified by (callback, registering thread); re-adding the same identity updates userdata, and emscripten_epoll_remove_listener removes by identity.
  • Broadcast with load balancing: every listener is signalled while uncollected ready events remain, and collectors race over the single shared ready list, so EPOLLET/EPOLLONESHOT items are collected by exactly one listener - the same distribution semantics as between multiple blocking epoll_wait callers on one epoll.
  • Firing is gated on the same readiness derivation used by the epoll fd's own poll handler (nesting), so a stale ready-list entry never spuriously fires.
  • Listeners hold a runtime keepalive while the set can still fire, following the Node.js model where registered I/O interest holds the event loop open: once every watched fd is closed (or every one-shot has fired) the set is terminal and the runtime is released, with no explicit disposal needed.
  • With pthreads, readiness is tracked on the main thread and each delivery is back-proxied to the registering thread, one in flight at a time and paced by its completion, with each listener holding its owner thread's keepalive while armed.
  • Listeners are epoll-instance state: shared across dup'd fds and removed on the last close.

Tests cover delivery and MOD re-arm with no producer event, edge and level modes on the callback path, multi-listener broadcast/load-balancing, registration identity and errors, drain-across-ticks with maxevents=1, nesting, dup sharing, terminal-set runtime release (including nested), a suspended blocking epoll_wait and a listener sharing one ready list (ASYNCIFY and JSPI), and real-socket delivery including PROXY_TO_PTHREAD cross-thread dispatch.

Made with AI assistance under my review

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.
A non-blocking readiness delivery mechanism for epoll: instead of blocking in
epoll_wait, the runtime invokes registered listener callbacks on the event loop
whenever the epoll set has ready events waiting to be collected (new
experimental <emscripten/epoll.h>), working without ASYNCIFY/JSPI.

A callback takes only its userdata and collects events itself via a
zero-timeout epoll_wait(epfd, ..., 0). Firing is gated on the shared readiness
derivation ($epollDerive) also used by the epoll fd's own poll handler, so a
stale ready-list entry never spuriously fires. Per-fd trigger modes apply
exactly as in epoll_wait: a level fd left undrained re-fires every tick, an
edge fd once per edge, a fired EPOLLONESHOT not until re-armed.

Any number of listeners may be added, keyed by (callback, registering thread);
re-adding the same identity updates userdata. Every listener is signalled
while uncollected ready events remain (broadcast) and collectors race over the
single shared ready list, so EPOLLET/EPOLLONESHOT items are collected by
exactly one listener - the same load balancing as between multiple blocking
epoll_wait callers on one epoll.

Listeners hold a runtime keepalive while the set can still fire, keyed on the
armed-registration count (a fired EPOLLONESHOT no longer counts): registered
I/O interest holds the event loop open, following the Node.js model, and a
terminal set (every watched fd closed) releases the runtime with no explicit
disposal needed. Listeners are instance state shared across dup'd fds; the
last close removes them all.

Under pthreads the registration body runs sync-proxied on the main thread, so
the registering thread is captured and each delivery is back-proxied to it via
emscripten_proxy_callback (new system/lib/pthread/emscripten_epoll_callback.c),
one delivery in flight at a time, paced by its completion
(_emscripten_epoll_delivery_done) with a monotonic token dropping stale
completions. While armed, each listener also holds its owner thread's
keepalive so it survives to receive deliveries.
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.

1 participant