Skip to content

Associate the process-init thread's theap with the thread-done key - #1393

Open
LongYinan (Brooooooklyn) wants to merge 1 commit into
microsoft:dev3from
napi-rs:fix/first-thread-done
Open

Associate the process-init thread's theap with the thread-done key#1393
LongYinan (Brooooooklyn) wants to merge 1 commit into
microsoft:dev3from
napi-rs:fix/first-thread-done

Conversation

@Brooooooklyn

@Brooooooklyn LongYinan (Brooooooklyn) commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Since v3.5.0 the thread that runs mi_process_init never gets its theap associated with the thread-done key. _mi_thread_init_with_heap runs before mi_process_setup_auto_thread_done creates the key, so _mi_prim_thread_associate_default_theap is a no-op for that thread, and the call that used to fix this up afterwards (_mi_theap_default_set(&mi_process_theap_main)) was removed in 5d9cb38 ("remove static main theap and tld"). If that first thread exits, _mi_thread_done never runs for it and its theap stays registered with its thread id.

We hit this with a Node.js addon that links mimalloc statically: a worker thread loads the addon first (so the constructor runs there), exits, and the next thread that gets the same pthread TCB aborts on its first allocation in debug builds:

mimalloc: assertion failed: at "src/init.c":326, _mi_thread_init_with_heap
  assertion: "theap==NULL"

Release builds skip the assert and just leak the dead thread's theap and pages. v3.4.5 is fine, v3.5.1 still has it. The v2 line does the equivalent association in _mi_heap_set_default_direct(&_mi_heap_main).

The fix associates the current theap with the key right after creating it, if it is already initialized. Teardown of the static main theap already works once the destructor fires (_mi_tld_detach_theaps, static memid skipped, thread_id set to MI_THREADID_INVALID).

ctest passes before and after; the existing tests never let the first thread exit, so they don't catch this.

Standalone repro (mimalloc built with -DCMAKE_BUILD_TYPE=Debug -DMI_BUILD_SHARED=ON)
#define _GNU_SOURCE
#include <dlfcn.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

typedef void* (*mi_malloc_fn)(size_t);
typedef void  (*mi_free_fn)(void*);

static const char*  lib_path;
static mi_malloc_fn mi_malloc_p;
static mi_free_fn   mi_free_p;

static void alloc_and_free(void) {
  void* p[4];
  for (int i = 0; i < 4; i++) p[i] = mi_malloc_p((size_t)64 << i);
  for (int i = 0; i < 4; i++) mi_free_p(p[i]);
}

// T1: loads mimalloc (constructor -> mi_process_init runs here), allocates, exits.
static void* t1(void* arg) {
  (void)arg;
  printf("T1 pthread_self=%p  (dlopen + first mi_malloc happen here, then T1 exits)\n", (void*)pthread_self());
  void* h = dlopen(lib_path, RTLD_NOW);  // never dlclose'd: the library stays loaded
  if (h == NULL) { fprintf(stderr, "dlopen: %s\n", dlerror()); exit(2); }
  mi_malloc_p = (mi_malloc_fn)dlsym(h, "mi_malloc");
  mi_free_p   = (mi_free_fn)dlsym(h, "mi_free");
  if (mi_malloc_p == NULL || mi_free_p == NULL) { fprintf(stderr, "dlsym: %s\n", dlerror()); exit(2); }
  alloc_and_free();
  return (void*)pthread_self();
}

// T2: a fresh thread; with the same stack size glibc's stack cache hands it T1's TCB.
static void* t2(void* arg) {
  (void)arg;
  printf("T2 pthread_self=%p  (calling mi_malloc on this thread)\n", (void*)pthread_self());
  fflush(stdout);
  alloc_and_free();
  return (void*)pthread_self();
}

int main(int argc, char** argv) {
  if (argc < 2) { fprintf(stderr, "usage: %s <libmimalloc.so>\n", argv[0]); return 2; }
  lib_path = argv[1];
  pthread_attr_t attr;
  pthread_attr_init(&attr);
  pthread_attr_setstacksize(&attr, (size_t)4 << 20);  // same size for every thread
  pthread_t th; void* t1_self; void* t2_self;
  if (pthread_create(&th, &attr, t1, NULL) != 0 || pthread_join(th, &t1_self) != 0) return 2;
  int n = 0;
  do {  // usually the very first T2 reuses T1's TCB; loop just in case
    n++;
    if (pthread_create(&th, &attr, t2, NULL) != 0 || pthread_join(th, &t2_self) != 0) return 2;
  } while (t2_self != t1_self && n < 64);
  printf("%s (after %d T2 thread%s)\n", t2_self == t1_self ? "T2 reused T1's TCB" : "no TCB reuse observed", n, n == 1 ? "" : "s");
  printf("OK\n");
  return 0;
}
cc -g first-thread-done-repro.c -o first-thread-done-repro -ldl -lpthread
./first-thread-done-repro build/libmimalloc-debug.so

Before: T1 and T2 print the same pthread_self, then the assertion above. After: OK.

The thread that runs `mi_process_init` initializes its default theap
before `mi_process_setup_auto_thread_done` creates the pthread thread-done
key, so `_mi_prim_thread_associate_default_theap` is a no-op for it and
`_mi_thread_done` never runs when that thread terminates. Up to v3.4.5,
`mi_process_setup_auto_thread_done` re-associated it right after creating
the key (`_mi_theap_default_set(&mi_process_theap_main)`); 5d9cb38
("remove static main theap and tld") dropped that call, so since v3.5.0
nothing associates the first thread.

When the first thread to touch mimalloc is short-lived (for example a
Node.js worker thread that dlopen's an addon statically linked with
mimalloc, then exits), its theap stays registered in the heap with its
thread id. A later thread that reuses the same pthread TCB finds it in
`mi_heap_check_for_existing_theap` and trips
`mi_assert_internal(theap==NULL)` in `_mi_thread_init_with_heap`
in debug builds; release builds skip the check but the dead
thread's theap and pages are never abandoned or freed.

Associate the current default theap with the key right after creating
it, when it is already initialized. The v2 line (dev) still does the
equivalent via `_mi_heap_set_default_direct(&_mi_heap_main)`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
LongYinan (Brooooooklyn) added a commit to napi-rs/mimalloc-safe that referenced this pull request Sep 4, 2026
…-done hook (#97)

mimalloc v3.5.0 never associates the process-init thread's theap with the pthread thread-done key; when that first thread exits (a Node.js worker that loads the addon first), the next thread that reuses its pthread TCB aborts in debug builds (init.c "theap==NULL") and release builds leak the dead thread's theap and pages. Point the mimalloc3 submodule at napi-rs/mimalloc:fix/first-thread-done = upstream v3.5.1 + the fix (microsoft/mimalloc#1393).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
@Brooooooklyn LongYinan (Brooooooklyn) changed the title fix: associate the process-init thread's theap with the thread-done key (first thread exiting leaves a stale theap, v3.5.0 regression) Associate the process-init thread's theap with the thread-done key Sep 4, 2026
LongYinan (Brooooooklyn) added a commit to rolldown/rolldown that referenced this pull request Sep 4, 2026
0.1.66 ships mimalloc v3.5.1 plus the fix for the process-init thread's
thread-done hook (see microsoft/mimalloc#1393, napi-rs/mimalloc-safe#97),
so the `=0.1.64` stopgap from 4855394 is no longer needed. Cargo.lock:
mimalloc-safe 0.1.64 -> 0.1.66, libmimalloc-sys2 0.1.60 -> 0.1.62.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant