Skip to content

perf(runtime): index object/bigint Map keys, single-pass timer drain (#6084) - #6285

Merged
proggeramlug merged 7 commits into
mainfrom
perf/6084-promise-map-timer-scans
Jul 11, 2026
Merged

perf(runtime): index object/bigint Map keys, single-pass timer drain (#6084)#6285
proggeramlug merged 7 commits into
mainfrom
perf/6084-promise-map-timer-scans

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Two of the remaining #6084 items, plus a correctness bug found while fixing them. Continues the incremental, independently-mergeable approach from #6126 (nextTick VecDeque) and #6257 (TIMER_REF_STATES leak bound).

1. Object-keyed Map was O(n) per operation → O(n²) workloads

Map's O(1) side-tables covered only bits-stable numeric keys and content-hashed strings. Every object, symbol, closure and bigint key fell through to a full linear scan of the entries buffer, so get/set/has/delete on an object-keyed Map were each O(n). Object-keyed Maps are the default cache/registry idiom.

Added MAP_PTR_INDEX, a third per-Map side-table keyed by MapPtrKey: objects/symbols/closures hash and compare by their raw NaN-box bits (identity — matching the linear scan's existing bit-equality), bigints by limb content.

Measured (20k inserts + 20k gets, same machine, perry-dev):

base main this PR
object-keyed insert+get 7,692 ms 4 ms
object-keyed has ×20k 3,737 ms 0 ms
string-keyed insert+get (control) 5 ms 6 ms

~1,900× on insert+get, matching the 1,793× the audit measured.

GC safety (the crux)

Stored key bits go stale whenever the generational GC evacuates a pointee, so the index is refreshed exactly the way Set's SET_INDEX already is: a new GcRewriteHookKind::MapIndex rebuilds it from the (already rewritten) entries buffer.

The three rewrite call sites — remembered-set dirty scan, copying field scan, and verify/force-evacuate rewrite — each open-coded a == GcRewriteHookKind::SetIndex comparison. Rather than adding a second special case to each, they now route through one shared run_gc_rewrite_hook dispatch, so a future hook kind needs wiring in exactly one place. map_header_moved_for_gc migrates the outer key when the MapHeader itself moves, and every mutation path (set, delete, clear, alloc, finalize) keeps the table exact — which is what lets a lookup miss be treated as definitive rather than falling back to a scan.

2. Correctness bug: bigint Map keys compared by identity, not value

Bigints previously passed is_safe_numeric_key (the old comment misidentified BIGINT_TAG as 0x7FFE, which is actually INT32_TAG — bigints are 0x7FFA) and so were indexed by raw pointer bits. Consequences on main today:

const m = new Map();
m.set(10n, "ten");
m.get(10n);        // undefined — a distinct 10n allocation

…and the indexed bits went stale across a GC move. jsvalue_eq now compares bigints by mathematical value per SameValueZero (23.1.3.9), and they are routed through the pointer index (content-hashed).

3. Timer drain: Vec::remove(i) inside a scan → single-pass partition

js_timer_tick / js_callback_timer_tick removed expired entries with queue.remove(i) inside an index scan, shifting the whole tail once per expired timer — O(n²) on bursts of same-deadline timers. Both now share one drain_expired_timers helper: a single-pass stable partition that preserves the exact ordering semantics of the old code (expired batch in creation order, survivors in queue order; cleared callback timers discarded in the same pass). Verified order-identical by A/B against base. The TIMER_REF_STATES bounded-eviction logic from #6257 is untouched.

Verification

  • cargo test -p perry-runtime: 1236 passed, 0 failed. cargo fmt --all -- --check clean.
  • New GC test test_copying_minor_rebuilds_map_pointer_key_index — the make-or-break case: an object key and the MapHeader are both evacuated by a copying minor, so every bit stored in the index is stale. Asserts both objects actually moved, the rebuilt index resolves the key at its new address, numeric keys survive the rebuild, and the stale pre-GC bits do not resolve. Verified to have teeth: stubbing out the MapIndex dispatch makes it fail.
  • New test test_map_bigint_keys_match_by_content_not_identity — SameValueZero for bigint keys; re-setting through a content-equal allocation overwrites rather than appends.
  • E2E vs node --experimental-strip-types — byte-identical: object-key identity vs structural equality, delete/re-insert iteration order, symbol and function keys, NaN/-0/+0 SameValueZero edges, bigint content equality incl. 2n**70n, bigint-vs-number non-equality, and a 500-key mixed-kind map (object/string/number/bigint) with allocation churn interleaved.
  • GC stress: the full correctness suite is byte-identical to Node under PERRY_GC_FORCE_EVACUATE=1, PERRY_GC_VERIFY_EVACUATION=1, and both together.

Scope / follow-ups

Left for separate PRs (disjoint files, each independently mergeable):

  • Promise.all / PROMISE_SETTLE_LISTENERS / PROMISE_OVERFLOW_REACTIONS whole-table scans on every settle (item 2) — needs the same GC-rekeying treatment as this index, so it gets its own PR rather than riding along.
  • PROMISE_CONTEXTS / AsyncLocalStorage churn (item 4).
  • The global GLOBAL_DESCRIPTORS_IN_USE write-path gate (item 6).
  • The timer BinaryHeap redesign (item 3's larger half).

Separately found, pre-existing, not fixed here: Perry fires an expired timer batch in creation order, whereas Node fires by deadline — so a 5 ms timer created after a 10 ms one fires second. Confirmed present on base main (identical output with timer.rs reverted), so it is not a regression from this PR; the BinaryHeap redesign is the natural place to fix it.

Part of #6084.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed Map lookups after GC by rebuilding the appropriate key lookup state after object field rewrites.
    • Added safe pointer-key indexing for Map keys so moved/rewritten keys remain resolvable.
    • Corrected Map BigInt key matching to use value/content equality (not allocation identity).
    • Improved timer tick handling to preserve queue order while batching expired timers correctly.
  • Tests

    • Added regression tests for Map pointer-key and BigInt behaviors across minor copying GC.
    • Added unit tests for timer draining and callback batch ordering, plus an end-to-end timer batch order test.

Ralph Küpper added 4 commits July 11, 2026 10:13
js_timer_tick and js_callback_timer_tick removed expired entries with
Vec::remove(i) inside an index scan, shifting the whole tail once per
expired timer -- O(n^2) on bursts of same-deadline timers. Replace both
with one shared drain_expired_timers helper: a single-pass stable
partition (mem::take + repartition) that preserves creation order for
the expired batch (same-deadline timers fire in scheduling order, Node
semantics) and queue order for survivors. Cleared callback timers are
still discarded in the same pass.

Deliberately minimal: the BinaryHeap redesign from #6084 is a separate
follow-up; the TIMER_REF_STATES bounded-eviction logic (#6257) is
untouched.

Part of #6084.
Map's O(1) side-tables covered only bits-stable numeric keys and
content-hashed strings; every object, symbol, closure and bigint key fell
through to a full linear scan of the entries buffer, so get/set/has/delete
on an object-keyed Map were O(n) each and object-keyed workloads O(n^2).
Object-keyed Maps are the default cache/registry idiom -- measured 1,793x
slower than string keys (20k insert+get: 7,173 ms vs 4 ms).

Add MAP_PTR_INDEX, a third per-Map side-table keyed by MapPtrKey: objects,
symbols and closures hash and compare by their raw NaN-box bits (identity,
matching the linear scan's bit-equality), bigints by limb CONTENT.

The stored key bits go stale whenever the generational GC evacuates a
pointee, so the index is refreshed exactly like Set's SET_INDEX: a new
GcRewriteHookKind::MapIndex rebuilds it from the (already rewritten)
entries buffer. The three rewrite call sites (remembered-set dirty scan,
copying field scan, verify/force-evacuate rewrite) each open-coded a
`== GcRewriteHookKind::SetIndex` check, so route them all through one
shared run_gc_rewrite_hook dispatch rather than adding a second special
case; map_header_moved_for_gc migrates the outer key when the MapHeader
itself moves, and every mutation path (set, delete, clear, alloc,
finalize) keeps the table exact so a miss is definitive.

Also fixes a latent correctness bug: bigint keys previously passed
is_safe_numeric_key (the old comment misidentified BIGINT_TAG as 0x7FFE,
which is INT32_TAG -- bigints are 0x7FFA) and so were indexed by raw
pointer bits. `m.set(1n); m.get(1n)` with two distinct 1n allocations
missed, and the indexed bits went stale across a GC move. jsvalue_eq now
compares bigints by mathematical value per SameValueZero (23.1.3.9).

Part of #6084.
…t content keys

test_copying_minor_rebuilds_map_pointer_key_index is the make-or-break case
for MAP_PTR_INDEX: an object key is evacuated by a copying minor (along with
the MapHeader itself), so every bit pattern stored in the index goes stale.
Asserts both objects actually moved, that the rebuilt index resolves the key
at its NEW address, that numeric keys survive the rebuild, and that the stale
pre-GC bits do NOT resolve. Verified to have teeth: stubbing out the
GcRewriteHookKind::MapIndex dispatch makes it fail.

test_map_bigint_keys_match_by_content_not_identity pins the SameValueZero
semantics -- two distinct 1234n allocations are the same Map key, and
re-setting through the content-equal allocation overwrites rather than
appends.

Both compare undefined by BITS, not as an f64: undefined is a NaN payload
and NaN != NaN, so assert_eq! on the f64 can never pass.

Part of #6084.
test_gc_type_metadata_covers_all_declared_types mirrors every declared GC
type's metadata so that adding a hook is a conscious, reviewed change.
Record map's new rewrite_hook_kind: MapIndex alongside set's SetIndex.

Part of #6084.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 64021e9d-1b8c-48b2-9bad-6b31cd669076

📥 Commits

Reviewing files that changed from the base of the PR and between 4472dcf and 5943316.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/gc/tests/copying.rs
  • crates/perry-runtime/src/gc/tests/copying_side_tables.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/timer.rs
  • crates/perry-runtime/src/timer/gc_scan.rs
📝 Walkthrough

Walkthrough

The PR adds GC-safe Map pointer-key indexing with BigInt content equality and centralized rewrite-hook dispatch. It updates copying and verification paths, adds Map GC regression tests, and replaces timer queue removal loops with stable expired-timer partitioning for promise and callback timers.

Changes

Map GC Indexing

Layer / File(s) Summary
Pointer-key index model
crates/perry-runtime/src/map.rs, crates/perry-runtime/src/gc/types.rs
Adds MAP_PTR_INDEX, pointer-key classification, BigInt value equality, and MapIndex GC metadata.
Map index maintenance and lookup
crates/perry-runtime/src/map.rs
Maintains pointer indexes across allocation, lookup, insertion, movement, clearing, rebuilding, and disposal.
GC rewrite dispatch and validation
crates/perry-runtime/src/gc/*.rs, crates/perry-runtime/src/gc/tests/*.rs
Dispatches rewrite hooks for Map objects and validates pointer-key evacuation and BigInt key semantics with tests.

Timer Queue Processing

Layer / File(s) Summary
Stable timer expiration partition
crates/perry-runtime/src/timer.rs, test-files/test_gap_6287_timer_batch_order.ts
Adds stable queue draining and callback-batch ordering for promise and callback timers, with unit and runtime ordering tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CopyingNurseryCollector
  participant run_gc_rewrite_hook
  participant rebuild_map_ptr_index_for_gc
  participant MAP_PTR_INDEX
  CopyingNurseryCollector->>run_gc_rewrite_hook: dispatch MapIndex after rewritten slots
  run_gc_rewrite_hook->>rebuild_map_ptr_index_for_gc: rebuild pointer-key index
  rebuild_map_ptr_index_for_gc->>MAP_PTR_INDEX: store rewritten key entries
Loading
sequenceDiagram
  participant TimerQueue
  participant drain_expired_timers
  participant order_expired_callback_batch
  participant Callbacks
  TimerQueue->>drain_expired_timers: provide expired and cleared entries
  drain_expired_timers->>TimerQueue: preserve surviving entries
  drain_expired_timers->>order_expired_callback_batch: return expired callbacks
  order_expired_callback_batch->>Callbacks: invoke deadline-ordered timers and FIFO immediates
Loading

Possibly related PRs

  • PerryTS/perry#6158: Both changes modify the Map key-index lookup path around find_key_index.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: Map key indexing and single-pass timer draining.
Description check ✅ Passed The description is detailed and covers the main changes and verification, though it does not follow the template exactly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/6084-promise-map-timer-scans

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/perry-runtime/src/gc/tests/copying.rs (1)

311-349: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the indexed BigInt lookup path.

With one entry, find_key_index uses the small-map linear scan, so this test can pass even if MAP_PTR_INDEX BigInt hashing/equality is broken. Add enough filler entries to exceed the eight-entry threshold.

Proposed test adjustment
     let map = crate::map::js_map_alloc(8);
+    for i in 0..9 {
+        crate::map::js_map_set(map, i as f64, i as f64);
+    }
     let boxed =
         |v: i64| crate::value::js_nanbox_bigint(crate::bigint::js_bigint_from_i64(v) as i64);
@@
     crate::map::js_map_set(map, a, 42.0);
+    assert!(crate::map::test_map_ptr_index_contains(map, b));
@@
-        1,
+        10,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/tests/copying.rs` around lines 311 - 349, Update
test_map_bigint_keys_match_by_content_not_identity to insert enough distinct
filler keys after the initial map setup to exceed the eight-entry threshold and
force the indexed MAP_PTR_INDEX lookup path. Adjust assertions to account for
the filler entries while preserving verification that content-equal BigInt keys
overwrite and different values miss.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-runtime/src/gc/tests/copying.rs`:
- Around line 311-349: Update test_map_bigint_keys_match_by_content_not_identity
to insert enough distinct filler keys after the initial map setup to exceed the
eight-entry threshold and force the indexed MAP_PTR_INDEX lookup path. Adjust
assertions to account for the filler entries while preserving verification that
content-equal BigInt keys overwrite and different values miss.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 55236e9e-c7a8-4f4a-bfc0-436a9553f61b

📥 Commits

Reviewing files that changed from the base of the PR and between 9229a14 and 6225d8d.

📒 Files selected for processing (8)
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/tests/alloc.rs
  • crates/perry-runtime/src/gc/tests/copying.rs
  • crates/perry-runtime/src/gc/types.rs
  • crates/perry-runtime/src/gc/verify.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/timer.rs

…rder (#6287) (#6289)

* fix(timer): fire an expired batch in event-loop order, not creation order

When several timers come due in the same turn, Perry fired them in queue
(creation) order. That diverged from Node on two counts:

1. Deadline order. Node's timers phase walks lists by expiry, so a 5 ms
   timer created AFTER a 10 ms one still fires first. Perry ran them in
   creation order:
       setTimeout(() => log("late10"), 10);
       setTimeout(() => log("reffed"), 5);
   node:  reffed, late10
   perry: late10, reffed

2. Timers before immediates. setImmediate runs in the CHECK phase, after
   the timers phase, so an expired setTimeout fires ahead of an immediate
   that was scheduled earlier. Perry interleaved both kinds in one
   creation-ordered queue:
       setImmediate(() => log("IMM1"));
       setTimeout(() => log("T5"), 5);      // loop blocked past 5 ms
   node:  T5, IMM1
   perry: IMM1, T5

Order the expired callback batch as (timeouts by deadline) then
(immediates in FIFO), and the expired promise-timer batch by deadline.
Both sorts are STABLE, which is what preserves the two orderings Perry
already had right: same-deadline timers still fire in creation order, and
immediates still fire in scheduling order.

Node's ordering was captured as ground truth from
`node --experimental-strip-types` (deterministic across runs) rather than
inferred, including the immediate-vs-timeout case that rules out a naive
sort by deadline alone: immediates carry a ~now deadline and would
otherwise sort ahead of an expired timeout.

Fixes #6287.

* test(timer): cover expired-batch event-loop ordering (#6287)

Unit tests for order_expired_callback_batch: deadline order for expired
timeouts, creation order preserved for same-deadline ties (the stable-sort
guarantee), and timeouts-before-immediates with immediates staying FIFO --
the case that rules out a naive sort by deadline alone, since an immediate
carries a ~now deadline and would otherwise sort ahead of an expired
timeout.

Plus a gap test (test_gap_6287_timer_batch_order.ts) that blocks the loop
past every deadline so the whole batch comes due in one turn, making the
ordering observable. Byte-identical to node across repeated runs, and the
blocking makes it deterministic rather than timing-dependent.

Part of #6287.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-runtime/src/timer.rs (1)

1122-1137: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prevent same-batch clearTimeout from firing a later expired timer
drain_expired_timers snapshots CALLBACK_TIMERS before any callback runs, so a callback that clears another timer in the same expired batch can’t mark that snapshot entry. if !timer.cleared on the drained copy will still pass and the cleared timer fires anyway. Re-check against live shared state or keep canceled state outside the drained batch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/timer.rs` around lines 1122 - 1137, Update the
expired-timer processing around drain_expired_timers so each timer is
revalidated against live CALLBACK_TIMERS state immediately before invocation.
Ensure a callback’s clearTimeout call marks later timers as canceled and
prevents them from firing in the same batch, rather than relying on the drained
timer copy’s cleared field.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-runtime/src/timer.rs`:
- Around line 1122-1137: Update the expired-timer processing around
drain_expired_timers so each timer is revalidated against live CALLBACK_TIMERS
state immediately before invocation. Ensure a callback’s clearTimeout call marks
later timers as canceled and prevents them from firing in the same batch, rather
than relying on the drained timer copy’s cleared field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 53bcd78f-1e32-4b11-9bdb-369fff9ebc7f

📥 Commits

Reviewing files that changed from the base of the PR and between 6225d8d and 4472dcf.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/timer.rs
  • test-files/test_gap_6287_timer_batch_order.ts

Ralph Küpper added 2 commits July 11, 2026 15:50
…odules

The 2000-line file gate (scripts/check_file_size.sh) failed the lint job:
timer.rs was 1998 lines on main -- two under the cap -- so the drain
partition, the expired-batch ordering, and their tests pushed it to 2163,
and the new Map pointer-index GC tests took gc/tests/copying.rs to 2040.

Mechanical cut, no behavior change:

- timer.rs: the whole trailing #[cfg(test)] region (test-only seed/snapshot
  helpers + the drain/ordering unit tests) moves to timer/tests.rs, next to
  the existing timer/ref_states.rs. The pub(crate) helpers are re-exported
  from timer.rs, so out-of-module call sites (crate::timer::test_seed_*,
  used by the gc root-scanner tests) resolve unchanged. 2163 -> 1913.
- gc/tests/copying.rs: the two Map pointer-key index tests move to
  copying/map_pointer_index.rs, alongside the existing copying/ siblings.
  2040 -> 1940.

map.rs stays over the gate but is already allowlisted.

cargo test -p perry-runtime: 1239 passed, 0 failed (unchanged). fmt,
file-size, GC store-site inventory and address-classification gates all
pass locally.
…ze gate

This PR pushed both files past the cap (timer.rs 1998 -> 2163, copying.rs
1939 -> 2040), failing lint. Extract the incremental GC root-scan machinery
into timer/gc_scan.rs (timer.rs is now 1947) and the side-table rewrite tests
into gc/tests/copying_side_tables.rs (copying.rs is now 1817).
@proggeramlug
proggeramlug merged commit d60676f into main Jul 11, 2026
25 checks passed
@proggeramlug
proggeramlug deleted the perf/6084-promise-map-timer-scans branch July 11, 2026 17:13
proggeramlug added a commit that referenced this pull request Jul 12, 2026
… bands (#6285 follow-up) (#6297)

`main` is red: the addr-class ratchet fails on a clean checkout
(`crates/perry-runtime/src/map.rs: 4 site(s), baseline allows 3`), so the lint
gate blocks every PR. #6285 added two hand-rolled address floors without
updating the baseline.

The floors are also wrong, which is what the ratchet exists to catch. Both
bare-address branches gate on `bits > 0x10000`:

    } else if bits >> 48 == 0 && bits > 0x10000 {   // map_ptr_from_receiver_bits
    } else if upper == 0 && bits > 0x10000 {        // bigint_ptr_from_bits

but `HANDLE_BAND_MAX` is `0x100000` — an order of magnitude higher. So every
fetch (`0x40000..0xE0000`), zlib and proxy handle clears that floor and is
accepted as a candidate heap address. Both call sites happen to survive today
because each re-checks before dereferencing (`is_registered_map` — an exact
registry lookup; `try_read_gc_header` — the guarded reader), so this is a
latent hazard rather than a live segfault. But it is exactly the shape that
segfaults on Linux once a caller trusts the address, which is the #1843 /
#4004 / #4665 / #4800 / #6271 family, and macOS's 2 TB heap floor hides it.

Use the sanctioned predicate — `value::addr_class::is_above_handle_band` — as
the floor instead. Strictly narrower: the only addresses it newly rejects are
`[0x10001, 0x100000)`, i.e. precisely the handle bands, which must never be
read as a Map or BigInt header. Real arena allocations are already above
`HANDLE_BAND_MAX`.

Drops map.rs from 4 handle-floor sites to 2 (baseline allows 3), so the ratchet
passes and main goes green again. perry-runtime lib suite 1264/1264.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

1 participant