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
26 changes: 26 additions & 0 deletions crates/rusty_alloc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **A failed re-commit no longer hands out unbacked memory (Windows, purging
on).** With `purge_delay >= 0` and `purge_decommits` on, a freed span is
decommitted, and reuse re-commits it — but the result of that commit was
discarded. Windows has no overcommit, so `MEM_COMMIT` fails once the
system's commit is exhausted, and the span was carved anyway: the first
store into it was an access violation in `page_extend`. One was seen in a
shipping consumer (`secure` on, `purge_delay = 0`, the machine at 98.5 % of
its commit limit). A failed re-commit is now a failed span: it stays on the
free list still marked purged, the allocator moves on, and true exhaustion
ends as an ordinary allocation failure. The default configuration
(`purge_delay = -1`) never purges and was not exposed. Details:
`docs/plans/recommit-failure-ignored.md`.
- **The same for whole segments.** `segment_free` and `huge_free` restore a
purged or guarded segment's commit and access before recycling it, and
dropped both results. A segment the OS will not restore is no longer
recycled: it is released to the OS, or, inside an arena, retired in place.

### Added

- `stats::commit_failures()`: a process-wide count of commits refused on
those paths, also printed by `stats::print_process` as `failed commits N`.
Non-zero after a fault in the page layer says the machine ran out of
commit.

## [2.2.2](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.2.1...rusty_alloc-v2.2.2) - 2026-10-01

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions crates/rusty_alloc/UNSAFE.md

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions crates/rusty_alloc/src/arena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,43 @@ pub fn chunk_free_n(p: *mut u8, n: usize) -> bool {
false
}

/// Whether `p` lies inside any arena's live chunks — the question
/// [`chunk_free`] answers as a side effect, asked without freeing anything.
///
/// A segment whose memory cannot be made usable again must not be returned
/// to its arena (arena memory is committed once, at reservation, and handed
/// out as-is), and a chunk inside an arena's reservation cannot be released
/// to the OS on its own either. Such a segment is retired in place, and this
/// is how the release path tells the two cases apart.
#[allow(clippy::needless_range_loop)] // indexed scan over a fixed atomic table
pub fn owns(p: *const u8) -> bool {
if crate::FIXED_REGION {
return false;
}
let addr = p.addr();
let n = ARENA_COUNT.load(Ordering::Acquire).min(MAX_ARENAS);
for id in 0..n {
let a = ARENAS[id].load(Ordering::Acquire);
if a.is_null() {
continue;
}
// SAFETY: live descriptor; only its base and live-chunk count are read.
unsafe {
let chunks = (*a).chunks_live.load(Ordering::Acquire);
let Some(span) = chunks.checked_mul(SEGMENT_SIZE) else {
continue;
};
let Some(end_addr) = (*a).base.addr().checked_add(span) else {
continue;
};
if addr >= (*a).base.addr() && addr < end_addr {
return true;
}
}
}
false
}

/// Return a chunk to its arena. True when the address belonged to one.
#[allow(clippy::needless_range_loop)] // indexed scan over a fixed atomic table
pub fn chunk_free(p: *mut u8) -> bool {
Expand Down
34 changes: 34 additions & 0 deletions crates/rusty_alloc/src/os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,44 @@ pub unsafe fn free(block: OsBlock) -> Result<(), PrimError> {
/// # Safety
/// Range must lie within a live block from [`alloc_aligned`], page-aligned.
pub unsafe fn commit(ptr: *mut u8, size: usize) -> Result<bool, PrimError> {
#[cfg(all(test, feature = "std"))]
if test_hooks::take_commit_failure() {
return Err(test_hooks::INJECTED);
}
// SAFETY: forwarded contract.
unsafe { prim::commit(ptr, page_align_up(size)) }
}

/// Test-only fault injection: make this thread's next `n` commits fail, the
/// way `VirtualAlloc(MEM_COMMIT)` fails on Windows once the system's commit is
/// exhausted. Thread-local, so a parallel test's commits are never taken.
#[cfg(all(test, feature = "std"))]
pub(crate) mod test_hooks {
use core::cell::Cell;

/// The synthetic error an injected failure returns.
pub const INJECTED: super::PrimError = 0xFA11;

std::thread_local! {
static FAIL_COMMITS: Cell<usize> = const { Cell::new(0) };
}

/// Fail this thread's next `n` commits.
pub fn fail_next_commits(n: usize) {
FAIL_COMMITS.with(|c| c.set(n));
}

pub(super) fn take_commit_failure() -> bool {
FAIL_COMMITS.with(|c| {
let n = c.get();
if n > 0 {
c.set(n - 1);
}
n > 0
})
}
}

/// Decommit a page-aligned sub-range. Returns whether recommit is required.
///
/// # Safety
Expand Down
202 changes: 170 additions & 32 deletions crates/rusty_alloc/src/segment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,34 @@ pub fn segment_alloc(arena_id: i32) -> Result<*mut Segment, PrimError> {
Ok(seg)
}

/// Make a dead segment's memory fit for a next tenant: lift guard-page
/// protection and re-commit purged spans (see `purged_any` / `guarded`).
/// Recycled memory is handed out as-is, so skipping this re-tenants an
/// inaccessible page (the M8 P0). Returns whether the memory is usable; when
/// the OS refuses either step (Windows refuses `MEM_COMMIT` once the system's
/// commit is exhausted), the caller must not recycle the segment, and the
/// failure is counted in [`crate::stats::commit_failures`].
///
/// # Safety
/// `seg` must be a live segment with no live blocks or references into it.
unsafe fn restore_for_reuse(seg: *mut Segment) -> bool {
// SAFETY: per contract; the range lies inside the segment's reservation.
unsafe {
if !((*seg).purged_any || (*seg).guarded) {
return true;
}
let base = seg.cast::<u8>().add(HEADER_SLICES * SEGMENT_SLICE_SIZE);
let bytes = (*seg).total_size - HEADER_SLICES * SEGMENT_SLICE_SIZE;
if os::protect(base, bytes, false).is_err() || os::commit(base, bytes).is_err() {
crate::stats::commit_failed();
return false;
}
(*seg).purged_any = false;
(*seg).guarded = false;
true
}
}

/// Release an empty Normal segment (caller has unlinked it from the heap):
/// back to its arena when it came from one, else to the OS.
///
Expand All @@ -416,27 +444,29 @@ pub unsafe fn segment_free(seg: *mut Segment) -> Result<(), PrimError> {
unsafe { wait_no_remote_in_flight(seg) };
segment_map::unregister(seg);
// SAFETY: seg is live and empty per the contract.
unsafe {
if (*seg).purged_any || (*seg).guarded {
// Restore full commitment AND accessibility before the memory can
// be re-tenanted from an arena (see purged_any / guarded).
let base = seg.cast::<u8>().add(HEADER_SLICES * SEGMENT_SLICE_SIZE);
let bytes = (*seg).total_size - HEADER_SLICES * SEGMENT_SLICE_SIZE;
let _ = os::protect(base, bytes, false);
let _ = os::commit(base, bytes);
(*seg).purged_any = false;
(*seg).guarded = false;
let usable = unsafe { restore_for_reuse(seg) };
if !usable {
// The memory could not be made accessible and committed again, so it
// must not be re-tenanted. Outside an arena, releasing it to the OS
// (below) needs neither; inside one, it cannot be released alone, so
// it stays marked used for the life of the process.
if crate::arena::owns(seg.cast()) {
return Ok(());
}
}
if crate::arena::chunk_free(seg.cast()) {
} else if crate::arena::chunk_free(seg.cast()) {
return Ok(());
}
// wasm: the slice pool is the free list (F2) — `expose_provenance` so a
// later `reserve_backing` may reconstruct a pointer to this range.
#[cfg(all(target_arch = "wasm32", not(miri)))]
// SAFETY: seg is live per the contract; reading total_size.
unsafe {
if crate::slice_pool::free_range(seg.cast::<u8>().expose_provenance(), (*seg).total_size) {
if usable
&& crate::slice_pool::free_range(
seg.cast::<u8>().expose_provenance(),
(*seg).total_size,
)
{
return Ok(());
}
}
Expand Down Expand Up @@ -656,11 +686,23 @@ pub unsafe fn span_alloc(seg: *mut Segment, slices: usize) -> (*mut Page, bool)
while !s.is_null() {
let len = (*s).slice_count as usize;
if len >= slices {
span_list_remove(seg, s);
let idx = page_index(seg, s);
// Re-commit BEFORE splitting so both halves are backed; the
// remainder inherits the (now cleared) purged state.
span_recommit(seg, idx, len);
//
// A failed re-commit is a failed allocation, never a span
// handed out unbacked: Windows has no overcommit, so
// `MEM_COMMIT` fails when the system's commit is exhausted,
// and the page layer's first store would then fault on
// reserved memory (an access violation in `page_extend`,
// `docs/plans/recommit-failure-ignored.md`). The span stays
// on the free list, still marked purged; null sends the
// caller to another segment, and a fresh one fails its own
// commit cleanly.
if !span_recommit(seg, idx, len) {
return (ptr::null_mut(), false);
}
span_list_remove(seg, s);
if len > slices {
// The remainder starts at a new slice: re-mark all of it.
span_mark_free(seg, idx + slices, len - slices, 1);
Expand Down Expand Up @@ -828,19 +870,25 @@ pub unsafe fn purge_free_spans(seg: *mut Segment) {
}
}

/// Re-commit a span that was purged while free (no-op otherwise).
/// Re-commit a span that was purged while free (no-op otherwise). Returns
/// whether the span is backed. On failure the span stays marked purged, so
/// nothing forgets that its memory is not there.
///
/// # Safety
/// `[idx, idx+len)` is a span of `seg` being handed to a caller.
unsafe fn span_recommit(seg: *mut Segment, idx: usize, len: usize) {
/// `[idx, idx+len)` is a span of `seg` about to be handed to a caller.
unsafe fn span_recommit(seg: *mut Segment, idx: usize, len: usize) -> bool {
// SAFETY: caller contract; range lies inside the segment reservation.
unsafe {
if !(*seg).pages[idx].purged {
return;
return true;
}
(*seg).pages[idx].purged = false;
let area = page_area(seg, idx);
let _ = os::commit(area, len * SEGMENT_SLICE_SIZE);
if os::commit(area, len * SEGMENT_SLICE_SIZE).is_err() {
crate::stats::commit_failed();
return false;
}
(*seg).pages[idx].purged = false;
true
}
}

Expand Down Expand Up @@ -1035,24 +1083,33 @@ pub unsafe fn huge_free(seg: *mut Segment) -> Result<(), PrimError> {
// memory must be handed back in a USABLE state: lift any guard-page
// protection and restore commitment first. Skipping this recycles an
// inaccessible page into the next tenant (the M8 P0).
if (*seg).total_size.is_multiple_of(SEGMENT_SIZE) {
if (*seg).guarded || (*seg).purged_any {
let base = seg.cast::<u8>().add(HEADER_SLICES * SEGMENT_SLICE_SIZE);
let bytes = (*seg).total_size - HEADER_SLICES * SEGMENT_SLICE_SIZE;
let _ = os::protect(base, bytes, false);
let _ = os::commit(base, bytes);
(*seg).guarded = false;
(*seg).purged_any = false;
}
if crate::arena::chunk_free_n(seg.cast(), (*seg).total_size / SEGMENT_SIZE) {
//
// When that restore fails, the memory must not be recycled at all:
// released to the OS if it is a reservation of its own, retired in
// place (left marked used) if it is chunks of an arena.
// Only chunk-multiple segments can reach an arena and need the
// restore; a ragged one is released whole (or pooled, on wasm).
let chunked = (*seg).total_size.is_multiple_of(SEGMENT_SIZE);
let usable = !chunked || restore_for_reuse(seg);
if chunked {
if !usable {
if crate::arena::owns(seg.cast()) {
return Ok(());
}
} else if crate::arena::chunk_free_n(seg.cast(), (*seg).total_size / SEGMENT_SIZE) {
return Ok(());
}
}
// wasm: ragged (slice-granular) huge reservations recycle through
// the slice pool — the F2 fix itself. Chunk-multiple ones only
// reach here when no arena owns them, and the pool takes those too.
#[cfg(all(target_arch = "wasm32", not(miri)))]
if crate::slice_pool::free_range(seg.cast::<u8>().expose_provenance(), (*seg).total_size) {
if usable
&& crate::slice_pool::free_range(
seg.cast::<u8>().expose_provenance(),
(*seg).total_size,
)
{
return Ok(());
}
let block = os::OsBlock {
Expand All @@ -1064,3 +1121,84 @@ pub unsafe fn huge_free(seg: *mut Segment) -> Result<(), PrimError> {
os::free(block)
}
}

#[cfg(all(test, feature = "std"))]
mod recommit_tests {
use super::*;
use crate::alloc::{collect, free, malloc, stats};
use crate::os::test_hooks::fail_next_commits;
use crate::types::MEDIUM_PAGE_SLICES;

/// A purged span whose re-commit FAILS must not be handed out.
///
/// The MATA desktop app died with an access violation writing a heap
/// address in `page_extend` (2026-10-04, `secure` on, `purge_delay = 0`,
/// the machine at 98.5 % of its commit limit). `span_recommit` dropped
/// the result of `os::commit`, and on Windows that call fails when the
/// system's commit is exhausted, so a span whose re-commit failed was
/// carved anyway and the page layer's first store hit reserved memory
/// (`docs/plans/recommit-failure-ignored.md`, mechanism A).
///
/// Without the fix the second allocation below lands back in the purged
/// span: on Windows, where the purge is a real `MEM_DECOMMIT`, writing it
/// is that access violation; everywhere, the "not in the purged span"
/// assertion fails. The last step checks the span still knows it is
/// purged, so a later re-commit that succeeds makes it usable again.
#[test]
fn a_failed_recommit_is_a_failed_span_not_an_unbacked_one() {
// A span `span_free` will purge: at least MEDIUM_PAGE_SLICES slices.
let span = SEGMENT_SLICE_SIZE * MEDIUM_PAGE_SLICES + SEGMENT_SLICE_SIZE;
crate::options::set(15, 0); // purge_delay: purge at once
let pin = malloc(span);
let p1 = malloc(span);
assert!(!pin.is_null() && !p1.is_null());
assert_eq!(
segment_of(pin),
segment_of(p1),
"setup: the pin must keep p1's segment alive"
);
// SAFETY: live blocks of `span` bytes.
unsafe {
core::ptr::write_bytes(pin, 1, span);
core::ptr::write_bytes(p1, 1, span);
}
let purges = stats().purges;
// SAFETY: p1 is live and freed once.
unsafe { free(p1) };
collect(true);
assert!(stats().purges > purges, "setup: p1's span was not purged");

let failures = crate::stats::commit_failures();
fail_next_commits(1);
let p2 = malloc(span);
fail_next_commits(0);
assert!(
!p2.is_null(),
"a failed re-commit must move on, not fail outright here"
);
assert_eq!(
crate::stats::commit_failures(),
failures + 1,
"the re-commit was attempted, failed, and was counted"
);
let in_p1 = (p1.addr()..p1.addr() + span).contains(&p2.addr());
assert!(!in_p1, "a span whose re-commit failed was handed out");
// SAFETY: p2 is a live block of `span` bytes; the write is the check.
unsafe { core::ptr::write_bytes(p2, 2, span) };

// The span kept its `purged` mark, so with commit available again
// whichever allocation reaches it re-commits it before use.
let p3 = malloc(span);
assert!(!p3.is_null());
// SAFETY: live block of `span` bytes.
unsafe { core::ptr::write_bytes(p3, 3, span) };

// SAFETY: all live, each freed once.
unsafe {
free(p3);
free(p2);
free(pin);
}
crate::options::set(15, -1); // restore the shipped default
}
}
Loading
Loading