From b7f259d32e25a49751bffba1f719034558eb30a5 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 28 Oct 2026 18:03:57 -0700 Subject: [PATCH 1/8] jsifier: dispatch a proxied 'auto' body directly under JSPI too The main-thread wrapper ran a call made on a pthread's behalf through the async helper, so even a plain result reached the proxy completion as a Promise and needed a microtask, as it already did not under ASYNCIFY. --- src/jsifier.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 6a05135ce6e5e..65fe05cbccf96 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -538,10 +538,11 @@ function handleAsyncFunction(snippet, sig, proxied) { handleAsync = makeReturn64(handleAsync); } // When dispatching on behalf of a proxied caller (PROXY_SYNC_ASYNC), the - // caller awaits the returned promise, so return it directly rather than - // suspending the main thread. + // caller awaits the result, so return it directly rather than suspending + // the main thread: a value or SyncDeferred then completes the call + // synchronously, which a Promise wrapper would defer to a microtask. let proxiedDispatch = ''; - if (ASYNCIFY == 1 && PTHREADS && proxied) { + if (PTHREADS && proxied) { proxiedDispatch = 'if (PThread.currentProxiedOperationCallerThread) return innerFunc();\n '; } return modifyJSFunction(snippet, (args, body, async_, oneliner) => { From 96081bca659d076936413a7dbffd9521a80655da Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 28 Oct 2026 18:03:58 -0700 Subject: [PATCH 2/8] Blocking pipe writes from pthreads wait for the reader A pthread's blocking write to a full pipe is held to the capacity and the proxied fd_write completes once the reader frees room. The wake comes from the reader's own proxied read, which the main thread services even while it is itself blocked (spinning in pthread_join), so the completion must not pass through a microtask: waits hand back a SyncDeferred, delivered synchronously to the proxied caller and adopted as a thenable where the stack suspends. poll() and epoll_wait() move to the same form, as their wake has the same source. --- ChangeLog.md | 3 +- src/lib/libepoll.js | 50 +++++----- src/lib/libpipefs.js | 7 +- src/lib/libpthread.js | 10 +- src/lib/libsyscall.js | 53 +++++++++- src/lib/libwasi.js | 71 +++++++++++++- .../test_codesize_file_preload.expected.js | 2 +- .../test_codesize_minimal_pthreads.json | 4 +- ...t_codesize_minimal_pthreads_memgrowth.json | 4 +- test/codesize/test_small_js_flags.expected.js | 2 +- test/other/test_pipe_write_blocking.c | 97 +++++++++++++++++++ test/test_other.py | 8 ++ 12 files changed, 269 insertions(+), 42 deletions(-) create mode 100644 test/other/test_pipe_write_blocking.c diff --git a/ChangeLog.md b/ChangeLog.md index 584f498653de8..73e8a6a997738 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -21,7 +21,8 @@ See docs/process.md for more on how version tagging works. 6.0.14 (in development) ----------------------- - Pipes now have a 64 KiB capacity: non-blocking writes to a full pipe fail - with `EAGAIN`. For now, blocking writes remain unbounded. (#27831) + with `EAGAIN`, and (JS FS) blocking writes from a pthread wait for the reader. + On the main thread blocking writes remain unbounded. (#27831) - When compiling and linking in a single command, auxiliary compiler output files (such as `.dwo` files from `-gsplit-dwarf` or `.json` files from `-ftime-trace`) are now named `-.`, matching the behavior diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 52cbcc97e74e2..5726b3a789a28 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -302,33 +302,33 @@ var EpollLibrary = { $epollPwait__internal: true, $epollPwait: (ep, ev, maxevents, timeout, sync) => { #if PTHREADS || ASYNCIFY - // Always resolve through a Promise here: when proxied from a worker the - // result is delivered by promise resolution, so a bare value would break - // the proxy (it has no `.then`). Block on the epoll's own readiness - each - // registration's persistent listener wakes ep.node on a leaf edge - and - // re-derive on wake, resolving the count or 0 after `timeout`. + // Block on the epoll's own readiness - each registration's persistent + // listener wakes ep.node on a leaf edge - and re-derive on wake, settling + // the count or 0 after `timeout`. A SyncDeferred rather than a Promise: + // the wake may be another pthread's proxied call while the main thread + // never turns its event loop. if (!sync) { - return new Promise((resolve) => { - var count = doEpollWait(ep, ev, maxevents); - if (count || !timeout) { - resolve(count); - return; - } - var done = false; - var reg = ep.node.addListener(() => { - if (done) return; - var c = doEpollWait(ep, ev, maxevents); - if (c) finish(c); - }); - var timer = timeout > 0 ? setTimeout(() => finish(0), timeout) : undefined; - function finish(c) { - if (done) return; - done = true; - reg.listeners.delete(reg.entry); - if (timer) clearTimeout(timer); - resolve(c); - } + var deferred = new SyncDeferred(); + var n = doEpollWait(ep, ev, maxevents); + if (n || !timeout) { + deferred.resolve(n); + return deferred; + } + var done = false; + var finish = (c) => { + if (done) return; + done = true; + reg.listeners.delete(reg.entry); + if (timer) clearTimeout(timer); + deferred.resolve(c); + }; + var reg = ep.node.addListener(() => { + if (done) return; + var c = doEpollWait(ep, ev, maxevents); + if (c) finish(c); }); + var timer = timeout > 0 ? setTimeout(() => finish(0), timeout) : undefined; + return deferred; } #endif var count = doEpollWait(ep, ev, maxevents); diff --git a/src/lib/libpipefs.js b/src/lib/libpipefs.js index 0383adcc4f88f..24d651d3ba9e0 100644 --- a/src/lib/libpipefs.js +++ b/src/lib/libpipefs.js @@ -229,9 +229,10 @@ addToLibrary({ throw new FS.ErrnoError({{{ cDefs.EPIPE }}}); } - // PIPE_BUF rule: a small write is all-or-nothing, a larger one takes - // what fits. - if (stream.flags & {{{ cDefs.O_NONBLOCK }}}) { + // A blocking writer is held to the capacity only where fd_write can + // wait for the reader (a pthread's). PIPE_BUF rule: a small write is + // all-or-nothing, a larger one takes what fits. + if ({{{ PTHREADS && !ASYNCIFY ? 'PThread.currentProxiedOperationCallerThread || ' : '' }}}(stream.flags & {{{ cDefs.O_NONBLOCK }}})) { var room = PIPEFS.CAPACITY - PIPEFS.queuedBytes(pipe); if (room <= 0 || (dataLen <= {{{ cDefs.PIPE_BUF }}} && dataLen > room)) { throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index eabb60ac34f87..edbd7b8914642 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -1079,8 +1079,14 @@ var LibraryPThread = { var coerceRtn = (rtn) => rtn; #endif if (ctx) { - // A PROXY_SYNC_ASYNC function may complete synchronously with a plain value. - Promise.resolve(rtn).then((rtn) => __emscripten_run_js_on_main_thread_done(ctx, ctxArgs, coerceRtn(rtn))); + // A plain value finishes the call at once, and a SyncDeferred when its + // wait-queue fires: this thread may be blocked on the caller, never + // reaching a microtask, which Promise.resolve would route either through. + if (rtn?.then) { + rtn.then((rtn) => __emscripten_run_js_on_main_thread_done(ctx, ctxArgs, coerceRtn(rtn))); + } else { + __emscripten_run_js_on_main_thread_done(ctx, ctxArgs, coerceRtn(rtn)); + } return; } rtn = coerceRtn(rtn); diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index 1a0fab921c049..017e0c8317bc2 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -628,9 +628,12 @@ var SyscallsLibrary = { // ready and `timeout` is non-zero, register one waiter per fd on its node // wait-queue and re-derive the whole set on any wake (the wake flags are just // the trigger), resolving then or, for a positive `timeout`, once it elapses. - // A negative `timeout` waits forever. Returns a Promise of the ready count. + // A negative `timeout` waits forever. Returns a SyncDeferred of the ready + // count, since the wake may come from another pthread's proxied call while + // the main thread never turns its event loop. $doPollAsync__internal: true, - $doPollAsync: (fds, nfds, timeout) => new Promise((resolve) => { + $doPollAsync: (fds, nfds, timeout) => { + var deferred = new SyncDeferred(); var regs = []; var timer; var done = false; @@ -650,7 +653,7 @@ var SyscallsLibrary = { done = true; for (var r of regs) r.listeners.delete(r.entry); if (timer) clearTimeout(timer); - resolve(count); + deferred.resolve(count); } var count = derive(); if (count || !timeout) { @@ -667,7 +670,49 @@ var SyscallsLibrary = { } if (timeout > 0) timer = setTimeout(() => finish(0), timeout); } - }), + return deferred; + }, + // Calls `cb` once poll() reports one of `events` on the stream, synchronously + // from the producer's notify. + $waitStreamEvents__internal: true, + $waitStreamEvents: (stream, events, cb) => { + var reg = stream.node.addListener(() => { + if (!pollOne(stream.fd, events)) return; + reg.listeners.delete(reg.entry); + cb(); + }); + }, + + // A result settled later but delivered synchronously, for a sync-proxied + // call that waits on a wait-queue. The pthread sits on a futex, and the + // main thread may be spinning in a blocking call of its own, servicing + // proxied calls but never turning its event loop, so a Promise's microtask + // could never reach it. Promise-shaped (then/resolve/reject, chaining) so + // the proxying completion takes either. + $SyncDeferred__internal: true, + $SyncDeferred: class SyncDeferred { + constructor() { + this.settle = null; + this.settled = null; + } + then(onFulfilled, onRejected) { + var next = new SyncDeferred(); + var settle = (fulfilled, v) => { + try { + if (fulfilled) next.resolve(onFulfilled ? onFulfilled(v) : v); + else if (onRejected) next.resolve(onRejected(v)); + else next.reject(v); + } catch (e) { + next.reject(e); + } + }; + if (this.settled) settle(...this.settled); + else this.settle = settle; + return next; + } + resolve(v) { this.settle ? this.settle(true, v) : this.settled = [true, v]; } + reject(e) { this.settle ? this.settle(false, e) : this.settled = [false, e]; } + }, #endif // libc routes zero-timeout poll() calls here: the same synchronous // readiness derivation as __syscall_poll, but as a plain import that never diff --git a/src/lib/libwasi.js b/src/lib/libwasi.js index 62ea2f7d7335c..d4675159bd621 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -237,6 +237,56 @@ var WasiLibrary = { } return FS.write(stream, view, 0, total, offset); }, +#if PTHREADS && !ASYNCIFY + // writev that waits where a blocking stream would block (a pthread awaits + // the proxied call), returning a SyncDeferred once it had to. + $doWritevBlocking: (stream, iov, iovcnt) => { + var buf, ptr, len; + if (iovcnt == 1) { + ptr = {{{ makeGetValue('iov', C_STRUCTS.iovec.iov_base, '*') }}}; + len = {{{ makeGetValue('iov', C_STRUCTS.iovec.iov_len, '*') }}}; + } else { + len = 0; + for (var i = 0, p = iov; i < iovcnt; i++, p += {{{ C_STRUCTS.iovec.__size__ }}}) { + len += {{{ makeGetValue('p', C_STRUCTS.iovec.iov_len, '*') }}}; + } + buf = new Uint8Array(len); + ptr = 0; + for (var i = 0; i < iovcnt; i++, iov += {{{ C_STRUCTS.iovec.__size__ }}}) { + var base = {{{ makeGetValue('iov', C_STRUCTS.iovec.iov_base, '*') }}}; + var n = {{{ makeGetValue('iov', C_STRUCTS.iovec.iov_len, '*') }}}; + buf.set(HEAPU8.subarray(base, base + n), ptr); + ptr += n; + } + ptr = 0; + } + var total = 0, deferred; + function step() { + while (len) { + var curr; + try { + curr = FS.write(stream, buf ?? HEAP8, ptr, len); + } catch (e) { + if (e.errno == {{{ cDefs.EAGAIN }}} && !(stream.flags & {{{ cDefs.O_NONBLOCK }}})) { + deferred ??= new SyncDeferred(); + waitStreamEvents(stream, {{{ cDefs.POLLOUT }}}, step); + return deferred; + } + if (total) break; + if (deferred) return deferred.reject(e); + throw e; + } + if (!curr) break; + total += curr; + ptr += curr; + len -= curr; + } + if (deferred) return deferred.resolve(total); + return total; + } + return step(); + }, +#endif #else // MEMFS filesystem disabled lite handling of stdout and stderr: $printCharBuffers: [null, [], []], // 1 => stdout, 2 => stderr @@ -257,6 +307,12 @@ var WasiLibrary = { #endif // SYSCALLS_REQUIRE_FILESYSTEM #if SYSCALLS_REQUIRE_FILESYSTEM +#if PTHREADS && !ASYNCIFY + // Waits where a blocking stream would block when proxied from a pthread + // (`sync` false). Not under ASYNCIFY, where 'auto' would make the import + // itself suspend and every printf from a callback cannot afford that. + fd_write__async: 'auto', +#endif #elif (!MINIMAL_RUNTIME || EXIT_RUNTIME) $flush_NO_FILESYSTEM: () => { // flush anything remaining in the buffers during shutdown @@ -270,10 +326,23 @@ var WasiLibrary = { fd_write__postset: () => addAtExit('flush_NO_FILESYSTEM()'), #else #endif - fd_write: (fd, iov, iovcnt, pnum) => { + fd_write: (fd, iov, iovcnt, pnum, sync) => { #if SYSCALLS_REQUIRE_FILESYSTEM var stream = SYSCALLS.getStreamFromFD(fd); +#if PTHREADS && !ASYNCIFY + var num = sync ? doWritev(stream, iov, iovcnt) : doWritevBlocking(stream, iov, iovcnt); + if (num instanceof SyncDeferred) { + return num.then((num) => { + {{{ makeSetValue('pnum', 0, 'num', SIZE_TYPE) }}}; + return 0; + }, (e) => { + if (e.name !== 'ErrnoError') throw e; + return e.errno; + }); + } +#else var num = doWritev(stream, iov, iovcnt); +#endif #else // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0 var num = 0; diff --git a/test/codesize/test_codesize_file_preload.expected.js b/test/codesize/test_codesize_file_preload.expected.js index 38c676696afdf..adfd934eaacfc 100644 --- a/test/codesize/test_codesize_file_preload.expected.js +++ b/test/codesize/test_codesize_file_preload.expected.js @@ -3068,7 +3068,7 @@ var SYSCALLS = { return FS.write(stream, view, 0, total, offset); }; -function _fd_write(fd, iov, iovcnt, pnum) { +function _fd_write(fd, iov, iovcnt, pnum, sync) { try { var stream = SYSCALLS.getStreamFromFD(fd); var num = doWritev(stream, iov, iovcnt); diff --git a/test/codesize/test_codesize_minimal_pthreads.json b/test/codesize/test_codesize_minimal_pthreads.json index d7133be62a0cf..c3ef8621abbd4 100644 --- a/test/codesize/test_codesize_minimal_pthreads.json +++ b/test/codesize/test_codesize_minimal_pthreads.json @@ -1,10 +1,10 @@ { "a.out.js": 6889, - "a.out.js.gz": 3404, + "a.out.js.gz": 3403, "a.out.nodebug.wasm": 19139, "a.out.nodebug.wasm.gz": 8819, "total": 26028, - "total_gz": 12223, + "total_gz": 12222, "sent": [ "a (memory)", "b (exit)", diff --git a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json index e209dad35c529..19dc1bc8a62f9 100644 --- a/test/codesize/test_codesize_minimal_pthreads_memgrowth.json +++ b/test/codesize/test_codesize_minimal_pthreads_memgrowth.json @@ -1,10 +1,10 @@ { "a.out.js": 7346, - "a.out.js.gz": 3620, + "a.out.js.gz": 3622, "a.out.nodebug.wasm": 19140, "a.out.nodebug.wasm.gz": 8820, "total": 26486, - "total_gz": 12440, + "total_gz": 12442, "sent": [ "a (memory)", "b (exit)", diff --git a/test/codesize/test_small_js_flags.expected.js b/test/codesize/test_small_js_flags.expected.js index 79b86f8cbcd41..62faa15b7f6e7 100644 --- a/test/codesize/test_small_js_flags.expected.js +++ b/test/codesize/test_small_js_flags.expected.js @@ -326,7 +326,7 @@ var printChar = (stream, curr) => { } }; -var _fd_write = (fd, iov, iovcnt, pnum) => { +var _fd_write = (fd, iov, iovcnt, pnum, sync) => { // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0 var num = 0; for (var i = 0; i < iovcnt; i++) { diff --git a/test/other/test_pipe_write_blocking.c b/test/other/test_pipe_write_blocking.c new file mode 100644 index 0000000000000..c89bf58bcdec5 --- /dev/null +++ b/test/other/test_pipe_write_blocking.c @@ -0,0 +1,97 @@ +/* + * Copyright 2026 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#define CAPACITY 65536 +#define TOTAL (1 << 20) +#define BIG (1 << 18) +#define CHUNK 16384 + +int p[2]; +size_t consumed; + +void consume(const unsigned char* buf, ssize_t n) { + for (ssize_t i = 0; i < n; i++) + assert(buf[i] == ((consumed + i) & 0xff)); + consumed += n; +} + +void fill(unsigned char* buf, size_t off, size_t n) { + for (size_t i = 0; i < n; i++) + buf[i] = (off + i) & 0xff; +} + +void write_all(void) { + unsigned char* buf = malloc(BIG); + fill(buf, 0, BIG); + assert(write(p[1], buf, BIG) == BIG); + for (size_t written = BIG; written < TOTAL; written += 4096) { + fill(buf, written, 4096); + assert(write(p[1], buf, 4096) == 4096); + } + free(buf); +} + +void* reader(void* arg) { + unsigned char buf[CHUNK]; + while (consumed < TOTAL) { + ssize_t n = read(p[0], buf, sizeof buf); + if (n < 0) { + assert(errno == EAGAIN); + struct pollfd pfd = {p[0], POLLIN, 0}; + assert(poll(&pfd, 1, -1) == 1); + continue; + } + consume(buf, n); + } + return NULL; +} + +void* closer(void* arg) { + usleep(50 * 1000); + assert(close(p[0]) == 0); + return NULL; +} + +void* run(void* arg) { + assert(pipe(p) == 0); + unsigned char* big = malloc(BIG); + memset(big, 'x', BIG); + pthread_t t; + assert(pthread_create(&t, NULL, closer, NULL) == 0); + assert(write(p[1], big, BIG) == CAPACITY); + assert(write(p[1], big, 1) == -1 && errno == EPIPE); + free(big); + assert(pthread_join(t, NULL) == 0); + assert(close(p[1]) == 0); + + assert(pipe(p) == 0); + assert(pthread_create(&t, NULL, reader, NULL) == 0); + write_all(); + assert(close(p[1]) == 0); + assert(pthread_join(t, NULL) == 0); + assert(consumed == TOTAL); + return NULL; +} + +// The writer is a pthread either way; without PROXY_TO_PTHREAD main blocks in +// pthread_join, servicing proxied calls but never its event loop. +int main() { + pthread_t t; + assert(pthread_create(&t, NULL, run, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + printf("done\n"); + return 0; +} diff --git a/test/test_other.py b/test/test_other.py index 21e718438b0f8..3f19a2f2f56ad 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13801,6 +13801,14 @@ def test_epoll_callback_level(self): # the callback every tick: documents the spin contract (use EPOLLET/unregister). self.do_runf('other/test_epoll_callback_level.c', 'done\n', cflags=['-sFORCE_FILESYSTEM', '-sEXIT_RUNTIME']) + @parameterized({ + '': (['-sPROXY_TO_PTHREAD'],), + 'main_joins': ([],), + }) + def test_pipe_write_blocking(self, args): + self.do_runf('other/test_pipe_write_blocking.c', 'done\n', + cflags=['-pthread', '-sEXIT_RUNTIME'] + args) + def test_epoll_callback_coalesce(self): # A burst of edges before the loop turns (many writes, a run of socket # chunks) schedules one delivery task, not one per edge: on hosts where From 87d8759168518ebec953b36cb6198d1c887cf54a Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 28 Oct 2026 19:14:32 -0700 Subject: [PATCH 3/8] proxying: check the ctx arg before the JS call, which may complete it With a sync-proxied call completed synchronously, the calling thread may have returned by the time run_js_func_with_ctx regains control, and its stack frame holding the arg with it. --- system/lib/pthread/proxying.c | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/system/lib/pthread/proxying.c b/system/lib/pthread/proxying.c index 1342cb2cc924f..0fed68b0d3dc5 100644 --- a/system/lib/pthread/proxying.c +++ b/system/lib/pthread/proxying.c @@ -641,13 +641,12 @@ static void run_js_func(void* arg) { static void run_js_func_with_ctx(em_proxying_ctx* ctx, void* arg) { proxied_js_func_t* f = (proxied_js_func_t*)arg; + // Always synchronously proxied, so arg is the calling thread's stack frame, + // never a copy. The call may complete inside the JS call, after which that + // frame is gone, so nothing reads it afterwards. + assert(!f->owned); _emscripten_receive_on_main_thread_js( f->funcIndex, f->emAsmAddr, f->callingThread, f->bufSize, f->argBuffer, ctx, arg); - - // run_js_func_with_ctx is always synchronously proxied and therefore arg - // should never be owned on the main thread (i.e. the argument here always - // exists on the stack of the calling thread, it's never copied/malloced). - assert(!f->owned); } void _emscripten_run_js_on_main_thread_done(void* ctx, void* arg, double result) { From 38fbc9431c86f949a5798e265d8c4b0083708098 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 29 Oct 2026 10:11:25 -0700 Subject: [PATCH 4/8] Hand the pthread's wait only to the directly proxied call The `sync` argument of an `__async: 'auto'` function and PIPEFS's capacity hold both read PThread.currentProxiedOperationCallerThread, which is set for the whole proxied call, so a blocking write the main thread makes while re-entered from one (MAIN_THREAD_EM_ASM into a function that writes) took the pthread path and returned a SyncDeferred that wasm coerced to 0. A sync-proxied body in a pthread build now keeps `sync` as its own trailing parameter and its two callers bind it: the wasm import entry passes true and the proxied table entry false. The import entry stays a function of the import's exact arity, which the engine's fast import call needs; an extra parameter on the import, defaulted or not, measured 7% slower per write. PIPEFS holds a writer to the capacity when doWritevBlocking marks the stream as able to wait, which also keeps the cap when the main thread is the reader, where the resumed write was previously uncapped. --- ChangeLog.md | 6 ++- src/jsifier.mjs | 50 +++++++++++++++++++------ src/lib/libpipefs.js | 6 +-- src/lib/libwasi.js | 3 ++ test/other/test_pipe_write_blocking.c | 53 ++++++++++++++++++++++++++- tools/emscripten.py | 21 +++++++---- 6 files changed, 113 insertions(+), 26 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 73e8a6a997738..02b34b132f8ed 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -21,8 +21,10 @@ See docs/process.md for more on how version tagging works. 6.0.14 (in development) ----------------------- - Pipes now have a 64 KiB capacity: non-blocking writes to a full pipe fail - with `EAGAIN`, and (JS FS) blocking writes from a pthread wait for the reader. - On the main thread blocking writes remain unbounded. (#27831) + with `EAGAIN`, and (JS FS) blocking writes from a pthread wait for the reader, + as on Linux. A pthread (including `main` under `PROXY_TO_PTHREAD`) that writes + more than 64 KiB before anything reads the pipe now blocks where it previously + completed. On the main thread blocking writes remain unbounded. (#27831) - When compiling and linking in a single command, auxiliary compiler output files (such as `.dwo` files from `-gsplit-dwarf` or `.json` files from `-ftime-trace`) are now named `-.`, matching the behavior diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 65fe05cbccf96..6e6c19d5e7110 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -507,14 +507,23 @@ ${body}; }); } +// Symbols whose body keeps `sync` as its own trailing parameter, bound by its +// two callers: the wasm import entry passes true and the proxied table entry +// false, so only the directly proxied call acts on a pthread's behalf and +// nothing it re-enters on the main thread does. The import entry stays a +// function of the import's exact arity, which the engine's fast import call +// needs. Each symbol maps to its import entry, emitted into wasmImports. +const callerBoundSync = new Set(); +const syncImportWrappers = {}; + // An `__async: 'auto'` body returns a value or a Promise, as it can. A body // whose last parameter is named `sync` receives there whether it must complete // synchronously on this call, as a returned Promise could not be waited for. // That is never the case under ASYNCIFY/JSPI, nor on behalf of a sync-proxied // pthread caller, which awaits it; otherwise it is. function handleSyncArg(symbol, snippet, sig, proxyingMode) { - const sync = ASYNCIFY ? 'false' : - PTHREADS && proxyingMode == 'sync' ? '!PThread.currentProxiedOperationCallerThread' : 'true'; + const fromCaller = !ASYNCIFY && PTHREADS && proxyingMode == 'sync'; + const sync = ASYNCIFY ? 'false' : 'true'; return modifyJSFunction(snippet, (args, body, async_, oneliner) => { const params = args.split(',').map((a) => a.trim()).filter((a) => a); if (params.at(-1) != 'sync') return snippet; @@ -522,6 +531,10 @@ function handleSyncArg(symbol, snippet, sig, proxyingMode) { error(`JS library error: '${symbol}' must take 'sync' as parameter ${sig.length}, after the ${sig.length - 1} of its '__sig'`); return snippet; } + if (fromCaller) { + callerBoundSync.add(symbol); + return snippet; + } if (!oneliner) body = `{\n${body}\n}`; const outer = params.slice(0, -1).join(', '); return `\ @@ -531,26 +544,29 @@ function(${outer}) { }); } -function handleAsyncFunction(snippet, sig, proxied) { +function handleAsyncFunction(symbol, snippet, sig, proxied) { const return64 = sig && (MEMORY64 && sig.startsWith('p') || sig.startsWith('j')) let handleAsync = 'Asyncify.handleAsync(innerFunc)' if (return64 && ASYNCIFY == 1) { handleAsync = makeReturn64(handleAsync); } - // When dispatching on behalf of a proxied caller (PROXY_SYNC_ASYNC), the - // caller awaits the result, so return it directly rather than suspending - // the main thread: a value or SyncDeferred then completes the call - // synchronously, which a Promise wrapper would defer to a microtask. + // On behalf of a proxied caller (PROXY_SYNC_ASYNC) the caller awaits the + // result, so return it directly rather than suspending the main thread: a + // value or SyncDeferred then completes the call synchronously, which a + // Promise wrapper would defer to a microtask. let proxiedDispatch = ''; + let syncArg = ''; if (PTHREADS && proxied) { - proxiedDispatch = 'if (PThread.currentProxiedOperationCallerThread) return innerFunc();\n '; + callerBoundSync.add(symbol); + proxiedDispatch = 'if (!sync) return innerFunc();\n '; + syncArg = 'sync'; } return modifyJSFunction(snippet, (args, body, async_, oneliner) => { if (!oneliner) { body = `{\n${body}\n}`; } return `\ -function(${args}) { +function(${args}${args && syncArg ? ', ' : ''}${syncArg}) { let innerFunc = ${async_} () => ${body}; ${proxiedDispatch}return ${handleAsync}; }\n`; @@ -677,7 +693,7 @@ function(${args}) { } if (ASYNCIFY && isAsyncFunction == 'auto') { - snippet = handleAsyncFunction(snippet, sig, proxyingMode == 'sync'); + snippet = handleAsyncFunction(symbol, snippet, sig, proxyingMode == 'sync'); } if (proxyingMode) { @@ -685,11 +701,20 @@ function(${args}) { error(`JS library error: invalid proxying mode '${symbol}__proxy: ${proxyingMode}' specified`); } if (SHARED_MEMORY && proxyingMode != 'none') { + let tableEntry = mangled; if (PTHREADS) { snippet = modifyJSFunction(snippet, (args, body, async_, oneliner) => { if (oneliner) { body = `return ${body}`; } + let callArgs = args; + if (callerBoundSync.has(symbol)) { + const params = args.split(',').map((a) => a.trim()).filter((a) => a); + callArgs = params.slice(0, -1).join(', '); + const sep = callArgs ? ', ' : ''; + tableEntry = `(${callArgs}) => ${mangled}(${callArgs}${sep}false)`; + syncImportWrappers[symbol] = `(${callArgs}) => ${mangled}(${callArgs}${sep}true)`; + } let proxyMode = PROXY_ASYNC; if (proxyingMode === 'sync') { const isAsyncFunction = LibraryManager.library[symbol + '__async']; @@ -705,7 +730,7 @@ function(${args}) { return ` ${async_}function(${args}) { if (ENVIRONMENT_IS_PTHREAD) - return ${proxyFunc}(${proxiedFunctionTable.length}, 0, ${proxyMode}${args ? ', ' : ''}${args}); + return ${proxyFunc}(${proxiedFunctionTable.length}, 0, ${proxyMode}${callArgs ? ', ' : ''}${callArgs}); ${body} }\n`; }); @@ -721,7 +746,7 @@ function(${args}) { }\n`, ); } - proxiedFunctionTable.push(mangled); + proxiedFunctionTable.push(tableEntry); } } @@ -1185,6 +1210,7 @@ var proxiedFunctionTable = [ nativeAliases, warnings: warningOccured(), asyncFuncs, + syncImportWrappers, libraryDefinitions: LibraryManager.libraryDefinitions, ATPRERUNS: ATPRERUNS.join('\n'), ATMODULES: ATMODULES.join('\n'), diff --git a/src/lib/libpipefs.js b/src/lib/libpipefs.js index 24d651d3ba9e0..2b954ee3688c6 100644 --- a/src/lib/libpipefs.js +++ b/src/lib/libpipefs.js @@ -230,9 +230,9 @@ addToLibrary({ } // A blocking writer is held to the capacity only where fd_write can - // wait for the reader (a pthread's). PIPE_BUF rule: a small write is - // all-or-nothing, a larger one takes what fits. - if ({{{ PTHREADS && !ASYNCIFY ? 'PThread.currentProxiedOperationCallerThread || ' : '' }}}(stream.flags & {{{ cDefs.O_NONBLOCK }}})) { + // wait for the reader, which it marks on the stream. PIPE_BUF rule: a + // small write is all-or-nothing, a larger one takes what fits. + if (stream.canWait || (stream.flags & {{{ cDefs.O_NONBLOCK }}})) { var room = PIPEFS.CAPACITY - PIPEFS.queuedBytes(pipe); if (room <= 0 || (dataLen <= {{{ cDefs.PIPE_BUF }}} && dataLen > room)) { throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); diff --git a/src/lib/libwasi.js b/src/lib/libwasi.js index d4675159bd621..81a343831d5be 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -264,9 +264,11 @@ var WasiLibrary = { function step() { while (len) { var curr; + stream.canWait = true; try { curr = FS.write(stream, buf ?? HEAP8, ptr, len); } catch (e) { + stream.canWait = false; if (e.errno == {{{ cDefs.EAGAIN }}} && !(stream.flags & {{{ cDefs.O_NONBLOCK }}})) { deferred ??= new SyncDeferred(); waitStreamEvents(stream, {{{ cDefs.POLLOUT }}}, step); @@ -276,6 +278,7 @@ var WasiLibrary = { if (deferred) return deferred.reject(e); throw e; } + stream.canWait = false; if (!curr) break; total += curr; ptr += curr; diff --git a/test/other/test_pipe_write_blocking.c b/test/other/test_pipe_write_blocking.c index c89bf58bcdec5..3b436cdfab0c4 100644 --- a/test/other/test_pipe_write_blocking.c +++ b/test/other/test_pipe_write_blocking.c @@ -6,6 +6,7 @@ */ #include +#include #include #include #include @@ -20,7 +21,7 @@ #define CHUNK 16384 int p[2]; -size_t consumed; +_Atomic size_t consumed; void consume(const unsigned char* buf, ssize_t n) { for (ssize_t i = 0; i < n; i++) @@ -65,6 +66,42 @@ void* closer(void* arg) { return NULL; } +// Runs on the main thread, re-entered from a pthread's proxied call: its +// write is the main thread's, unbounded and not a pthread's wait. +EMSCRIPTEN_KEEPALIVE int main_writer(int n) { + unsigned char* buf = malloc(n); + fill(buf, 0, n); + int rtn = write(p[1], buf, n); + free(buf); + return rtn; +} + +void read_back(size_t n) { + unsigned char buf[CHUNK]; + consumed = 0; + while (consumed < n) { + ssize_t got = read(p[0], buf, sizeof buf); + if (got < 0) { + assert(errno == EAGAIN); + usleep(1000); + continue; + } + consume(buf, got); + } +} + +void* big_writer(void* arg) { + unsigned char* buf = malloc(BIG); + fill(buf, 0, BIG); + assert(write(p[1], buf, BIG) == BIG); + // Held to the capacity: the write completes only once the reader has taken + // the rest, whichever thread reads. It completes inside the read that frees + // the last room, before that chunk is counted. + assert(consumed >= BIG - CAPACITY - CHUNK); + free(buf); + return NULL; +} + void* run(void* arg) { assert(pipe(p) == 0); unsigned char* big = malloc(BIG); @@ -83,6 +120,13 @@ void* run(void* arg) { assert(close(p[1]) == 0); assert(pthread_join(t, NULL) == 0); assert(consumed == TOTAL); + assert(close(p[0]) == 0); + + assert(pipe(p) == 0); + assert(MAIN_THREAD_EM_ASM_INT({ return _main_writer($0); }, BIG) == BIG); + read_back(BIG); + assert(close(p[0]) == 0); + assert(close(p[1]) == 0); return NULL; } @@ -92,6 +136,13 @@ int main() { pthread_t t; assert(pthread_create(&t, NULL, run, NULL) == 0); assert(pthread_join(t, NULL) == 0); + + assert(pipe(p) == 0); + assert(pthread_create(&t, NULL, big_writer, NULL) == 0); + read_back(BIG); + assert(pthread_join(t, NULL) == 0); + assert(close(p[0]) == 0); + assert(close(p[1]) == 0); printf("done\n"); return 0; } diff --git a/tools/emscripten.py b/tools/emscripten.py index fbda905a0923f..ad5b6c9455dc8 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -482,7 +482,8 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat parts = [pre] parts += create_module(metadata, function_exports, other_exports, - forwarded_json['librarySymbols'], forwarded_json['nativeAliases']) + forwarded_json['librarySymbols'], forwarded_json['nativeAliases'], + forwarded_json['syncImportWrappers']) parts.append(post) settings.ALIASES = list(forwarded_json['nativeAliases'].keys()) @@ -867,8 +868,9 @@ def add_standard_wasm_imports(send_items_map): send_items_map[s] = s -def create_sending(metadata, library_symbols): - # Map of wasm imports to mangled/external/JS names +def create_sending(metadata, library_symbols, import_wrappers): + # Map of wasm imports to mangled/external/JS names. An entry in + # import_wrappers is sent as that wrapper expression instead. send_items_map = {name: name for name in metadata.invoke_funcs} for name in metadata.imports: @@ -911,8 +913,9 @@ def create_sending(metadata, library_symbols): if not sorted_items: # With no JS->wasm imports emit no `export {}` at all rather than an empty one. return exports - elems = ',\n '.join(f'{v} as {k}' for k, v in sorted_items) - exports += f"export {{\n {elems}\n}};" + elems = ',\n '.join(f'{v} as {k}' for k, v in sorted_items if k not in import_wrappers) + exports += f"export {{\n {elems}\n}};\n" + exports += ''.join(f'export const {k} = {import_wrappers[k]};\n' for k, _ in sorted_items if k in import_wrappers) return exports prefix = '' @@ -923,7 +926,9 @@ def create_sending(metadata, library_symbols): elems = [] for k, v in sorted_items: - if k == v: + if k in import_wrappers: + elems.append(f'{prefix}{k}: {import_wrappers[k]}') + elif k == v: elems.append(f'{prefix}{k}') else: elems.append(f'{prefix}{k}: {v}') @@ -1106,11 +1111,11 @@ def create_receiving(function_exports, other_exports, library_symbols, aliases): return '\n'.join(receiving) -def create_module(metadata, function_exports, other_exports, library_symbols, aliases): +def create_module(metadata, function_exports, other_exports, library_symbols, aliases, import_wrappers): module = [] module.append(create_receiving(function_exports, other_exports, library_symbols, aliases)) - sending = create_sending(metadata, library_symbols) + sending = create_sending(metadata, library_symbols, import_wrappers) if settings.WASM_ESM_INTEGRATION: module.append(sending) elif settings.PTHREADS or settings.WASM_WORKERS or (settings.IMPORTED_MEMORY and settings.MODULARIZE == 'instance'): From 0d7ebd1b9d493c7881429341fdd15acedab30e1d Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 29 Oct 2026 12:19:29 -0700 Subject: [PATCH 5/8] Declare the sync import wrapper beside its function The DCE graph pass takes wasmImports values as identifiers, so the import entry is a named function declaration the jsifier emits next to the library function, and wasmImports sends that name. --- src/jsifier.mjs | 10 +++++++--- tools/emscripten.py | 23 ++++++++++------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 6e6c19d5e7110..49b414469315b 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -512,7 +512,8 @@ ${body}; // false, so only the directly proxied call acts on a pthread's behalf and // nothing it re-enters on the main thread does. The import entry stays a // function of the import's exact arity, which the engine's fast import call -// needs. Each symbol maps to its import entry, emitted into wasmImports. +// needs. Each symbol maps to the import entry declared beside it, which +// wasmImports sends in its place. const callerBoundSync = new Set(); const syncImportWrappers = {}; @@ -713,7 +714,7 @@ function(${args}) { callArgs = params.slice(0, -1).join(', '); const sep = callArgs ? ', ' : ''; tableEntry = `(${callArgs}) => ${mangled}(${callArgs}${sep}false)`; - syncImportWrappers[symbol] = `(${callArgs}) => ${mangled}(${callArgs}${sep}true)`; + syncImportWrappers[symbol] = `function ${mangled}$import(${callArgs}) { return ${mangled}(${callArgs}${sep}true); }`; } let proxyMode = PROXY_ASYNC; if (proxyingMode === 'sync') { @@ -1028,6 +1029,9 @@ function(${args}) { // Handle regular (non-arrow) functions contentText = contentText.replace(/function(?:\s+([^(]+))?\s*\(/, `function ${mangled}(`); } + if (symbol in syncImportWrappers) { + contentText += '\n' + syncImportWrappers[symbol]; + } } else if (typeof snippet === 'string' && snippet.startsWith(';')) { // In JS libraries // foo: ';[code here verbatim]' @@ -1210,7 +1214,7 @@ var proxiedFunctionTable = [ nativeAliases, warnings: warningOccured(), asyncFuncs, - syncImportWrappers, + syncImports: Object.fromEntries(Object.keys(syncImportWrappers).map((s) => [s, `${mangleCSymbolName(s)}$import`])), libraryDefinitions: LibraryManager.libraryDefinitions, ATPRERUNS: ATPRERUNS.join('\n'), ATMODULES: ATMODULES.join('\n'), diff --git a/tools/emscripten.py b/tools/emscripten.py index ad5b6c9455dc8..d4740e445bfa6 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -483,7 +483,7 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat parts = [pre] parts += create_module(metadata, function_exports, other_exports, forwarded_json['librarySymbols'], forwarded_json['nativeAliases'], - forwarded_json['syncImportWrappers']) + forwarded_json['syncImports']) parts.append(post) settings.ALIASES = list(forwarded_json['nativeAliases'].keys()) @@ -868,9 +868,9 @@ def add_standard_wasm_imports(send_items_map): send_items_map[s] = s -def create_sending(metadata, library_symbols, import_wrappers): - # Map of wasm imports to mangled/external/JS names. An entry in - # import_wrappers is sent as that wrapper expression instead. +def create_sending(metadata, library_symbols, sync_imports): + # Map of wasm imports to mangled/external/JS names. A sync_imports entry + # names the import-side wrapper the jsifier declared for that symbol. send_items_map = {name: name for name in metadata.invoke_funcs} for name in metadata.imports: @@ -880,7 +880,7 @@ def create_sending(metadata, library_symbols, import_wrappers): if not settings.WASM_ESM_INTEGRATION: send_items_map[name] = name else: - send_items_map[name] = asmjs_mangle(name) + send_items_map[name] = sync_imports.get(name) or asmjs_mangle(name) add_standard_wasm_imports(send_items_map) @@ -913,9 +913,8 @@ def create_sending(metadata, library_symbols, import_wrappers): if not sorted_items: # With no JS->wasm imports emit no `export {}` at all rather than an empty one. return exports - elems = ',\n '.join(f'{v} as {k}' for k, v in sorted_items if k not in import_wrappers) - exports += f"export {{\n {elems}\n}};\n" - exports += ''.join(f'export const {k} = {import_wrappers[k]};\n' for k, _ in sorted_items if k in import_wrappers) + elems = ',\n '.join(f'{v} as {k}' for k, v in sorted_items) + exports += f"export {{\n {elems}\n}};" return exports prefix = '' @@ -926,9 +925,7 @@ def create_sending(metadata, library_symbols, import_wrappers): elems = [] for k, v in sorted_items: - if k in import_wrappers: - elems.append(f'{prefix}{k}: {import_wrappers[k]}') - elif k == v: + if k == v: elems.append(f'{prefix}{k}') else: elems.append(f'{prefix}{k}: {v}') @@ -1111,11 +1108,11 @@ def create_receiving(function_exports, other_exports, library_symbols, aliases): return '\n'.join(receiving) -def create_module(metadata, function_exports, other_exports, library_symbols, aliases, import_wrappers): +def create_module(metadata, function_exports, other_exports, library_symbols, aliases, sync_imports): module = [] module.append(create_receiving(function_exports, other_exports, library_symbols, aliases)) - sending = create_sending(metadata, library_symbols, import_wrappers) + sending = create_sending(metadata, library_symbols, sync_imports) if settings.WASM_ESM_INTEGRATION: module.append(sending) elif settings.PTHREADS or settings.WASM_WORKERS or (settings.IMPORTED_MEMORY and settings.MODULARIZE == 'instance'): From 566ee9a66cb7b9aa4b44c322fc8089293127fcb4 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 29 Oct 2026 18:53:29 -0700 Subject: [PATCH 6/8] Give the sync-bound import its symbol's own name Under MAIN_MODULE=1 create_sending sends every library function to wasm by its own name, which replaced the import wrapper with the five-parameter body, so `sync` arrived undefined and a main-thread write to a full pipe took the pthread path. The symbol's own name is now the function of the import's exact arity, passing true, and the body is declared beside it as `$sync`, which the proxied table entry calls with false. The import object, dlsym and exports all reach it by its normal name, so the emscripten.py plumbing goes away. --- src/jsifier.mjs | 26 +++++++++++++------------- src/lib/libpthread.js | 7 ++++--- test/other/test_pipe_write_blocking.c | 10 +++++++--- test/test_other.py | 2 ++ tools/emscripten.py | 14 ++++++-------- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 49b414469315b..75af13f4bcfaf 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -508,14 +508,13 @@ ${body}; } // Symbols whose body keeps `sync` as its own trailing parameter, bound by its -// two callers: the wasm import entry passes true and the proxied table entry -// false, so only the directly proxied call acts on a pthread's behalf and -// nothing it re-enters on the main thread does. The import entry stays a -// function of the import's exact arity, which the engine's fast import call -// needs. Each symbol maps to the import entry declared beside it, which -// wasmImports sends in its place. +// two callers. The symbol's own name is a function of the import's exact +// arity (which the engine's fast import call needs) that passes true, and the +// proxied table entry passes false, so only the directly proxied call acts on +// a pthread's behalf and nothing it re-enters on the main thread does. The +// body is declared beside it as `$sync`. const callerBoundSync = new Set(); -const syncImportWrappers = {}; +const syncBodies = {}; // An `__async: 'auto'` body returns a value or a Promise, as it can. A body // whose last parameter is named `sync` receives there whether it must complete @@ -713,8 +712,9 @@ function(${args}) { const params = args.split(',').map((a) => a.trim()).filter((a) => a); callArgs = params.slice(0, -1).join(', '); const sep = callArgs ? ', ' : ''; - tableEntry = `(${callArgs}) => ${mangled}(${callArgs}${sep}false)`; - syncImportWrappers[symbol] = `function ${mangled}$import(${callArgs}) { return ${mangled}(${callArgs}${sep}true); }`; + tableEntry = `(${callArgs}) => ${mangled}$sync(${callArgs}${sep}false)`; + syncBodies[symbol] = `${async_}function ${mangled}$sync(${args}) {\n${body}\n}\n`; + body = `return ${mangled}$sync(${callArgs}${sep}true);`; } let proxyMode = PROXY_ASYNC; if (proxyingMode === 'sync') { @@ -729,7 +729,7 @@ function(${args}) { const proxyFunc = MEMORY64 && rtnType == 'p' ? 'proxyToMainThreadPtr' : 'proxyToMainThread'; return ` -${async_}function(${args}) { +${async_}function(${callArgs}) { if (ENVIRONMENT_IS_PTHREAD) return ${proxyFunc}(${proxiedFunctionTable.length}, 0, ${proxyMode}${callArgs ? ', ' : ''}${callArgs}); ${body} @@ -978,6 +978,7 @@ function(${args}) { snippet = processLibraryFunction(snippet, symbol, mangled, deps, isStub); if (!isStub) { addImplicitDeps(snippet); + if (symbol in syncBodies) addImplicitDeps(syncBodies[symbol]); } } @@ -1029,8 +1030,8 @@ function(${args}) { // Handle regular (non-arrow) functions contentText = contentText.replace(/function(?:\s+([^(]+))?\s*\(/, `function ${mangled}(`); } - if (symbol in syncImportWrappers) { - contentText += '\n' + syncImportWrappers[symbol]; + if (symbol in syncBodies) { + contentText = syncBodies[symbol] + contentText; } } else if (typeof snippet === 'string' && snippet.startsWith(';')) { // In JS libraries @@ -1214,7 +1215,6 @@ var proxiedFunctionTable = [ nativeAliases, warnings: warningOccured(), asyncFuncs, - syncImports: Object.fromEntries(Object.keys(syncImportWrappers).map((s) => [s, `${mangleCSymbolName(s)}$import`])), libraryDefinitions: LibraryManager.libraryDefinitions, ATPRERUNS: ATPRERUNS.join('\n'), ATMODULES: ATMODULES.join('\n'), diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index edbd7b8914642..7668ed3bf1639 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -1079,9 +1079,10 @@ var LibraryPThread = { var coerceRtn = (rtn) => rtn; #endif if (ctx) { - // A plain value finishes the call at once, and a SyncDeferred when its - // wait-queue fires: this thread may be blocked on the caller, never - // reaching a microtask, which Promise.resolve would route either through. + // The main thread may itself be blocked on the caller (e.g. in + // pthread_join), servicing proxied calls but never its event loop, so a + // microtask would never run. Finish a plain value at once, and a + // thenable (a SyncDeferred) when it settles. if (rtn?.then) { rtn.then((rtn) => __emscripten_run_js_on_main_thread_done(ctx, ctxArgs, coerceRtn(rtn))); } else { diff --git a/test/other/test_pipe_write_blocking.c b/test/other/test_pipe_write_blocking.c index 3b436cdfab0c4..3454ef855c483 100644 --- a/test/other/test_pipe_write_blocking.c +++ b/test/other/test_pipe_write_blocking.c @@ -14,6 +14,7 @@ #include #include #include +#include #define CAPACITY 65536 #define TOTAL (1 << 20) @@ -67,13 +68,16 @@ void* closer(void* arg) { } // Runs on the main thread, re-entered from a pthread's proxied call: its -// write is the main thread's, unbounded and not a pthread's wait. +// write is the main thread's, unbounded and not a pthread's wait. Calls the +// import directly so a count it fails to store reads as 0. EMSCRIPTEN_KEEPALIVE int main_writer(int n) { unsigned char* buf = malloc(n); fill(buf, 0, n); - int rtn = write(p[1], buf, n); + __wasi_ciovec_t iov = {buf, n}; + __wasi_size_t num = 0; + assert(__wasi_fd_write(p[1], &iov, 1, &num) == 0); free(buf); - return rtn; + return num; } void read_back(size_t n) { diff --git a/test/test_other.py b/test/test_other.py index 3f19a2f2f56ad..8e0dc34ae4d33 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13804,6 +13804,8 @@ def test_epoll_callback_level(self): @parameterized({ '': (['-sPROXY_TO_PTHREAD'],), 'main_joins': ([],), + # MAIN_MODULE=1 sends every library function to wasm by name as well. + 'main_module': (['-sMAIN_MODULE', '-Wno-experimental'],), }) def test_pipe_write_blocking(self, args): self.do_runf('other/test_pipe_write_blocking.c', 'done\n', diff --git a/tools/emscripten.py b/tools/emscripten.py index d4740e445bfa6..fbda905a0923f 100644 --- a/tools/emscripten.py +++ b/tools/emscripten.py @@ -482,8 +482,7 @@ def emscript(in_wasm, out_wasm, outfile_js, js_syms, finalize=True, base_metadat parts = [pre] parts += create_module(metadata, function_exports, other_exports, - forwarded_json['librarySymbols'], forwarded_json['nativeAliases'], - forwarded_json['syncImports']) + forwarded_json['librarySymbols'], forwarded_json['nativeAliases']) parts.append(post) settings.ALIASES = list(forwarded_json['nativeAliases'].keys()) @@ -868,9 +867,8 @@ def add_standard_wasm_imports(send_items_map): send_items_map[s] = s -def create_sending(metadata, library_symbols, sync_imports): - # Map of wasm imports to mangled/external/JS names. A sync_imports entry - # names the import-side wrapper the jsifier declared for that symbol. +def create_sending(metadata, library_symbols): + # Map of wasm imports to mangled/external/JS names send_items_map = {name: name for name in metadata.invoke_funcs} for name in metadata.imports: @@ -880,7 +878,7 @@ def create_sending(metadata, library_symbols, sync_imports): if not settings.WASM_ESM_INTEGRATION: send_items_map[name] = name else: - send_items_map[name] = sync_imports.get(name) or asmjs_mangle(name) + send_items_map[name] = asmjs_mangle(name) add_standard_wasm_imports(send_items_map) @@ -1108,11 +1106,11 @@ def create_receiving(function_exports, other_exports, library_symbols, aliases): return '\n'.join(receiving) -def create_module(metadata, function_exports, other_exports, library_symbols, aliases, sync_imports): +def create_module(metadata, function_exports, other_exports, library_symbols, aliases): module = [] module.append(create_receiving(function_exports, other_exports, library_symbols, aliases)) - sending = create_sending(metadata, library_symbols, sync_imports) + sending = create_sending(metadata, library_symbols) if settings.WASM_ESM_INTEGRATION: module.append(sending) elif settings.PTHREADS or settings.WASM_WORKERS or (settings.IMPORTED_MEMORY and settings.MODULARIZE == 'instance'): From bcc6b9472912da1d028b587b0a147b840dfb707d Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 29 Oct 2026 18:59:23 -0700 Subject: [PATCH 7/8] Keep fd_write unchanged in builds without the pthread wait fd_write is only `'auto'` under pthreads without ASYNCIFY, so elsewhere its trailing `sync` parameter was emitted as is, and the stream mark check in PIPEFS likewise. Both are now conditional on the same guard, so those builds emit the same fd_write and PIPEFS as before. --- src/lib/libpipefs.js | 2 +- src/lib/libwasi.js | 2 +- test/codesize/test_codesize_file_preload.expected.js | 2 +- test/codesize/test_small_js_flags.expected.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/libpipefs.js b/src/lib/libpipefs.js index 2b954ee3688c6..d19a67299f7e4 100644 --- a/src/lib/libpipefs.js +++ b/src/lib/libpipefs.js @@ -232,7 +232,7 @@ addToLibrary({ // A blocking writer is held to the capacity only where fd_write can // wait for the reader, which it marks on the stream. PIPE_BUF rule: a // small write is all-or-nothing, a larger one takes what fits. - if (stream.canWait || (stream.flags & {{{ cDefs.O_NONBLOCK }}})) { + if ({{{ PTHREADS && !ASYNCIFY ? 'stream.canWait || ' : '' }}}(stream.flags & {{{ cDefs.O_NONBLOCK }}})) { var room = PIPEFS.CAPACITY - PIPEFS.queuedBytes(pipe); if (room <= 0 || (dataLen <= {{{ cDefs.PIPE_BUF }}} && dataLen > room)) { throw new FS.ErrnoError({{{ cDefs.EAGAIN }}}); diff --git a/src/lib/libwasi.js b/src/lib/libwasi.js index 81a343831d5be..659615e593e90 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -329,7 +329,7 @@ var WasiLibrary = { fd_write__postset: () => addAtExit('flush_NO_FILESYSTEM()'), #else #endif - fd_write: (fd, iov, iovcnt, pnum, sync) => { + fd_write: (fd, iov, iovcnt, pnum{{{ SYSCALLS_REQUIRE_FILESYSTEM && PTHREADS && !ASYNCIFY ? ', sync' : '' }}}) => { #if SYSCALLS_REQUIRE_FILESYSTEM var stream = SYSCALLS.getStreamFromFD(fd); #if PTHREADS && !ASYNCIFY diff --git a/test/codesize/test_codesize_file_preload.expected.js b/test/codesize/test_codesize_file_preload.expected.js index adfd934eaacfc..38c676696afdf 100644 --- a/test/codesize/test_codesize_file_preload.expected.js +++ b/test/codesize/test_codesize_file_preload.expected.js @@ -3068,7 +3068,7 @@ var SYSCALLS = { return FS.write(stream, view, 0, total, offset); }; -function _fd_write(fd, iov, iovcnt, pnum, sync) { +function _fd_write(fd, iov, iovcnt, pnum) { try { var stream = SYSCALLS.getStreamFromFD(fd); var num = doWritev(stream, iov, iovcnt); diff --git a/test/codesize/test_small_js_flags.expected.js b/test/codesize/test_small_js_flags.expected.js index 62faa15b7f6e7..79b86f8cbcd41 100644 --- a/test/codesize/test_small_js_flags.expected.js +++ b/test/codesize/test_small_js_flags.expected.js @@ -326,7 +326,7 @@ var printChar = (stream, curr) => { } }; -var _fd_write = (fd, iov, iovcnt, pnum, sync) => { +var _fd_write = (fd, iov, iovcnt, pnum) => { // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0 var num = 0; for (var i = 0; i < iovcnt; i++) { From 13d5929f5c4d43ef351d4caadca57b783e2312eb Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 29 Oct 2026 19:02:56 -0700 Subject: [PATCH 8/8] Tighten comments --- src/jsifier.mjs | 15 +++++---------- src/lib/libepoll.js | 7 ++----- src/lib/libpipefs.js | 5 ++--- src/lib/libpthread.js | 6 ++---- src/lib/libsyscall.js | 16 +++++----------- src/lib/libwasi.js | 8 +++----- system/lib/pthread/proxying.c | 5 ++--- test/other/test_pipe_write_blocking.c | 13 +++++-------- test/test_other.py | 2 +- 9 files changed, 27 insertions(+), 50 deletions(-) diff --git a/src/jsifier.mjs b/src/jsifier.mjs index 75af13f4bcfaf..f8263bf858090 100644 --- a/src/jsifier.mjs +++ b/src/jsifier.mjs @@ -507,12 +507,9 @@ ${body}; }); } -// Symbols whose body keeps `sync` as its own trailing parameter, bound by its -// two callers. The symbol's own name is a function of the import's exact -// arity (which the engine's fast import call needs) that passes true, and the -// proxied table entry passes false, so only the directly proxied call acts on -// a pthread's behalf and nothing it re-enters on the main thread does. The -// body is declared beside it as `$sync`. +// A sync-proxied body taking `sync` becomes `$sync`. The symbol keeps +// the import's arity and passes true, and only the proxied table entry passes +// false, so nothing the call re-enters on the main thread sees the pthread's. const callerBoundSync = new Set(); const syncBodies = {}; @@ -550,10 +547,8 @@ function handleAsyncFunction(symbol, snippet, sig, proxied) { if (return64 && ASYNCIFY == 1) { handleAsync = makeReturn64(handleAsync); } - // On behalf of a proxied caller (PROXY_SYNC_ASYNC) the caller awaits the - // result, so return it directly rather than suspending the main thread: a - // value or SyncDeferred then completes the call synchronously, which a - // Promise wrapper would defer to a microtask. + // A proxied caller awaits the result itself, and a Promise wrapper would + // defer a synchronous completion to a microtask, so return it directly. let proxiedDispatch = ''; let syncArg = ''; if (PTHREADS && proxied) { diff --git a/src/lib/libepoll.js b/src/lib/libepoll.js index 5726b3a789a28..85e2531491388 100644 --- a/src/lib/libepoll.js +++ b/src/lib/libepoll.js @@ -302,11 +302,8 @@ var EpollLibrary = { $epollPwait__internal: true, $epollPwait: (ep, ev, maxevents, timeout, sync) => { #if PTHREADS || ASYNCIFY - // Block on the epoll's own readiness - each registration's persistent - // listener wakes ep.node on a leaf edge - and re-derive on wake, settling - // the count or 0 after `timeout`. A SyncDeferred rather than a Promise: - // the wake may be another pthread's proxied call while the main thread - // never turns its event loop. + // Re-derives readiness on each wake of ep.node and settles a SyncDeferred + // with the count, or with 0 once `timeout` passes. if (!sync) { var deferred = new SyncDeferred(); var n = doEpollWait(ep, ev, maxevents); diff --git a/src/lib/libpipefs.js b/src/lib/libpipefs.js index d19a67299f7e4..766f5d5ad658e 100644 --- a/src/lib/libpipefs.js +++ b/src/lib/libpipefs.js @@ -229,9 +229,8 @@ addToLibrary({ throw new FS.ErrnoError({{{ cDefs.EPIPE }}}); } - // A blocking writer is held to the capacity only where fd_write can - // wait for the reader, which it marks on the stream. PIPE_BUF rule: a - // small write is all-or-nothing, a larger one takes what fits. + // A blocking writer is capped only when fd_write marks it able to + // wait. A write up to PIPE_BUF is all-or-nothing, a larger one partial. if ({{{ PTHREADS && !ASYNCIFY ? 'stream.canWait || ' : '' }}}(stream.flags & {{{ cDefs.O_NONBLOCK }}})) { var room = PIPEFS.CAPACITY - PIPEFS.queuedBytes(pipe); if (room <= 0 || (dataLen <= {{{ cDefs.PIPE_BUF }}} && dataLen > room)) { diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index 7668ed3bf1639..a407f12c9b78a 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -1079,10 +1079,8 @@ var LibraryPThread = { var coerceRtn = (rtn) => rtn; #endif if (ctx) { - // The main thread may itself be blocked on the caller (e.g. in - // pthread_join), servicing proxied calls but never its event loop, so a - // microtask would never run. Finish a plain value at once, and a - // thenable (a SyncDeferred) when it settles. + // The main thread may itself be blocked on the caller and never reach a + // microtask, so finish a value at once and a thenable when it settles. if (rtn?.then) { rtn.then((rtn) => __emscripten_run_js_on_main_thread_done(ctx, ctxArgs, coerceRtn(rtn))); } else { diff --git a/src/lib/libsyscall.js b/src/lib/libsyscall.js index 017e0c8317bc2..6256492f204e0 100644 --- a/src/lib/libsyscall.js +++ b/src/lib/libsyscall.js @@ -628,9 +628,7 @@ var SyscallsLibrary = { // ready and `timeout` is non-zero, register one waiter per fd on its node // wait-queue and re-derive the whole set on any wake (the wake flags are just // the trigger), resolving then or, for a positive `timeout`, once it elapses. - // A negative `timeout` waits forever. Returns a SyncDeferred of the ready - // count, since the wake may come from another pthread's proxied call while - // the main thread never turns its event loop. + // A negative `timeout` waits forever. Settles a SyncDeferred with the count. $doPollAsync__internal: true, $doPollAsync: (fds, nfds, timeout) => { var deferred = new SyncDeferred(); @@ -672,8 +670,7 @@ var SyscallsLibrary = { } return deferred; }, - // Calls `cb` once poll() reports one of `events` on the stream, synchronously - // from the producer's notify. + // Calls `cb` synchronously from the notify that makes `events` ready. $waitStreamEvents__internal: true, $waitStreamEvents: (stream, events, cb) => { var reg = stream.node.addListener(() => { @@ -683,12 +680,9 @@ var SyscallsLibrary = { }); }, - // A result settled later but delivered synchronously, for a sync-proxied - // call that waits on a wait-queue. The pthread sits on a futex, and the - // main thread may be spinning in a blocking call of its own, servicing - // proxied calls but never turning its event loop, so a Promise's microtask - // could never reach it. Promise-shaped (then/resolve/reject, chaining) so - // the proxying completion takes either. + // A Promise-shaped result that settles synchronously. The main thread may be + // blocked servicing proxied calls without turning its event loop, where a + // Promise's microtask would never reach the waiting pthread. $SyncDeferred__internal: true, $SyncDeferred: class SyncDeferred { constructor() { diff --git a/src/lib/libwasi.js b/src/lib/libwasi.js index 659615e593e90..976a9f6cd845d 100644 --- a/src/lib/libwasi.js +++ b/src/lib/libwasi.js @@ -238,8 +238,7 @@ var WasiLibrary = { return FS.write(stream, view, 0, total, offset); }, #if PTHREADS && !ASYNCIFY - // writev that waits where a blocking stream would block (a pthread awaits - // the proxied call), returning a SyncDeferred once it had to. + // writev that waits on a blocking stream, giving a SyncDeferred if it must. $doWritevBlocking: (stream, iov, iovcnt) => { var buf, ptr, len; if (iovcnt == 1) { @@ -311,9 +310,8 @@ var WasiLibrary = { #if SYSCALLS_REQUIRE_FILESYSTEM #if PTHREADS && !ASYNCIFY - // Waits where a blocking stream would block when proxied from a pthread - // (`sync` false). Not under ASYNCIFY, where 'auto' would make the import - // itself suspend and every printf from a callback cannot afford that. + // Not under ASYNCIFY, where 'auto' would make the import itself suspend, + // which a printf from a callback cannot afford. fd_write__async: 'auto', #endif #elif (!MINIMAL_RUNTIME || EXIT_RUNTIME) diff --git a/system/lib/pthread/proxying.c b/system/lib/pthread/proxying.c index 0fed68b0d3dc5..d655650c0dd4d 100644 --- a/system/lib/pthread/proxying.c +++ b/system/lib/pthread/proxying.c @@ -641,9 +641,8 @@ static void run_js_func(void* arg) { static void run_js_func_with_ctx(em_proxying_ctx* ctx, void* arg) { proxied_js_func_t* f = (proxied_js_func_t*)arg; - // Always synchronously proxied, so arg is the calling thread's stack frame, - // never a copy. The call may complete inside the JS call, after which that - // frame is gone, so nothing reads it afterwards. + // arg is the caller's stack frame, which is gone once the JS call completes + // the proxy, so it is checked first. assert(!f->owned); _emscripten_receive_on_main_thread_js( f->funcIndex, f->emAsmAddr, f->callingThread, f->bufSize, f->argBuffer, ctx, arg); diff --git a/test/other/test_pipe_write_blocking.c b/test/other/test_pipe_write_blocking.c index 3454ef855c483..52dc81035e19c 100644 --- a/test/other/test_pipe_write_blocking.c +++ b/test/other/test_pipe_write_blocking.c @@ -67,9 +67,8 @@ void* closer(void* arg) { return NULL; } -// Runs on the main thread, re-entered from a pthread's proxied call: its -// write is the main thread's, unbounded and not a pthread's wait. Calls the -// import directly so a count it fails to store reads as 0. +// The main thread's own write, re-entered from a proxied call, is not held. +// Calls the import directly so an unstored count reads as 0. EMSCRIPTEN_KEEPALIVE int main_writer(int n) { unsigned char* buf = malloc(n); fill(buf, 0, n); @@ -98,9 +97,8 @@ void* big_writer(void* arg) { unsigned char* buf = malloc(BIG); fill(buf, 0, BIG); assert(write(p[1], buf, BIG) == BIG); - // Held to the capacity: the write completes only once the reader has taken - // the rest, whichever thread reads. It completes inside the read that frees - // the last room, before that chunk is counted. + // Held whichever thread reads. It completes inside the read that frees the + // last room, before that chunk is counted. assert(consumed >= BIG - CAPACITY - CHUNK); free(buf); return NULL; @@ -134,8 +132,7 @@ void* run(void* arg) { return NULL; } -// The writer is a pthread either way; without PROXY_TO_PTHREAD main blocks in -// pthread_join, servicing proxied calls but never its event loop. +// Without PROXY_TO_PTHREAD main waits in pthread_join, off its event loop. int main() { pthread_t t; assert(pthread_create(&t, NULL, run, NULL) == 0); diff --git a/test/test_other.py b/test/test_other.py index 8e0dc34ae4d33..8a97ce470ff21 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -13804,7 +13804,7 @@ def test_epoll_callback_level(self): @parameterized({ '': (['-sPROXY_TO_PTHREAD'],), 'main_joins': ([],), - # MAIN_MODULE=1 sends every library function to wasm by name as well. + # MAIN_MODULE=1 also sends library functions to wasm by name. 'main_module': (['-sMAIN_MODULE', '-Wno-experimental'],), }) def test_pipe_write_blocking(self, args):