macOS v3: #110 switches back to `MI_TLS_MODEL=PTHREADS` and brings back the exit-time crash (Mode B of #71)

オープン
#116 コメント 0 件 リアクション 0 件 担当者 1 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@shulaoda がすでに取り組んでいます。

2026年10月29日 から。

評価

この issue はまだ評価されていません。

説明

Summary

#110 (libmimalloc-sys2 0.1.64 / mimalloc-safe 0.1.68) changed the TLS model for v3 on macOS from MI_TLS_MODEL_LOCAL + MI_TLS_RECURSE_GUARD to MI_TLS_MODEL=PTHREADS. This brings back the bug that #71 and #82 fixed. It is Mode B in #71 and Problem 2 in microsoft/mimalloc#1301.

At exit(), mi_process_done deletes the default-theap pthread key on the main thread. Other threads are still running at that point, so any thread that allocates afterwards loses its theap.

With v3.6.0 the symptoms are:

  • Debug builds: SIGABRT from mi_assert_internal(theap==NULL) in _mi_thread_init_with_heap. Nothing is printed to stderr.
  • Release builds: no crash in my runs. However, every allocation after that point creates a new theap. In the probe below, allocations got about 500× slower and RSS grew by about 350 MB within 100 ms.

I found this in rolldown. After the bump to 0.1.68 (rolldown/rolldown#11297), the macOS node test job fails on most runs: the rolldown CLI finishes the build and is then killed with SIGABRT. Example: https://github.com/rolldown/rolldown/actions/runs/40803444105/job/123222619759

Reproduction

The repro does not involve rolldown or Node.js. Background threads keep allocating while main returns.

Cargo.toml:

[package]
name = "mi-repro"
version = "0.1.0"
edition = "2021"

[dependencies]
mimalloc-safe = { version = "=0.1.68", features = ["skip_collect_on_exit", "v3"] }

src/main.rs:

// Background threads keep allocating while the main thread returns from
// `main`, i.e. calls `exit()`, which runs mimalloc's `mi_process_done`.
#[global_allocator]
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;

fn main() {
  for _ in 0..4 {
    std::thread::spawn(|| loop {
      let v: Vec<u8> = Vec::with_capacity(64);
      std::hint::black_box(&v);
    });
  }
  std::thread::sleep(std::time::Duration::from_millis(2));
}
cargo build
for i in $(seq 500); do ./target/debug/mi-repro >/dev/null 2>&1 || echo "exit $?"; done | sort | uniq -c
#   14 exit 134      <- 0.1.68; no output with 0.1.67
Crash report (debug, 0.1.68)
Thread 0 (main):
  _pthread_mutex_firstfit_lock_slow
  ftrylockfile / tryflush / _fwalk
  exit
  dyld start

Thread 4 (crashed, SIGABRT):
  __pthread_kill
  pthread_kill
  abort
  _mi_assert_fail
  _mi_thread_init_with_heap + 248     // "theap==NULL", src/heap/init.c:358
  _mi_thread_init
  mi_theap_init
  mi_malloc_generic_admin
  mi_malloc_generic_fallback
  _mi_malloc_generic
  _mi_theap_malloc_zero
  ...
  mi_malloc_aligned
  <mimalloc_safe::MiMalloc as GlobalAlloc>::alloc

Results

The two changes in 0.1.64, the mimalloc version and the TLS model, can be separated with a [patch.crates-io] copy of libmimalloc-sys2 that has only the macOS TLS lines in build.rs swapped. Each cell below is 500 debug runs of the repro above.

LOCAL + RECURSE_GUARD PTHREADS
mimalloc v3.5.2 (sys2 0.1.63) 0 (stock 0.1.67) 26 × SIGABRT
mimalloc v3.6.0 (sys2 0.1.64) 0 14 × SIGABRT (stock 0.1.68)

The TLS model decides the outcome. The mimalloc version does not. Release builds of all four variants had no abnormal exits in 500 runs.

In rolldown, I ran the CLI 1800 times (12 parallel loops × 50 runs × 3 rounds):

rolldown build aborts
commit before the bump 0/1800
the bump commit 44/1800
current main with only mimalloc-safe/libmimalloc-sys2 pinned back to 0.1.67/0.1.63 0/1800

Root cause

0.1.64 bundles upstream v3.6.0 (b18ab4ee) unmodified, so all line links below point to upstream.

  1. The main thread calls exit(). mimalloc's destructor runs mi_process_done → mi_process_unwind_once → _mi_tls_slots_done().
  2. In the PTHREADS model, _mi_tls_slots_done sets the global _mi_theap_default_key to MI_PTHREAD_KEY_INVALID and calls pthread_key_delete. This affects every thread at once.
  3. exit() runs its cleanup chain before it terminates the other threads, so they keep running. In rolldown these are rayon and tokio workers.
  4. _mi_theap_default() reads that global key. Once the key is invalid it returns NULL on every thread, so the next malloc takes the thread-init path.
  5. mi_heap_check_for_existing_theap finds the theap that this thread still owns, and the debug assertion theap==NULL fails.
  6. In release builds, a new tld and theap are created. However, _mi_theap_default_set cannot store the new theap while the key is invalid, and _mi_tls_slots_init is a do_once that will not recreate the key. As a result, every later allocation on that thread repeats the init.
  7. The assertion message never shows up because os_preloading = true is set at the end of process done, which stops mimalloc's output.

With LOCAL, _mi_theap_default() reads a per-thread __thread variable and _mi_tls_slots_done() does nothing. A live thread keeps its theap after process done. Under RECURSE_GUARD, the assertion in step 5 is also compiled out.

Upstream dev3 still calls _mi_tls_slots_done() unconditionally on unix (src/heap/process.c, checked 2026-10-29), and v3.5.2 behaves the same way. PTHREADS is also upstream's default TLS model on Apple, OpenBSD, and Android.

Release impact

Probe: call mi_process_done() by hand while 4 workers keep allocating (release)
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;

#[global_allocator]
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;

unsafe extern "C" {
  fn mi_process_done();
}

static COUNT: AtomicU64 = AtomicU64::new(0);

fn rss_kb() -> u64 {
  let out = std::process::Command::new("ps")
    .args(["-o", "rss=", "-p", &std::process::id().to_string()])
    .output()
    .unwrap();
  String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0)
}

fn rate(window: Duration) -> u64 {
  let start = COUNT.load(Ordering::Relaxed);
  std::thread::sleep(window);
  (COUNT.load(Ordering::Relaxed) - start) * 1000 / window.as_millis() as u64
}

fn main() {
  for _ in 0..4 {
    std::thread::spawn(|| loop {
      let v: Vec<u8> = Vec::with_capacity(64);
      std::hint::black_box(&v);
      drop(v);
      COUNT.fetch_add(1, Ordering::Relaxed);
    });
  }
  std::thread::sleep(Duration::from_millis(50));
  let before_rate = rate(Duration::from_millis(100));
  let before_rss = rss_kb();
  unsafe { mi_process_done() };
  let after_rate = rate(Duration::from_millis(100));
  let after_rss = rss_kb();
  println!("allocs/s before={before_rate} after={after_rate}  rss_kb before={before_rss} after={after_rss}");
  std::process::exit(0);
}
allocs/s before → after RSS before → after (100 ms)
0.1.67 (LOCAL) ~31M → ~31M 2.2 MB → 2.3 MB
0.1.68 (PTHREADS) ~64M → ~0.13M 2.3 MB → ~355 MB

The exit window is usually short, so release users are mostly saved by timing. If threads stay busy during a long exit, for example a process.exit() in the middle of a build, they leak a theap on every allocation until _exit.

Possible fixes

#110 moved to PTHREADS because v3.6.0 with LOCAL hits the cached-theap refcount assertion during thread teardown. A fix should keep that case working.

  1. Keep PTHREADS and do not delete the TLS keys while other threads are still registered. I tried this patch against v3.6.0:

    --- a/src/heap/init.c
    +++ b/src/heap/init.c
    @@ -676,7 +676,12 @@ static void mi_process_unwind_once(void)
         }
       }
    
    -  _mi_tls_slots_done();
    +  // Other threads may still be running (and allocating) while the process exits.
    +  // With pthread keys, deleting the default theap key makes all of them lose their theap,
    +  // so only release the TLS slots when no other thread is registered.
    +  if (mi_atomic_load_acquire(&_mi_subproc_main()->thread_count) <= 1) {
    +    _mi_tls_slots_done();
    +  }
     }
    

    Results with the repro and probe above:

    • debug: 0/500 aborts (unpatched 0.1.68 had 21/500 in the same session);
    • release probe: no change after mi_process_done (~50–60M allocs/s, RSS stays at 2.3 MB).

    Open question: with live threads, the keys are now leaked at exit. That costs nothing at process exit, but a dlclose with live threads would leave _mi_theap_cached_key's destructor pointing into the unloaded image. This probably belongs upstream in microsoft/mimalloc, since PTHREADS is the default model there.

  2. Go back to LOCAL + RECURSE_GUARD on macOS and fix the thread-teardown assertion that motivated #110. My repro passes on v3.6.0 + LOCAL (0/500). However, it never exits a thread, so it does not cover the case #110 was about.

Downstream workaround for now: pin mimalloc-safe = "=0.1.67" and libmimalloc-sys2 = "=0.1.63".

Environment

  • macOS 26.7.1 (25G241), arm64
  • Apple clang 21.0.0, rustc 1.99.0
  • mimalloc-safe 0.1.68 / libmimalloc-sys2 0.1.64 (mimalloc v3.6.0 b18ab4ee), features v3 and skip_collect_on_exit (same as rolldown's binding)
主要言語
Rust
スター
26
フォーク
5
平均マージ
1日 44分
マージ済み PR(30日)
12

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

napi-rs/mimalloc-safe のほかの issue

napi-rs/mimalloc-safe の issue をすべて見る

似ている issue

Rust の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。