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

## [Unreleased]

## [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

- **A merged free span no longer hides a decommitted neighbour** (Windows,
purging on). When a freed span merged with an already-purged neighbour and
the merged span was not re-purged as a whole (the purge failed, or purging
had been switched off at runtime), the merged span kept a clear `purged`
flag over the neighbour's decommitted memory, and the next allocation wrote
to it: an access violation, reproduced as `STATUS_ACCESS_VIOLATION`. The
merged span now keeps the flag if any part of it was purged, so it is
re-committed before reuse. Cost: about 2 instructions per span free.
Mechanism B of `docs/plans/recommit-failure-ignored.md`.

## [2.2.5](https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.2.4...rusty_alloc-v2.2.5) - 2026-10-08

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion crates/rusty_alloc/UNSAFE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ unsafe in DEPENDENCIES, and ours are `libc` plus bindings-only `windows-sys`.
| `alloc.rs` | 110 | The public entry points: raw-pointer reads on the malloc fast path (must never form `&mut` on the shared empty-heap sentinel), pointer-derived metadata on the free path (`segment_of`/`page_of`), block-content copies in the realloc family. **+15 on 2026-08-22**: the free path's two `asm!` sites — the memory-destination `used--` whose flags drive the retire branch (its `label` block is a separate item and carries its own `unsafe`), and the fused `cmp {tid}, fs:0` in both `free_inline` and `free_general` — plus `malloc_or`/`malloc_or_slow`, which give `operator new` a fast path whose miss is a tail call. Each asm reads or writes exactly one field it already had a valid pointer to; none widens what the surrounding code could already touch. **+2 on 2026-09-24 (instruction-count campaign, LEDGER):** `realloc` split into an inline null test and `unsafe fn realloc_live(p: NonNull<u8>, ..)` — one signature, and the `NonNull::new_unchecked` sits inside the block that already called it, on a pointer the line above proved non-null. The body is the old one unchanged; nothing new is dereferenced. **+10 on 2026-09-24 (round two, LEDGER):** (a) `realloc_move`, the moving arm shared by the growth and the shrink — one `unsafe fn`, its body block, and the two call-site blocks: the old inline move code, now reached with the copy length each arm already holds; (b) `zalloc` in `malloc`'s raw-read shape — its fast-path block reads through `hb`, which may be the sentinel, exactly as `malloc` does (no `&mut`, no write unless a block was popped, which proves the heap is real), and the cold `unsafe fn zalloc_slow` that performs the sentinel test the fast path dropped (signature + block); (c) `free_local_owned` / `free_local_no_xheap`, `owner_heap` split so its heap-creating fallback is a tail call — two signatures and their two blocks, the same loads and the same `free_local_at` as before; (d) `malloc_aligned_pow2`, an `unsafe fn` only because its caller must have proven the alignment a power of two (a violation is not memory-unsafe — the bound test still refuses a mask at or above half a segment). Nothing new is dereferenced anywhere; each carries its SAFETY line. The baseline had drifted to 106 without a row edit; recorded now. **+4 on 2026-10-07 (the Miri Stacked Borrows failure on `main`):** the deferred-free hook used to fire inside `Heap::malloc_generic`, where `&mut self` is protected, so a hook that allocates re-entered the heap through a raw pointer under that borrow. It now fires at the allocation entries before any `&mut Heap` exists: `fire_deferred_if_due` (an `unsafe fn` and its block: a raw read and write of the heap's own `deferred_due` flag, then the hook) and `malloc_slow_deferred` (an `unsafe fn` and its block: the same, then the `malloc_generic` call `malloc_slow` already made). The nine entry blocks that call it existed before; nothing new is dereferenced | 2026-10-07 |
| `heap.rs` | 80 | Owner-thread page/queue manipulation under raw pointers (no two `&mut Page` may coexist), the aligned-allocation peek, span carving. **+3 on 2026-08-19**: `try_unlink_huge_segment` split out of `remove_huge_segment`. **+15 on 2026-08-22**: `malloc_generic` split into a small entry plus `malloc_generic_walk`, with `grow_front`, `try_guarded` and `drain_delayed` as cold arms — each split adds an `unsafe fn` signature and its block while dereferencing nothing the single function did not — and the immortal `EMPTY_DELAYED` sentinel that lets the heartbeat read its list without a null test. **+2 on 2026-09-07 (P4e, §2.15 of `docs/plans/small-metal.md`)**: the reclamation fixes — one `unsafe` for the periodic `generic_collect` sweep and one for the reclaim-and-retry that runs before `malloc_generic` returns null. Both call `collect_inner`, which allocates nothing and touches only this heap’s own pages on the owner thread; each carries its SAFETY line. `malloc_generic` itself became a safe wrapper over the renamed `malloc_generic_once`, so the split added no signature. **+2 on 2026-09-08:** the medium collect-and-retry ahead of the heartbeat -- one `unsafe` around `page_collect` + `page_pop` on the bin queue front, one reading `free_is_zero` off the page just popped. Both are the SAME operations `malloc_generic_walk` performs on the same page a few lines later, on the owner thread under the heap lock: the block moves earlier, nothing new is dereferenced. Each carries its SAFETY line. **+2 on 2026-09-16 (Openheimer):** the two refuse arms in `malloc_aligned_at_slow` when `bins::aligned_at_from` reports that `block + offset + align` would wrap for a caller-chosen offset — each is one `free_local` of the block just allocated on this thread, which has not escaped. **+1 on 2026-09-24 (instruction-count campaign, LEDGER):** the medium collect-and-retry's `page_collect` + `page_pop` block became two blocks so the `saw_remote_free` latch is stored between them — same two calls on the same page, owner thread, in the same order; nothing new is dereferenced. **+1 on 2026-09-24 (round two):** a medium miss grows the front page directly — one block around the `capacity < reserved` test and the `grow_front` call, on the same page the block above just collected, which is `grow_front`'s whole contract **+2 on 2026-09-25 (CURIOSITY ROUND FOUR):** the medium arm pops the front page BEFORE collecting it — one `page_pop` block and the `free_is_zero` read beside it, the same two operations the collect-then-pop below already performs on the same owner-thread page. **+1 on 2026-10-07 (`docs/opps.md` vein census, vein 2):** `collect_inner` hands the per-heap cached large span (`Heap::large_cache`) back with `retire_span` — one block around `segment_of` on the cached page's own address and the same `retire_span` a large free performed before the cache existed, on the owner thread. **+2 the same day:** `free_large_span` — an `unsafe fn` and its block — is the large-span arm of `free_local_at` moved into its own function (double-free check, cache, free-run flag); the same operations on the same page, owner thread | 2026-10-07 |
| `init.rs` | 42 | Thread/heap lifecycle: the initial-exec TLS slot (`global_asm!` + fs-relative asm reads), thread-pointer register reads (`fs:0`/`gs:0x30`/`tpidrro_el0`), heap-box creation/teardown, the abandonment path run inside platform TLS destructors. **+1 on 2026-09-09 (`firmware-what-is-left.md` §7.3): an ATTRIBUTE, not an operation** — `unsafe(link_section = ".rodata.…")` on `EMPTY_HEAP_BOX`, bare metal only, so the never-written sentinel lives in flash instead of costing 1,752 bytes of RAM. The token is counted because the census is a text search; the contract it rests on (raw reads only, no page ever stores its address) is the one the `Sync` impl below already carries, and a write would now fault against the flash cache instead of silently landing. `create_heap`'s template copy from it is a `ptr::read` inside the block that already existed. **+5 on 2026-09-16 (Openheimer, OH-rusty_alloc-13):** thread exit now abandons EVERY heap the dying thread still owns, not only the one in `done_slot` — a first-class heap that was never `heap_delete`d, or one installed with `set_default_heap`, used to stay DELAYED under a dead owner forever. `take_heap_owned_by` unlinks one such box under `HEAPS_LOCK` (one block: the registry walk); `thread_done` reads `owner_tid` off its own box and calls the split-out `unsafe fn thread_done_one` for the primary box and for each extra one (three sites plus the signature). Nothing new is dereferenced that the single function did not; each carries its SAFETY line | 2026-09-16 |
| `segment.rs` | 45 | Segment/page metadata addressing: the mask trick (`segment_of`), `page_of`'s contract-based indexing — **the bound is now PROVED for every in-segment offset by `proofs.rs` (Kani), not merely asserted** — span tiling, purge/recommit. The baseline had drifted to 36 without a row edit; recorded now. **+2 on 2026-10-04 (`docs/plans/recommit-failure-ignored.md`):** `restore_for_reuse` — an `unsafe fn` and its block — is the guard-lift and re-commit `segment_free` and `huge_free` each did inline, moved into one function so both can act on its answer: a segment whose memory the OS will not make accessible and committed again is released whole or retired in place, never recycled. It touches the same range the inline code did; `segment_free`'s own block became the expression that calls it (no net change). `span_recommit` now returns whether the span is backed (same block, same call). **+5 the same day, all `#[cfg(test)]`:** `a_failed_recommit_is_a_failed_span_not_an_unbacked_one` writes and frees the blocks it allocated (four blocks) and frees them at the end (one); each carries its SAFETY line. **+2 on 2026-10-07 (`docs/opps.md` vein census, vein 1):** `scrub_recycled` — an `unsafe fn` and its block — replaces the whole-header `write_bytes` that `segment_alloc` and `huge_alloc` did on every recycled chunk: it zeroes the 2 KB owner table (all of it for a Normal segment, entry 0 for a Huge one, which overwrites the rest) and the first `slots` page slots, inside the same header the old call covered. The other slots of a Normal segment are zeroed where the bump cursor first carves them (inside `span_alloc`'s existing block); a Huge segment reads only slots 0 and 1, and `wait_no_remote_in_flight` now scans slot 1 alone there. Nothing new is dereferenced | 2026-10-07 |
| `segment.rs` | 55 | Segment/page metadata addressing: the mask trick (`segment_of`), `page_of`'s contract-based indexing — **the bound is now PROVED for every in-segment offset by `proofs.rs` (Kani), not merely asserted** — span tiling, purge/recommit. The baseline had drifted to 36 without a row edit; recorded now. **+2 on 2026-10-04 (`docs/plans/recommit-failure-ignored.md`):** `restore_for_reuse` — an `unsafe fn` and its block — is the guard-lift and re-commit `segment_free` and `huge_free` each did inline, moved into one function so both can act on its answer: a segment whose memory the OS will not make accessible and committed again is released whole or retired in place, never recycled. It touches the same range the inline code did; `segment_free`'s own block became the expression that calls it (no net change). `span_recommit` now returns whether the span is backed (same block, same call). **+5 the same day, all `#[cfg(test)]`:** `a_failed_recommit_is_a_failed_span_not_an_unbacked_one` writes and frees the blocks it allocated (four blocks) and frees them at the end (one); each carries its SAFETY line. **+2 on 2026-10-07 (`docs/opps.md` vein census, vein 1):** `scrub_recycled` — an `unsafe fn` and its block — replaces the whole-header `write_bytes` that `segment_alloc` and `huge_alloc` did on every recycled chunk: it zeroes the 2 KB owner table (all of it for a Normal segment, entry 0 for a Huge one, which overwrites the rest) and the first `slots` page slots, inside the same header the old call covered. The other slots of a Normal segment are zeroed where the bump cursor first carves them (inside `span_alloc`'s existing block); a Huge segment reads only slots 0 and 1, and `wait_no_remote_in_flight` now scans slot 1 alone there. Nothing new is dereferenced. **+10 on 2026-10-09, all test-only:** `recommit_tests` gains the two merge tests for mechanism B of `docs/plans/recommit-failure-ignored.md` and their helpers (block writes and slot reads on the test's own live spans). The fix itself, carrying a merged neighbour's `purged` flag in `span_free`, adds none: it sits in the existing block | 2026-10-09 |
| `prim/windows.rs` | 34 | OS FFI: VirtualAlloc family, FLS destructors, QPC, BCryptGenRandom. **+3 on 2026-09-16 (Openheimer):** `range_is_reserved` (`zeroed` `MEMORY_BASIC_INFORMATION` + `VirtualQuery`, the same OS answer `unix.rs` gets from `mincore`), and `alloc_aligned` now refuses a garbage alignment and a `size + align` that wraps BEFORE it reserves anything, and releases a re-reservation that the OS placed anywhere but the aligned address it asked for instead of returning it misaligned (`VirtualFree`, one block). **+1 on 2026-09-24 (`docs/plans/youslowbro.md` §4):** `getenv` — one `GetEnvironmentVariableA` into a caller buffer of exactly the length passed, the allocation-free environment read that replaced `std::env::var`'s owned strings in the options pass (251 allocations through the global allocator on every process's first allocation, now none) | 2026-09-24 |
| `page.rs` | 38 | Free-list links written into dead blocks, the lock-free `xthread_free` four-state protocol (loom-modeled in `tests/loom_xthread.rs`), the immortal `EMPTY_PAGE` sentinel — **+1 on 2026-09-09: the same `unsafe(link_section)` attribute as `init.rs`, placing it in flash on bare metal; its free list is permanently null, so nothing writes it**. **+8 on 2026-08-22**: `page_link_local` split out of `page_push_local` so the caller can decrement `used` in one memory-destination RMW, `page_collect_impl` const-generic over whether it also writes the protocol flag, and `USED_OFFSET` — whose value is asserted against `offset_of!(Page, used)` by a unit test, because an asm operand is not type-checked and a field reordering would silently decrement the wrong bytes | 2026-08-22 (free campaign) |
| `prim/unix.rs` | 33 | OS FFI: mmap family, madvise/decommit, pthread keys, /dev/urandom. **+1 on 2026-09-16 (Openheimer, OH-201):** `range_is_reserved` asks `mincore` whether a caller-supplied `manage_os_memory` range is mapped at all before it becomes an arena — one FFI call on a page-aligned probe. **+1 on 2026-09-24 (`docs/plans/youslowbro.md` §4):** `getenv` — `libc::getenv` and a byte-by-byte read of the returned C string up to its NUL or the caller buffer's end, nothing written through it; the same call upstream's prim makes, with the same standing caveat about a concurrent `setenv` **+2 on 2026-09-25:** `env_for_each` — an `unsafe extern "C"` declaration of `environ` and one block that walks it to its NULL terminator, handing each NUL-terminated entry on as a pointer; read only, with the same concurrent-`setenv` caveat as `getenv`. **+2 on 2026-10-01 (macOS):** the Apple arm of `range_is_reserved` — an `unsafe extern "C"` declaration of `mach_vm_region` and the `mach_task_self_` global, and one call per region: a read-only query of our own task into a `packed(4)` `vm_region_basic_info_64` whose 36-byte size is asserted at compile time and is exactly the word count passed, because XNU's `mincore` succeeds on unmapped ranges | 2026-10-01 |
Expand Down
8 changes: 6 additions & 2 deletions crates/rusty_alloc/src/arena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,7 @@ fn heapless_fmt(
}

#[cfg(test)]
mod adopt_tests {
pub(crate) mod adopt_tests {
use super::*;

/// One lock for all adoption tests. They are the only adopters on native
Expand All @@ -795,7 +795,11 @@ mod adopt_tests {
/// written to survive LANDING in a shared or pre-extended arena, because
/// arenas are never unregistered and an earlier test's arena can adopt a
/// later test's adjacent block.
fn lock() -> std::sync::MutexGuard<'static, ()> {
///
/// Visible to the crate: tests elsewhere that allocate whole segments (and so may
/// take a chunk from an arena one of these tests just adopted, before it
/// drains it) take this lock too. `segment::recommit_tests` does.
pub fn lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
Expand Down
20 changes: 20 additions & 0 deletions crates/rusty_alloc/src/os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,22 @@ pub(crate) mod test_hooks {

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

/// Fail this thread's next `n` purges.
pub fn fail_next_purges(n: usize) {
FAIL_PURGES.with(|c| c.set(n));
}

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

/// Fail this thread's next `n` commits.
Expand Down Expand Up @@ -250,6 +266,10 @@ pub unsafe fn decommit(ptr: *mut u8, size: usize) -> Result<bool, PrimError> {
/// # Safety
/// As [`commit`]; contents are lost either way.
pub unsafe fn purge(ptr: *mut u8, size: usize, purge_decommits: bool) -> Result<bool, PrimError> {
#[cfg(all(test, feature = "std"))]
if test_hooks::take_purge_failure() {
return Err(test_hooks::INJECTED);
}
if purge_decommits {
// SAFETY: forwarded contract.
unsafe { decommit(ptr, size) }
Expand Down
Loading
Loading