macOS v3: #110 switches back to `MI_TLS_MODEL=PTHREADS` and brings back the exit-time crash (Mode B of #71)
メンテナーはふだん 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:
SIGABRTfrommi_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.
- The main thread calls
exit(). mimalloc's destructor runsmi_process_done→mi_process_unwind_once→_mi_tls_slots_done(). - In the PTHREADS model,
_mi_tls_slots_donesets the global_mi_theap_default_keytoMI_PTHREAD_KEY_INVALIDand callspthread_key_delete. This affects every thread at once. exit()runs its cleanup chain before it terminates the other threads, so they keep running. In rolldown these are rayon and tokio workers._mi_theap_default()reads that global key. Once the key is invalid it returnsNULLon every thread, so the nextmalloctakes the thread-init path.mi_heap_check_for_existing_theapfinds the theap that this thread still owns, and the debug assertiontheap==NULLfails.- In release builds, a new tld and theap are created. However,
_mi_theap_default_setcannot store the new theap while the key is invalid, and_mi_tls_slots_initis ado_oncethat will not recreate the key. As a result, every later allocation on that thread repeats the init. - The assertion message never shows up because
os_preloading = trueis 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.
-
Keep
PTHREADSand 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
dlclosewith live threads would leave_mi_theap_cached_key's destructor pointing into the unloaded image. This probably belongs upstream in microsoft/mimalloc, sincePTHREADSis the default model there. -
Go back to
LOCAL+RECURSE_GUARDon 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-safe0.1.68 /libmimalloc-sys20.1.64 (mimalloc v3.6.0b18ab4ee), featuresv3andskip_collect_on_exit(same as rolldown's binding)
- 主要言語
- Rust
- スター
- 26
- フォーク
- 5
- 平均マージ
- 1日 44分
- マージ済み PR(30日)
- 12
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
napi-rs/mimalloc-safe のほかの issue
-
Dependency Dashboardオープン
難易度 1/5 1時間未満 初心者へのやさしさ 15/100
napi-rs/mimalloc-safe#5 ·
メンテナーはふだん 1 日以内に返信
napi-rs/mimalloc-safe の issue をすべて見る
似ている issue
-
comp-formats
難易度 2/5 半日 初心者へのやさしさ 64/100
ClickHouse/ClickHouse#131694 ·
メンテナーはふだん 1 日以内に返信
-
cli: `execCargoMetadata` decodes stdout per chunk, corrupting non-ASCII paths across pipe boundariesオープン
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信
-
enhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
Copperpour/Copperpour#500 ·
メンテナーはふだん 1 日以内に返信
-
state:triage-needed
難易度 1/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信