From d76b349a47c8bf98c6dff882f66d7cccff2c00e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 22 Oct 2026 23:32:25 +0200 Subject: [PATCH 1/4] Free side module TLS when a thread exits _emscripten_tls_init allocates a TLS block for each side module on every thread, but nothing called _emscripten_tls_free, so each thread leaked the TLS of every loaded side module. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/libpthread.js | 10 +++++ src/lib/libsigs.js | 1 + system/lib/pthread/pthread_create.c | 5 +++ system/lib/pthread/threading_internal.h | 1 + .../pthread/test_pthread_dylink_tls_free.c | 42 +++++++++++++++++++ .../test_pthread_dylink_tls_free_side.c | 6 +++ test/test_core.py | 12 ++++++ tools/link.py | 4 +- 8 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 test/core/pthread/test_pthread_dylink_tls_free.c create mode 100644 test/core/pthread/test_pthread_dylink_tls_free_side.c diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index 9b2807e48d42a..f1dd98564dff8 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -111,6 +111,10 @@ var LibraryPThread = { // starting the next thread is faster. unusedWorkers: [], tlsInitFunctions: [], +#if MAIN_MODULE + // The _emscripten_tls_free of each side module, run when a thread exits. + tlsFreeFunctions: [], +#endif // Maps pthread_t pointers to the workers on which they are running. For // the reverse mapping, each worker has a `pthread_ptr` when its running a // pthread. @@ -694,6 +698,10 @@ var LibraryPThread = { // Register this function so that its gets called for each thread on // startup. PThread.tlsInitFunctions.push(tlsInitWrapper); + var tlsFreeFunc = moduleExports?.['_emscripten_tls_free']; + if (tlsFreeFunc) { + PThread.tlsFreeFunctions.push(tlsFreeFunc); + } // If the main thread is already running we also need to call this function // now. If the main thread is not yet running this will happen when it @@ -1218,6 +1226,8 @@ var LibraryPThread = { else postMessage({ cmd: {{{ CMD_MARK_AS_FINISHED }}}, thread }); }, + _emscripten_thread_free_tls: () => PThread.tlsFreeFunctions.forEach((f) => f()), + $markAsFinished: (pthread_ptr) => { #if PTHREADS_DEBUG dbg(`markAsFinished: ${ptrToString(pthread_ptr)}`); diff --git a/src/lib/libsigs.js b/src/lib/libsigs.js index f73ba4919dbd8..c769101a9cbeb 100644 --- a/src/lib/libsigs.js +++ b/src/lib/libsigs.js @@ -349,6 +349,7 @@ sigs = { _emscripten_system__sig: 'ip', _emscripten_thread_cleanup__sig: 'vp', _emscripten_thread_exit_joinable__sig: 'vp', + _emscripten_thread_free_tls__sig: 'v', _emscripten_thread_mailbox_await__sig: 'vp', _emscripten_thread_set_strongref__sig: 'vp', _emscripten_throw_longjmp__sig: 'v', diff --git a/system/lib/pthread/pthread_create.c b/system/lib/pthread/pthread_create.c index b4b0c7e1da8ff..1bbee8fa02f53 100644 --- a/system/lib/pthread/pthread_create.c +++ b/system/lib/pthread/pthread_create.c @@ -330,6 +330,11 @@ void _emscripten_thread_exit(void* result) { exit(0); } +#ifdef EMSCRIPTEN_DYNAMIC_LINKING + // Free the TLS blocks that side modules allocated for this thread. + _emscripten_thread_free_tls(); +#endif + /* At this point we are committed to thread termination. */ /* The thread list lock must be AS-safe. */ diff --git a/system/lib/pthread/threading_internal.h b/system/lib/pthread/threading_internal.h index 5667129e6e48c..6a113ebfd3fa4 100644 --- a/system/lib/pthread/threading_internal.h +++ b/system/lib/pthread/threading_internal.h @@ -80,6 +80,7 @@ void _emscripten_thread_set_strongref(pthread_t thread); int _emscripten_thread_is_valid(pthread_t thread); void _emscripten_thread_exit_joinable(pthread_t thread); +void _emscripten_thread_free_tls(void); void _emscripten_thread_exit(void* result); void _emscripten_process_dlopen_queue(void); extern em_proxying_queue* _Atomic _dlopen_proxying_queue; diff --git a/test/core/pthread/test_pthread_dylink_tls_free.c b/test/core/pthread/test_pthread_dylink_tls_free.c new file mode 100644 index 0000000000000..5f8d57ca234a4 --- /dev/null +++ b/test/core/pthread/test_pthread_dylink_tls_free.c @@ -0,0 +1,42 @@ +#include +#include +#include +#include +#include + +#define NUM_THREADS 100 + +typedef int (*touch_side_tls_type)(int); + +static touch_side_tls_type touch_side_tls; + +static void* thread_main(void* arg) { + assert(touch_side_tls(1) == 1); + return NULL; +} + +static void run_threads(int count) { + for (int i = 0; i < count; i++) { + pthread_t thread; + pthread_create(&thread, NULL, thread_main, NULL); + pthread_join(thread, NULL); + } +} + +int main() { + void* handle = dlopen("libside.so", RTLD_NOW); + assert(handle); + touch_side_tls = (touch_side_tls_type)dlsym(handle, "touch_side_tls"); + assert(touch_side_tls); + + run_threads(10); + size_t before = mallinfo().uordblks; + run_threads(NUM_THREADS); + size_t after = mallinfo().uordblks; + + // Each thread allocates the side module's 4 KiB TLS block, which must be + // freed when the thread exits. + assert(after < before + NUM_THREADS * 4096 / 2); + printf("done\n"); + return 0; +} diff --git a/test/core/pthread/test_pthread_dylink_tls_free_side.c b/test/core/pthread/test_pthread_dylink_tls_free_side.c new file mode 100644 index 0000000000000..2450dd118c7fd --- /dev/null +++ b/test/core/pthread/test_pthread_dylink_tls_free_side.c @@ -0,0 +1,6 @@ +_Thread_local char side_tls[4096]; + +int touch_side_tls(int value) { + side_tls[0] = value; + return side_tls[0]; +} diff --git a/test/test_core.py b/test/test_core.py index 7d7785b77b47c..f8243965ffa06 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9441,6 +9441,18 @@ def test_pthread_dylink_tls(self): self.cflags += ['-Wno-experimental', '-pthread'] self.dylink_testf(test_file('core/pthread/test_pthread_dylink_tls.c')) + @needs_dylink + @requires_pthreads + def test_pthread_dylink_tls_free(self): + self.cflags += ['-Wno-experimental', '-pthread'] + self.build_dlfcn_lib(test_file('core/pthread/test_pthread_dylink_tls_free_side.c')) + + self.cflags += ['--embed-file', 'libside.so@libside.so'] + self.prep_dlfcn_main() + self.set_setting('EXIT_RUNTIME') + self.set_setting('PROXY_TO_PTHREAD') + self.do_runf('core/pthread/test_pthread_dylink_tls_free.c', 'done\n') + @needs_dylink @requires_pthreads def test_pthread_dylink_longjmp(self): diff --git a/tools/link.py b/tools/link.py index 838e81125c207..f205c64e5e869 100644 --- a/tools/link.py +++ b/tools/link.py @@ -1785,7 +1785,7 @@ def get_full_import_name(name): if settings.SIDE_MODULE: # For side modules, we ignore all REQUIRED_EXPORTS that might have been added above. # They all come from either libc or compiler-rt. The exception is __wasm_call_ctors - # and _emscripten_tls_init which are per-module exports. + # and _emscripten_tls_init/_emscripten_tls_free which are per-module exports. settings.REQUIRED_EXPORTS.clear() if not settings.STANDALONE_WASM: @@ -1793,6 +1793,8 @@ def get_full_import_name(name): settings.REQUIRED_EXPORTS.append('__wasm_call_ctors') if settings.PTHREADS: settings.REQUIRED_EXPORTS.append('_emscripten_tls_init') + if settings.SIDE_MODULE: + settings.REQUIRED_EXPORTS.append('_emscripten_tls_free') if settings.NODERAWFS: default_setting('NODE_HOST_ENV', 1) From 4f1d5b4fbf1e7cc9e903df2a7d40de26630cc32e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Mon, 26 Oct 2026 14:54:01 +0100 Subject: [PATCH 2/4] Free side module TLS after robust mutex cleanup _emscripten_thread_exit freed side module TLS before processing the robust mutex list. A robust mutex living in side module TLS would then have its userspace cleanup write into the freed block, corrupting the heap. Move _emscripten_thread_free_tls() after __tl_unlock(), once the robust list has been processed and the thread unlinked, keeping the thread identity valid until the TLS blocks are gone. Add a regression test that exits a thread holding a robust mutex in side module TLS and then exercises the allocator. Co-Authored-By: Claude Opus 4.8 --- system/lib/pthread/pthread_create.c | 11 +++--- .../test_pthread_dylink_tls_free_robust.c | 34 +++++++++++++++++++ ...test_pthread_dylink_tls_free_robust_side.c | 23 +++++++++++++ test/test_core.py | 12 +++++++ 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 test/core/pthread/test_pthread_dylink_tls_free_robust.c create mode 100644 test/core/pthread/test_pthread_dylink_tls_free_robust_side.c diff --git a/system/lib/pthread/pthread_create.c b/system/lib/pthread/pthread_create.c index 1bbee8fa02f53..25134159cd88b 100644 --- a/system/lib/pthread/pthread_create.c +++ b/system/lib/pthread/pthread_create.c @@ -330,11 +330,6 @@ void _emscripten_thread_exit(void* result) { exit(0); } -#ifdef EMSCRIPTEN_DYNAMIC_LINKING - // Free the TLS blocks that side modules allocated for this thread. - _emscripten_thread_free_tls(); -#endif - /* At this point we are committed to thread termination. */ /* The thread list lock must be AS-safe. */ @@ -371,6 +366,12 @@ void _emscripten_thread_exit(void* result) { __tl_unlock(); +#ifdef EMSCRIPTEN_DYNAMIC_LINKING + // Free side module TLS after cleanup, since robust mutexes may live in it. + // Keep the thread identity valid until the TLS blocks have been freed. + _emscripten_thread_free_tls(); +#endif + // Not hosting a pthread anymore in this worker set __pthread_self to NULL __set_thread_state(NULL, 0, 0, 1); diff --git a/test/core/pthread/test_pthread_dylink_tls_free_robust.c b/test/core/pthread/test_pthread_dylink_tls_free_robust.c new file mode 100644 index 0000000000000..6865e4d39f1f4 --- /dev/null +++ b/test/core/pthread/test_pthread_dylink_tls_free_robust.c @@ -0,0 +1,34 @@ +#include +#include +#include +#include +#include + +static void* (*leave_locked)(void); + +static void* thread_main(void* arg) { return leave_locked(); } + +int main() { + void* handle = dlopen("libside.so", RTLD_NOW); + assert(handle); + leave_locked = dlsym(handle, "leave_locked"); + assert(leave_locked); + + for (int i = 0; i < 10; ++i) { + pthread_t thread; + assert(pthread_create(&thread, NULL, thread_main, NULL) == 0); + void* guard; + assert(pthread_join(thread, &guard) == 0); + + // Robust mutex cleanup must finish before freeing the TLS containing the + // mutex. Otherwise it writes into the freed block and corrupts the heap. + volatile char* block = malloc(4096); + assert(block); + block[0] = 0x55; + free((void*)block); + free(guard); + } + + puts("done"); + return 0; +} diff --git a/test/core/pthread/test_pthread_dylink_tls_free_robust_side.c b/test/core/pthread/test_pthread_dylink_tls_free_robust_side.c new file mode 100644 index 0000000000000..d3bc16520153d --- /dev/null +++ b/test/core/pthread/test_pthread_dylink_tls_free_robust_side.c @@ -0,0 +1,23 @@ +#include +#include +#include + +static _Thread_local struct { + pthread_mutex_t mutex; + char padding[4096]; +} tls; + +void* leave_locked(void) { + pthread_mutexattr_t attr; + assert(pthread_mutexattr_init(&attr) == 0); + assert(pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST) == 0); + assert(pthread_mutex_init(&tls.mutex, &attr) == 0); + assert(pthread_mutexattr_destroy(&attr) == 0); + assert(pthread_mutex_lock(&tls.mutex) == 0); + + // Keep the following block allocated so that freeing TLS cannot merge it + // with the top of the heap. Leave the mutex locked for thread-exit cleanup. + void* guard = malloc(32); + assert(guard); + return guard; +} diff --git a/test/test_core.py b/test/test_core.py index f8243965ffa06..6641798bfb44b 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9453,6 +9453,18 @@ def test_pthread_dylink_tls_free(self): self.set_setting('PROXY_TO_PTHREAD') self.do_runf('core/pthread/test_pthread_dylink_tls_free.c', 'done\n') + @needs_dylink + @requires_pthreads + def test_pthread_dylink_tls_free_robust(self): + self.cflags += ['-Wno-experimental', '-pthread'] + self.build_dlfcn_lib(test_file('core/pthread/test_pthread_dylink_tls_free_robust_side.c')) + + self.cflags += ['--embed-file', 'libside.so@libside.so'] + self.prep_dlfcn_main() + self.set_setting('EXIT_RUNTIME') + self.set_setting('PROXY_TO_PTHREAD') + self.do_runf('core/pthread/test_pthread_dylink_tls_free_robust.c', 'done\n') + @needs_dylink @requires_pthreads def test_pthread_dylink_longjmp(self): From 29f9ce352893e48dec022fa0db7051e3af79d068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 29 Oct 2026 17:45:29 +0100 Subject: [PATCH 3/4] Address review feedback on side-module TLS tests - Simplify the comment on the free in _emscripten_thread_exit. - Drop the MAIN_MODULE guard on PThread.tlsFreeFunctions so its declaration matches tlsInitFunctions; the push and the free of its entries already live inside MAIN_MODULE-only code. - Rely on the linked side module for dlopen instead of embedding it, so the --embed-file lines are no longer needed. - Drop unnecessary static qualifiers in the leak test and remove the redundant mutexattr_destroy in the robust test. Co-Authored-By: Claude Opus 4.8 --- src/lib/libpthread.js | 3 --- system/lib/pthread/pthread_create.c | 3 +-- test/core/pthread/test_pthread_dylink_tls_free.c | 15 ++++++++++----- .../test_pthread_dylink_tls_free_robust_side.c | 1 - test/test_core.py | 2 -- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index 5296347e9a36c..f3abe91a1ac44 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -116,10 +116,7 @@ var LibraryPThread = { // starting the next thread is faster. unusedWorkers: [], tlsInitFunctions: [], -#if MAIN_MODULE - // The _emscripten_tls_free of each side module, run when a thread exits. tlsFreeFunctions: [], -#endif // Maps pthread_t pointers to the workers on which they are running. For // the reverse mapping, each worker has a `pthread_ptr` when its running a // pthread. diff --git a/system/lib/pthread/pthread_create.c b/system/lib/pthread/pthread_create.c index 25134159cd88b..d88b7b1efd941 100644 --- a/system/lib/pthread/pthread_create.c +++ b/system/lib/pthread/pthread_create.c @@ -367,8 +367,7 @@ void _emscripten_thread_exit(void* result) { __tl_unlock(); #ifdef EMSCRIPTEN_DYNAMIC_LINKING - // Free side module TLS after cleanup, since robust mutexes may live in it. - // Keep the thread identity valid until the TLS blocks have been freed. + // Free the TLS data of all loaded side modules. _emscripten_thread_free_tls(); #endif diff --git a/test/core/pthread/test_pthread_dylink_tls_free.c b/test/core/pthread/test_pthread_dylink_tls_free.c index 5f8d57ca234a4..ed2b07f2e6428 100644 --- a/test/core/pthread/test_pthread_dylink_tls_free.c +++ b/test/core/pthread/test_pthread_dylink_tls_free.c @@ -8,14 +8,16 @@ typedef int (*touch_side_tls_type)(int); +// Kept static so it does not clash with the side module's exported +// touch_side_tls symbol under MAIN_MODULE. static touch_side_tls_type touch_side_tls; -static void* thread_main(void* arg) { +void* thread_main(void* arg) { assert(touch_side_tls(1) == 1); return NULL; } -static void run_threads(int count) { +void run_threads(int count) { for (int i = 0; i < count; i++) { pthread_t thread; pthread_create(&thread, NULL, thread_main, NULL); @@ -29,14 +31,17 @@ int main() { touch_side_tls = (touch_side_tls_type)dlsym(handle, "touch_side_tls"); assert(touch_side_tls); + // Each thread allocates the side module's 4 KiB TLS block on first access and + // must free it when it exits. Without the fix the heap grows by one block + // per thread; with it, growth is bounded by the last thread's not-yet- + // reclaimed stack, far below NUM_THREADS blocks. NUM_THREADS is large so a + // real leak clearly exceeds that fixed slack. run_threads(10); size_t before = mallinfo().uordblks; run_threads(NUM_THREADS); size_t after = mallinfo().uordblks; - - // Each thread allocates the side module's 4 KiB TLS block, which must be - // freed when the thread exits. assert(after < before + NUM_THREADS * 4096 / 2); + printf("done\n"); return 0; } diff --git a/test/core/pthread/test_pthread_dylink_tls_free_robust_side.c b/test/core/pthread/test_pthread_dylink_tls_free_robust_side.c index d3bc16520153d..b729ab662e5a6 100644 --- a/test/core/pthread/test_pthread_dylink_tls_free_robust_side.c +++ b/test/core/pthread/test_pthread_dylink_tls_free_robust_side.c @@ -12,7 +12,6 @@ void* leave_locked(void) { assert(pthread_mutexattr_init(&attr) == 0); assert(pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST) == 0); assert(pthread_mutex_init(&tls.mutex, &attr) == 0); - assert(pthread_mutexattr_destroy(&attr) == 0); assert(pthread_mutex_lock(&tls.mutex) == 0); // Keep the following block allocated so that freeing TLS cannot merge it diff --git a/test/test_core.py b/test/test_core.py index 6641798bfb44b..1a5c5caebb9ba 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -9447,7 +9447,6 @@ def test_pthread_dylink_tls_free(self): self.cflags += ['-Wno-experimental', '-pthread'] self.build_dlfcn_lib(test_file('core/pthread/test_pthread_dylink_tls_free_side.c')) - self.cflags += ['--embed-file', 'libside.so@libside.so'] self.prep_dlfcn_main() self.set_setting('EXIT_RUNTIME') self.set_setting('PROXY_TO_PTHREAD') @@ -9459,7 +9458,6 @@ def test_pthread_dylink_tls_free_robust(self): self.cflags += ['-Wno-experimental', '-pthread'] self.build_dlfcn_lib(test_file('core/pthread/test_pthread_dylink_tls_free_robust_side.c')) - self.cflags += ['--embed-file', 'libside.so@libside.so'] self.prep_dlfcn_main() self.set_setting('EXIT_RUNTIME') self.set_setting('PROXY_TO_PTHREAD') From 955504ef73d02b3e725136278fb15b9499865db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20P=C3=A9ron?= Date: Thu, 29 Oct 2026 18:19:56 +0100 Subject: [PATCH 4/4] Guard tlsFreeFunctions declaration with MAIN_MODULE Keep the TLS-free JS code uniformly MAIN_MODULE-only: the push in $registerTLSInit and _emscripten_thread_free_tls already live in MAIN_MODULE blocks, so guard the declaration too rather than leaving it unconditional. Matches the EMSCRIPTEN_DYNAMIC_LINKING guard on the call site in pthread_create.c. Co-Authored-By: Claude Opus 4.8 --- src/lib/libpthread.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/libpthread.js b/src/lib/libpthread.js index f3abe91a1ac44..3459aa3cedabe 100644 --- a/src/lib/libpthread.js +++ b/src/lib/libpthread.js @@ -116,7 +116,9 @@ var LibraryPThread = { // starting the next thread is faster. unusedWorkers: [], tlsInitFunctions: [], +#if MAIN_MODULE tlsFreeFunctions: [], +#endif // Maps pthread_t pointers to the workers on which they are running. For // the reverse mapping, each worker has a `pthread_ptr` when its running a // pthread.