Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions crates/rusty_alloc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions crates/rusty_alloc/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) };
Expand Down
7 changes: 7 additions & 0 deletions crates/rusty_alloc/src/prim/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions crates/rusty_alloc/src/prim/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TlsDtor>) -> Option<TlsSlotImpl> {
Expand Down
87 changes: 87 additions & 0 deletions crates/rusty_alloc/tests/exit_hang.rs
Original file line number Diff line number Diff line change
@@ -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");
}
8 changes: 8 additions & 0 deletions crates/rusty_alloc_api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/rusty_alloc_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading