Skip to content

fix(rallocator): bound bump allocations that cannot fit a chunk segment - #756

Open
martintmk wants to merge 1 commit into
mainfrom
harden/rallocator-tests-20260911
Open

martintmk wants to merge 1 commit into
mainfrom
harden/rallocator-tests-20260911

Conversation

@martintmk

Copy link
Copy Markdown
Member

Summary

can_fit_tracked_in_chunk_segment admitted a bump layout whenever size + size_of::<TrackingHeader>() + (align - 1) fitted BUMP_SEGMENT_SIZE, ignoring the allocator-owned prefix that begins every chunk segment. allocate_tracked places the user pointer after that prefix, so requests landing in the gap were admitted, never fitted, and drove the retry loop to map a fresh 64 KiB chunk on every attempt until the process exhausted memory.

This is reachable from safe code with the default options, because max_allocation_bytes defaults to BUMP_SEGMENT_SIZE:

let heap = Heap::bump(bump::Options::new());
with_hint(&heap, || {
    let mut values: Vec<u8> = Vec::new();
    values.resize(32_713, 0); // never returns; maps chunks until memory runs out
});

The test-only allocate already used can_fit_in_chunk_segment, which does account for size_of::<BumpChunk>(), so only the tracked production variant was affected.

Defect window

Each size was probed in its own process with a fresh bump heap, under an external memory/time supervisor, with the original predicate in place:

Size (align 16) Result Why
≤ 32_704 ok served by a chunk's roomier second segment
32_705..=32_713 runaway admitted, but fits neither segment
≥ 32_714 ok rejected by the old predicate, fell back to the general heap

That window is nine bytes wide, which is why uniform sampling had not reached it.

Fix

Model the placement allocate_tracked actually performs, keyed to a fresh normal chunk's first segment. Because advance_chunk yields either a chunk's second segment (a 16-byte prefix) or a new/recycled normal chunk reset through chunk_start (a 32-byte aligned prefix), the first segment is the tightest placement the retry loop can be forced onto. An admitted layout therefore always fits within a bounded number of advances, and the largest requests fall back to the general heap. The measurable cost is at most 16 bytes of bump capability at the very top of the range.

A debug_assert! states the invariant directly rather than silently capping retries, so future arithmetic drift fails loudly instead of hanging. Restoring the defective predicate makes that assertion fire in 0.00 s, which was verified by fault injection.

Hardening coverage

Adds 78 tests: payload, alignment, reallocation and cross-thread invariants; arena lifecycles including attachment-cache eviction and escaped allocations; private region, retirement and failure-injection paths; real container workloads through the installed global allocator; and a bounded operation-trace model shared by a seeded soak and a Bolero fuzz target.

Test design follows practices taken from mimalloc (cookie-stamped payloads verified at free), jemalloc (packing and accounting-based retention rather than RSS), snmalloc (live-set uniqueness, dirty-then-zero calloc), tcmalloc (realloc pattern preservation across size-class boundaries) and bumpalo (operation model with overlap and containment invariants). Boundary sizes around the chunk segment capacity are enumerated explicitly, since a nine-byte window is not reachable by sampling.

Verification

Check Result
cargo test -p rallocator --all-features --locked 175 passed, 0 failed (141 before)
cargo clippy --all-targets --all-features -- -D warnings clean
cargo fmt --check clean
Miri (lib, hardening, arena_hardening, global_hardening) 94 passed, no undefined behaviour
libFuzzer + AddressSanitizer, 20 min 23 752 runs, 0 crashes
8 192-seed soak 65 s, 34 MB peak RSS

The soak returns live_bytes to baseline on every seed and mapped_bytes plateaus at 4 784 128 bytes from roughly seed 3 840, against a derived ceiling of 105 218 048 bytes. Seed 74, which originally consumed 741 691 392 bytes of private commit, now peaks at 286 720 bytes.

seeded_soak is #[ignore]d and opt-in through RALLOCATOR_STRESS_SEEDS and RALLOCATOR_STRESS_START_SEED; any scenario panic reports the exact seed to replay.

Limitations

  • The invariant assertion is debug-only, so release-build drift still needs an external timeout or memory supervisor.
  • The soak bound covers allocator-reported committed backing only; it cannot bound System allocations, recorder buffers or virtual address reservations.
  • Concurrency coverage is schedule-dependent and does not establish race freedom under all interleavings.
  • No finite campaign can prove the absence of memory corruption; these checks bound specific failure modes and raise detection probability.

`can_fit_tracked_in_chunk_segment` admitted a layout whenever
`size + size_of::<TrackingHeader>() + (align - 1)` fitted
`BUMP_SEGMENT_SIZE`, ignoring the allocator-owned prefix that begins every
chunk segment. `allocate_tracked` places the user pointer after that prefix,
so requests in the resulting gap were admitted, never fitted, and drove the
retry loop to map a fresh 64 KiB chunk on every attempt until the process
exhausted memory.

The window is reachable from safe code with the default options, because
`max_allocation_bytes` defaults to `BUMP_SEGMENT_SIZE`: a `Vec` grown to
roughly 32.7 KiB inside `with_hint(&Heap::bump(bump::Options::new()), ..)`
is enough. Probing each size in its own process with a fresh bump heap put
the defect at exactly 32_705..=32_713 for 16-byte alignment; sizes through
32_704 were served by a chunk's roomier second segment and sizes from 32_714
were already rejected and fell back to the general heap.

Model the placement `allocate_tracked` actually performs, keyed to a fresh
normal chunk's first segment. That segment is the tightest one the retry loop
can be forced onto, so an admitted layout now always fits within a bounded
number of advances, and the largest requests fall back to the general heap.
A debug assertion states the invariant directly instead of silently capping
retries, so a future arithmetic regression fails loudly rather than hanging.

Add hardening coverage for the allocator: payload, alignment, reallocation
and cross-thread invariants, arena lifecycles, private region and failure
paths, real container workloads, and a bounded operation-trace model shared
by a seeded soak and a Bolero fuzz target.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fb1df27a-b81e-4982-ad7d-42cfeee3fde3
Copilot AI lite review requested due to automatic review settings September 14, 2026 07:18
let heap = hal::map(size_of::<ReusableHeapState>()).cast::<ReusableHeapState>();
assert!(!heap.is_null());
unsafe {
heap.write(ReusableHeapState::new(options, domain));
let allocator = unsafe { Rallocator::<Standard>::new() };
let domain = new_domain_state();
let heap = new_retirable_heap(domain, GeneralOptions::from_values(MEDIUM_SLICE_SIZE, MEDIUM_SLICE_SIZE));
let heap_ref = unsafe { &mut *heap };
assert!(!direct.is_null());
assert_eq!(domain_used_slices(domain), 2);
unsafe {
direct.write(0xDA);
let domain = new_domain_state();
let heap = new_retirable_heap(domain, GeneralOptions::from_values(MEDIUM_SLICE_SIZE, 0));
let layout = Layout::from_size_align(MEDIUM_SLICE_SIZE, 16).unwrap();
let live = allocator.allocate_medium(layout, unsafe { &mut *heap }, None);
let live = allocator.allocate_medium(layout, unsafe { &mut *heap }, None);
assert!(!live.is_null());
unsafe {
live.write(0xA5);
let medium_layout = Layout::from_size_align(MEDIUM_SLICE_SIZE, 16).unwrap();
let direct_layout = Layout::from_size_align(96, MAX_MEDIUM_ALIGNMENT * 2).unwrap();

let medium = allocator.allocate_medium(medium_layout, unsafe { &mut *heap }, None);
assert!(!medium.is_null());
assert!(!direct.is_null());
unsafe {
medium.write(0xC3);
unsafe {
medium.write(0xC3);
medium.add(MEDIUM_SLICE_SIZE - 1).write(0x3C);
direct.write(0xD1);

unsafe { retire_general_heap(heap) };
assert!(slice_marked(region, medium_slice));
assert_eq!(unsafe { medium.read() }, 0xC3);
assert!(slice_marked(region, medium_slice));
assert_eq!(unsafe { medium.read() }, 0xC3);
assert_eq!(unsafe { medium.add(MEDIUM_SLICE_SIZE - 1).read() }, 0x3C);
assert_eq!(unsafe { direct.read() }, 0xD1);

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Extensive allocator and hardening changes warrant final human review.

Pull request overview

Fixes tracked bump allocations that could retry indefinitely by accounting for chunk-segment prefixes, with extensive allocator hardening coverage.

Changes:

  • Corrects tracked allocation bounds and adds a debug invariant.
  • Adds lifecycle, alignment, reallocation, concurrency, and container tests.
  • Adds bounded scenario testing and Bolero fuzz coverage.
File summaries
File Reviewed changes
crates/rallocator/tests/scenarios/mod.rs Adds bounded mixed-heap allocation scenarios.
crates/rallocator/tests/hardening.rs Adds boundary, lifecycle, concurrency, and soak tests.
crates/rallocator/tests/global_hardening.rs Adds global container workload coverage.
crates/rallocator/tests/common/mod.rs Adds allocation payload and ownership helpers.
crates/rallocator/tests/bolero_allocator.rs Adds fuzz-driven allocation sequence testing.
crates/rallocator/tests/arena_hardening.rs Adds arena lifecycle and retention tests.
crates/rallocator/src/heap/bump/state.rs Fixes tracked bump admission bounds and adds regression coverage.
crates/rallocator/src/allocator/hardening.rs Adds internal allocator lifecycle and failure-path tests.
crates/rallocator/src/allocator.rs Registers allocator hardening tests.
crates/rallocator/Cargo.toml Adds hardening-test dependencies.
Cargo.lock Records the new dependency versions.
Review details

Suppressed comments (2)

crates/rallocator/tests/arena_hardening.rs:237

  • After drop(heap) at line 243, the loop at lines 247–249 calls Block::reallocate without with_hint; Block::reallocate forwards directly to Rallocator::realloc (tests/common/mod.rs:90-105), and the allocation path only consults the active hint (src/allocator.rs:1683-1692). Thus the first growth leaves the bump heap, but the later sizes remain on the general heap—they never move “back” into the arena. Please correct the comment or keep the hint active if a round trip is intended, otherwise the claimed coverage is misleading.
    // A bump-backed allocation escapes its dropped heap handle and is then
    // reallocated. Growing past the 32 KiB bump maximum moves it out of the
    // arena into the general heap and back; the preserved prefix must survive
    // every move. `Block::reallocate` verifies the preserved prefix before
    // initializing any extension.

crates/rallocator/tests/global_hardening.rs:32

  • rallocator::rallocator!() above installs GlobalRallocator as this binary's #[global_allocator], so System is not the process allocator here. The test's Vec, Arc, and other bookkeeping allocations therefore do run through rallocator and can affect its counters/hint state; please update this safety rationale to describe the intentional global-allocator coverage instead of claiming the opposite.
    }

    fn check(&self, id: usize) {
  • Files reviewed: 10/11 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.47090% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.9%. Comparing base (84083b8) to head (0ed0a21).

Files with missing lines Patch % Lines
crates/rallocator/src/allocator/hardening.rs 99.3% 2 Missing ⚠️

❌ Your project check has failed because the head coverage (99.9%) is below the target coverage (100.0%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #756     +/-   ##
========================================
- Coverage   100.0%   99.9%   -0.1%     
========================================
  Files         634     635      +1     
  Lines       84746   85116    +370     
========================================
+ Hits        84746   85114    +368     
- Misses          0       2      +2     
Flag Coverage Δ
linux 99.9% <99.4%> (-0.1%) ⬇️
linux-arm 99.9% <99.4%> (-0.1%) ⬇️
scheduled ?
windows 99.9% <99.4%> (-0.1%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

.and_then(|size| size.checked_add(layout.align().max(align_of::<TrackingHeader>()) - 1))
.is_some_and(|size| size <= BUMP_SEGMENT_SIZE)
let alignment = layout.align().max(align_of::<TrackingHeader>());
align_up(size_of::<BumpChunk>(), 16)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖: Non-blocking — The segment-prefix offset remains independently encoded in admission and placement, so a later change to chunk_start can again admit a layout that never fits and repeatedly maps chunks.

Please share the prefix-alignment constant and first-segment offset calculation between chunk_start, both admission predicates, and the test oracle. Today can_fit_tracked_in_chunk_segment, can_fit_in_chunk_segment, and tracked_first_segment_capacity each repeat align_up(size_of::<BumpChunk>(), 16), while chunk_start computes the real pointer separately. The 32_688 assertion pins today's copied model, but it would not fail if placement alone changed; a shared helper would make the admission/placement invariant mechanical and leave the tests as a backstop.

@martin-kolinek martin-kolinek left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖: Approved.

@martintmk martintmk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent

Complete Review Lens coverage found two non-blocking naming and maintainability issues. Existing unresolved discussion and failing exact-head CodeQL and Codecov checks mean this is not a clean report.

Coverage included API design, correctness, tests, performance, naming, telemetry, resilience, consistency, public API, and public documentation across all 11 changed files. The paired public API and rustdoc comparisons found no public-surface changes.

Existing unresolved items remain around independently encoded segment-prefix arithmetic, two inaccurate safety/lifecycle comments, 14 open high-severity CodeQL invalid-pointer alerts, and two uncovered changed lines reported by Codecov.

Material limitations: Miri was unavailable locally (the exact-head runtime-analysis CI passed); rustdoc coverage used default features on the host target; concurrent benchmark runs were not a paired Criterion comparison.

// the rallocator counters or inadvertently consume active allocation hints.
static ALLOCATOR: rallocator::Rallocator = unsafe { rallocator::Rallocator::new() };
static TEST_LOCK: Mutex<()> = Mutex::new(());
const MAX_SCENARIO_LIVE_BLOCKS: usize = if cfg!(miri) { 4 } else { 16 };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent · Non-blocking

The scenario live-set limit has two names and definitions

Problem
MAX_SCENARIO_LIVE_BLOCKS duplicates scenarios::MAX_LIVE, including their cfg!(miri) values.

Why this matters
Changing one definition without the other silently invalidates scenario_backing_retention_bound, weakening the soak retention check.

Suggested fix
Expose one scenarios::MAX_LIVE_BLOCKS constant and use it in both the scenario runner and bound calculation.

pub(crate) const INPUT_BYTES: usize = 1 + 6 * MAX_OPERATIONS;
const MAX_LIVE: usize = if cfg!(miri) { 4 } else { 16 };

pub(crate) struct Recording;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent · Nit

The recording scope diverges from the crate’s guard naming

Problem
Recording changes global recorder configuration and restores it through Drop, while the sibling RAII type is named RecordingGuard.

Why this matters
The current name reads like captured data and obscures the type's cleanup behavior.

Suggested fix
Rename it to RecordingGuard and update its three consumers.

@martintmk martintmk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent

Reviewed the complete pinned allocator-hardening change at 0ed0a2172322c04f269d9e72acca8d2a74f7b558 across all ten required areas. I found one new non-blocking regression-coverage gap: tracked bump admission is tested only through 4 KiB alignment even though the internal option supports alignments through 32 KiB. Existing discussions about duplicated segment-prefix arithmetic, scenario live-set constants, guard naming, the PR test-count claim, and arena reallocation wording remain applicable and were not duplicated.

The exact-head default-feature rallocator suite and targeted hardening tests passed. Default-feature host public API captures were byte-identical and public rustdoc had no changes. CodeQL remains red with 14 invalid-pointer annotations concentrated in allocator hardening/test paths, and Codecov reports two uncovered lines in allocator/hardening.rs; the required CI owners still need to disposition those checks.

assert_eq!(size_of::<TrackingHeader>(), 40);
assert_eq!(tracked_first_segment_capacity(16), 32_688);

for &align in &[1_usize, 16, 64, 4096] {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent · Non-blocking

Tracked admission tests omit supported high-alignment boundaries

Problem
This matrix stops at 4 KiB, while with_max_alignment accepts 8, 16, and 32 KiB and the first-segment capacity changes with alignment. The integration tests do not distinguish bump allocation from fallback to the general heap.

Why this matters
A regression in high-alignment admission could remain hidden because payload correctness still holds after fallback.

Suggested fix
Cover 8, 16, and 32 KiB at the exact first-segment capacity and one byte beyond it, and include those admitted layouts in the bounded chunk-growth test.

@martintmk martintmk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent

A fresh complete Review Lens pass at the pinned head found no additional findings. Existing unresolved discussion remains around shared segment-prefix arithmetic, scenario live-set constants, guard naming, high-alignment admission coverage, and two inaccurate test comments; those were not duplicated.

All ten required areas completed. The paired public API and rustdoc comparisons found no public-surface changes, and cargo check -p rallocator passed. Exact-head CodeQL still reports 14 invalid-pointer annotations, while Codecov reports two uncovered changed lines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants