Skip to content

gc(windows): RtlVirtualUnwind native-root stack walker — PERRY_RS4GC works on x86-64 Windows (#7354) - #7355

Merged
proggeramlug merged 2 commits into
mainfrom
feat/windows-gc-walker
Aug 4, 2026
Merged

gc(windows): RtlVirtualUnwind native-root stack walker — PERRY_RS4GC works on x86-64 Windows (#7354)#7355
proggeramlug merged 2 commits into
mainfrom
feat/windows-gc-walker

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes #7354.

What this adds

The COFF .pgcmap section and its PE lookup existed (#7351) but Windows had no stack walker — _Unwind_* does not exist there — so compact_and_assemble refused every COFF target. This PR adds the walker and enables the platform, verified end-to-end on a real Windows 11 host.

  • gc/roots/stack_maps.rs — a #[cfg(all(windows, x86_64))] mod unwind walks native frames with RtlCaptureContextRtlLookupFunctionEntryRtlVirtualUnwind. Each step yields the frame's Rip/Rsp/Rbp directly, so SP-relative roots read the real Rsp — the CFA derivation the Itanium path needs is skipped entirely, per the issue's recommendation. The hand-declared CONTEXT layout is pinned by compile-time offset_of! asserts. Fail-closed: only Win64 nonvolatile base registers are trusted (a volatile register's CONTEXT slot holds some inner frame's value — handing it out would give the collector a wild address it then writes through); every candidate slot is bounded by GetCurrentThreadStackLimits; a frame with no unwind info, a non-outward step, or an out-of-bounds/misaligned slot abandons the walk. The SP monotonicity check is also the termination guarantee.
  • exception.rs — main's unguarded eh_walker:: calls (an Itanium-only module) meant perry-runtime did not compile on Windows at all; now #[cfg(not(windows))].
  • gc_map.rs — the COFF refusal narrows to non-x86-64 instead of being deleted: ARM64 Windows passes the arch gate and is COFF but still has no walker (different CONTEXT layout/register model), so deleting the refusal outright would re-open the silent-lost-roots hole there. windows_is_refused_until_it_has_a_walker is replaced by x86_64_windows_is_no_longer_refused plus the ARM64 pin.
  • linker.rs / inprocess.rs — a measured negative: windows-msvc try lowers to WinEH funclet pads, and LLVM's rewrite-statepoints-for-gc crashes on them (0xC0000005 in opt 22.1.3; reproducible with an 8-line module — one invoke unwinding to a catchswitch, see below). RS4GC now refuses funclet modules before the pass runs, on both the external-opt and in-process paths (where the crash would take the whole compiler down). A failed opt pipeline now also writes its input IR to disk and prints a one-command repro — the crash previously left only a symbol-less stack dump.
  • gc-native-roots.ymlwindows-latest matrix arm: PE section asserts via llvm-readobj from the same pinned clang+llvm archive that supplies opt (one directory, no version skew), probe 09 pinned as the named funclet refusal (goes red the day the pass learns funclets), and a --require-locations telemetry liveness gate via gc_walker_trace_assert.py — this walker has no Itanium unwinder under it and no verify-mode cross-check, so non-zero telemetry is the only proof it ran.

Telemetry from the real run (the "prove it ran" numbers)

Toolchain: opt+clang 22.1.3 from one release archive; Node v26.5.1 (zip sha512 matches the external-tools.json SRI pin). Runtime + compiler built with -C force-frame-pointers=yes -C force-unwind-tables=yes, perry-dev profile. All runs under PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off, byte-diffed against node --expose-gc --experimental-strip-types:

01_nursery_churn:             walks=7  frames_visited=89   records_matched=7    locations_visited=12
02_survivor_promotion:        walks=7  frames_visited=90   records_matched=7    locations_visited=24
03_cross_gen_writes:          walks=10 frames_visited=125  records_matched=10   locations_visited=16
04_dead_after_deep_stack:     walks=15 frames_visited=5626 records_matched=5449 locations_visited=22
05_closure_capture:           walks=9  frames_visited=126  records_matched=17   locations_visited=12
06_string_retention:          walks=12 frames_visited=176  records_matched=23   locations_visited=0
07_array_grow_evacuate:       walks=13 frames_visited=191  records_matched=25   locations_visited=0
08_map_set_sidetables:        walks=14 frames_visited=180  records_matched=14   locations_visited=28
10_store_receiver_across_alloc: walks=6 frames_visited=81  records_matched=6    locations_visited=0

All 9 runnable probes byte-match the oracle. Probe 04's deep recursion shows the walker stepping 5,626 mapped frames and matching 5,449 records. The forced evacuation was live, not vacuous: 5,946–90,271 objects copied per probe (copying_nursery.copied_objects), under PERRY_GC_VERIFY_EVACUATION=1. PERRY_STACKMAP_WALKER=unwind gives identical output with fallback_walks=0, confirming mode wiring. Every binary carries a non-empty .pgcmap (e.g. probe 04: VirtualSize 0x2A9) and zero llvm_st* section remnants.

The measured negative: probe 09 (try/catch)

rewrite-statepoints-for-gc crashes on WinEH funclets. Minimal repro (crashes opt 22.1.3 identically to the full probe):

target triple = "x86_64-pc-windows-msvc"
declare i32 @__C_specific_handler(...)
declare i32 @filter(ptr, ptr)
declare void @may_throw()
define void @f() gc "statepoint-example" personality ptr @__C_specific_handler {
entry:
  invoke void @may_throw() to label %cont unwind label %pad
pad:
  %cs = catchswitch within none [label %catch] unwind to caller
catch:
  %cp = catchpad within %cs [ptr @filter]
  catchret from %cp to label %cont
cont:
  ret void
}

opt -passes=rewrite-statepoints-for-gc → Exception Code 0xC0000005. Perry now refuses this shape by name before the pass runs, and the CI arm pins the refusal.

Tests

  • cargo test -p perry-codegen --lib: 599 passed, 0 failed on Windows (includes the new funclet-refusal and Windows-enablement tests).
  • cargo test -p perry-runtime --lib: all gc::roots::stack_maps and exception:: tests pass. The full suite has never been runnable on Windows before this PR (the crate didn't compile); running it now surfaces pre-existing platform gaps unrelated to this work (dyn_eval SEH-throw aborts under the test harness, a setjmp smoke AV, a date/TZ assertion, child_process spawn-sync fields, glibc-assuming malloc_trim assert, a teardown accounting off-by-2) — inventoried in Windows: perry-runtime --lib test suite has pre-existing failures now that the crate compiles there (#7355 fallout inventory) #7356 rather than folded into this PR.

Summary by CodeRabbit

  • New Features
    • Added native garbage-collection root support for 64-bit Windows.
    • Added Windows stack walking and root-location reporting for improved runtime compatibility.
  • Bug Fixes
    • Prevented compiler crashes when unsupported Windows exception-handling constructs are encountered.
    • Improved failure messages with saved intermediate output and reproducible troubleshooting commands.
  • Tests
    • Added Windows CI coverage and validation for stack-walker telemetry and supported platform configurations.

…works on x86-64 Windows (#7354)

The COFF `.pgcmap` section and its PE lookup existed (#7351) but Windows had
no stack walker — `_Unwind_*` does not exist there — so RS4GC was refused for
every COFF target. This adds the missing walker and enables the platform:

- `gc/roots/stack_maps.rs`: `#[cfg(windows, x86_64)] mod unwind` steps a
  hand-declared `CONTEXT` (layout pinned by compile-time offset asserts)
  outward with RtlLookupFunctionEntry + RtlVirtualUnwind. SP-relative roots
  read the frame's real Rsp — no CFA derivation. Fail-closed: only Win64
  nonvolatile base registers are trusted, every slot is bounded by
  GetCurrentThreadStackLimits, a frame without unwind info or a
  non-outward step ends the walk.
- exception.rs: the unguarded `eh_walker` calls (Itanium-only module) broke
  the whole Windows build of perry-runtime; now `#[cfg(not(windows))]`.
- gc_map.rs: the COFF refusal narrows to non-x86-64 (ARM64 Windows still has
  no walker and must stay refused); `windows_is_refused_until_it_has_a_walker`
  replaced by `x86_64_windows_is_no_longer_refused` + the ARM64 pin.
- linker.rs/inprocess.rs: WinEH funclet pads (windows-msvc `try` lowering)
  crash LLVM's rewrite-statepoints-for-gc outright (0xC0000005, opt 22.1.3,
  8-line repro), so RS4GC now refuses funclet modules BEFORE the pass runs,
  and a failed opt pipeline writes its input IR to disk for reproduction.
- gc-native-roots.yml: windows-latest arm (PE section asserts via
  llvm-readobj, probe 09 pinned as the funclet refusal, and a
  `--require-locations` walker-liveness telemetry gate).

Verified on a real Windows host (opt+clang 22.1.3 one-dir pair, Node 26.5.1
SRI-matched): 9/9 runnable probes byte-match the oracle under
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 with 5.9k-90k objects
copied per probe; telemetry shows the walker ran (probe 04:
frames_visited=5626, records_matched=5449, locations_visited=22).
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 173ede68-dc20-4814-a344-84c0f756e1a4

📥 Commits

Reviewing files that changed from the base of the PR and between 2494595 and daa14f7.

📒 Files selected for processing (9)
  • .github/workflows/gc-native-roots.yml
  • changelog.d/7355-windows-gc-walker.md
  • crates/perry-codegen/src/gc_map.rs
  • crates/perry-codegen/src/inprocess.rs
  • crates/perry-codegen/src/linker.rs
  • crates/perry-codegen/src/linker_tests.rs
  • crates/perry-runtime/src/exception.rs
  • crates/perry-runtime/src/gc/roots/stack_maps.rs
  • scripts/gc_walker_trace_assert.py

📝 Walkthrough

Walkthrough

The compiler now enables native GC maps for Windows x86-64. The runtime walks Win64 frames with RtlVirtualUnwind, RS4GC rejects unsupported WinEH funclets, and CI validates PE sections, probe behavior, and walker telemetry.

Changes

Windows native GC roots

Layer / File(s) Summary
Windows target enablement
crates/perry-codegen/src/gc_map.rs
COFF native GC-map emission now accepts Windows x86-64 and continues to reject unsupported architectures, including Windows ARM64.
Win64 stack walking
crates/perry-runtime/src/gc/roots/stack_maps.rs, crates/perry-runtime/src/exception.rs
The runtime uses RtlVirtualUnwind to restore registers, match stack-map records, enumerate root slots, and stop on invalid unwind or stack data. It excludes Itanium-specific fast exception transport on Windows.
WinEH and RS4GC safeguards
crates/perry-codegen/src/linker.rs, crates/perry-codegen/src/inprocess.rs, crates/perry-codegen/src/linker_tests.rs, changelog.d/7355-windows-gc-walker.md
RS4GC rejects WinEH funclet instructions before LLVM processing. Failed external pipelines preserve input IR and report a reproducible command. Regression tests cover supported and rejected EH forms.
Windows CI and telemetry validation
.github/workflows/gc-native-roots.yml, scripts/gc_walker_trace_assert.py
CI adds a Windows x86-64 matrix arm, matched LLVM tools, PE section checks, funclet refusal checks, and required nonzero frame, record, and root-location telemetry.

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

Sequence Diagram(s)

sequenceDiagram
  participant GC
  participant native_stack_maps_visit
  participant RtlVirtualUnwind
  participant StackMapIndex
  GC->>native_stack_maps_visit: Request native root enumeration
  native_stack_maps_visit->>RtlVirtualUnwind: Capture and unwind current frame
  RtlVirtualUnwind-->>native_stack_maps_visit: Return frame registers and stack pointer
  native_stack_maps_visit->>StackMapIndex: Match instruction pointer to records
  StackMapIndex-->>native_stack_maps_visit: Return root locations
  native_stack_maps_visit-->>GC: Visit validated root slots and telemetry
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7352: Introduced the Windows native GC-map pipeline that this PR enables with a stack walker.
  • PerryTS/perry#7301: Modified the in-process and linker RS4GC paths that this PR extends with WinEH refusal handling.
  • PerryTS/perry#7322: Established the native-root CI and telemetry validation that this PR extends to Windows.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/windows-gc-walker

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.

@proggeramlug
proggeramlug merged commit 7428437 into main Aug 4, 2026
8 of 15 checks passed
@proggeramlug
proggeramlug deleted the feat/windows-gc-walker branch August 4, 2026 08:36
proggeramlug added a commit that referenced this pull request Aug 4, 2026
`cargo fmt --all -- --check` exits 1 on main HEAD (7428437). One file:
`crates/perry-codegen/src/linker.rs`, where `rs4gc_funclet_refusal`'s needle
array crossed the width that makes rustfmt break it vertically.

`lint` is a REQUIRED context. A required check that is red on main does not
block anything -- it means every subsequent merge is a bypass, which is the
hazard CLAUDE.md lists and that this repo has already paid for once (lint sat
red across three nightlies on 16 oversized files, and every merge in that
window went in unchecked).

No behavior change: whitespace only.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 4, 2026
…tform matrix (#7367)

The plan still said statepoints were aarch64-only (#7321), that the matrix
"therefore runs on macos-14", that `statepoints-refuse-x86` pinned the refusal,
and it spelled the knob `PERRY_STATEPOINTS` four times. None of that is true
now, and this document is what the adoption decision gets made from.

What actually changed:

- x86-64 is unblocked. `_Unwind_GetGR(ctx, 7)` does segfault and cannot be
  fixed as stated -- libgcc tracks only the columns CFI restores and RSP is
  derived, not tracked. #7349 stopped asking for it and derives the SP-relative
  base from `_Unwind_GetCFA`, with a per-arch return-address adjustment (x86-64
  `call` pushes one, aarch64 `bl` does not). x86-64 Linux is a first-class arm.
- Windows works via RtlVirtualUnwind (#7355), the one walker with no Itanium
  unwinder beneath it.
- aarch64+ELF is now covered too (#7360) -- the only shape where LLVM spells
  32-bit stack-map fields `.word`.
- One mechanism, not two: PERRY_STATEPOINTS and the plain-map bridge are
  deleted, so the kill-policy line about "a mode that still exists" no longer
  applies to this pair.
- The gate proves something now. Until today the Unix arms reported 7 frames
  and ZERO locations -- they would have passed with a walker that visited
  nothing. #7359's deep-collect probe took them to 221 locations.
- watchOS/visionOS are not blocked by Perry: they build on stable without
  `dyn-eval`, and fail three crates away in psm's Mach-O guard.

So the remaining adoption gate is `llvm-inprocess` becoming a default cargo
feature, plus sequencing step 2 (root density) -- adopting today would regress
binary size on root-dense code.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 4, 2026
…#7369)

* fix(lint): split index_set.rs, over the 2000-line cap since #7342 (#7366)

`scripts/check_file_size.sh` exits 1 on main HEAD:
`crates/perry-codegen/src/expr/index_set.rs` is 2035 lines against a 2000
cap. It crossed in #7342.

That script runs inside the `lint` job, which is a REQUIRED context -- so
this is the second independent way `lint` was red on main today (the first
was rustfmt on linker.rs, #7361). A required check that is red on main blocks
nothing; it means every merge is a bypass.

The split follows the recipe in the script's own failure message: extract a
topical group into a sibling module. `lower_inline_dyn_typed_array_set` and
its `emit_inline_ta_int_store` helper are the guarded inline typed-array
store for a type-erased receiver -- one coherent unit, moved verbatim to
`index_set_typed_array.rs`. index_set.rs drops to 1749 lines, leaving real
headroom rather than landing one line under the cap.

Mechanical move: the two functions are byte-identical, only the imports they
need travelled with them and `lower_inline_dyn_typed_array_set` became
`pub(super)` so its one caller can still reach it.

cargo test -p perry-codegen --lib: 609 passed.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

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

* docs(plan): both statepoint adoption gates are closed; record the platform matrix (#7367)

The plan still said statepoints were aarch64-only (#7321), that the matrix
"therefore runs on macos-14", that `statepoints-refuse-x86` pinned the refusal,
and it spelled the knob `PERRY_STATEPOINTS` four times. None of that is true
now, and this document is what the adoption decision gets made from.

What actually changed:

- x86-64 is unblocked. `_Unwind_GetGR(ctx, 7)` does segfault and cannot be
  fixed as stated -- libgcc tracks only the columns CFI restores and RSP is
  derived, not tracked. #7349 stopped asking for it and derives the SP-relative
  base from `_Unwind_GetCFA`, with a per-arch return-address adjustment (x86-64
  `call` pushes one, aarch64 `bl` does not). x86-64 Linux is a first-class arm.
- Windows works via RtlVirtualUnwind (#7355), the one walker with no Itanium
  unwinder beneath it.
- aarch64+ELF is now covered too (#7360) -- the only shape where LLVM spells
  32-bit stack-map fields `.word`.
- One mechanism, not two: PERRY_STATEPOINTS and the plain-map bridge are
  deleted, so the kill-policy line about "a mode that still exists" no longer
  applies to this pair.
- The gate proves something now. Until today the Unix arms reported 7 frames
  and ZERO locations -- they would have passed with a walker that visited
  nothing. #7359's deep-collect probe took them to 221 locations.
- watchOS/visionOS are not blocked by Perry: they build on stable without
  `dyn-eval`, and fail three crates away in psm's Mach-O guard.

So the remaining adoption gate is `llvm-inprocess` becoming a default cargo
feature, plus sequencing step 2 (root density) -- adopting today would regress
binary size on root-dense code.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

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

* gc: admit two provably-leaf helpers, and measure that it buys nothing

js_gc_register_global_root was the most frequent non-leaf callee in the probe
suite (148 call sites) and is provably GC-leaf: its whole body is
runtime_write_barrier_root_heap_word -- which js_write_barrier_root_heap_word,
already CannotCollect, wraps in one line -- plus a TLS Vec::push. The "malloc
count threshold" trigger does not apply to that push: the counter is
MALLOC_STATE.objects.len(), a registry of Perry GC objects, and the
#[global_allocator] is plain mimalloc/System with no GC hook.
js_typed_feedback_maybe_dump_trace joins its already-admitted family siblings.

Measured A/B on the same tree, and the result is a null:

  probe                  safepoints    roots   total bytes   __text
  06_string_retention     105 -> 100   27=27             0     -4 B
  09_try_catch_roots      343 -> 339  259=259            0     -4 B
  11_collect_at_depth     120 -> 117   36=36             0     -4 B

Root counts are IDENTICAL. The 40 safepoints removed across the suite were all
rootless, and a rootless safepoint costs essentially nothing -- which is what
docs/engine-plan.md already says: "the axis is not 'statepoints are bigger', it
is 'roots are bigger'". Recording it as evidence: the safepoint-count lever is
not the binary-size lever, so sequencing step 2 must attack live-root SETS.

Two tests come with it. One pins the wrapper's classification to the barrier it
wraps. The other pins js_nanbox_string OUT of the allowlist: at 120 call sites
it is the obvious next candidate and reads as pure bit manipulation, but its
null guard calls js_string_from_bytes to allocate an empty string.

Probe suite 11/11 byte-identical under forced evacuation + verification.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 4, 2026
…7368)

* fix(codegen): statepoint report counted zero safepoints since #7348

#7348 deleted the explicit bridge and with it the only callers of
note_statepoint and note_skipped -- they lived in the bridge, which counted
safepoints as it emitted them. The methods survived with no callers, so
statepoints, relocations, max_live_roots, skipped_non_safepoints,
live_roots_histogram and both by-callee maps went structurally zero in
production. A real compile printed "0 statepoints emitted" while its binary
carried 120.

Counting at IR-emission time cannot work any more, and that is the lesson:
Perry no longer decides which calls become safepoints -- RewriteStatepointsForGC
does, inside LLVM. The only honest source is the compact-map rewrite, which
already parses the assembly LLVM emitted and computed these exact numbers
before dropping them into log::debug!. The report reads from there now:

  120 safepoints across 6 function(s) in 1 module(s)
  36 live roots recorded, 0.30 per safepoint

An absent measurement no longer renders as a measured zero: gc_map.modules == 0
means "never reported", the text report says UNAVAILABLE rather than printing
zeros, and JSON carries gc_map separately from totals so a consumer can tell
them apart. schema_version -> 2.

The CI gate now asserts the counts, not just the label. --only-backend rs4gc
passed throughout the regression -- the label was right, the numbers were
fiction. It now also requires records > 0 and roots > 0; verified against a
synthetic report with the #7348 shape, where the label check still reports 9
functions green while the count checks exit 1.

Second round of dead counters here (#7362 removed four that never had a writer
at all). The new test documents why the first invariant missed this one:
every_rendered_counter_has_a_writer called the mutators itself, so "has a
writer" passed while "is written" was false.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

* gc: admit two provably-leaf helpers, and measure that it buys nothing (#7369)

* fix(lint): split index_set.rs, over the 2000-line cap since #7342 (#7366)

`scripts/check_file_size.sh` exits 1 on main HEAD:
`crates/perry-codegen/src/expr/index_set.rs` is 2035 lines against a 2000
cap. It crossed in #7342.

That script runs inside the `lint` job, which is a REQUIRED context -- so
this is the second independent way `lint` was red on main today (the first
was rustfmt on linker.rs, #7361). A required check that is red on main blocks
nothing; it means every merge is a bypass.

The split follows the recipe in the script's own failure message: extract a
topical group into a sibling module. `lower_inline_dyn_typed_array_set` and
its `emit_inline_ta_int_store` helper are the guarded inline typed-array
store for a type-erased receiver -- one coherent unit, moved verbatim to
`index_set_typed_array.rs`. index_set.rs drops to 1749 lines, leaving real
headroom rather than landing one line under the cap.

Mechanical move: the two functions are byte-identical, only the imports they
need travelled with them and `lower_inline_dyn_typed_array_set` became
`pub(super)` so its one caller can still reach it.

cargo test -p perry-codegen --lib: 609 passed.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

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

* docs(plan): both statepoint adoption gates are closed; record the platform matrix (#7367)

The plan still said statepoints were aarch64-only (#7321), that the matrix
"therefore runs on macos-14", that `statepoints-refuse-x86` pinned the refusal,
and it spelled the knob `PERRY_STATEPOINTS` four times. None of that is true
now, and this document is what the adoption decision gets made from.

What actually changed:

- x86-64 is unblocked. `_Unwind_GetGR(ctx, 7)` does segfault and cannot be
  fixed as stated -- libgcc tracks only the columns CFI restores and RSP is
  derived, not tracked. #7349 stopped asking for it and derives the SP-relative
  base from `_Unwind_GetCFA`, with a per-arch return-address adjustment (x86-64
  `call` pushes one, aarch64 `bl` does not). x86-64 Linux is a first-class arm.
- Windows works via RtlVirtualUnwind (#7355), the one walker with no Itanium
  unwinder beneath it.
- aarch64+ELF is now covered too (#7360) -- the only shape where LLVM spells
  32-bit stack-map fields `.word`.
- One mechanism, not two: PERRY_STATEPOINTS and the plain-map bridge are
  deleted, so the kill-policy line about "a mode that still exists" no longer
  applies to this pair.
- The gate proves something now. Until today the Unix arms reported 7 frames
  and ZERO locations -- they would have passed with a walker that visited
  nothing. #7359's deep-collect probe took them to 221 locations.
- watchOS/visionOS are not blocked by Perry: they build on stable without
  `dyn-eval`, and fail three crates away in psm's Mach-O guard.

So the remaining adoption gate is `llvm-inprocess` becoming a default cargo
feature, plus sequencing step 2 (root density) -- adopting today would regress
binary size on root-dense code.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

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

* gc: admit two provably-leaf helpers, and measure that it buys nothing

js_gc_register_global_root was the most frequent non-leaf callee in the probe
suite (148 call sites) and is provably GC-leaf: its whole body is
runtime_write_barrier_root_heap_word -- which js_write_barrier_root_heap_word,
already CannotCollect, wraps in one line -- plus a TLS Vec::push. The "malloc
count threshold" trigger does not apply to that push: the counter is
MALLOC_STATE.objects.len(), a registry of Perry GC objects, and the
#[global_allocator] is plain mimalloc/System with no GC hook.
js_typed_feedback_maybe_dump_trace joins its already-admitted family siblings.

Measured A/B on the same tree, and the result is a null:

  probe                  safepoints    roots   total bytes   __text
  06_string_retention     105 -> 100   27=27             0     -4 B
  09_try_catch_roots      343 -> 339  259=259            0     -4 B
  11_collect_at_depth     120 -> 117   36=36             0     -4 B

Root counts are IDENTICAL. The 40 safepoints removed across the suite were all
rootless, and a rootless safepoint costs essentially nothing -- which is what
docs/engine-plan.md already says: "the axis is not 'statepoints are bigger', it
is 'roots are bigger'". Recording it as evidence: the safepoint-count lever is
not the binary-size lever, so sequencing step 2 must attack live-root SETS.

Two tests come with it. One pins the wrapper's classification to the barrier it
wraps. The other pins js_nanbox_string OUT of the allowlist: at 120 call sites
it is the obvious next candidate and reads as pure bit manipulation, but its
null guard calls js_string_from_bytes to allocate an empty string.

Probe suite 11/11 byte-identical under forced evacuation + verification.

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

---------

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

* fix(ci): report assertion pinned to a probe Windows cannot compile

Three review fixes on #7368.

The report assertion ran on 09_try_catch_roots, which contains four `try`
blocks. RS4GC cannot rewrite WinEH funclet pads, so linker.rs's
rs4gc_funclet_refusal rejects that probe on windows-msvc -- the probe loop
above tolerates it by grepping the compile log for "funclet", but this step
did not. A gate pinned to a probe that cannot compile on one arm fails for a
reason unrelated to its subject. The portable assertion now uses
11_collect_at_depth (no `try`, compiles on all four arms); 09_try_catch_roots
keeps its own non-Windows step so the try-specific coverage that justified
deleting the bridge is not lost.

The gc_map doc claimed records/roots would be ABSENT when unmeasured. They
are plain u64 fields on a plain derive and always serialise; `modules` is the
sentinel. Fixed to describe what the code actually does -- the same class of
comment-vs-code drift this PR exists to clean up.

The "map never reported" guard fired for any --require-*/--print, including
fields that live in `totals` and are counted at IR-emission time whether or
not the rewrite ran. Now scoped to map-backed fields: --require-positive
textual_calls is answered from its measured value (verified exit 0) while
--require-positive records still fails on an unreported map (exit 1).

Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 4, 2026
…fork (#7371)

Three corrections, one of which is a number the plan explicitly warns
against quoting and was carrying anyway.

1. THE SIZE FIGURE. Only +18.95% appears on main -- a synthetic worst
   case with three heap values live across an allocation in EVERY one of
   2000 functions. The dependency-scale measurement is +1.86% (zod, 81
   native modules, 29 MB binary), an order of magnitude lower. The
   correction was written when the synthetic was retracted but never
   reached main: #7345 squash-merged as 24 insertions, the first commit
   only, so the follow-up correction commit was dropped. That is the same
   failure mode this document records for #7321 -- a wrong explanation
   outliving its own disproof -- so the real number now leads and the
   worst case is explicitly marked do-not-quote.

2. SEQUENCING STEP 2 said root density was a PREREQUISITE for adoption,
   reasoning from that retracted figure. Adoption shipped in #7370
   without it. Still worth doing, and still the same lever #7296 proved
   worth 9.9x, but it gates nothing.

3. THE ADOPTION FORK IS CLOSED. Every gate shut: llvm-inprocess default
   (#7353), x86-64 (#7349), Windows (#7355), bridge deleted (#7348), and
   the 479-test suite with no env matching the shadow baseline exactly.
   The target-aware shape is recorded because it is the part that
   generalises: native roots where the runtime can walk, shadow stack
   where it cannot.

Also: layer 2 now reads THE DEFAULT rather than landed opt-in, layer 3's
count is 41 rather than 54 after #7363, and the 2026-08-03 status header
no longer says 'not yet adopted'.

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.

Windows: native-root stack walker so PERRY_RS4GC=1 works there (#7173)

1 participant