epoll listeners: cross-thread registration and delivery - #27772
Open
guybedford wants to merge 17 commits into
Open
guybedford wants to merge 17 commits into
guybedford wants to merge 17 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_listenercalled from another thread registers a listener on main whose deliveries are dispatched back to the registering thread:emscripten_epoll_remove_listenerremoves the calling thread's listener; theENOTSUPpath is gone.emscripten_proxy_callback(newsystem/lib/pthread/emscripten_epoll_callback.c, modelled onhtml5/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.emscripten_runtime_keepalive_push()/pop()on that thread.Tests: the
PROXY_TO_PTHREADvariants 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