Skip to content

fix: close five confirmed TypeScript parity regressions - #7052

Merged
proggeramlug merged 7 commits into
mainfrom
fix/open-issues-triage
Jul 30, 2026
Merged

fix: close five confirmed TypeScript parity regressions#7052
proggeramlug merged 7 commits into
mainfrom
fix/open-issues-triage

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • make getUTCFullYear, getUTCMonth, and getUTCDate independent of the process timezone
  • stop using declared typed/class receiver proofs after a local is reassigned
  • coerce out-of-bounds numeric TypedArray reads to NaN in arithmetic contexts without adding calls to the in-bounds path
  • preserve hoisted var bindings across static loop unrolling
  • route dynamic __proto__ assignment through the inherited legacy setter while preserving own-descriptor and null-prototype behavior

Fixes #6967.
Fixes #6906.
Fixes #6884.
Fixes #6876.
Fixes #6828.

Validation

  • cargo test -p perry-transform (54 passed)
  • cargo test -p perry-runtime --lib date::tests::utc_getters_ignore_process_timezone -- --exact --nocapture
  • cargo test -p perry-codegen --test native_proof_regressions (251 passed)
  • canonical parity harness, focused on each changed test (4/4 passed, no crashes)
  • combined Node/Perry repro probe under TZ=America/Los_Angeles (exact output match)
  • cargo fmt --all --check

./scripts/pre-tag-check.sh --quick still reports baseline failures unrelated to this branch: workspace architecture/public benchmark drift, pre-existing oversized files, and existing GC/address-inventory entries.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed UTC Date getters to be timezone-independent, with added checks against local-time behavior.
    • Improved loop unrolling for hoisted var, preserving original binding identity across iterations.
    • Corrected typed-array out-of-bounds reads to yield NaN in arithmetic contexts (while maintaining in-bounds behavior).
    • Fixed reassignments for typed/class locals (including closure-captured cases) to use runtime values.
    • Strengthened Annex B __proto__ behavior for both descriptor/interception and proxy assignment, including primitive handling and null-prototype objects.
  • Tests
    • Added regression coverage for timezone isolation, __proto__, typed-array reassignment, and arithmetic OOB semantics.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a6708200-5834-4c00-b600-33fd89a3de7f

📥 Commits

Reviewing files that changed from the base of the PR and between a615ecf and 51afbbf.

📒 Files selected for processing (6)
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/collectors/mod.rs
  • crates/perry-codegen/src/collectors/spec_abi_sites.rs
  • crates/perry-runtime/src/date.rs
  • test-files/test_gap_specabi_reassign.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/date.rs

📝 Walkthrough

Walkthrough

Fixes five TypeScript parity regressions involving UTC date getters, hoisted var handling, typed-array arithmetic reads, reassigned locals, and dynamic __proto__ assignment. Regression tests and a changelog entry are included.

Changes

Parity regression fixes

Layer / File(s) Summary
Reassignment-aware code generation
crates/perry-codegen/src/{expr/mod.rs,type_analysis/*}.rs, crates/perry-codegen/src/codegen/{artifacts,closure,entry,function,method}.rs, test-files/test_gap_specabi_reassign.ts
Compilation contexts track reassigned locals across module and closure boundaries, preventing stale class-specific lowering.
Typed-array numeric reads
crates/perry-codegen/src/expr/{binary,ta_param_f64_read}.rs, crates/perry-codegen/src/type_analysis/pod.rs, test-files/test_gap_ta_param_numeric_read.ts
Arithmetic typed-array reads convert out-of-bounds and fallback values to NaN, while value-context reads retain undefined.
Timezone-independent UTC getters
crates/perry-runtime/src/date.rs
UTC calendar getters decode timestamps directly, with coverage under America/Los_Angeles.
Legacy __proto__ assignment
crates/perry-runtime/src/{object/descriptor_state.rs,proxy.rs}, test-files/test_gap_object_proto_proxy_2820_2846.ts
Dynamic assignments invoke the inherited setter where applicable and preserve primitive, null-prototype, and own-property behavior.
Hoisted var preservation
crates/perry-transform/src/unroll/{escape_analysis.rs,mod.rs}, test-files/test_gap_uninit_let_loop_reset.ts
Escape analysis counts shared declarations so unrolled copies preserve function-scoped var values.

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

Possibly related PRs

Suggested labels: bug

Suggested reviewers: thehypnoo, andrewtdiz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the five parity-regression fixes in the PR.
Description check ✅ Passed The description includes the key summary and validation details required by the template.
Linked Issues check ✅ Passed The code changes address all five linked issues: UTC date getters, reassigned locals, typed-array NaN coercion, var loop unrolling, and proto assignment.
Out of Scope Changes check ✅ Passed No clear out-of-scope changes are present; the diffs align with the five linked parity regressions and supporting tests/helpers.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ 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 fix/open-issues-triage

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.

Actionable comments posted: 3

🤖 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.

Inline comments:
In `@crates/perry-codegen/src/codegen/closure.rs`:
- Line 790: Update compile_closure and its callers to accept the enclosing
reassignment set, then union it with reassigned_locals(body) when constructing
the closure FnCtx. Ensure receiver_class_name uses the combined set so outer
bindings reassigned before closure emission cannot be treated as stable class
receivers.

In `@crates/perry-runtime/src/date.rs`:
- Around line 1712-1734: Update the child branch of
utc_getters_ignore_process_timezone to assert that js_date_get_date(timestamp)
returns 19.0 before checking the UTC getters, ensuring the timezone-isolated
process actually uses Los Angeles local time while preserving the existing UTC
expectations.

In `@crates/perry-runtime/src/object/descriptor_state.rs`:
- Around line 288-296: Root the NaN-boxed key with RuntimeHandleScope before the
key_to_rust_string coercion in descriptor_state.rs:288-296, then reload it
before the later obj_value_has_own_key call. In proxy.rs:1648-1661, root and
reload current, key, value, and receiver across coercion, preserving both
prototype-walk behavior and the js_object_set_prototype_of call arguments.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a148c6ee-da0e-41e5-bd41-ebee73025153

📥 Commits

Reviewing files that changed from the base of the PR and between e279b2d and a615ecf.

📒 Files selected for processing (19)
  • changelog.d/7052-parity-regressions.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/ta_param_f64_read.rs
  • crates/perry-codegen/src/type_analysis/pod.rs
  • crates/perry-codegen/src/type_analysis/predicates.rs
  • crates/perry-runtime/src/date.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-transform/src/unroll/escape_analysis.rs
  • crates/perry-transform/src/unroll/mod.rs
  • test-files/test_gap_object_proto_proxy_2820_2846.ts
  • test-files/test_gap_specabi_reassign.ts
  • test-files/test_gap_ta_param_numeric_read.ts
  • test-files/test_gap_uninit_let_loop_reset.ts

Comment thread crates/perry-codegen/src/codegen/closure.rs Outdated
Comment thread crates/perry-runtime/src/date.rs
Comment thread crates/perry-runtime/src/object/descriptor_state.rs
@proggeramlug
proggeramlug merged commit abfadeb into main Jul 30, 2026
7 checks passed
@proggeramlug
proggeramlug deleted the fix/open-issues-triage branch July 30, 2026 09:04
proggeramlug pushed a commit that referenced this pull request Jul 30, 2026
proggeramlug added a commit that referenced this pull request Jul 30, 2026
* gc: reserve arena blocks with no arena borrow live (#7051)

#7050 moved the allocation-point GC trigger out of Arena::alloc's &mut self
borrow. The emergency-reclaim path kept the same shape: alloc_after_gc ->
alloc_fresh_block -> install_fresh_block -> alloc_block, all under &mut self,
and alloc_block calls gc_try_emergency_reclaim() when the OS refuses memory.
That collection allocates into the arenas, so self.blocks.push(..) could grow
the Vec underneath the live borrow -- the same aliasing violation, on the path
that runs under heap exhaustion.

Splits block acquisition in two:

  reserve_arena_block(min_size)  -- may collect; NO arena borrow may be live
                                    across it. Used by arena_cell_alloc, which
                                    already runs borrow-free at that point.
  alloc_block_no_gc(min_size)    -- never collects; for the callers that hold a
                                    live borrow, two of which are already
                                    executing inside a collection.

Arena::install_reserved_block installs a block reserved by the former.

The cfg(test) borrow-depth probe from #7050 now covers this path too, and
try_alloc_block gains an injectable failure hook so a test can force the
null-alloc branch that would otherwise need real heap exhaustion.

Refs #7051, #7022.

* changelog: fragment for #7052

---------

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

* feat(gc): two diagnostics for localizing an uncaught-throw rooting bug

PERRY_UNCAUGHT_BACKTRACE=1 emits a symbolicated native backtrace on the
uncaught-throw path, reusing the libc backtrace pair arena::quarantine
already uses. A #7154-class rooting bug surfaces in a function nowhere near
the code that lost the value, and the JS-level stack this path prints reads
'at <anonymous>'.

PERRY_KEEP_SYMBOLS=1 skips ONLY the final strip. PERRY_DEBUG_SYMBOLS does
that too, but every consumer reads it with is_some(), so it also turns on
-g -- and on the #7803 corpus the symbolized build passed 13 seeds that the
plain build fails at 44%. Asking for symbols changed the subject. This knob
leaves codegen byte-identical to the build that reproduces.

Both default off and are parsed BY VALUE, not by presence (#7993).

* fix(codegen): root the callee across argument evaluation in three call arms

rooting/temp_root.rs already decides 'root, re-derive or reuse?' correctly
and in one place, and already says module globals and locals must be ROOTED
rather than reloaded. The gap is the POSITION it is asked about: that
machinery protects call OPERANDS. Three arms lower the CALLEE into a bare
register, lower the arguments after it -- each of which can allocate -- and
then pass the original register:

  new_dynamic.rs  (both js_new_function_construct arms)
  call_spread.rs  (cb_box, across js_array_like_to_array and the concat)
  early_branches.rs (recv_box, unmasked into closure_handle after the args)

Under the shipping statepoint lowering that register is in no live bundle,
so nothing marks it and nothing relocates it. A root and not a reload: JS
resolves the callee BEFORE the arguments, so re-reading below them would
hand the call whatever an argument assigned.

Measured on the dependency-scale corpus under the native lowering:
66 -> 26 hazards, sink=js_new_function_construct 24 -> 0,
sink=js_closure_call_apply_with_spread 16 -> 0, live bundles 39073 -> 39140.

This does NOT close #7803: the failure rate is unmoved (3/8 -> 5/16 -> 8/16
across the three binaries, all noise at this sample size). Landing it on the
static ratchet alone. NOT YET gap-suite tested.

* docs(gc-handoff): #7803 session 2 -- localized, and the fix that did not fix it

* docs(gc-handoff): #7803 is on the new-Function/dyn_eval path (0/16 vs 8/16)

zod compiles a fastpass parser with new Function for every object schema
(core/schemas.ts:2028), which on Perry runs through the dyn_eval interpreter
-- the frames under #7803's throw. Taking that path out of the workload with
zod's own jitless switch takes the failure with it: 0/16 against 8/16 on the
same compiler, runtime and zod pin, with the instrument hot (5054-5434 forced
collections, ~765k objects moved per run) and the answer byte-identical.

Not a clean single-variable A/B and recorded as such: jitless also drops the
workload from 6840 to 5056 safepoints. What makes it persuasive is the
conjunction with the captured stack, not the sweep alone.

Two traps on the way, both recorded: the config must run BEFORE any schema is
constructed (jit is captured in the  ctor, and the schema modules
run at import), and the first attempt still entered interp_thunk through the
identical stack -- a clean sweep of it would have been quoted as evidence
while the interpreter was still running the parse.

* feat(gc): give the dyn_eval interpreter cooperative GC safepoints (#7803)

The interpreter offered the collector NO safepoints. Compiled code polls at
loop back-edges; interpreted code polled nowhere, so a collection could only
reach it at an allocation point -- and that arm forces a conservative stack
scan, which makes the copying minor ineligible.

The consequence is not that the interpreter is safe, it is that it is
untestable: PERRY_GC_ZEAL forces collection at safepoints and there were none,
PERRY_GC_SCHEDULE_SEED selects safepoints and there were none, and
gc_root_dominance_check.py reads emitted LLVM IR of which the interpreter has
none. The one rooting domain with no static checker also had no dynamic one,
so mod.rs's claim that interpreter frames hold EVERY live JSValue in a rooted
stack was unfalsifiable by anything in the tree.

PERRY_GC_INTERP_SAFEPOINTS=1 calls js_gc_loop_safepoint at every eval_expr
node and exec_stmt -- through the shared entry point deliberately, so the
entry guards and the seeded-schedule ordinal apply exactly as they do to a
compiled back-edge. An interpreter safepoint is the same safepoint, not a
second kind.

Subject asserted live (seed 2, rate 1, one binary): loop_polls 24029 -> 93210,
safepoints 2725 -> 6973, moved 369076 -> 866480. Output byte-identical.

Opt-in, not on: if the interpreter's rooting is complete, default-on is
strictly better; if it is not, the flip turns a latent hole into a live crash
for ajv / fast-json-stringify / find-my-way. Same sequencing
PERRY_GC_MOVING_LOOP_POLLS had between #7161 and #7721.

* ci(gc): gate the fourth corpus/lowering cell — dependency-scale, native roots

gc-root-dominance.yml emitted three of four corpus x lowering combinations.
#7280 fixed the POPULATION (curated files lack the shapes a real library
produces) and added the dependency corpus; #7452 fixed the LOWERING
(statepoints ship; a PERRY_RS4GC=0 corpus contains none of that root form) and
added the native corpus. Neither reached the other's cell, so the zod corpus
compiled the way shipped binaries are compiled had never been checked.

First measurement: 66 unrooted hazards, against a curated arm calibrated to
ZERO -- 24 sinking into js_new_function_construct and 39 into the
js_closure_call family, which are #7803's two observed messages. 40 of those
were the callee-outlives-arguments defect fixed in 95d9fbb9d, leaving 26.

Lands as a budget that can only go down, not an allowlist: the residual is a
population under triage (19 are the js_box_get_bits mutable-capture-box shape),
not a list anyone has adjudicated entry by entry -- the same reasoning the
--stale-registers budget records. Carries the same liveness floors and the
--seeded-violations 40 arm as its curated sibling, so a corpus that did not
exercise the subject cannot read as clean.

NOT yet promoted to a required check: a new gate has never been green, so it
runs once before anyone depends on it (CLAUDE.md, hazard-4 corollary).

* ci(gc): tighten the dep-native budget 26 -> 3, the clean-build number

The third fixed arm (early_branches.rs) was never measured statically: the 26
came from a corpus emitted before that fix existed. A from-scratch rebuild
reads 3 -- js_new_function_construct 24->0, js_closure_call_apply_with_spread
16->0, js_closure_call1/2 23->0, leaving one each of js_array_concat,
js_rel_ge and js_get_string_pointer_unified.

The lesson is about the 26. It came from an incremental build and went into a
committed ratchet; a ratchet's number has to come from a tree someone else can
reproduce. Caught only because the box swept the worktree and forced a clean
rebuild.

* docs(gc-handoff): the interp-safepoint A/B points away from the interpreter's own frames

Same binary, one variable: safepoints off 6/8 fail, on 2/8. Collecting MORE
often inside the interpreter made it fail LESS -- the opposite of what
'interpreted frames hold the unrooted value' predicts. n=8, p~0.13, so it
settles nothing alone, but with the jitless result it narrows the position:
the failure needs the new-Function PATH, and the interpreter's own locals are
not obviously the holder. Next look is the BOUNDARY (bridge.rs,
dispatch_with_arity, the interpreted-dispatch caches), not dyn_eval's locals,
which §21 audited and found sound.

* docs(gc-handoff): state what #7803 verified, what it did not, and the gap-suite block

The three call arms change the lowering of every new-expression, spread call
and closure-typed-local call in the language, and the gap suite has NOT run
against them. This box could not give a trustworthy run -- load average 60
with 47 sibling worktrees building, the suite slowing from 25 tests in 3
minutes to 30 in 19 -- so it was stopped rather than finished badly. Partial
30/554 with 0 failures is evidence of nothing except that the first 30 do not
crash. run_gap_tests.sh + cargo test -p perry-codegen on a quiet host before
that change goes into a PR.

* fix(gc): refresh the argument buffer in two dispatch arms that collect first (#7803)

js_native_call_method roots its receiver and arguments in a RuntimeHandleScope
and #7528 added refreshed_args() so a use below a collection point re-reads
them. That fix reached ten sites; several dispatch arms still pass the
CALLER's raw args_ptr, which is the caller's memory -- arg_handles is what the
collector rewrites, the buffer is not.

Two arms verified to have a collection point between entry and dispatch:
the dynamic-prop-on-a-closure arm (clone_closure_rebind_this allocates) and
the accessor-getter arm (js_closure_call0 runs user code, then the rebind
allocates). Both now refresh.

Fits #7803's symptom: zod's generated fastpass calls
shape[k]._zod.run({ value, issues: [] }, ctx) -- a freshly allocated object
literal, the youngest thing on the heap, handed to the callee at its pre-move
address -- and _zod is an accessor, which is the second arm. Not yet proven:
the rate A/B has not run.

The remaining raw-args_ptr arms are deliberately untouched; each needs its own
'can anything above me collect?' argument rather than a uniform guess.

* docs(gc-handoff): close out session 2 -- what landed, what is blocked on host load

* docs(gc-handoff): §25 refuted too — four rooting fixes, bug survives all four

Seed 4 still fails on the argument-buffer fix, so that is a real defect found
and fixed and a cause refuted, not a cause established. Adds a scorecard: four
separate rooting defects, all real, none of them this bug -- the corpus under a
rate-1 unprotected schedule is not a one-defect workload.

Notes the pattern worth pulling on next: the two interventions that make it
vanish (--debug-symbols, the from-space quarantine) both change memory LAYOUT,
while all four that change ROOTING leave it untouched. That fits a stale raw
pointer in a runtime-side cache keyed on an address rather than a value on a
stack -- the class CLAUDE.md says the static checker cannot see.

* docs(gc-handoff): §25 final rate 6/16 — mid-baseline, no effect

* docs(gc-handoff): gap suite partial — no regressions, and a gap test that can never pass

Through 68/554 on a quiet host: two known failures and test_gap_4510_enum_
forward_ref, which is NOT a regression -- Perry prints the correct answer and
NODE cannot run the file (--experimental-strip-types rejects enum, which is not
erasable syntax).

It is red rather than skipped because run_parity_tests.sh records node_fail
only for an ABNORMAL exit; a clean exit 1 falls through to the output
comparison against Node's crash text. So the test can never pass under the
pinned Node. That is the mirror of the hazard CLAUDE.md documents for this
suite (node-unrunnable tests silently DROPPED); this one is silently RED.
Needs an expected-output file or a widened node_fail predicate. Unrelated to
#7803.

* docs(gc-handoff): gap suite 554/554 — no regressions from this branch

The two flagged regressions are both cleared: test_gap_specabi_reassign fails
byte-identically with the three codegen files reverted to 410dadd (so it is
pre-existing on main, and is #6906/#7052's own regression test failing
unnoticed because parity is tag-gated), and test_gap_zlib_4917_level's
compile_fail was spurious -- I ran a cargo build concurrently with the suite
and swapped the perry binary mid-run; recompiled by hand it is clean and
byte-matches node.

The ten node_fail -> parity_fail flips are all oracle-side: six need npm
packages this worktree lacks, four are TypeScript node cannot strip (enum,
parameter properties). They read RED rather than skipped because node_fail is
recorded only for an abnormal exit.

The codegen PR's blocker is cleared, with the caveats stated.

* feat(gc): layout-neutral from-space poisoning, PERRY_GC_POISON_FROMSPACE (#7803)

Establishes WHY every existing instrument suppresses this bug.
reset_region_to_zero is misleadingly named: it resets block.offset, it does
NOT zero the bytes. Retired from-space therefore keeps its dead objects intact
until new allocations bump over them, so:

  unprotected  pages recycle into Eden, new objects overwrite the dead ones,
               and a stale pointer reads A DIFFERENT OBJECT -> property miss
               -> undefined. The failure.
  quarantined  pages are held out of Eden, nothing overwrites them, a stale
               pointer reads its own dead object still intact, and the program
               is CORRECT. The suppression.

So the quarantine does not miss #7803 by luck, it hides it by construction --
and --debug-symbols hides it for the same family of reasons. Both
interventions that make the bug vanish are LAYOUT interventions; four separate
rooting fixes left it untouched.

This mode changes no layout: same pages, same order, same addresses, recycled
at the same moment, with the retired bytes scribbled first. Only [0, offset)
is touched, so pages the allocator has not faulted in stay untouched. A stale
read then finds the poison word instead of a plausible object.

Control: the unscheduled corpus run is byte-identical with it on, i.e. nothing
in a healthy run reads retired from-space.

* docs(gc-handoff): poison A/B is inconclusive — and the session's real blocker is experiment power

0/6 vs 3/6 looks like a fifth suppression and is not supportable: the two arms'
schedules differ by ±0.7%, the same magnitude as the fixed-seed run-to-run
drift §1 measured, and Fisher gives p~0.09.

States the design problem plainly. A ~40% failure rate, ~1-4% schedule drift,
and every intervention perturbing the schedule by about that much means no
6-16 run sweep can attribute anything; ~40 runs per arm would be needed, at
3-20 min each. Four of this session's rate comparisons are under-powered; only
the jitless result (0/16 vs 8/16) clears the bar.

The fix is a deterministic reproducer, not more runs, and the lever has been
unused since the first task list: PERRY_GC_SCHEDULE_ALLOC_KB=0 removes the
allocation-pacing feedback, leaving the candidate set equal to loop_polls --
which §1 already measured as STABLE at 63,936 across runs. Run in flight.

* docs(gc-handoff): unpaced schedule works — and more collections make the bug LESS likely

PERRY_GC_SCHEDULE_ALLOC_KB=0 gives polls_paced=0 and safepoints=63941 (=
loop_polls + 5 event-loop boundaries), i.e. the candidate set is now the one
quantity §1 measured as stable across runs. 63,941 collections, 9.4x the paced
run -- and it passed.

That is the third independent observation of the same shape (paced ~40% fail;
interpreter safepoints on 2/8 vs 6/8; unpaced passed). More collection
pressure makes this bug LESS likely, which is backwards for a value held
unrooted across a collection point, and fits four rooting fixes changing
nothing.

Hypothesis that predicts all of it: moved_objects barely changed (892k vs
862k) despite 9.4x the cycles, so denser collections promote survivors out of
the evacuating nursery sooner (two-bit aging tenures after 2 minors, and
old-gen objects do not move on a minor). Fewer relocations per object ->
safer. The quarantine and --debug-symbols are explained by the same
'the object was not relocated into reused memory' mechanism, and rooting fixes
are explained by not changing promotion at all.

Next experiment is the promotion boundary itself, not the schedule: force
promotion on the first minor (predicts the failure vanishes) and suppress
tenuring entirely (predicts it becomes reliable -- which would be the
deterministic reproducer this session lacked).

* docs(gc-handoff): the unpaced schedule REPLAYS exactly — the missing experimental control

Two seed-1 runs: safepoints / scheduled_collections / copying_minors all
63941 exactly, polls_paced 0, moved_objects 892662 vs 892062 (0.07%). Against
~4% schedule drift in the paced config.

That fixes the design problem §30 named. With the schedule pinned, an
intervention that changes the outcome at a fixed seed has changed something
real, and one run per arm can say so instead of forty. Use ALLOC_KB=0 for
every A/B from here; the paced config is a rate-survey tool only. Cost is
~9.4x the collections, 30-60 min per run, which is cheap next to forty paced
runs that still could not attribute anything.

* feat(gc): PERRY_GC_TENURING_SURVIVALS pins the promotion age (#7803 diagnostic)

Overrides the adaptive threshold (#7432) so the promotion hypothesis can be
tested directly rather than through the schedule.

Three independent measurements say #7803 gets LESS likely as collections get
denser (paced ~30-50%; interpreter safepoints on 2/8 vs 6/8; unpaced, 9.4x the
cycles, passing). That is backwards for a value held unrooted across a
collection point, and it is what four rooting fixes failing to move the rate
looks like. moved_objects explains it: 892k unpaced vs 862k paced despite 9.4x
the cycles, so the extra collections promote the same objects SOONER, and an
old-gen object is not moved by a minor -- denser collections mean FEWER
relocations per object.

  =1    promote on the first minor -> predicts the failure disappears
  =255  never promote by age -> every survivor re-evacuated every cycle ->
        predicts the failure becomes reliable, i.e. the deterministic
        reproducer this bug has never had

Pairs with PERRY_GC_SCHEDULE_ALLOC_KB=0, which pins the schedule exactly
(63,941 safepoints, reproduced to the digit), so an outcome change at a fixed
seed is attributable to this knob alone. Unset = adaptive, unchanged.

* docs(gc-handoff): promotion hypothesis not supported — and why the sampling route is exhausted

PERRY_GC_TENURING_SURVIVALS pinned: =255 (most relocations) 0/5, =1 (fewest)
1/5, adaptive ~40%. §31 predicted =255 becomes reliable and =1 disappears;
neither happened. The follow-on 'it is the adaptive transitions' story dies
with =1's failure -- a pinned threshold has no transitions. The result is
non-monotonic and no relocation-count story fits it; at n=5 no cell is
significant anyway.

Five hypotheses tested, two real defects fixed, bug still standing. Stopping
the sampling route deliberately: a ~40% base rate with ~1-4% schedule drift and
five-run arms cannot attribute anything, and a sixth hypothesis would be
pattern-matching on noise.

Next person: either search seeds under the PINNED schedule (ALLOC_KB=0, 63941
safepoints reproduced to the digit) until one fails -- after which every A/B is
one run per arm -- or attack the interpreted/compiled boundary statically,
where a hazard can be found by reading rather than sampling.

* docs(gc-handoff): the dispatch tower's stale-argument population is 36 sites, not 10

Let the compiler count instead of eyeballing: shadow args_ptr/args_len to ()
right after arg_handles is built, and cargo check reports 36 errors -- 36 arms
that reach past the rooted handles for the caller's memory. #7528 converted
ten; the other 26 were never distinguished from those ten by anything but an
author's per-arm judgement.

The file's own #7528 rationale is what makes it a defect: the receiver is
re-read at every use because 'this function then runs ~1160 more lines across a
dozen probes that allocate'. arg_handles is the slot, args_ptr is the copy, and
the argument that forces one forces the other.

Reverted rather than landed: doing it right needs a per-site refreshed_args()
(a single refresh at the top is the exact mistake #7528 documents), i.e. 36
individually-checked edits plus a gap run -- a focused change for a clean host,
with the shadowing landed alongside so the population cannot regrow. The hot
path is unaffected: try_class_vtable_fast_dispatch returns above the scope, so
all 36 are already slow paths.

* docs(gc-handoff): self-contained brief for closing #7803

* docs(changelog): fragment for PR #8084

* docs(gc-handoff): RATE=1 unpaced seed search cannot distinguish seeds (#7803)

schedule_hit short-circuits to true at rate 1, so ALLOC_KB=0 + RATE=1
makes every seed the same 63,941-collection run. Seed 1 already passed
that twice. The seed only selects when RATE < 1; pair that with
ALLOC_KB=0 so the candidate set stays pinned.

* feat(gc): name the copying walk in the pin-latch abort (#7803)

RATE=0.1 + ALLOC_KB=0 makes the seed select. Seed 1 passes the pinned
candidate set; seeds 2 and 3 abort the pin-latch on incoherent headers
(INTERNED Map, 2 GiB native_pod_view). That is a stale slot, not a real
pin. The latch used to print only the garbage; it now prints which walk
followed it.

* docs(gc-handoff): seed 3 fails 2/2 under RATE=0.1 ALLOC_KB=0 (#7803)

Same class both times (incoherent pinned header), not the same
safepoint. Seed 1 still the passing control.

* docs(gc-handoff): seed 3 is the #7803 reproducer (3/3 fail)

Two of three aborts land on safepoint 21547. Seed 2 is 1/2. Seed 1
passes. The latch is a layout lottery on a pinned schedule.

* feat(gc): split the pin-latch walk phase by root-slot kind (#7803)

Seed 3 on the walk-phase binary aborted in mutable_root_slots
(safepoints=52836). That walk is three populations. Label each slot
shadow_stack / native_stack / global_root so the next abort names
which one held the stale pointer.

* feat(gc): dump the mutator backtrace on the pin-latch abort (#7803)

Seed 3's stale pointer is in a native stack-map root — an RS4GC live
bundle. The collection is at a safepoint; the frames below the copier
name the compiled function that held the slot.

* docs(gc-handoff): #7803 slot is a native stack map in Doc.write / generateFastpass

Seed 3 backtrace: js_gc_loop_safepoint in Doc.write, called from
generateFastpass (schemas 135), called from $ZodObjectJIT.parse
(schemas 138). parse.ts:65 is the victim. jitless 0/16 follows.

* fix(ci): read STATEPOINT_REWRITE_PASSES across rustfmt-wrapped lines

#8068's edit to inprocess.rs made rustfmt wrap the const initializer onto
its own line, and both corpus scripts' single-line sed then read nothing:
gc-root-dominance has failed every scheduled run on main since (three in a
row on 2026-08-14, all '::error::could not read STATEPOINT_REWRITE_PASSES').
Join the declaration with continuation lines up to the terminating
semicolon before extracting the quoted string, so either formatting reads.
Still single-sourced from the Rust const, never retyped.

* feat(gc): name the owning frame and slot in the pin-latch abort (#7803)

The native stack-map walker resolves the owning function, statepoint
record and slot address for every root it visits, then discarded all of
it one call before the pin-latch abort printed
'mutable_root_slots/native_stack' with no owner. Publish the ResolvedRoot
provenance through a thread-local around each visit and print it in the
latch report, dladdr-symbolicated. Two Cell stores per slot visit.

A latch abort now names (function, base register, frame offset,
slot address) — the exact bundle slot that held the stale pointer —
instead of leaving every frame in the mutator backtrace a suspect.

* fix(codegen): root the spread-new bundle accumulator and callee (#7803)

THE #7803 heap corruption. Expr::NewDynamicSpread bundled its arguments
with the accumulator in a bare i64 register: every regular argument's
lowering and every spread part's js_array_like_to_array can run a moving
minor, after which js_array_push_f64 / js_array_concat wrote through the
accumulator's PRE-MOVE address — into from-space pages that same cycle
had already recycled into Eden. The element written is typically a
NaN-boxed string, and every garbage header the pin-latch ever recorded
on this bug is the high half of one (sizes 0x7FFF02AB / 0x7FFF03AF /
0x7FFF03FF / 0x7FFF0543, their low bits tracking each run's ASLR heap
base). zod's Doc.compile — new F(...args, lines.join('\n')), run at the
end of every generateFastpass — is the corridor that hit it, which is
why the failure needed the new-Function path (jitless: 0/16) and why
parse.ts:65 read .issues off garbage.

The callee had the same defect: this spread arm was not among the three
8842a0b fixed. Both now go through call_spread's bundle_args_rooted
(now pub(crate) — no private copies of that loop), with the callee in a
RootedGroup, re-read below the bundle. The dynamic super.m(...spread)
arm carried an identical private copy; same fix, and its 'this' load
moves below the bundle (a slot re-read; 'this' is immutable).

Tests: IR-ordering assertions in call_spread_rooting_tests.rs — the
accumulator each fold reads and the callee the dispatch reads must be
defined BELOW the last collection point of the bundle. Verified to FAIL
against the pre-fix lowering (both new tests red under a stash of the
two files) and pass with it.

* docs(gc-handoff): §37 — #7803 named: spread-new bundle wrote through a moved accumulator

* fix(gc): rebuild the promoted remembered set AFTER the drain, not before (#7803)

THE #7803 root cause. rebuild_evacuated_old_to_young_remembered_set ran
above collector.drain(), so it walked moved_headers while the list held
only root-phase promotions; every object the drain promoted — i.e. every
transitively-reachable object — was appended after the rebuild had
already run. A parent promoted to Old mid-drain whose child stays young
therefore had no remembered-set entry: the collector's own drain rewrote
its slots (collector writes fire no mutator barrier, so its page was
never dirty), the next minor moved the child again without tracing the
parent, and the parent kept the previous survivor-space address.

zod's schema metadata — built at module init, promoted after two
survivals, never written again — is exactly that shape. The whole-heap
from-space scan (PERRY_GC_FROMSPACE_SCAN_ABORT=1, seed 3 pinned
schedule) catches it at scheduled collection #2 of every run:

  owner space=Old +120 bare -> Survivor1 MISSING-REWRITE
  [dirty_now=false ever_dirty=false]   never_dirty=1 not_in_snapshot=1

Under production pacing a child promotes ~2 cycles after its parent, so
the stale window is short and rare (the original 1-in-60); the seeded
schedule multiplies exposed edges, which is the corpus's 30-50%.
Downstream reads through the stale slot resurrect recycled bytes as
objects — the parse.ts:65 TypeErrors and every incoherent-header
pin-latch abort (the '0x7FFF-high-half sizes' were NaN-boxed words of
whatever now lives at the old address).

The rebuild now runs below the post-drain runtime-scanner walks — the
last phase that can move an object — where moved_headers is complete and
every slot already holds its final address, making the young-pointer
classification exact. Headers still carry GC_FLAG_MARKED there, which
the per-object gate requires. The old-young edge verifier moves with it.

* docs(gc-handoff): §38 — the real #7803 cause: remembered-set rebuild ran before the drain

* feat(gc): fromspace-scan abort dumps the owner object; drain-promotion regression test (#7803)

The abort names owner type/offset, which for an array does not say WHICH
array. Dump header words and the first 24 payload words with per-word
heap classification so one run identifies the structure. Plus the
drain-promoted-parent remembered-set test (its sabotage arm is currently
absorbed by page-granular dirty coverage in this staging — kept for the
fix-behavior half it does pin, see test comments).

* fix(gc): fromspace scan skips array capacity slack; pin-latch identifies its target (#7803)

The scan's cycle-2 'missing rewrite' was a FALSE POSITIVE: the owner dump
showed a live 8-element array whose unused capacity (a hole-reused old
block) still held a dead StringHeader ('StringDecoder') and a stale-looking
survivor word at element 14 — past length, invisible to every collector
walk BY DESIGN, and therefore un-rewritable. Bound the scan at
ArrayHeader + length like the collector's own element range, with the
exclusion counted (array_slack_skipped=) so the shrink is visible.

The pin-latch abort now also prints the victim slot's raw word (boxed tag
vs bare), a 20-word neighborhood dump around the reported header, and —
decisively — the census-backed ENCLOSING live object of the followed
address: an interior pointer into live data and a stale pointer into
recycled memory print differently, and every hypothesis burned on #7803 so
far has died on exactly that ambiguity.

* docs(gc-handoff): §38 second amendment — scan cycle-2 finding retracted (array slack)

* fix(gc): carry RS4GC derived pointers through the compact map — gc_map v4 (#7803)

THE #7803 root cause. The compact GC map was built on the premise that
'Perry has no interior pointers' and collapsed every statepoint
(base, derived) pair to one slot. The premise is false: the RS4GC
prelude (mem2reg,sccp) hoists for-of element GEPs into values that live
across the poll, and LLVM records them as DERIVED pointers. With the
pairing discarded, the runtime walker treated &elements[i] as an object
start: it read array element bytes as a 'header' (the pin-latch's
INTERNED-on-map / 0x7FFF-size aborts — the latch-side identification
dump showed the victim slot holding a boxed address +176 into a live
52-element string array, i.e. element 21's slot, and the implicit-this
cell holding a one-past-end cursor), and on a moving cycle it never
rewrote the slot as base'+delta — the dangling cursor whose next deref
is zod's parse.ts:65 TypeError. The shadow-stack era re-derived cursors
per iteration, which is why #7370's statepoint default is where this
class was born, and why --debug-symbols (different regalloc) suppresses.

Format v4: the record header word gains a has-derived bit; records
carry (base_index, reg, offset) derived entries after their roots,
sharing the repeat flag. The runtime decoder keeps derived slots OUT of
the visited-root set and rewrites each as new_base + (old_derived -
old_base) after its base is visited, preserving the slot's stored form
(boxed tag or bare), bounded by a 64 MiB delta guard, in all three
walkers (Itanium unwind, aarch64 fp-chain, Windows RtlVirtualUnwind).
Version mismatch still fails closed on both sides.

Tests: v4 decode (pairing, repeat carries deriveds, fail-closed on an
out-of-range base index), emitter round-trip incl. repeat-flag sharing
and the corruption checks re-aimed at the v4 header.

* docs(gc-handoff): §39 — #7803 named and fixed: v3 map collapsed derived pointers

* feat(gc): PERRY_GC_THIS_SET_CHECK — trap the producer of an incoherent implicit this (#7803)

* feat(gc): this-set trap grades both directions (slot vs cell corruption) (#7803)

* feat(gc): PERRY_GC_NATIVE_SLOT_VERIFY — abort on the cycle that creates a stale native slot (#7803)

* feat(gc): native-slot verifier reports cycle kind and target space (#7803)

* feat(gc): native-slot verifier compares against the rewrite walk's stats (#7803)

* feat(gc): native-slot verifier prints the collector's own classification (#7803)

* feat(gc): native-slot verifier dumps the target's raw header (#7803)

* docs(gc-handoff): §40 — v4 flips seeds 1/2/5; seed-3 residual pinned to slot and cycle

* docs(changelog): gc_map v4 + second root cause for PR #8084

* docs(gc-handoff): rewrite the front door — v4 landed, seed-3 residual is the remaining work

* feat(gc): native-slot verifier dumps every matched record and slot value (#7803)

* docs(gc-handoff): §41 — gap suite green on the v4 tree; chatter is host noise

* docs(gc-handoff): §42 — seed-3 victim target is a constant arena offset invisible to the snapshot

* style: rustfmt reflow

* docs(gc-handoff): §43 — widened poll set keeps the dep-native arm at 2/3 (40/40 seeded)

* fix(gc): unbreak cargo-test and clear the lint gates for #7803

Three merge blockers, none touching the rooting fixes themselves.

cargo-test (a REQUIRED context) never reached a summary: the branch's
neighborhood dump in `pinned_young_move_report` dereferences
`header_addr - 64 .. +88` with no mapping check, and four pre-existing
tests call it with fabricated addresses (`0x1000 - 64` is guaranteed
unmapped). The binary died with SIGSEGV at test 452 of 2390. A report
printed on the way to an abort must not SIGSEGV and destroy itself, so
both this dump and the native root-slot read now classify against the
arena's page metadata — a real mapping check, not a magnitude guess —
and print a placeholder instead. A stale from-space address, the #7803
case the dump exists for, still classifies into a live space, so the
diagnostic is unchanged where it matters.

2000-line cap: `stack_maps.rs` 2284 -> 1676 and `copying.rs` 2004 ->
1764, by pure code motion. The decode primitives and the object-file
section loaders move to `stack_maps_decode.rs` and
`stack_maps_sections.rs`; the pointer-classification unit moves to
`copying_pointer_set.rs` as a SIBLING of `copying.rs` rather than a
child, because its bodies name `super::gc_moving_loop_polls_enabled`
and `super::malloc::ensure_set_built` — under `gc` those still resolve,
so the move is zero-diff.

addr-class ratchet: `this_binding.rs` used a hand-rolled GcHeader cast.
`addr_class::try_read_gc_header` already exists and is behaviorally
identical here, so route through it rather than take an allowlist
exemption.

Also drops a leftover `// TEMP PROBE` eprintln from the #7803
regression test.

perry-runtime --lib: 2386 passed, 0 failed, 4 ignored.

---------

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