Skip to content

epoll listeners: cross-thread registration and delivery - #27772

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

guybedford wants to merge 17 commits into
emscripten-core:mainfrom
guybedford:epoll-callback-threads

Conversation

@guybedford

Copy link
Copy Markdown
Collaborator

Stacked on #27547 (the first 16 commits are that PR; this PR is the last commit). Split out per review so the initial listener API is main-thread only and this adds the cross-thread support separately.

With pthreads, epoll state lives on the main thread (the syscalls are proxied there), so emscripten_epoll_add_listener called from another thread registers a listener on main whose deliveries are dispatched back to the registering thread:

  • Listener identity gains the registering thread: (thread, callback, userdata). emscripten_epoll_remove_listener removes the calling thread's listener; the ENOTSUP path is gone.
  • Each cross-thread delivery goes through emscripten_proxy_callback (new system/lib/pthread/emscripten_epoll_callback.c, modelled on html5/callback.c) with a completion that reports back to main via _emscripten_epoll_delivery_done(token), resolving that delivery's promise in the listener's async loop. This paces deliveries to one in flight per listener, so a still-ready level fd is not re-signalled in a tight spin while the owner thread drains it. A delivery to a thread that has exited drops the listener.
  • As on the main thread, the listener holds nothing itself; a program keeps its registering thread alive with emscripten_runtime_keepalive_push()/pop() on that thread.

Tests: the PROXY_TO_PTHREAD variants of the unref contract (no hold: exit at main's return, callback never runs; with a push: delivery on the registering thread then exit), drain/remove-before-delivery still letting main exit, and the real-socket listener, unref and force-exit cases.

Made with AI assistance under my review

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 ($epollWouldBlock) 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.
…xit guard

Only armed registrations on host-backed fds (sockets; nested epolls counted
conservatively) hold the runtime alive. A pipe can only be written by wasm,
which is already running and held when it does, so a net-enabled runtime
whose waker pipe stayed armed would otherwise never exit under EXIT_RUNTIME.
A scheduled or in-flight delivery holds the runtime separately until it runs
(as safeSetTimeout does), so a pipe write from live work still delivers; the
post-callback re-wake moves inside the callUserCallback wrapper so that hold
precedes maybeExit.

emscripten_force_exit forfeits every hold before exitRuntime, whose FS.quit
then closes the epoll fd and released the listener's hold, underflowing the
counter. All epoll holds now go through one helper that treats a release on a
zero counter as forfeited.
A scheduled delivery holds the runtime until it runs, but a wake raised
by teardown can never deliver, and a hold taken there outlives the exit:
exitRuntime's FS.quit closes every open fd in fd order, and each close
wakes the listener - the fd's own POLLNVAL, and for a pipe the peer
end's close reporting POLLHUP on the still-armed registration. The hold
then leaves keepRuntimeAlive() set when _proc_exit runs, so Module.onExit
is skipped and the exit is left to the host loop draining.

A registration now forwards the cause of its wake to the epoll node
(POLLNVAL for a closing fd, POLLIN otherwise), and a listener wake
holds only for a readiness wake while FS.initialized, which FS.quit
clears before its first close. Under pthreads the FS.quit wakes were
also what pushed a keepalive to an owner thread that had already exited.

Test: a pipe registered and quiet at main's return, created before its
epoll so its ends close first; the runtime exits (atexit) and the exit
completes (onExit).
A delivery was queued as a microtask. Hosts may drain the microtask
queue synchronously inside unrelated calls (workerd does on a Node
builtin load, which its connect() path performs), so a listener ran
re-entrantly under the frames of the wasm call that had just made the
set ready. Schedule deliveries with emSetImmediate instead; the two
tests that ordered a check after deliveries by timeout now queue it as
a later immediate.
A listener registered from a pthread runs its callbacks there, so the holds
epoll takes for it (host-armed interest, a pending or in-flight delivery)
must hold that thread's runtime too. Proxying a push to the owner lands only
on its next event-loop turn, after it may already have decided to exit (its
return from main, or a callback's end), so acquires were racy.

Add struct pthread.keepalive_holds, an atomic count of holds other threads
place on a thread's runtime, read by keepRuntimeAlive() alongside the JS-side
counter, with _emscripten_thread_keepalive(t, delta) to adjust it; a release
also queues a no-op task so the target re-evaluates. epoll mirrors its
listener-scoped holds onto the owner with this, acquiring before releasing
where one hold hands over to another so the owner never sees a gap.

A delivery to an owner that has exited is dropped and clears the listener
instead of asserting.
This is an automatic change generated by tools/maint/rebaseline_tests.py.

The following (12) test expectation files were updated by
running the tests with `--rebaseline`:

```
codesize/test_codesize_cxx_ctors2.json: 152604 => 152604 [+0 bytes / +0.00%]
codesize/test_codesize_cxx_except_wasm.json: 168663 => 168663 [+0 bytes / +0.00%]
codesize/test_codesize_cxx_except_wasm_legacy.json: 166523 => 166523 [+0 bytes / +0.00%]
codesize/test_codesize_cxx_lto.json: 119632 => 119632 [+0 bytes / +0.00%]
codesize/test_codesize_hello_dylink.json: 43250 => 43250 [+0 bytes / +0.00%]
codesize/test_codesize_hello_dylink_all.json: 859704 => 859814 [+110 bytes / +0.01%]
codesize/test_codesize_mem_O3_grow_standalone.json: 9306 => 9306 [+0 bytes / +0.00%]
codesize/test_codesize_mem_O3_standalone.json: 9140 => 9140 [+0 bytes / +0.00%]
codesize/test_codesize_mem_O3_standalone_narg.json: 8455 => 8455 [+0 bytes / +0.00%]
codesize/test_codesize_mem_O3_standalone_narg_flto.json: 7386 => 7386 [+0 bytes / +0.00%]
codesize/test_codesize_minimal_pthreads.json: 26030 => 26109 [+79 bytes / +0.30%]
codesize/test_codesize_minimal_pthreads_memgrowth.json: 26489 => 26573 [+84 bytes / +0.32%]

Average change: +0.05% (+0.00% - +0.32%)
```
A listener never keeps the runtime or its registering thread alive; a
program holds the runtime itself with emscripten_runtime_keepalive_push/pop.
Drops the host-armed keepalive model, the cross-thread keepalive_holds in
struct pthread and the keepRuntimeAlive() change. The pending-delivery hold
stays. Tests rewritten to the new contract.
Per review: the cross-thread delivery moves to a follow-up so the initial
version is main-thread only (ENOTSUP from other threads), and listeners
are identified by the (callback, userdata) pair like the html5 event
handlers, so remove_listener takes userdata too and a duplicate pair is
EEXIST.
With pthreads, readiness is tracked on the main thread and each delivery
is back-proxied to the registering thread via emscripten_proxy_callback,
one in flight at a time and paced by its completion, which resolves the
delivery's promise through _emscripten_epoll_delivery_done. A delivery
to a thread that has exited drops the listener. Listener identity gains
the registering thread.

This branch has not been deployed

No deployments
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