Skip to content

Windows TLS - Only register the atexit hook when cleanup can be unloaded#157645

Open
ohadravid wants to merge 1 commit into
rust-lang:mainfrom
ohadravid:windows-tls-leak-on-dll-instead-of-maybe-deadlock-on-shutdown
Open

Windows TLS - Only register the atexit hook when cleanup can be unloaded#157645
ohadravid wants to merge 1 commit into
rust-lang:mainfrom
ohadravid:windows-tls-leak-on-dll-instead-of-maybe-deadlock-on-shutdown

Conversation

@ohadravid

@ohadravid ohadravid commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Followup to #148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the docs). However, it is still possible to deadlock on Windows with a destructor that uses a join, but only during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling FlsFree from the atexit hook from the main executable can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if cleanup can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering FlsFree at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the cleanup hook is unload-able is done by checking if it is in the same module as the main exe GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null()).

Join-on-Drop on Process Exit Deadlock Example
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {   
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}


fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}

r? @ChrisDenton

@rustbot rustbot added A-run-make Area: port run-make Makefiles to rmake.rs O-windows Operating system: Windows S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels Jun 9, 2026
@rustbot

rustbot commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

ChrisDenton is not on the review rotation at the moment.
They may take a while to respond.

@ChrisDenton

Copy link
Copy Markdown
Member

I like avoiding atexit for the exe itself. I'm less certain about skipping TLS dtors entirely on DLL unload. In any case, let's first check what CI says.

@bors try jobs=msvc,mingw

@rust-bors

This comment has been minimized.

rust-bors Bot pushed a commit that referenced this pull request Jun 9, 2026
…maybe-deadlock-on-shutdown, r=<try>

Windows TLS - when `cleanup` can be unloaded, leak instead of running dtors in `atexit`


try-job: *msvc*
try-job: *mingw*
@ChrisDenton

Copy link
Copy Markdown
Member

Hm, looking at the current docs for LocalKey, it says:

Note that a “best effort” is made to ensure that destructors for types stored in thread local storage are run...

This suggests to me we should favour attempting to run dtors but document the caveats. A deadlock is not a memory safety issue and many types of dtors (e.g. freeing memory) likely won't deadlock. Unloading a DLL (outside of program termination) is a niche use case but not so niche that I think we can justify not running dtors at all if the API claims to bias towards running them.

@ohadravid

Copy link
Copy Markdown
Contributor Author

I'm less certain about skipping TLS dtors entirely on DLL unload. In any case, let's first check what CI says.

Actually, seems like I was wrong about the cause of the deadlock: the issue is specifically calling FlsFree from the main exec atexit (and not the cleanup hook's content - we deadlock before we get there), so I think it's possible to have DLL-unload-dtors as well as not deadlocking in this case.

I'll need to investigate this a bit more and I'll update the impl once I have a better idea if the fix is legit.

@rust-bors

rust-bors Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

☀️ Try build successful (CI)
Build commit: 6ae382d (6ae382dfd22a3b67ed22b0a695d5d1f95d3a7fde, parent: a9625940af29aa50d785aee438b90f3b272c156f)

@ohadravid ohadravid force-pushed the windows-tls-leak-on-dll-instead-of-maybe-deadlock-on-shutdown branch from 282e4fe to 6bf8d21 Compare June 9, 2026 15:52
@ohadravid ohadravid changed the title Windows TLS - when cleanup can be unloaded, leak instead of running dtors in atexit Windows TLS - when cleanup can be unloaded, do not register the atexit hook Jun 9, 2026
@ohadravid ohadravid force-pushed the windows-tls-leak-on-dll-instead-of-maybe-deadlock-on-shutdown branch from 6bf8d21 to 6830dac Compare June 9, 2026 17:23
@ohadravid ohadravid changed the title Windows TLS - when cleanup can be unloaded, do not register the atexit hook Windows TLS - Only register the atexit hook when cleanup can be unloaded Jun 9, 2026
@ohadravid ohadravid force-pushed the windows-tls-leak-on-dll-instead-of-maybe-deadlock-on-shutdown branch from 6830dac to b545eb8 Compare June 9, 2026 17:28
@ohadravid

Copy link
Copy Markdown
Contributor Author

@ChrisDenton changed to not-leak when DLL unload, kept the no-atexit-in-main-exec because that solves the FlsFree lock. I don't think the docs need any changes now because if you unload the DLL we say that the dtor will run so they must run in the loader lock.

@ChrisDenton ChrisDenton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'm happier with this. Just one nit I've commented on.

View changes since this review

Comment thread library/std/src/sys/thread_local/guard/windows.rs Outdated
@ohadravid ohadravid force-pushed the windows-tls-leak-on-dll-instead-of-maybe-deadlock-on-shutdown branch from b545eb8 to f5e5709 Compare June 10, 2026 14:09
@ChrisDenton

Copy link
Copy Markdown
Member

@bors r+

@rust-bors

rust-bors Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

📌 Commit f5e5709 has been approved by ChrisDenton

It is now in the queue for this repository.

@rust-bors rust-bors Bot added S-waiting-on-bors Status: Waiting on bors to run and complete tests. Bors will change the label on completion. and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels Jun 10, 2026
JonathanBrouwer added a commit to JonathanBrouwer/rust that referenced this pull request Jun 10, 2026
…instead-of-maybe-deadlock-on-shutdown, r=ChrisDenton

Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded

Followup to rust-lang#148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the [docs](https://doc.rust-lang.org/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors)). However, it is still _possible_ to deadlock on Windows with a destructor that uses a `join`, but **only** during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling `FlsFree` from the `atexit` hook _from the main executable_ can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if `cleanup` can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering `FlsFree` at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the `cleanup` hook is unload-able is done by checking if it is in the same module as the main exe `GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null())`.

<details>

<summary>Join-on-Drop on Process Exit Deadlock Example</summary>

```rust
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}

fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}
```
</details>

r? @ChrisDenton
jhpratt added a commit to jhpratt/rust that referenced this pull request Jun 10, 2026
…instead-of-maybe-deadlock-on-shutdown, r=ChrisDenton

Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded

Followup to rust-lang#148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the [docs](https://doc.rust-lang.org/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors)). However, it is still _possible_ to deadlock on Windows with a destructor that uses a `join`, but **only** during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling `FlsFree` from the `atexit` hook _from the main executable_ can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if `cleanup` can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering `FlsFree` at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the `cleanup` hook is unload-able is done by checking if it is in the same module as the main exe `GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null())`.

<details>

<summary>Join-on-Drop on Process Exit Deadlock Example</summary>

```rust
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}

fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}
```
</details>

r? @ChrisDenton
rust-bors Bot pushed a commit that referenced this pull request Jun 10, 2026
Rollup of 23 pull requests

Successful merges:

 - #157280 (traits: Allow escaping self types in ExistentialTraitRef::with_self_ty)
 - #157282 (Fix post-monomorphization error note race in the parallel frontend)
 - #157352 (Make the retained dep graph deterministic under the parallel frontend)
 - #157601 (Emit error for unused target expression in glob and list delegations)
 - #157611 (Update `browser-ui-test` version to `0.24.0`)
 - #157620 (Add a strategy FnMut to inject behavior into the flush cycle)
 - #157645 (Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded)
 - #157647 (Start using comptime for reflection intrinsics and their wrapper functions)
 - #157719 (resolve: Partially revert "Remove a special case for dummy imports")
 - #156497 (fix-155516: Don't suggest wrong unwrap expect)
 - #156583 (Support defaults for static EIIs)
 - #157013 (Ensure inferred let pattern types are well-formed)
 - #157230 (borrowck: avoid ICE describing fields on generic params)
 - #157288 (platform support: add SNaN erratum to MIPS targets)
 - #157350 (compiletest: ignore SVG `y` offset in by-lines comparison)
 - #157355 (Add `or_try_*` variants for `HashMap` and `BTreeMap` Entry APIs)
 - #157384 (Add `#[rustc_dump_generics]` attribute)
 - #157577 (Fix parser error recovery treating 'dyn' as a strict keyword in Rust 2015 when used in `dyn + dyn`)
 - #157670 (Rename `errors.rs` file to `diagnostics.rs` (4/N))
 - #157691 (Move symbol hiding code to a new file)
 - #157697 (Add more tests that exercise the well-formedness checking of lazy type aliases)
 - #157700 (Rename `errors.rs` file to `diagnostics.rs` (5/N))
 - #157703 (Fix doc link to Instant sub in saturating caveat)

Failed merges:

 - #157699 (Arg splat experiment - hir FnDecl impl)
rust-bors Bot pushed a commit that referenced this pull request Jun 11, 2026
Rollup of 23 pull requests

Successful merges:

 - #157280 (traits: Allow escaping self types in ExistentialTraitRef::with_self_ty)
 - #157282 (Fix post-monomorphization error note race in the parallel frontend)
 - #157352 (Make the retained dep graph deterministic under the parallel frontend)
 - #157601 (Emit error for unused target expression in glob and list delegations)
 - #157611 (Update `browser-ui-test` version to `0.24.0`)
 - #157620 (Add a strategy FnMut to inject behavior into the flush cycle)
 - #157645 (Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded)
 - #157647 (Start using comptime for reflection intrinsics and their wrapper functions)
 - #157719 (resolve: Partially revert "Remove a special case for dummy imports")
 - #156497 (fix-155516: Don't suggest wrong unwrap expect)
 - #156583 (Support defaults for static EIIs)
 - #157013 (Ensure inferred let pattern types are well-formed)
 - #157230 (borrowck: avoid ICE describing fields on generic params)
 - #157288 (platform support: add SNaN erratum to MIPS targets)
 - #157350 (compiletest: ignore SVG `y` offset in by-lines comparison)
 - #157355 (Add `or_try_*` variants for `HashMap` and `BTreeMap` Entry APIs)
 - #157384 (Add `#[rustc_dump_generics]` attribute)
 - #157577 (Fix parser error recovery treating 'dyn' as a strict keyword in Rust 2015 when used in `dyn + dyn`)
 - #157670 (Rename `errors.rs` file to `diagnostics.rs` (4/N))
 - #157691 (Move symbol hiding code to a new file)
 - #157697 (Add more tests that exercise the well-formedness checking of lazy type aliases)
 - #157700 (Rename `errors.rs` file to `diagnostics.rs` (5/N))
 - #157703 (Fix doc link to Instant sub in saturating caveat)

Failed merges:

 - #157699 (Arg splat experiment - hir FnDecl impl)
jhpratt added a commit to jhpratt/rust that referenced this pull request Jun 11, 2026
…instead-of-maybe-deadlock-on-shutdown, r=ChrisDenton

Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded

Followup to rust-lang#148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the [docs](https://doc.rust-lang.org/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors)). However, it is still _possible_ to deadlock on Windows with a destructor that uses a `join`, but **only** during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling `FlsFree` from the `atexit` hook _from the main executable_ can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if `cleanup` can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering `FlsFree` at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the `cleanup` hook is unload-able is done by checking if it is in the same module as the main exe `GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null())`.

<details>

<summary>Join-on-Drop on Process Exit Deadlock Example</summary>

```rust
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}

fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}
```
</details>

r? @ChrisDenton
jhpratt added a commit to jhpratt/rust that referenced this pull request Jun 11, 2026
…instead-of-maybe-deadlock-on-shutdown, r=ChrisDenton

Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded

Followup to rust-lang#148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the [docs](https://doc.rust-lang.org/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors)). However, it is still _possible_ to deadlock on Windows with a destructor that uses a `join`, but **only** during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling `FlsFree` from the `atexit` hook _from the main executable_ can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if `cleanup` can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering `FlsFree` at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the `cleanup` hook is unload-able is done by checking if it is in the same module as the main exe `GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null())`.

<details>

<summary>Join-on-Drop on Process Exit Deadlock Example</summary>

```rust
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}

fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}
```
</details>

r? @ChrisDenton
jhpratt added a commit to jhpratt/rust that referenced this pull request Jun 11, 2026
…instead-of-maybe-deadlock-on-shutdown, r=ChrisDenton

Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded

Followup to rust-lang#148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the [docs](https://doc.rust-lang.org/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors)). However, it is still _possible_ to deadlock on Windows with a destructor that uses a `join`, but **only** during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling `FlsFree` from the `atexit` hook _from the main executable_ can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if `cleanup` can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering `FlsFree` at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the `cleanup` hook is unload-able is done by checking if it is in the same module as the main exe `GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null())`.

<details>

<summary>Join-on-Drop on Process Exit Deadlock Example</summary>

```rust
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}

fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}
```
</details>

r? @ChrisDenton
jhpratt added a commit to jhpratt/rust that referenced this pull request Jun 11, 2026
…instead-of-maybe-deadlock-on-shutdown, r=ChrisDenton

Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded

Followup to rust-lang#148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the [docs](https://doc.rust-lang.org/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors)). However, it is still _possible_ to deadlock on Windows with a destructor that uses a `join`, but **only** during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling `FlsFree` from the `atexit` hook _from the main executable_ can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if `cleanup` can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering `FlsFree` at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the `cleanup` hook is unload-able is done by checking if it is in the same module as the main exe `GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null())`.

<details>

<summary>Join-on-Drop on Process Exit Deadlock Example</summary>

```rust
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}

fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}
```
</details>

r? @ChrisDenton
jhpratt added a commit to jhpratt/rust that referenced this pull request Jun 11, 2026
…instead-of-maybe-deadlock-on-shutdown, r=ChrisDenton

Windows TLS - Only register the `atexit` hook when `cleanup` can be unloaded

Followup to rust-lang#148799 - One of the motivations was to remove the "Synchronization in thread-local destructors" limitation (see the [docs](https://doc.rust-lang.org/std/thread/struct.LocalKey.html#synchronization-in-thread-local-destructors)). However, it is still _possible_ to deadlock on Windows with a destructor that uses a `join`, but **only** during an application shutdown.

(Edited. The original PR leaked the dtors when a DLL was unloaded.)

This is because calling `FlsFree` from the `atexit` hook _from the main executable_ can deadlock if other threads are also running FLS destructors.

This PR tweaks the implementation to only register the hook if `cleanup` can be unloaded, meaning we are loaded as a DLL.

This has an added benefit of not triggering `FlsFree` at all during normal application shutdown, which is the most common case and is now handled more naturally by the Windows runtime (which calls the FLS hooks without holding the load-lock).

Checking if the `cleanup` hook is unload-able is done by checking if it is in the same module as the main exe `GetModuleHandleExW(FLAG_FROM_ADDRESS, cleanup) == GetModuleHandleW(ptr::null())`.

<details>

<summary>Join-on-Drop on Process Exit Deadlock Example</summary>

```rust
use std::thread::JoinHandle;
use std::time::Duration;

struct JoinOnDrop(Option<JoinHandle<()>>);

impl Drop for JoinOnDrop {
    fn drop(&mut self) {
        println!("Joining thread... ({:?})", std::thread::current());
        self.0.take().unwrap().join().unwrap();
        println!("Thread joined");
    }
}

thread_local! {
    static HANDLE: JoinOnDrop = {
        let thread = std::thread::spawn(|| {
            println!("Starting...");
            std::thread::sleep(Duration::from_secs(3));
            println!("Done ({:?})", std::thread::current());
        });

        JoinOnDrop(Some(thread))
    };
}

fn main() {
    let thread = std::thread::spawn(|| {
        HANDLE.with(|_| {
            println!("Some other thread ({:?})", std::thread::current());
        })
    });

    // ***Here***, because we do not join beforehand, the drops are called from atexit which is done while in loader-lock, so the the binary will deadlock.
    // thread.join().unwrap();
    std::thread::sleep(Duration::from_secs(1));

    println!("Done ({:?})", std::thread::current());
}
```
</details>

r? @ChrisDenton
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-run-make Area: port run-make Makefiles to rmake.rs O-windows Operating system: Windows S-waiting-on-bors Status: Waiting on bors to run and complete tests. Bors will change the label on completion. T-compiler Relevant to the compiler team, which will review and decide on the PR/issue. T-libs Relevant to the library team, which will review and decide on the PR/issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants