Skip to content

double-free in safe code using thread-locals, when calling process::exit() #164243

Open

Description

@jsnell

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

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Oct 13, 2026
  2. maxdexh commented on Oct 13, 2026

    @maxdexh
    Member

    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.

    I have some way more contrived soundness bugs open ^^
    These are always welcome, so thanks for finding this!

  3. added
    A-runtimeArea: std's runtime and "pre-main" init for handling backtraces, unwinds, stack overflows
    I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness
    A-thread-localsArea: Thread local storage (TLS)
    T-libsRelevant to the library team, which will review and decide on the PR/issue.
    and removed
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Oct 13, 2026
  4. added
    I-prioritizeIssue needs a team member to assess the impact. Will be replaced by P-{low,medium,high,critical}
    on Oct 13, 2026
  5. maxdexh commented on Oct 13, 2026

    @maxdexh
    Member

    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

  6. theemathas commented on Oct 13, 2026

    @theemathas
    Contributor

    If something this unrealistic is annoying, please let me know and I'll adjust my threshold.

    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.

  7. maxdexh commented on Oct 13, 2026

    @maxdexh
    Member

    (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 ^^)

  8. maxdexh commented on Oct 13, 2026

    @maxdexh
    Member

    Simpler:

    use std::cell::RefCell;
    
    struct Ends;
    impl Drop for Ends {
        fn drop(&mut self) {
            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);
    }
  9. maxdexh commented on Oct 13, 2026

    @maxdexh
    Member

    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 with closure, and are creating a mutable ref at exit to drop it.

  10. 10 remaining items

  11. theemathas commented on Oct 13, 2026

    @theemathas
    Contributor

    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

  12. joboet commented on Oct 14, 2026

    @joboet
    Member

    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 making exit abort is much more surprising; accessing TLS should not have such non-local effects.

    @rustbot claim

  13. jsnell commented on Oct 14, 2026

    @jsnell
    Author

    @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.)

  14. theemathas commented on Oct 14, 2026

    @theemathas
    Contributor

    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.

  15. Eh2406 commented on Oct 14, 2026

    @Eh2406
    Contributor

    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!

  16. added
    O-linuxOperating system: Linux
    O-linux-gnuOperating system: Linux with glibc (i.e. a bug that can't happen with musl)
    and removed
    O-linuxOperating system: Linux
    on Oct 15, 2026
  17. joboet commented on Oct 15, 2026

    @joboet
    Member

    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 making exit abort is much more surprising; accessing TLS should not have such non-local effects.

    It turns out that this is neigh unimplementable on macOS. In general, this choice means we cannot use destructor mechanisms (_tlv_atexit on macOS and __cxa_thread_atexit_impl on Linux) intended for usage by the C++ standard library, as those are specified to run before atexit handlers when exit is called (N4950, 17.5.9.1):

    First, objects with thread storage duration and associated with the current thread are destroyed.
    Next, objects with static storage duration are destroyed and functions registered by calling atexit
    are called.

    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_impl is 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 an atexit handler to notice whether the _tlv_atexit callback was called in response to an exit since atexit handlers 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.

  18. added
    O-unixOperating system: Unix-like
    and removed
    O-linux-gnuOperating system: Linux with glibc (i.e. a bug that can't happen with musl)
    on Oct 21, 2026
  19. added
    I-libs-radarLibs issues that are tracked on the team's radar.
    and removed
    I-libs-radarLibs issues that are tracked on the team's radar.
    on Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

A-runtimeArea: std's runtime and "pre-main" init for handling backtraces, unwinds, stack overflowsA-thread-localsArea: Thread local storage (TLS)C-bugCategory: This is a bug.I-libs-radarLibs issues that are tracked on the team's radar.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessO-unixOperating system: Unix-likeP-highHigh priorityT-libsRelevant to the library team, which will review and decide on the PR/issue.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions