Repository navigation
double-free in safe code using thread-locals, when calling process::exit() #164243
Description
In this code:
use std::cell::{Cell, RefCell};
struct Ends;
thread_local!(static FIRST: Cell<bool> = Cell::new(true));
impl Drop for Ends {
fn drop(&mut self) {
if FIRST.try_with(|f| f.replace(false)).unwrap_or(false) {
std::process::exit(0);
}
}
}
thread_local!(static PAIR: RefCell<Option<(Box<[u8; 100]>, Ends)>> = RefCell::new(Some((Box::new([1; 100]), Ends))));
pub fn main() {
PAIR.with(|p| *p.borrow_mut() = None);
}I expected to see this happen: the process exits cleanly
Instead, this happened:
free(): double free detected in tcache 2
Program terminated with signal SIGABRT (6)
Basically the bug requires a thread-local to be in an inconsistent, half-changed state when process::exit is called from that same thread.
Meta
Happens on both current nightly and stables at least back to 1.85.0. I think this is distinct from #127637 (this is unsoundness, not just a documentation issue) and #126600 (this isn't about concurrent calls to exit).
Godbolt: https://rust.godbolt.org/z/aejM3nPYe
For my own calibration, are reports for such contrived scenarios useful or a nuisance? I'm triaging through a bunch of unsoundness and miscompilation bugs found as a side-effect of a project. If something this unrealistic is annoying, please let me know and I'll adjust my threshold.
Activity
maxdexh commentedon Oct 13, 2026
I have some way more contrived soundness bugs open ^^
These are always welcome, so thanks for finding this!
maxdexh commentedon Oct 13, 2026
I've looked at the std code for this a million times in the past, and I thought its logic was correct for handling cases like this. This is pretty surprising to me
@rustbot claim
theemathas commentedon Oct 13, 2026
We don't have a threshold for how realistic an unsoundness must be before we'll accept it as an issue (although we do have some cases, such as
/proc/self/mem, which are explicitly unsupported). For example, #135011 is an extremely unrealistic unsoundness that's still an open issue.maxdexh commentedon Oct 13, 2026
(when I said "these are always welcome", I meant that as in "always feel free to open these" (unless they're duplicates). even in the unlikely case we decide a soundness issue is unrealistic to fix, we still wanna know about it ^^)
maxdexh commentedon Oct 13, 2026
Simpler:
maxdexh commentedon Oct 13, 2026
Oh now I see it, this is an aliasing violation as well.
We're cleaning up the thread local during
with.This means we have an immutable ref in the
withclosure, and are creating a mutable ref at exit to drop it.10 remaining items
theemathas commentedon Oct 13, 2026
Out of curiosity, what kind of project led to you filing these unsoundness issues? Is it an LLM thing that you can share your harness setup or something? @jsnell
joboet commentedon Oct 14, 2026
I have a strong preference for option b), i.e. not running TLS destructors on
exit. We don't clean up the thread's stack either, so that seems like the most consistent and expected behaviour. The alternative of makingexitabort is much more surprising; accessing TLS should not have such non-local effects.@rustbot claim
jsnell commentedon Oct 14, 2026
@theemathas An LLM thing, but there's not really any harness setup to share.
I was having Claude Code implement a frontend for a language with a Rust-inspired but different type system. One thing it chose to do was to reason about that new frontend's potential soundness programs and write programs to probe for the weak points. It also translated the probe programs to Rust and tested them on rustc.
So these were not originally targeting rustc or based on auditing the rustc source or Rust rules, they're the byproducts of targeting the issues in totally unrelated sourcebase, and even that was a side-quest from actually implementing the front-end. I'd expect that just handing Claude the rustc source code and asking it to systematically find soundness bugs to give a better yield than what I was doing.
(I'm only reporting the issues that I've verified by hand. All the prose is written by me, the program text is by Claude.)
theemathas commentedon Oct 14, 2026
Whatever you did, it seemed to have given the LLM a different "angle of attack", making it find issues that other people and other AIs haven't.
Eh2406 commentedon Oct 14, 2026
That is a fascinating and unexpected harness. I am so glad that it is working for you, and that you're willing to submit these bugs! I am currently employed by the Rust Foundation to try facilitate getting AI discovered bugs fixed. You seem to have a workflow that is working for you and for the maintainers and follows the LLM policy. If there's anything I can do to help, please let me know!
joboet commentedon Oct 15, 2026
It turns out that this is neigh unimplementable on macOS. In general, this choice means we cannot use destructor mechanisms (
_tlv_atexiton macOS and__cxa_thread_atexit_implon Linux) intended for usage by the C++ standard library, as those are specified to run beforeatexithandlers whenexitis called (N4950, 17.5.9.1):On non-Apple platforms, we can circumvent this by scheduling the destructor callback using a pthread TLS key like we currently do if
__cxa_thread_atexit_implis missing. But unfortunately, Apple's implementation of linker-enabled thread locals uses pthread keys internally, which means that by the time our destructor callback is run, the thread local storage might have already been deallocated, thus leaking all our TLS variables. On macOS, the only way to run destructors for linker-enabled TLS variables is to use_tlv_atexit. Unfortunately, we cannot even use anatexithandler to notice whether the_tlv_atexitcallback was called in response to anexitsinceatexithandlers run after the TLS destructors. The only conceivable strategy would be to disable native TLS on macOS, but that seems undesirable from a performance perspective.