Windows: a native-root stack walker so PERRY_RS4GC=1 works there
You are in Perry (native TypeScript→machine-code compiler, Rust + LLVM). Read
CLAUDE.md first. You are on a Windows machine, which is the point: this task
was deliberately left undone because it cannot be written blind.
Setup (verbatim)
git clone https://github.com/PerryTS/perry.git
cd perry
git checkout -b feat/windows-gc-walker origin/main
You need: Rust (stable, x86_64-pc-windows-msvc), the MSVC build tools, LLVM
(clang and opt from the same install — see below), and the Node version
pinned in .node-version at the repo root. perry doctor will check the
toolchain.
What already exists — do not rebuild it
Merged in #7351:
- The COFF section.
crates/perry-codegen/src/gc_map.rs emits .pgcmap
into a COFF object. The name is seven bytes on purpose: a PE image section
header has an 8-byte name field, and longer names survive only in object files
as a string-table offset the linker does not carry into the image. Do not
rename it without changing COFF_SECTION_NAME and the runtime lookup together.
- The PE lookup.
loaded_stack_map_section() in
crates/perry-runtime/src/gc/roots/stack_maps.rs, #[cfg(target_os = "windows")], finds that section in the running image via
GetModuleHandleW(NULL) → IMAGE_DOS_HEADER → NT headers → section table. It
compiles for x86_64-pc-windows-msvc; it has never run.
What is missing, and why Windows is refused today
There is no stack walker on Windows. _Unwind_Backtrace / _Unwind_GetIP /
_Unwind_GetGR / _Unwind_GetCFA do not exist there, so the unwind module is
#[cfg(any(target_vendor = "apple", target_os = "linux"))] and Windows falls to
a stub that visits nothing.
A map with no walker is worse than no map: the collector would find zero
roots and free live objects, silently. So compact_and_assemble in
gc_map.rs currently refuses COFF targets with a message naming the missing
piece. Removing that refusal is the last step of this task, not the first.
The contract your walker must satisfy
Implement, for #[cfg(target_os = "windows")], a module exposing exactly what
the other two do:
pub(super) fn visit<F: FnMut(MutableRootSlot)>(
index: &StackMapIndex,
visit: &mut F,
) -> NativeStackWalkStats
For each native frame, walking outward from the current one:
- Get the frame's return address and call
index.match_records(ip).
Matching is a ±16-byte nearest-PC window plus a containment check: the
record's function must be the greatest mapped function start ≤ ip. Both
already exist; you only supply ip.
- For each
location in index.locations(record), compute the slot address
from the location's base register and signed offset, and hand it to visit
as a MutableRootSlot { kind: MutableRootSlotKind::NativeStack, ptr }.
- Fill in
NativeStackWalkStats — walks, frames_visited,
records_matched, locations_visited. These are load-bearing, see
"prove it ran" below.
Base registers are DWARF numbers. On x86-64: 6 = RBP, 7 = RSP. The map's
short tags are aarch64-literal by design (tag 0 → 29, tag 1 → 31); an x86-64
root therefore arrives with an explicit register number, which round-trips
already. ARCH_DWARF_SP is the runtime-local constant for "which register is
the stack pointer here".
Two approaches — prototype both, choose on evidence
RtlVirtualUnwind is the supported path. RtlLookupFunctionEntry +
RtlVirtualUnwind step a CONTEXT outward, and each step gives you Rip,
Rsp and Rbp directly. That is simpler here than the Unix path, which has
to derive SP from the CFA — you can read the real register values, so skip that
derivation entirely rather than porting it.
An fp-chain walk is far less code: Perry emits "frame-pointer"="non-leaf",
so RBP does chain, with [RBP] = caller RBP and [RBP+8] = return address —
the same shape the aarch64 fp_chain module already walks. It is only valid
while every frame in the chain is Perry-generated; a runtime or OS frame in the
middle breaks it.
Recommendation: get RtlVirtualUnwind correct first, because it is the one that
survives mixed frames. Add fp-chain later only if you can measure it mattering.
Whatever you pick, fail closed: on any anomaly (null/misaligned frame
pointer, an address outside the thread's stack, an unrecognised frame) abandon
the walk and return, rather than visiting a slot you are unsure about. Every
walker in this file already does that, and the reason is that a wrong root
address means the collector writes through it.
stack_top() on Windows: GetCurrentThreadStackLimits, or
NtCurrentTeb()->NtTib.StackBase. Bound every candidate slot by it.
Prove it ran — this is not optional
This project has repeatedly shipped gates that could not fail. A green probe run
proves nothing on its own: if your walker visits zero frames, most probes still
print the right answer, because other root sources cover them.
After a run, PERRY_GC_TRACE=1 prints root-source telemetry. You must see
non-zero walks, frames_visited, records_matched and locations_visited
from native_stack_maps. For reference, the same probes on other platforms give
frames_visited around 7–10 with locations_visited of 0–2 — low, because the
probes end in a manual gc() from a shallow stack. If Windows reports numbers in
that range, you have parity; if it reports all zeros, the walker did not run and
the probes passing means nothing.
Acceptance
- All probes in
benchmarks/gc_ratchet/probes/*.ts byte-match the pinned Node
oracle under PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1. The oracle is
node --expose-gc --experimental-strip-types <probe> with the exact
version in .node-version.
- Every produced binary carries a non-empty
.pgcmap section and no
__llvm_stackmaps. Assert both — checking only the first still passes if the
compact rewrite silently stopped.
- Telemetry shows the walker ran (above).
cargo test -p perry-runtime --lib and -p perry-codegen --lib green.
- Only then: delete the COFF refusal in
compact_and_assemble, and delete the
windows_is_refused_until_it_has_a_walker test with it.
- Add a Windows arm to
.github/workflows/gc-native-roots.yml. It is a matrix
over host shapes; windows-latest is a new entry. Use readelf/otool
equivalents appropriate to PE — dumpbin /headers or llvm-readobj.
Traps this project has already paid for — do not re-pay them
opt and clang must come from the same LLVM install. RS4GC pipes each
module through opt and hands the result to clang; a newer opt emits
attributes an older clang rejects. Pin PERRY_LLVM_OPT and
PERRY_LLVM_CLANG to one directory.
- Never trust a timing or a pass without checking output against the oracle
first. A "10× faster" binary in this campaign turned out to be segfaulting.
- A fallback nothing takes is an untested configuration. If you add one,
measure how often it fires; if the answer is zero, delete it and fail loudly
instead.
perry-runtime/perry-stdlib are rlib-only — the .a/.lib comes from
the -static wrapper crates. Build
-p perry -p perry-runtime-static -p perry-stdlib-static as one package set,
or you link a stale archive and both arms behave identically.
- Capture full logs to a file.
2>&1 | tail -3 has already swallowed the one
line that explained an 80-minute failure here.
Deliverables
- A PR to
PerryTS/perry with the walker, the refusal removed, and the CI arm.
changelog.d/<PR>-windows-gc-walker.md (no version bump — the maintainer
does that at merge; see CLAUDE.md).
- The telemetry numbers, quoted, from a real run on the Windows host.
A measured negative is a success. "RtlVirtualUnwind cannot give a usable base
for these frames because X, here is the repro" is a real result and worth more
than a walker that passes the probes without ever having run.
Windows: a native-root stack walker so
PERRY_RS4GC=1works thereYou are in Perry (native TypeScript→machine-code compiler, Rust + LLVM). Read
CLAUDE.mdfirst. You are on a Windows machine, which is the point: this taskwas deliberately left undone because it cannot be written blind.
Setup (verbatim)
You need: Rust (stable,
x86_64-pc-windows-msvc), the MSVC build tools, LLVM(
clangandoptfrom the same install — see below), and the Node versionpinned in
.node-versionat the repo root.perry doctorwill check thetoolchain.
What already exists — do not rebuild it
Merged in #7351:
crates/perry-codegen/src/gc_map.rsemits.pgcmapinto a COFF object. The name is seven bytes on purpose: a PE image section
header has an 8-byte name field, and longer names survive only in object files
as a string-table offset the linker does not carry into the image. Do not
rename it without changing
COFF_SECTION_NAMEand the runtime lookup together.loaded_stack_map_section()incrates/perry-runtime/src/gc/roots/stack_maps.rs,#[cfg(target_os = "windows")], finds that section in the running image viaGetModuleHandleW(NULL)→IMAGE_DOS_HEADER→ NT headers → section table. Itcompiles for
x86_64-pc-windows-msvc; it has never run.What is missing, and why Windows is refused today
There is no stack walker on Windows.
_Unwind_Backtrace/_Unwind_GetIP/_Unwind_GetGR/_Unwind_GetCFAdo not exist there, so theunwindmodule is#[cfg(any(target_vendor = "apple", target_os = "linux"))]and Windows falls toa stub that visits nothing.
A map with no walker is worse than no map: the collector would find zero
roots and free live objects, silently. So
compact_and_assembleingc_map.rscurrently refuses COFF targets with a message naming the missingpiece. Removing that refusal is the last step of this task, not the first.
The contract your walker must satisfy
Implement, for
#[cfg(target_os = "windows")], a module exposing exactly whatthe other two do:
For each native frame, walking outward from the current one:
index.match_records(ip).Matching is a ±16-byte nearest-PC window plus a containment check: the
record's function must be the greatest mapped function start ≤
ip. Bothalready exist; you only supply
ip.locationinindex.locations(record), compute the slot addressfrom the location's base register and signed offset, and hand it to
visitas a
MutableRootSlot { kind: MutableRootSlotKind::NativeStack, ptr }.NativeStackWalkStats—walks,frames_visited,records_matched,locations_visited. These are load-bearing, see"prove it ran" below.
Base registers are DWARF numbers. On x86-64: 6 = RBP, 7 = RSP. The map's
short tags are aarch64-literal by design (tag 0 → 29, tag 1 → 31); an x86-64
root therefore arrives with an explicit register number, which round-trips
already.
ARCH_DWARF_SPis the runtime-local constant for "which register isthe stack pointer here".
Two approaches — prototype both, choose on evidence
RtlVirtualUnwindis the supported path.RtlLookupFunctionEntry+RtlVirtualUnwindstep aCONTEXToutward, and each step gives youRip,RspandRbpdirectly. That is simpler here than the Unix path, which hasto derive SP from the CFA — you can read the real register values, so skip that
derivation entirely rather than porting it.
An fp-chain walk is far less code: Perry emits
"frame-pointer"="non-leaf",so RBP does chain, with
[RBP]= caller RBP and[RBP+8]= return address —the same shape the aarch64
fp_chainmodule already walks. It is only validwhile every frame in the chain is Perry-generated; a runtime or OS frame in the
middle breaks it.
Recommendation: get
RtlVirtualUnwindcorrect first, because it is the one thatsurvives mixed frames. Add fp-chain later only if you can measure it mattering.
Whatever you pick, fail closed: on any anomaly (null/misaligned frame
pointer, an address outside the thread's stack, an unrecognised frame) abandon
the walk and return, rather than visiting a slot you are unsure about. Every
walker in this file already does that, and the reason is that a wrong root
address means the collector writes through it.
stack_top()on Windows:GetCurrentThreadStackLimits, orNtCurrentTeb()->NtTib.StackBase. Bound every candidate slot by it.Prove it ran — this is not optional
This project has repeatedly shipped gates that could not fail. A green probe run
proves nothing on its own: if your walker visits zero frames, most probes still
print the right answer, because other root sources cover them.
After a run,
PERRY_GC_TRACE=1prints root-source telemetry. You must seenon-zero
walks,frames_visited,records_matchedandlocations_visitedfrom
native_stack_maps. For reference, the same probes on other platforms giveframes_visitedaround 7–10 withlocations_visitedof 0–2 — low, because theprobes end in a manual
gc()from a shallow stack. If Windows reports numbers inthat range, you have parity; if it reports all zeros, the walker did not run and
the probes passing means nothing.
Acceptance
benchmarks/gc_ratchet/probes/*.tsbyte-match the pinned Nodeoracle under
PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1. The oracle isnode --expose-gc --experimental-strip-types <probe>with the exactversion in
.node-version..pgcmapsection and no__llvm_stackmaps. Assert both — checking only the first still passes if thecompact rewrite silently stopped.
cargo test -p perry-runtime --liband-p perry-codegen --libgreen.compact_and_assemble, and delete thewindows_is_refused_until_it_has_a_walkertest with it..github/workflows/gc-native-roots.yml. It is a matrixover host shapes;
windows-latestis a new entry. Usereadelf/otoolequivalents appropriate to PE —
dumpbin /headersorllvm-readobj.Traps this project has already paid for — do not re-pay them
optandclangmust come from the same LLVM install. RS4GC pipes eachmodule through
optand hands the result toclang; a neweroptemitsattributes an older
clangrejects. PinPERRY_LLVM_OPTandPERRY_LLVM_CLANGto one directory.first. A "10× faster" binary in this campaign turned out to be segfaulting.
measure how often it fires; if the answer is zero, delete it and fail loudly
instead.
perry-runtime/perry-stdlibare rlib-only — the.a/.libcomes fromthe
-staticwrapper crates. Build-p perry -p perry-runtime-static -p perry-stdlib-staticas one package set,or you link a stale archive and both arms behave identically.
2>&1 | tail -3has already swallowed the oneline that explained an 80-minute failure here.
Deliverables
PerryTS/perrywith the walker, the refusal removed, and the CI arm.changelog.d/<PR>-windows-gc-walker.md(no version bump — the maintainerdoes that at merge; see
CLAUDE.md).A measured negative is a success. "
RtlVirtualUnwindcannot give a usable basefor these frames because X, here is the repro" is a real result and worth more
than a walker that passes the probes without ever having run.