Skip to content

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

Description

@proggeramlug

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:

  1. 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.
  2. 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 }.
  3. Fill in NativeStackWalkStatswalks, 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

  1. A PR to PerryTS/perry with the walker, the refusal removed, and the CI arm.
  2. changelog.d/<PR>-windows-gc-walker.md (no version bump — the maintainer
    does that at merge; see CLAUDE.md).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions