Skip to content

Support blocking accept() and recv() on JS sockets under pthreads and ASYNCIFY/JSPI - #27724

Open
guybedford wants to merge 3 commits into
emscripten-core:mainfrom
guybedford:accept-blocking
Open

guybedford wants to merge 3 commits into
emscripten-core:mainfrom
guybedford:accept-blocking

Conversation

@guybedford

@guybedford guybedford commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

This makes blocking accept(), recv(), recvfrom() and recvmsg() on JS-backed sockets actually block wherever the calling stack can wait: a sync-proxied pthread (including main() under PROXY_TO_PTHREAD), or ASYNCIFY/JSPI. Previously these returned EAGAIN on a blocking fd regardless.

The three syscalls become __async: 'auto' library functions taking the sync argument of #28022. The backends stay strictly synchronous and throw EAGAIN when they would block; a shared sockCall helper in libsyscall.js runs the syscall body, and on EAGAIN for a blocking fd (no O_NONBLOCK, no MSG_DONTWAIT) with sync false returns a Promise that parks on the socket's inode wait-queue (the same queue poll()/epoll use) until it reports readable, hung up or errored, then retries. A proxied pthread caller awaits that Promise; ASYNCIFY/JSPI suspend on it. Where no stack can wait (sync true: the event-loop thread itself) the EAGAIN surfaces unchanged, with the ASSERTIONS warning moved from the node backend to this layer.

  • TCP recvmsg in the node backend now returns and clears a pending socket error (as the dgram branch already did, and as Linux does) instead of EAGAIN, so a blocked recv() woken by a connection error returns that error rather than spinning. The error takes precedence over EOF, since node emits 'close' right after 'error'.
  • MSG_DONTWAIT is added to struct_info for the recv flag check. No libc changes.
  • A sync-proxied __async: 'auto' body that returns a plain value is delivered to the pthread on the spot rather than through a microtask. Microtasks only run when the main thread returns to its event loop, which a main() blocked in pthread_join (or a mutex, or sleep) never does, though its futex wait still runs proxied calls; the pthread would wait forever on a call that had already completed. sockCall tries the operation synchronously first and only parks on a would-block, so an accept() or recv() that can complete at once does. Under JSPI the main-thread wrapper of an 'auto' body likewise hands a proxied call's result back directly instead of through the async helper, which would wrap even a plain value in a Promise.

Tested with test_noderawsockets_tcp_blocking (non-blocking/MSG_DONTWAIT EAGAIN, blocking accept() woken by a late connection, blocking recv() woken by data and by peer close, and already-ready calls returning without waiting) and test_noderawsockets_tcp_blocking_error (blocking recv() woken by an injected connection error, SO_ERROR cleared after), each under PROXY_TO_PTHREAD, JSPI and ASYNCIFY. test_noderawsockets_tcp_blocking_join is a plain -pthread build where main() sits in pthread_join on a thread whose accept() and recv() have a connection and data already pending; it hangs without the synchronous completion, in both the plain and the JSPI build. test_nonblock_flags drops its assertion that a blocking accept() would-blocks.

Made with AI assistance under my review

@guybedford guybedford changed the title Support blocking accept() and recv() on JS sockets under pthreads and ASYNCIFY/JSPI Sep 16, 2026
@guybedford guybedford changed the title Support blocking accept() and recv() on JS sockets under pthreads and JSPI Sep 16, 2026
guybedford added a commit to guybedford/emscripten that referenced this pull request Oct 5, 2026
…function

A JS library function marked __async: 'maybe' returns either a pointer-sized
value or a Promise of one, as it can. The generated wrapper dispatches: a
value is returned as is; a Promise is awaited by a sync-proxied pthread
caller, suspended on under ASYNCIFY/JSPI, and otherwise - where the stack
cannot wait - replaced by the declared fallback:

  foo__async: 'maybe',
  foo__wouldblock: -EAGAIN,   // aborts if none is declared

This is the sometimes-suspending pattern getaddrinfo (emscripten-core#27693) and the
blocking socket calls (emscripten-core#27724) each hand-wrote, with its ASYNCIFY=1 rewind
re-entry handled once in the wrapper. The existing modes keep their meaning:
'auto' always suspends, true leaves Asyncify to the body.

Aliases of such a function may declare other result forms of the same body:

  foo_fd: 'foo',
  foo_fd__result: 'fd',           // an fd readable once settled; read() is the
                                  // value (POLLERR/EIO on rejection)
  foo_promise: 'foo',
  foo_promise__result: 'promise', // an em_promise_t on the calling thread

The body runs on the main thread. On a pthread the promise form is built on
the fd form through emscripten_fd_promise, since descriptors are
process-wide and promises per-thread: the fd is the primitive that crosses
threads, the promise the per-thread view of it.
guybedford added a commit to guybedford/emscripten that referenced this pull request Oct 5, 2026
… such a function

An __async: 'auto' library function returns a pointer-sized value or a
Promise of one, as it can; the wrapper suspends on a Promise under
ASYNCIFY/JSPI and hands it to a sync-proxied pthread caller to await. Without
ASYNCIFY on the main thread nothing could wait, and the Promise object itself
leaked into wasm. The wrapper now returns a declared fallback there instead:

  foo__async: 'auto',
  foo__wouldblock: -EAGAIN,   // aborts if none is declared

This is the sometimes-suspending dispatch getaddrinfo (emscripten-core#27693) and the
blocking socket calls (emscripten-core#27724) each hand-wrote in the body.

Aliases of such a function may declare other result forms of the same body:

  foo_fd: 'foo',
  foo_fd__result: 'fd',           // an fd readable once settled; read() is the
                                  // value (POLLERR/EIO on rejection)
  foo_promise: 'foo',
  foo_promise__result: 'promise', // an em_promise_t on the calling thread

The body runs on the main thread. On a pthread the promise form is built on
the fd form through emscripten_fd_promise, since descriptors are
process-wide and promises per-thread: the fd is the primitive that crosses
threads, the promise the per-thread view of it.
guybedford added a commit to guybedford/emscripten that referenced this pull request Oct 5, 2026
… such a function

An __async: 'auto' library function returns a pointer-sized value or a
Promise of one, as it can; the wrapper suspends on a Promise under
ASYNCIFY/JSPI and hands it to a sync-proxied pthread caller to await. Without
ASYNCIFY on the main thread nothing could wait, and the Promise object itself
leaked into wasm. The call would block there, so the wrapper now returns
-EWOULDBLOCK (the syscall convention), or the value a function declares:

  foo__async: 'auto',
  foo__wouldblock: EAI_AGAIN,

This is the sometimes-suspending dispatch getaddrinfo (emscripten-core#27693) and the
blocking socket calls (emscripten-core#27724) each hand-wrote in the body.

Aliases of such a function may declare other result forms of the same body:

  foo_fd: 'foo',
  foo_fd__result: 'fd',           // an fd readable once settled; read() is the
                                  // value (POLLERR/EIO on rejection)
  foo_promise: 'foo',
  foo_promise__result: 'promise', // an em_promise_t on the calling thread

The body runs on the main thread. On a pthread the promise form is built on
the fd form through emscripten_fd_promise, since descriptors are
process-wide and promises per-thread: the fd is the primitive that crosses
threads, the promise the per-thread view of it.
guybedford added a commit to guybedford/emscripten that referenced this pull request Oct 6, 2026
An __async: 'auto' library function returns a pointer-sized value or a
Promise of one, as it can. A body naming one parameter more than its __sig
now receives there `canWait`: whether a returned Promise could be waited for
on this call - always under ASYNCIFY/JSPI, on behalf of a sync-proxied
pthread caller (which awaits it), and never otherwise, where the body must
complete synchronously instead. Bodies not naming it are unchanged.
__syscall_poll and epoll_pwait, which computed this by hand, take it.

This is the sometimes-suspending dispatch getaddrinfo (emscripten-core#27693) and the
blocking socket calls (emscripten-core#27724) each hand-wrote in the body.

Aliases of such a function named with a form suffix return the same result in
another shape, for callers that cannot block, and so always pass canWait:

  foo_fd: 'foo',       // an fd readable once settled; read() is the value
                       // (POLLERR/EIO on rejection)
  foo_promise: 'foo',  // an em_promise_t on the calling thread

The body runs on the main thread. On a pthread the promise form is built on
the fd form through emscripten_fd_promise, since descriptors are
process-wide and promises per-thread: the fd is the primitive that crosses
threads, the promise the per-thread view of it.
@guybedford
guybedford changed the base branch from main to gbedford/dns-forms October 13, 2026 00:17
@guybedford
guybedford force-pushed the gbedford/dns-forms branch 2 times, most recently from 7db881d to 77a780b Compare October 13, 2026 00:30
@sbc100
sbc100 force-pushed the gbedford/dns-forms branch from 6a44272 to 69b64a8 Compare October 28, 2026 02:42
@guybedford
guybedford force-pushed the accept-blocking branch 2 times, most recently from 9181437 to 4909dc4 Compare October 28, 2026 03:31
@guybedford
guybedford force-pushed the gbedford/dns-forms branch 2 times, most recently from 3d91547 to 3c4c7e5 Compare October 28, 2026 22:49
A sync-proxied __async: 'auto' body that returns a plain value is now
delivered to the pthread on the spot instead of through a microtask, which
never runs while main() is blocked on the main thread (pthread_join, a
mutex, sleep), though its futex wait does run the proxied call. sockCall
tries the operation synchronously first and only parks on a would-block,
so an accept() or recv() that can complete at once does.
@guybedford
guybedford changed the base branch from gbedford/dns-forms to main October 28, 2026 22:58
Under JSPI the main-thread wrapper of an 'auto' body ran even a proxied
call through the async helper, so a plain value reached the proxy
completion wrapped in a Promise and needed a microtask, hanging a pthread
whose main() is blocked on the main 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

1 participant