From 8b60e22f0dd905c026b6c9d692c243073cb4fed1 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/3] 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 69032f8e483e43da19fc149064dcd3387c7acf73 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/3] 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 956f02c5617201820408d4ba101a08d469f7e9fd 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/3] 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 7d7785b77b47c..eac0f61fea3e8 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')