Repository navigation
Regression 1.98 → 1.99: thread_local! destructors no longer run at thread exit on wasm32-wasip1-threads #163748
Description
thread_local! destructors stopped running at thread exit on wasm32-wasip1-threads in Rust 1.99.0. Spawned threads that own a thread_local! value with a Drop impl leak it; the destructor never runs, not even after JoinHandle::join returns.
Code
I tried this code:
use std::cell::Cell;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct Probe(Arc<AtomicUsize>);
impl Drop for Probe {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
thread_local! {
static PROBE: Cell<Option<Probe>> = const { Cell::new(None) };
}
fn main() {
let completed = Arc::new(AtomicUsize::new(0));
let n = 4;
let handles: Vec<_> = (0..n)
.map(|_| {
let c = Arc::clone(&completed);
std::thread::spawn(move || PROBE.with(|slot| slot.set(Some(Probe(c)))))
})
.collect();
for h in handles {
h.join().unwrap();
}
let seen = completed.load(Ordering::SeqCst);
println!("threads={n} tls_destructors_ran={seen}");
assert_eq!(seen, n, "TLS destructors did not run on wasm32-wasip1-threads");
}cargo +1.98.1 build --target wasm32-wasip1-threads && wasmtime run -S threads -- target/wasm32-wasip1-threads/debug/tls-probe.wasm
cargo +1.99.0 build --target wasm32-wasip1-threads && wasmtime run -S threads -- target/wasm32-wasip1-threads/debug/tls-probe.wasm(wasmtime 35.0.0; newer wasmtime releases dropped the -S threads flag.)
I expected to see this happen: threads=4 tls_destructors_ran=4, as on 1.98.1 and every earlier release I tried.
Instead, this happened:
threads=4 tls_destructors_ran=0
thread 'main' (1) panicked at src/main.rs:30:5:
assertion `left == right` failed: TLS destructors did not run on wasm32-wasip1-threads
left: 0
right: 4
Version it worked on
It most recently worked on: Rust 1.98.1
rustc 1.98.1 (48a229cea 2026-09-01)
Version with regression
rustc --version --verbose:
rustc 1.99.0 (b940084d7 2026-09-28)
binary: rustc
commit-hash: b940084d7eb6a299eb4bfeb8e34901bc051e7ac4
commit-date: 2026-09-28
host: aarch64-apple-darwin
release: 1.99.0
Also reproduces on rustc 1.100.0-nightly (4aa1fbcf4 2026-09-08), and the relevant cfgs are unchanged on current master.
Cause
library/std/src/sys/thread_local/mod.rs. Before 1.99 the guard arm that selects the no-op "for now we just leak everything" implementation carved out wasm32-wasip1-threads, and the key module (pthread-key based TLS, used to trigger the destructor list at thread exit) was enabled for it:
// 1.98.1 — guard
any(
all(target_family = "wasm", not(
all(target_os = "wasi", target_env = "p1", target_feature = "atomics")
)),
target_os = "uefi", ...
) => { pub(crate) fn enable() { /* leak everything */ } }
// 1.98.1 — key
all(target_os = "wasi", target_env = "p1", target_feature = "atomics"),#159733 switched every WASI target to the pthread-key implementation. That exposed a wasi-libc bug on wasip1/wasip2 (#160828), so #160868 reverted it for non-p3 targets — but the revert replaced the condition with not(target_env = "p3") instead of restoring the original carve-out, so wasm32-wasip1-threads (target_env = "p1", atomics enabled) now lands in the leak-everything arm and key is no longer built for it:
// 1.99.0 — guard
any(
all(target_family = "wasm", not(target_env = "p3")),
target_os = "uefi", ...
) => { pub(crate) fn enable() { /* leak everything */ } }
// 1.99.0 — key
all(target_os = "wasi", target_env = "p3"),So thread_local! destructors are silently skipped on wasm32-wasip1-threads, which does have real thread exit (wasi-libc pthread_exit runs TSD destructors before the thread becomes joinable; that is what 1.98 relied on).
Suggested fix
Restore the carve-out in both arms:
// guard
all(target_family = "wasm", not(target_env = "p3"),
not(all(target_os = "wasi", target_env = "p1", target_feature = "atomics"))),
// key
any(
all(target_os = "wasi", target_env = "p3"),
all(target_os = "wasi", target_env = "p1", target_feature = "atomics"),
),Alternatively, now that wasi-sdk-34 is used on master (the condition #160868 named for dropping the workaround), #159733 could be re-applied as-is (target_os = "wasi" in both arms). The minimal carve-out restore seems like the safer candidate for a beta/stable backport.
Found via napi-rs CI, where a test asserting that worker-thread TLS destructors complete before a thread-pool shutdown() returns started failing on wasm32-wasip1-threads the day 1.99.0 shipped: https://github.com/napi-rs/napi-rs/actions/runs/37112042192/job/111172762587
@rustbot modify labels: +regression-from-stable-to-stable, +O-wasi, +T-libs, +A-thread-locals
Activity
maxdexh commentedon Oct 4, 2026
cc @alexcrichton
test(async-runtime): skip the worker TLS destructor barrier check whe…
alexcrichton commentedon Oct 4, 2026
Happy to review a PR for this, but I'll note that wasm32-wasip1-threads is a tier 2 target, tests aren't run for tier 2in CI, and I'm additionally not a maintainer of that target.
maxdexh commentedon Oct 5, 2026
No idea what's going on with the cfgs, but I've worked on this code before, so I'll give it a shot
@rustbot claim
Auto merge of #163809 - maxdexh:destroy-the-locals, r=alexcrichton,cl…
Rollup merge of rust-lang#163809 - maxdexh:destroy-the-locals, r=alex…
Rollup merge of rust-lang#163809 - maxdexh:destroy-the-locals, r=alex…
Rollup merge of rust-lang#163809 - maxdexh:destroy-the-locals, r=alex…
Rollup merge of rust-lang#163809 - maxdexh:destroy-the-locals, r=alex…
Rollup merge of rust-lang#163809 - maxdexh:destroy-the-locals, r=alex…
Rollup merge of rust-lang#163809 - maxdexh:destroy-the-locals, r=alex…
Unrolled build for #163809 in rollup 163957
Rollup merge of #163809 - maxdexh:destroy-the-locals, r=alexcrichton