From 090fc2a77c0e4739206c8ecfa64d3cbc1b96aac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 22 Oct 2026 15:40:38 +0200 Subject: [PATCH 1/6] Release the task queue lock while running cancel handlers em_task_queue_cancel ran cancel handlers with the queue lock held. A handler can block, and a blocked thread processes its dlopen queue, which can be the queue being cancelled: a thread exiting during dlopen() then deadlocks on its own lock. em_task_queue_execute already unlocks around tasks. See #18887. --- system/lib/pthread/em_task_queue.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/system/lib/pthread/em_task_queue.c b/system/lib/pthread/em_task_queue.c index d9c72f5384162..695205499af88 100644 --- a/system/lib/pthread/em_task_queue.c +++ b/system/lib/pthread/em_task_queue.c @@ -188,7 +188,9 @@ void em_task_queue_cancel(em_task_queue* queue) { while (!em_task_queue_is_empty(queue)) { task t = em_task_queue_dequeue(queue); if (t.cancel) { + pthread_mutex_unlock(&queue->mutex); t.cancel(t.arg); + pthread_mutex_lock(&queue->mutex); } } pthread_mutex_unlock(&queue->mutex); From 593ffd711e22de72153091796d250b1c87f9f21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 22 Oct 2026 20:48:54 +0200 Subject: [PATCH 2/6] Guard task queue cancellation against reentrant execution --- system/lib/pthread/em_task_queue.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/system/lib/pthread/em_task_queue.c b/system/lib/pthread/em_task_queue.c index 695205499af88..a4d8fce852e60 100644 --- a/system/lib/pthread/em_task_queue.c +++ b/system/lib/pthread/em_task_queue.c @@ -184,6 +184,7 @@ void em_task_queue_execute(em_task_queue* queue) { } void em_task_queue_cancel(em_task_queue* queue) { + queue->processing = 1; pthread_mutex_lock(&queue->mutex); while (!em_task_queue_is_empty(queue)) { task t = em_task_queue_dequeue(queue); @@ -200,6 +201,7 @@ void em_task_queue_cancel(em_task_queue* queue) { // proxying queue and mailbox cases, there are no more outstanding references // to the queue after thread shutdown. queue->notification = NOTIFICATION_NONE; + queue->processing = 0; } bool em_task_queue_enqueue(em_task_queue* queue, task t) { From 454e51713da7793dc2c67fffb4fffbf7043600b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 22 Oct 2026 22:47:04 +0200 Subject: [PATCH 3/6] Clear task queue notification after cancellation cleanup --- system/lib/pthread/em_task_queue.c | 5 +- test/core/pthread/test_pthread_dlopen_exit.c | 46 +++++++++++++++++++ .../pthread/test_pthread_dlopen_exit_side.c | 3 ++ test/test_core.py | 17 +++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 test/core/pthread/test_pthread_dlopen_exit.c create mode 100644 test/core/pthread/test_pthread_dlopen_exit_side.c diff --git a/system/lib/pthread/em_task_queue.c b/system/lib/pthread/em_task_queue.c index a4d8fce852e60..3a15d36c5b6e2 100644 --- a/system/lib/pthread/em_task_queue.c +++ b/system/lib/pthread/em_task_queue.c @@ -195,13 +195,14 @@ void em_task_queue_cancel(em_task_queue* queue) { } } pthread_mutex_unlock(&queue->mutex); + queue->processing = 0; // Any subsequent messages to this queue (for example if a pthread struct is // reused for a future thread, potentially on a different worker) will require // a new notification. Clearing the flag is safe here because in both the // proxying queue and mailbox cases, there are no more outstanding references - // to the queue after thread shutdown. + // to the queue after thread shutdown. This must be the last access to the + // queue because cull_zombies may free it once the flag is cleared. queue->notification = NOTIFICATION_NONE; - queue->processing = 0; } bool em_task_queue_enqueue(em_task_queue* queue, task t) { diff --git a/test/core/pthread/test_pthread_dlopen_exit.c b/test/core/pthread/test_pthread_dlopen_exit.c new file mode 100644 index 0000000000000..37540afe1ff3b --- /dev/null +++ b/test/core/pthread/test_pthread_dlopen_exit.c @@ -0,0 +1,46 @@ +#include +#include +#include +#include + +static pthread_barrier_t barrier; + +static void* exiting_worker(void* arg) { + pthread_barrier_wait(&barrier); + // Race thread shutdown against dynamic link synchronization. + return NULL; +} + +static void* loader_thread(void* arg) { + for (int i = 0; i < NUM_ITERATIONS; ++i) { + char filename[32]; + snprintf(filename, sizeof(filename), "libside%d.so", i); + // Pair with every exiting worker, not just the first one. + pthread_barrier_wait(&barrier); + void* handle = dlopen(filename, RTLD_NOW | RTLD_GLOBAL); + assert(handle && "dlopen failed"); + int (*side_func)(void) = dlsym(handle, "side_func"); + assert(side_func); + assert(side_func() == 42); + assert(dlclose(handle) == 0); + } + return NULL; +} + +int main() { + assert(pthread_barrier_init(&barrier, NULL, 2) == 0); + + pthread_t loader; + assert(pthread_create(&loader, NULL, loader_thread, NULL) == 0); + + for (int i = 0; i < NUM_ITERATIONS; ++i) { + pthread_t worker; + assert(pthread_create(&worker, NULL, exiting_worker, NULL) == 0); + assert(pthread_join(worker, NULL) == 0); + } + + assert(pthread_join(loader, NULL) == 0); + assert(pthread_barrier_destroy(&barrier) == 0); + puts("PASSED"); + return 0; +} diff --git a/test/core/pthread/test_pthread_dlopen_exit_side.c b/test/core/pthread/test_pthread_dlopen_exit_side.c new file mode 100644 index 0000000000000..3ee9da24d9bbf --- /dev/null +++ b/test/core/pthread/test_pthread_dlopen_exit_side.c @@ -0,0 +1,3 @@ +int side_func(void) { + return 42; +} diff --git a/test/test_core.py b/test/test_core.py index b4810f185a707..faae8ccbb5f9d 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9394,6 +9394,23 @@ def test_pthread_dlopen(self, args): ['side module ctor', 'done\n', 'side module atexit'], assert_all=True, cflags=args) + @needs_dylink + @requires_pthreads + def test_pthread_dlopen_exit(self): + iterations = 100 + self.cflags += ['-Wno-experimental', '-pthread'] + self.build_dlfcn_lib(test_file('core/pthread/test_pthread_dlopen_exit_side.c')) + # dlclose does not unload libraries, so use a fresh name each time to + # generate dynamic link synchronization work on every iteration. + for i in range(iterations): + shutil.copy('libside.so', f'libside{i}.so') + + self.prep_dlfcn_main() + self.set_setting('EXIT_RUNTIME') + self.set_setting('PROXY_TO_PTHREAD') + self.do_runf('core/pthread/test_pthread_dlopen_exit.c', 'PASSED', + cflags=[f'-DNUM_ITERATIONS={iterations}']) + @needs_dylink @requires_pthreads @flaky('https://github.com/emscripten-core/emscripten/issues/18887') From 634f8f0a9f8e72015d085dd78925a9cc9be2518b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Mon, 2 Nov 2026 21:50:41 +0100 Subject: [PATCH 4/6] Update codesize expectations for task queue cancel changes Co-Authored-By: Claude Opus 5.5 --- test/codesize/test_codesize_minimal_pthreads.json | 8 ++++---- .../test_codesize_minimal_pthreads_memgrowth.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/codesize/test_codesize_minimal_pthreads.json b/test/codesize/test_codesize_minimal_pthreads.json index f6fde17b4d3be..67fef0d162398 100644 --- a/test/codesize/test_codesize_minimal_pthreads.json +++ b/test/codesize/test_codesize_minimal_pthreads.json @@ -1,10 +1,10 @@ { "a.out.js": 6861, "a.out.js.gz": 3403, - "a.out.nodebug.wasm": 19139, - "a.out.nodebug.wasm.gz": 8819, - "total": 26000, - "total_gz": 12222, + "a.out.nodebug.wasm": 19162, + "a.out.nodebug.wasm.gz": 8825, + "total": 26023, + "total_gz": 12228, "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 7f2c442ab8633..ad3806ebbb245 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": 7319, "a.out.js.gz": 3625, - "a.out.nodebug.wasm": 19140, - "a.out.nodebug.wasm.gz": 8820, - "total": 26459, - "total_gz": 12445, + "a.out.nodebug.wasm": 19163, + "a.out.nodebug.wasm.gz": 8828, + "total": 26482, + "total_gz": 12453, "sent": [ "a (memory)", "b (exit)", From bb24a12b3497c350590be1aa1a83db75ed00de7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Fri, 6 Nov 2026 10:29:12 +0000 Subject: [PATCH 5/6] Address review feedback on test_pthread_dlopen_exit - Print `done` to signal success, following #26926. - Rename NUM_ITERATIONS to NUM_SIDE_MODULES (and `iterations` to `num_libs` in the runner) since it is the number of libsideN.so copies, and document why the worker and loader loop counts must match. - Pass EXIT_RUNTIME and PROXY_TO_PTHREAD via `cflags=`. - Drop top-level `static` keywords, following #27873. Co-Authored-By: Claude Opus 5.5 --- test/core/pthread/test_pthread_dlopen_exit.c | 20 +++++++++++++------- test/test_core.py | 10 ++++------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/test/core/pthread/test_pthread_dlopen_exit.c b/test/core/pthread/test_pthread_dlopen_exit.c index 37540afe1ff3b..ca6f1169b3704 100644 --- a/test/core/pthread/test_pthread_dlopen_exit.c +++ b/test/core/pthread/test_pthread_dlopen_exit.c @@ -3,19 +3,23 @@ #include #include -static pthread_barrier_t barrier; +// NUM_SIDE_MODULES is passed on the command line and matches the number of +// libsideN.so copies created by the test runner. Each one is loaded while a +// worker thread is exiting, so it is also the number of worker threads. -static void* exiting_worker(void* arg) { +pthread_barrier_t barrier; + +void* exiting_worker(void* arg) { pthread_barrier_wait(&barrier); // Race thread shutdown against dynamic link synchronization. return NULL; } -static void* loader_thread(void* arg) { - for (int i = 0; i < NUM_ITERATIONS; ++i) { +void* loader_thread(void* arg) { + for (int i = 0; i < NUM_SIDE_MODULES; ++i) { char filename[32]; snprintf(filename, sizeof(filename), "libside%d.so", i); - // Pair with every exiting worker, not just the first one. + // Pair each dlopen with the exit of one worker thread. pthread_barrier_wait(&barrier); void* handle = dlopen(filename, RTLD_NOW | RTLD_GLOBAL); assert(handle && "dlopen failed"); @@ -33,7 +37,9 @@ int main() { pthread_t loader; assert(pthread_create(&loader, NULL, loader_thread, NULL) == 0); - for (int i = 0; i < NUM_ITERATIONS; ++i) { + // One worker per side module: the barrier pairs each worker with one + // iteration of the loader thread, so the two loop counts must match. + for (int i = 0; i < NUM_SIDE_MODULES; ++i) { pthread_t worker; assert(pthread_create(&worker, NULL, exiting_worker, NULL) == 0); assert(pthread_join(worker, NULL) == 0); @@ -41,6 +47,6 @@ int main() { assert(pthread_join(loader, NULL) == 0); assert(pthread_barrier_destroy(&barrier) == 0); - puts("PASSED"); + printf("done\n"); return 0; } diff --git a/test/test_core.py b/test/test_core.py index faae8ccbb5f9d..3045a4c49f1a6 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9397,19 +9397,17 @@ def test_pthread_dlopen(self, args): @needs_dylink @requires_pthreads def test_pthread_dlopen_exit(self): - iterations = 100 + num_libs = 100 self.cflags += ['-Wno-experimental', '-pthread'] self.build_dlfcn_lib(test_file('core/pthread/test_pthread_dlopen_exit_side.c')) # dlclose does not unload libraries, so use a fresh name each time to # generate dynamic link synchronization work on every iteration. - for i in range(iterations): + for i in range(num_libs): shutil.copy('libside.so', f'libside{i}.so') self.prep_dlfcn_main() - self.set_setting('EXIT_RUNTIME') - self.set_setting('PROXY_TO_PTHREAD') - self.do_runf('core/pthread/test_pthread_dlopen_exit.c', 'PASSED', - cflags=[f'-DNUM_ITERATIONS={iterations}']) + self.do_runf('core/pthread/test_pthread_dlopen_exit.c', 'done\n', + cflags=['-sEXIT_RUNTIME', '-sPROXY_TO_PTHREAD', f'-DNUM_SIDE_MODULES={num_libs}']) @needs_dylink @requires_pthreads From f3b80b36a90b55a19220b04931c54174812239dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Fri, 6 Nov 2026 12:32:26 +0000 Subject: [PATCH 6/6] Make test_pthread_dlopen_exit reproduce the cancellation deadlock The previous version kept only one short-lived thread alive at a time and almost never hit the bug. Instead, keep several threads creating and exiting threads while main dlopens the side modules, so that threads regularly exit with dlsync tasks still queued. Without the fix this aborts with "pthread mutex deadlock detected" in em_task_queue_execute in roughly 30% of runs; with the fix it passes. Co-Authored-By: Claude Opus 5.5 --- test/core/pthread/test_pthread_dlopen_exit.c | 57 ++++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/test/core/pthread/test_pthread_dlopen_exit.c b/test/core/pthread/test_pthread_dlopen_exit.c index ca6f1169b3704..bc9fef288104e 100644 --- a/test/core/pthread/test_pthread_dlopen_exit.c +++ b/test/core/pthread/test_pthread_dlopen_exit.c @@ -1,52 +1,51 @@ #include #include #include +#include #include -// NUM_SIDE_MODULES is passed on the command line and matches the number of -// libsideN.so copies created by the test runner. Each one is loaded while a -// worker thread is exiting, so it is also the number of worker threads. +// Each dlopen makes the main runtime thread queue a dlsync task for every live +// thread. Keep threads exiting while libraries are loaded, so that threads +// regularly exit with dlsync tasks still queued and have to cancel them. -pthread_barrier_t barrier; +#define NUM_CHURN_THREADS 6 -void* exiting_worker(void* arg) { - pthread_barrier_wait(&barrier); - // Race thread shutdown against dynamic link synchronization. +_Atomic bool loading = true; + +void* exiting_thread(void* arg) { + return NULL; +} + +void* churn_thread(void* arg) { + while (loading) { + pthread_t t; + assert(pthread_create(&t, NULL, exiting_thread, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + } return NULL; } -void* loader_thread(void* arg) { +int main() { + pthread_t churners[NUM_CHURN_THREADS]; + for (int i = 0; i < NUM_CHURN_THREADS; ++i) { + assert(pthread_create(&churners[i], NULL, churn_thread, NULL) == 0); + } + + // Each libsideN.so is a separate copy, so every dlopen does real work. for (int i = 0; i < NUM_SIDE_MODULES; ++i) { char filename[32]; snprintf(filename, sizeof(filename), "libside%d.so", i); - // Pair each dlopen with the exit of one worker thread. - pthread_barrier_wait(&barrier); void* handle = dlopen(filename, RTLD_NOW | RTLD_GLOBAL); - assert(handle && "dlopen failed"); + assert(handle); int (*side_func)(void) = dlsym(handle, "side_func"); assert(side_func); assert(side_func() == 42); - assert(dlclose(handle) == 0); } - return NULL; -} - -int main() { - assert(pthread_barrier_init(&barrier, NULL, 2) == 0); - pthread_t loader; - assert(pthread_create(&loader, NULL, loader_thread, NULL) == 0); - - // One worker per side module: the barrier pairs each worker with one - // iteration of the loader thread, so the two loop counts must match. - for (int i = 0; i < NUM_SIDE_MODULES; ++i) { - pthread_t worker; - assert(pthread_create(&worker, NULL, exiting_worker, NULL) == 0); - assert(pthread_join(worker, NULL) == 0); + loading = false; + for (int i = 0; i < NUM_CHURN_THREADS; ++i) { + assert(pthread_join(churners[i], NULL) == 0); } - - assert(pthread_join(loader, NULL) == 0); - assert(pthread_barrier_destroy(&barrier) == 0); printf("done\n"); return 0; }