From 895699052a08fafbdfe0ee3fcedba8e671c25d65 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 24 Oct 2026 07:04:02 -0700 Subject: [PATCH 1/2] fix(init): skip heap teardown once ExitProcess has begun ExitProcess terminates every other thread wherever it stands, then runs the exiting thread's FLS callbacks, thread_done_cb among them. A thread killed while holding HEAPS_LOCK (a pool worker in its own teardown) left heaps_unregister spinning forever, in a process TerminateProcess cannot end. ArtCraft hit it 2 times in ~1,060 runs; thread_done_cb+0x6e2 in their binary is the heaps_lock spin. thread_done_cb now returns when RtlDllShutdownInProgress() is true: nothing can adopt the heap and the address space is about to go. tests/exit_hang.rs parks a thread inside for_each_heap (HEAPS_LOCK held) and exits the process; it hung on 2.2.6 and exits in under a second now. Co-Authored-By: Claude Opus 5.5 --- crates/rusty_alloc/CHANGELOG.md | 16 +++++ crates/rusty_alloc/src/init.rs | 12 ++++ crates/rusty_alloc/src/prim/mod.rs | 7 +++ crates/rusty_alloc/src/prim/windows.rs | 20 ++++++ crates/rusty_alloc/tests/exit_hang.rs | 87 ++++++++++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 crates/rusty_alloc/tests/exit_hang.rs diff --git a/crates/rusty_alloc/CHANGELOG.md b/crates/rusty_alloc/CHANGELOG.md index cd29b30..027091b 100644 --- a/crates/rusty_alloc/CHANGELOG.md +++ b/crates/rusty_alloc/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.2.7](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.2.6...rusty_alloc-v2.2.7) - 2026-10-24 + +### Fixed + +- **A process no longer hangs in `ExitProcess`** (Windows, multithreaded). + `ExitProcess` terminates every other thread wherever it stands, then runs + the exiting thread's FLS callbacks, including the one that abandons its + heap. A thread killed while it held the heap-registry lock (for example a + pool worker in its own teardown) left that teardown spinning on the lock + forever, at 100 % of a core, in a process `TerminateProcess` cannot end. + Reported by ArtCraft: 2 hangs in about 1,060 runs of a rayon program, one + core, loaded box. The callback now does nothing once process shutdown has + begun (`RtlDllShutdownInProgress`); the address space is about to be + discarded and nothing could adopt the abandoned heap anyway. Ordinary + thread exits are unchanged. Regression test: `tests/exit_hang.rs`. + ## [2.2.6](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.2.5...rusty_alloc-v2.2.6) - 2026-10-09 ### Fixed diff --git a/crates/rusty_alloc/src/init.rs b/crates/rusty_alloc/src/init.rs index c9673aa..ed66d50 100644 --- a/crates/rusty_alloc/src/init.rs +++ b/crates/rusty_alloc/src/init.rs @@ -753,6 +753,18 @@ fn done_slot() -> prim::TlsSlot { #[cfg(all(windows, not(miri)))] unsafe extern "system" fn thread_done_cb(v: *const c_void) { + // Inside `ExitProcess` this runs on the exiting thread AFTER the kernel + // has terminated every other thread wherever it stood — possibly inside + // one of our spin-locked critical sections (`HEAPS_LOCK`, + // `abandoned_lock`, an arena lock) or with a page held in + // `XFLAG_FREEING`. Teardown then waits on a thread that no longer exists, + // forever, in a process `TerminateProcess` can no longer end: the 2.2.6 + // exit hang (ArtCraft, spinning in `heaps_unregister`; + // `tests/exit_hang.rs`). Nothing will ever adopt what we would abandon, + // and the address space is about to go, so there is nothing to do. + if prim::process_exiting() { + return; + } if !v.is_null() { // SAFETY: v is the HeapBox this thread stored in the done slot. unsafe { thread_done(v.cast_mut().cast()) }; diff --git a/crates/rusty_alloc/src/prim/mod.rs b/crates/rusty_alloc/src/prim/mod.rs index ecfe55c..137708b 100644 --- a/crates/rusty_alloc/src/prim/mod.rs +++ b/crates/rusty_alloc/src/prim/mod.rs @@ -250,6 +250,13 @@ pub fn clock_now() -> u64 { sys::clock_now() } +/// Whether the process is exiting: `ExitProcess` has started, and every other +/// thread has already been terminated wherever it stood. +#[cfg(all(windows, not(miri)))] +pub fn process_exiting() -> bool { + sys::process_exiting() +} + /// Platform-native TLS destructor signature. On x86-64/aarch64 the `"system"` /// and `"C"` ABIs coincide; the alias exists so each backend hands its OS the /// exact type it expects. diff --git a/crates/rusty_alloc/src/prim/windows.rs b/crates/rusty_alloc/src/prim/windows.rs index dd4524a..79ba22a 100644 --- a/crates/rusty_alloc/src/prim/windows.rs +++ b/crates/rusty_alloc/src/prim/windows.rs @@ -312,6 +312,26 @@ pub(super) fn clock_now() -> u64 { ticks_to_ns(count.max(0) as u64, freq, SCALE.load(Ordering::Relaxed)) } +// Exported by ntdll since NT 4 but absent from windows-sys, so there is no +// import library to name; `raw-dylib` needs none. On x86 the default import +// name would be stdcall-decorated, which ntdll does not export. +#[cfg_attr( + target_arch = "x86", + link(name = "ntdll", kind = "raw-dylib", import_name_type = "undecorated") +)] +#[cfg_attr(not(target_arch = "x86"), link(name = "ntdll", kind = "raw-dylib"))] +unsafe extern "system" { + fn RtlDllShutdownInProgress() -> u8; +} + +/// True once `ExitProcess` has begun: the loader sets this flag in +/// `LdrShutdownProcess`, after the kernel has terminated every other thread +/// and before it runs the exiting thread's FLS callbacks. +pub(super) fn process_exiting() -> bool { + // SAFETY: no arguments, no preconditions; it reads a loader flag. + unsafe { RtlDllShutdownInProgress() != 0 } +} + pub(super) struct TlsSlotImpl(u32); pub(super) fn tls_new(dtor: Option) -> Option { diff --git a/crates/rusty_alloc/tests/exit_hang.rs b/crates/rusty_alloc/tests/exit_hang.rs new file mode 100644 index 0000000..8039b92 --- /dev/null +++ b/crates/rusty_alloc/tests/exit_hang.rs @@ -0,0 +1,87 @@ +//! A process must EXIT even when another thread died holding an allocator lock. +//! +//! REGRESSION TEST for the 2.2.6 exit hang (ArtCraft, DesignCraft CLI, 1 run in +//! ~260, one core, a loaded box). `ExitProcess` terminates every other thread +//! wherever it stands, THEN runs the FLS callbacks of the exiting thread, and +//! `thread_done_cb` is one of them. A rayon worker that was killed mid-way +//! through its own teardown still held `HEAPS_LOCK`; the exiting thread's +//! `heaps_unregister` spun on it forever, at 100% of a core, in a process that +//! `TerminateProcess` cannot end because it is already terminating. +//! +//! The race needs a thread to be killed inside a few-instruction critical +//! section, which is why it took hundreds of runs to see. This test removes the +//! race: a thread parks INSIDE `for_each_heap`'s callback, where `HEAPS_LOCK` is +//! held, and the test thread — which owns a heap, so its FLS callback is armed — +//! exits the process. Deterministic on Windows. On unix it passes trivially: +//! `exit` runs no thread-key destructors, so `thread_done` never runs there. +//! +//! ⚠ If this regresses, the child hangs and CANNOT be killed: `TerminateProcess` +//! refuses a terminating process. The test fails at the timeout and names the +//! pid; end the child by terminating its one remaining thread (`TerminateThread`). + +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +const MARKER: &str = "RUSTY_ALLOC_EXIT_HANG_CHILD"; +const EXIT_CODE: i32 = 41; + +fn child() -> ! { + static HELD: AtomicBool = AtomicBool::new(false); + // Arm this thread's FLS callback: its first allocation creates its heap + // and stores it in the done slot. + let p = rusty_alloc::alloc::malloc(64); + assert!(!p.is_null(), "child: malloc failed"); + + std::thread::spawn(|| { + rusty_alloc::init::for_each_heap(&mut |_| { + HELD.store(true, Ordering::Release); + loop { + std::thread::park(); + } + }); + }); + while !HELD.load(Ordering::Acquire) { + std::thread::yield_now(); + } + // HEAPS_LOCK is held by a thread `exit` is about to kill. + std::process::exit(EXIT_CODE); +} + +// Miri cannot spawn a child process. +#[cfg_attr(miri, ignore)] +#[test] +fn exit_with_a_lock_held_by_a_killed_thread_does_not_hang() { + if std::env::var(MARKER).is_ok() { + child(); + } + + let exe = std::env::current_exe().expect("current_exe"); + let mut c = Command::new(exe) + .env(MARKER, "1") + .args([ + "--exact", + "exit_with_a_lock_held_by_a_killed_thread_does_not_hang", + ]) + // Not inherited: a hung child would hold the harness's pipes open and + // stall `cargo test` long after this test has failed. + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn child"); + + let deadline = Instant::now() + Duration::from_secs(30); + let status = loop { + if let Some(s) = c.try_wait().expect("wait child") { + break s; + } + assert!( + Instant::now() < deadline, + "child {} did not exit in 30 s: it is spinning in thread teardown on a lock \ + held by a thread ExitProcess killed (it cannot be ended with TerminateProcess)", + c.id() + ); + std::thread::sleep(Duration::from_millis(50)); + }; + assert_eq!(status.code(), Some(EXIT_CODE), "child exited abnormally"); +} From b1211b12f9aae7e7898358f070c9c7a1e671e027 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 24 Oct 2026 07:04:29 -0700 Subject: [PATCH 2/2] chore: release v2.2.7 -- the ExitProcess hang fix alone, on 2.2.6 Cut from the 2.2.6 tag so the unreleased speed work on main does not ship with it, the same way 2.2.6 was cut from 2.2.5. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- crates/rusty_alloc_api/CHANGELOG.md | 8 ++++++++ crates/rusty_alloc_api/Cargo.toml | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd54b3e..47c00fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,7 +275,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty_alloc" -version = "2.2.6" +version = "2.2.7" dependencies = [ "libc", "loom", @@ -286,14 +286,14 @@ dependencies = [ [[package]] name = "rusty_alloc-api" -version = "2.2.6" +version = "2.2.7" dependencies = [ "rusty_alloc", ] [[package]] name = "rusty_alloc-bench" -version = "2.2.6" +version = "2.2.7" dependencies = [ "libloading", "rusty_alloc", @@ -302,14 +302,14 @@ dependencies = [ [[package]] name = "rusty_alloc-ffi" -version = "2.2.6" +version = "2.2.7" dependencies = [ "rusty_alloc", ] [[package]] name = "rusty_alloc-override" -version = "2.2.6" +version = "2.2.7" dependencies = [ "rusty_alloc", "rusty_alloc-ffi", @@ -317,7 +317,7 @@ dependencies = [ [[package]] name = "rusty_alloc-wasm" -version = "2.2.6" +version = "2.2.7" dependencies = [ "rusty_alloc", ] diff --git a/Cargo.toml b/Cargo.toml index eeb9fb6..6d4d9c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,7 +132,7 @@ members = [ # different files that never got the section. Fixed here, in both, and # verified by extracting the packaged `.crate` before publishing rather than # checking the live page afterwards. -version = "2.2.6" +version = "2.2.7" edition = "2024" # MSRV. Declared for the first time in 1.0.1 because this release genuinely # needs it: `asm!` label blocks (`asm_goto`, stable 1.87) carry the free path's diff --git a/crates/rusty_alloc_api/CHANGELOG.md b/crates/rusty_alloc_api/CHANGELOG.md index a822243..1f53335 100644 --- a/crates/rusty_alloc_api/CHANGELOG.md +++ b/crates/rusty_alloc_api/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.2.7](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-api-v2.2.6...rusty_alloc-api-v2.2.7) - 2026-10-24 + +### Fixed + +- Picks up `rusty_alloc` 2.2.7: a multithreaded process no longer hangs in + `ExitProcess` on Windows when a thread was killed holding an allocator + lock. This crate's code is unchanged. + ## [2.2.6](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-api-v2.2.5...rusty_alloc-api-v2.2.6) - 2026-10-09 ### Fixed diff --git a/crates/rusty_alloc_api/Cargo.toml b/crates/rusty_alloc_api/Cargo.toml index 7e0e20e..2a72351 100644 --- a/crates/rusty_alloc_api/Cargo.toml +++ b/crates/rusty_alloc_api/Cargo.toml @@ -14,7 +14,7 @@ readme = "README.md" [dependencies] # A published crate needs a VERSION alongside the path: the path is what the # workspace builds against, the version is what crates.io resolves. -rusty_alloc = { path = "../rusty_alloc", version = "2.2.6", default-features = false } +rusty_alloc = { path = "../rusty_alloc", version = "2.2.7", default-features = false } [features] # Mirrors the core's (P3, docs/plans/small-metal.md). Default-on, so every