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 分钟
- 30 天内合并 PR
- 12
环境准备
这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
napi-rs/mimalloc-safe 的其他 Issue
-
难度 1/5 1 小时以内 新手友好度 15/100
napi-rs/mimalloc-safe#5 ·
维护者通常 1 天内回复
查看 napi-rs/mimalloc-safe 的全部 Issue
相似的 Issue
-
[Bug]: opencode_go chat route drops reasoning_effort (even 'none'), while the same endpoint honors it via custom_llm_provider=openai可能已有人在做 @arvindkumar-ship-it 今天认领。 未关闭llm translation
难度 2/5 1-3 小时 新手友好度 70/100
BerriAI/litellm#51871 · 1 条评论 ·
维护者通常 1 天内回复
-
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 天内回复
-
C-Bug S-Needs-Triage
难度 2/5 1-3 小时 新手友好度 72/100
bevyengine/bevy#26558 ·
维护者通常 2 天内回复