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/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"); +} 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