Skip to content

Support Node.js blocking socket operations#27277

Open
guybedford wants to merge 7 commits into
emscripten-core:mainfrom
guybedford:node-sockets-blocking
Open

Support Node.js blocking socket operations#27277
guybedford wants to merge 7 commits into
emscripten-core:mainfrom
guybedford:node-sockets-blocking

Conversation

@guybedford

@guybedford guybedford commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

This is based to #27207, so the work described here is only the last commit in this PR.

This adds support for blocking socket operations using a unified blocking layer based on the epoll listener system, without introducing new functions for each blocking call via a single $streamWaitOp in libfs.js.

This way we can support blocking accept, connect, recvfrom, sendto, sendmsg and recvmsg for both pthreads with proxying and JSPI, which are then registered to the completion event.

fd_read and fd_write route through the same $streamWaitOp, so a blocking read()/write() on a socket or pipe suspends until the inode signals readiness, exactly like recv/send. This is gated on ASYNCIFY || PTHREADS: a purely-synchronous build keeps the direct doReadv/doWritev path byte-for-byte, so hello-world code size is unchanged.

Tested via a blocking-only loopback echo (now exercising both recv/send and read/write) and the epoll tests under PROXY_TO_PTHREAD and JSPI.

@guybedford guybedford force-pushed the node-sockets-blocking branch from 62b3677 to 8b0fb62 Compare July 9, 2026 00:28
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, and
blocking waits under PROXY_TO_PTHREAD, ASYNCIFY, and JSPI.

Also add emscripten_epoll_set_callback (new experimental <emscripten/epoll.h>),
a non-blocking variant that delivers an epoll set's readiness to a JS callback
without ASYNCIFY/JSPI.
emscripten_epoll_set_callback is a sync-proxied syscall, so its body (and
every readiness re-derivation) runs on the thread that owns the filesystem.
Previously the callback also fired there, so under PROXY_TO_PTHREAD it never
reached the application thread. Now the registering thread is captured and each
delivery is back-proxied to it, while readiness stays tracked on the FS thread.

- Track an `armed` count (registrations with a live listener, so a fired
  EPOLLONESHOT no longer counts) and key the keepalive on it: a set of only
  terminal registrations can never fire again and releases the runtime.
- Hold the runtime keepalive on both threads while armed - the FS thread (runs
  the derivation) and the owner thread (runs the callback and must survive to
  receive it) - proxying push/pop to the owner via
  _emscripten_epoll_keepalive_on_thread. Only fires on empty<->populated
  threshold crossings, so no chatter under constant mutation.
- Dispatch deliveries one-at-a-time via emscripten_proxy_callback: the owner
  drains inside the callback, so its completion (do_epoll_done ->
  _emscripten_epoll_delivery_done) paces the next derive, avoiding a
  level-triggered re-drain spin. A monotonic token re-finds the interest and
  drops stale completions whose interest was cleared mid-flight.
- A replace from another thread hands ownership over cleanly (clear + reconcile
  before taking the new owner's keepalive).

New system/lib/pthread/emscripten_epoll_callback.c carries the proxying helpers.
test_noderawsockets_epoll_callback now also runs under PROXY_TO_PTHREAD.
dup(2) of an epoll fd must yield another reference to the same epoll
instance (Linux eventpoll semantics): registrations, the ready list, and
the persistent readiness callback are all shared across every fd. This is
how tokio's single-threaded JSPI reactor drives the epoll - it arms
emscripten_epoll_set_callback on one fd and routes epoll_ctl(ADD) through
a dup of it.

FS.dupStream shallow-copies the stream, so the reassigned scalars
(rdlHead/rdlTail, armed, keepalive, interest) diverged between the two
handles while the interest map and node stayed shared. A registration
added via the dup appended to the dup's ready list, but the callback
armed on the original drained the original's (always-empty) list, so it
delivered 0 events forever and the reactor deadlocked.

Hoist the instance state onto the stream's shared open-file-description
object, which dup already propagates by reference, so every fd observes
one instance. Add a refcounted dup/close so only the last close reclaims
the instance. Self-watch and nesting checks now key on the instance
rather than the fd.
@guybedford guybedford force-pushed the node-sockets-blocking branch from 298e250 to 912edbe Compare July 10, 2026 05:37
Implements a unified blocking layer via `$streamWaitOp` in libfs.js: it
attempts a stream op once, and if it would-block, registers a listener on that
inode's readiness wait-queue and suspends. The wake comes only from that
readiness event - no polling or spin loop.

The socket syscalls move from libsyscall.js into libsockfs.js. connect,
accept4, recvfrom, sendto, sendmsg and recvmsg gain __async:'auto' and delegate
to $streamWaitOp; accept4 also applies SOCK_NONBLOCK/SOCK_CLOEXEC to the new fd.

fd_read and fd_write route through $streamWaitOp too, so a blocking read/write
on a socket or pipe suspends until the inode signals readiness. This is gated on
ASYNCIFY || PTHREADS: a purely-synchronous build keeps the direct
doReadv/doWritev path byte-for-byte, so hello-world code size is unchanged.

A blocking socket call may suspend the wasm stack, so a callback scheduled onto
the JS event loop may itself need to suspend when it makes one. Add
emscripten_async_call_promising, a variant of emscripten_async_call whose
callback is dispatched through the existing promising dyncall path
(makeDynCall's promising flag, as used by the pthread thread-entry) so it can
suspend under JSPI. Without JSPI it is identical to emscripten_async_call.

Tested via blocking-only loopback echo and epoll tests under PROXY_TO_PTHREAD
and JSPI.
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