Repository navigation
Blocking pipe writes from pthreads wait for the reader - #28083
guybedford wants to merge 8 commits into
Conversation
b9a888e to
3dfc093
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces a 64 KiB capacity limit for pipes, aligning Emscripten's pipe behavior with Linux. It implements non-blocking write failures with EAGAIN when a pipe is full, and blocking write support for pthreads. The changes span JS library files (libpipefs.js, libpthread.js, libsyscall.js, and libwasi.js) and WasmFS C++ files, along with new test cases. Feedback on the changes highlights a bug in __syscall_poll where O_RDWR pipes are not polled correctly for both reading and writing. Additionally, using instanceof Promise in libpthread.js and libwasi.js should be replaced with a more robust thenable check to avoid cross-realm issues.
c1f9a48 to
05d2c4a
Compare
05d2c4a to
fd1b763
Compare
|
Tried this branch (fd1b763) against main (05afea5) on node 24 with the 6.0.13 LLVM. With plain // emcc -pthread -sEXIT_RUNTIME join_writer.c
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define TOTAL (256 * 1024)
int p[2];
void* writer(void* arg) {
static char buf[TOTAL];
memset(buf, 'x', sizeof buf);
write(p[1], buf, sizeof buf);
close(p[1]);
return NULL;
}
void* reader(void* arg) {
char buf[4096];
size_t got = 0;
while (got < TOTAL) {
ssize_t n = read(p[0], buf, sizeof buf);
if (n > 0) got += n;
else usleep(1000);
}
return NULL;
}
int main() {
pipe(p);
pthread_t w, r;
pthread_create(&r, NULL, reader, NULL);
pthread_create(&w, NULL, writer, NULL);
pthread_join(w, NULL);
pthread_join(r, NULL);
printf("done\n");
}On main it prints and never reaches the Separately, the ChangeLog line reads as if it applies everywhere, but WasmFS blocking writes are still unbounded (the same program under A direction that avoids the event loop entirely (I haven't prototyped it): let the blocked pthread do the waiting rather than the main thread. When the pipe is full, |
|
Thanks for the feedback on this @PedroRosalba I'm pushing a change now to support the non-proxied pthread wait scenario. |
fd1b763 to
52df7b4
Compare
|
Thanks, the plain One regression against main: a blocking #include <emscripten.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define N 100000
int p[2];
EMSCRIPTEN_KEEPALIVE int main_writer(int n) {
static char buf[N];
memset(buf, 'x', sizeof buf);
return write(p[1], buf, n);
}
void* thr(void* arg) {
printf("write returned %d\n", MAIN_THREAD_EM_ASM_INT({ return _main_writer($0); }, N));
return NULL;
}
int main() {
pipe(p);
pthread_t t;
pthread_create(&t, NULL, thr, NULL);
pthread_join(t, NULL);
}Built with The cause: the One option is for the proxying path to hand the "on behalf of a pthread" bit to the function it calls, and to nothing that function calls in turn. Do you see a reason the global has to stay visible to nested calls? Two tradeoffs that may be worth a line in the ChangeLog or the PR body:
Cost: a hello world with AI-generated with Claude, reviewed by me before posting. |
5608101 to
db8c1c6
Compare
|
Thanks, all four points were right. Nested write. Reproduced and fixed. Both reads of The import entry stays a function of the import's exact arity rather than taking Deadlock. Documented in the ChangeLog and PR body, including that Cap when main reads. Fixed by the same stream mark, since the resumed write sets it regardless of which thread's read woke it. Added a test where the main thread drains a pthread's 256 KiB write and the write is checked to complete only once all but a capacity's worth was read. Size. Confirmed at +1915 B for that combination. The bulk is AI generated under my review |
| // 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 |
There was a problem hiding this comment.
What is "this thread" you are referring to?
This function (_emscripten_receive_on_main_thread_js) can only ever run on the main thread right?
There was a problem hiding this comment.
Yeah it's the main thread, so if the main thread is itself blocked on the calling thread it can still be handling proxied calls just not suspending to the event loop and a microtask can't run.
There was a problem hiding this comment.
(comment updated)
| // 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); |
There was a problem hiding this comment.
Is there a reason to move this assertion up like this? Maybe this can be split out and landed separately?
There was a problem hiding this comment.
This PR makes a proxied call able to complete synchronously which wakes the caller and it can return before we get back here. f->owned becomes a use-after-return, which was caught by the lsam wasm64 job. While this case doesn't affect the main branch, it is very much associated with this PR change.
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.
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.
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.
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.
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.
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 `<name>$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.
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.
66c3881 to
bcc6b94
Compare
Holds a blocking pipe writer to the pipe's capacity where it can wait for the reader: from a pthread, on the JS FS. Builds on #28082.
With #28082 a pipe holds 64 KiB and a non-blocking writer gets
EAGAINwhen it is full, but a blocking writer is still let through without bound, sincefd_writehas no way to wait. A pthread's write is proxied to the main thread and the pthread sits on a futex until the call completes, so on a pthread's behalffd_writeholds the call open where the stream would block, as a blocking write does on Linux:PIPE_BUFall-or-nothing rule) whenfd_writecan wait for the reader, which it marks on the stream around each write it issues, as it already does for a non-blocking writer.fd_writebecomes an__async: 'auto'function taking thesyncargument of Passsyncto__async: 'auto'library functions #28022. When proxied it writes throughdoWritevBlocking: onEAGAINfrom a blocking stream it parks on the pipe's wait-queue forPOLLOUTand continues once the reader frees room, completing the whole request. It stays synchronous when nothing blocks, so ordinary pthread writes are unchanged. An error after some data went gives the short count, and recurs on the next call, so a writer blocked on a pipe whose reader closes gets the count that fit, thenEPIPE.The wait completes synchronously rather than through a Promise. The room is freed by the reader's own proxied
read, which the main thread services even while it is itself blocked (main()inpthread_join, or a mutex, orsleep) and never returning to its event loop, so a microtask could never deliver the result and the writer would hang. Waits instead hand back aSyncDeferred, a thenable whosethenfires on settle: the proxy completion inlibpthread.jscalls it directly, delivering a plain value or a settled deferred on the spot, while a real Promise still defers. Where the stack suspends under ASYNCIFY/JSPI the result isawaited, which adopts the thenable, so the one return serves every waiting stack.poll()andepoll_wait()move to the same form, since a pipe wakes them from the same source; a blockingpoll()on a pipe from a pthread hung in exactly the same way. Under JSPI the main-thread wrapper of an'auto'body now also dispatches a proxied call directly instead of through the async helper, as it already did under ASYNCIFY, so the result is not re-wrapped in a Promise.The
syncargument is bound by the caller, not read fromPThread.currentProxiedOperationCallerThread. That global is set for the whole proxied call, including any wasm the call re-enters, so a blocking write the main thread made inside aMAIN_THREAD_EM_ASMfrom a pthread looked like the pthread's own. A sync-proxied'auto'body in a pthread build now keepssyncas its own trailing parameter, declared as<name>$sync, and its two callers bind it: the symbol's own function, which is what wasm imports, passestrue, and the proxied table entry passesfalse. So only the directly proxied call acts on the pthread's behalf.Since the symbol keeps its own name and the import's arity, the import object,
MAIN_MODULE's by-name sends and exports all reach it unchanged. This shape measured at parity with main perwrite(), where giving the import itself an extra parameter, defaulted or not, measured about 7% slower. Only functions with a trailingsyncunder__proxy: 'sync'in a pthread build take this form; every other function is emitted as before.fd_writeis'auto'only under pthreads without ASYNCIFY, as'auto'would otherwise make the import itself suspend, which everyprintffrom a callback cannot afford under JSPI. So on the main thread, and in ASYNCIFY builds, a blocking write to a full pipe remains unbounded for now; WasmFS is unchanged. Builds without pthreads, and ASYNCIFY builds, emit the samefd_writeand PIPEFS as before. A pthread (includingmain()underPROXY_TO_PTHREAD) that writes more than 64 KiB to a pipe nothing reads until after it is joined now blocks, as on Linux, where it previously completed; the ChangeLog notes this.Tests:
other.test_pipe_write_blockingstreams 1 MiB through a 64 KiB pipe with a slow reader thread blocking inpoll(), including a single 256 KiB write, and checks a writer blocked on a full pipe getsEPIPEonce the reader closes. It also writes 256 KiB from the main thread inside aMAIN_THREAD_EM_ASMissued by a pthread, expecting the full count at once, and has the main thread drain a pthread's 256 KiB write, checking the write completed only once all but a capacity's worth had been read. The scenario runs on a pthread thatmain()joins, underPROXY_TO_PTHREAD, as a plain-pthreadbuild wheremain()spins inpthread_joinon the main thread, and underMAIN_MODULE.Made with AI assistance under my review