Skip to content

fix(gc): re-derive a string-literal operand below the collection point (#7114) - #7116

Merged
proggeramlug merged 6 commits into
mainfrom
fix/7114-string-literal-operand-stale-after-evacuation
Jul 31, 2026
Merged

fix(gc): re-derive a string-literal operand below the collection point (#7114)#7116
proggeramlug merged 6 commits into
mainfrom
fix/7114-string-literal-operand-stale-after-evacuation

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #7114.

console.log("acc:" + run(10_000_000)) printed an empty line and exited 0.
No crash, no diagnostic — every gate that checks exit status passed.

What was wrong

The literal's __perry_init_strings_* handle was loaded above the call and
masked to a pointer below it:

%r1 = load double, ptr @m_ts_.str.1.handle          ; read BEFORE
%r2 = call double @perry_fn_m_ts__run__spec_i32(i32 10000000)
%r3 = bitcast double %r1 to i64                     ; STALE
%r4 = and  i64 %r3, 281474976710655
%r5 = call i64 @js_string_concat_value(i64 %r4, double %r2)

The handle global is a registered GC root, so the string was never swept and
the global was rewritten when the copying minor relocated it. The register
taken beforehand was not.

Root cause: one contract, two implementations, drifted

expr/temp_root.rs suppresses a string literal from temp rooting — correctly,
it is already a root — and compensates by re-deriving it below the collection
point
. RootedOperands (new C(a, b), native collection methods) did both
halves. lower_exprs_rooted — behind lower_operand_pair_rooted, the
array-literal element list and the string-concat chain — did only the
suppression, and its own comment argued the residual staleness "is not the
hazard #6951 is about". It was #7114.

The invariant

No operand register may outlive a collection point. After the last thing
that can collect, every operand is either re-read from a root the collector
rewrote, or re-derived from immutable storage — never reused.

A root buys three things and they are not the same: liveness, a rewritten
location, and the value the consuming call actually observes. Only the third
was missing here, which is why the object survived, the root was updated, and
the answer was still wrong.

The fix routes both helper families through one operand_protection() decision
(Root / Reload / Reuse), so the pair cannot drift again.

Which mechanism owns this case: the codegen shadow-stack / temp-root
mechanism, not RuntimeHandleScope. The stale value lived in an LLVM SSA
register in generated code; RuntimeHandleScope is a Rust-side thread-local
handle stack for runtime helpers and cannot see that. The boundary between the
two is already sound — js_array_push_f64_temp_rooted documents that its
value argument is rooted by js_array_push_f64's own RuntimeHandleScope
which is why the variadic-argument path was unaffected.

Cost: zero runtime calls. Reload re-emits the load that was going to be
emitted anyway, and only when a later operand can collect, so "user_" + i and
every other non-collecting concat keep their previous IR byte for byte.

Evidence

Host: perry@perry-macos.local (Apple M1, 8 cores) — which is also the pinned
gc-ratchet baseline host. --release, Node 26.5.1 (the .node-version pin).
Base = ff85fd483. CI is not the gate right now (runner backlog); everything
below was gathered locally.

Reproduced and fixed, byte-diffed against the oracle

compiler first line exit
ff85fd483 !119999700000 0
this branch acc:119999700000 0
node --experimental-strip-types acc:119999700000 0

Genuinely different binaries: perry sha256 2df0831b… (base) vs 86d9575a…
(fix). libperry_runtime.a is byte-identical (143b184c…) — correct, this is a
codegen-only change, so a stale .a cannot make the A/B vacuous here.

A copying collection actually ran

Same run, default GC settings, no env at all: 60 cycles, 2 255 476 objects
copied by the copying minor
([gc-copy-minor] ran copied_objects=). Under the
evacuating arm at --pressure 8: 1 457 095 copied — base MISMATCH, fix MATCH.
A non-moving collection cannot expose a stale pointer, so minor_cycles > 0
alone would not have been evidence.

Three independent shapes were broken, not one

Each promoted to first position in its own file, N = 400 000:

shape ff85fd483 this branch
"acc:" + run(N) empty line match
["arr", run(N)].join("|") empty line match
`tpl:${run(N)}` empty line match
run(N) + ":right" match match
"a" + "b" + run(N) match match

The last two were always correct: in those orders the literal is loaded after
the call.

Sabotage, three directions

sabotage result
revert only the fix, keep the tests the two positive tests FAIL; the gate and all pre-existing tests pass
re-derive unconditionally (ignore the collects window) the gate FAILS; the positives pass
half-close the literal asymmetry (suppress WtfString from operand_needs_root only) the WTF-8 gate FAILS; the other ten pass

Every assertion is anchored with unwrap_or_else(panic!) on its fixture, so a
fixture that stopped producing the subject fails rather than passing on an
unreachable assertion.

Why the probe is the first statement

Every literal in a module is materialized together by __perry_init_strings_*
before user code runs, so the first evacuating cycle relocates all of them at
once; afterwards they are old-gen and a nursery scavenge cannot move them again.
A program gets exactly one chance to observe this. Measured at --release,
default settings: N=50 000 → 0 collections (vacuous pass); N=100 000 → 7 cycles,
590 472 scavenged, still passes; N=150 000 is the first failing size; the
file ships at N=400 000 for ~3x margin. A collector retune degrades it to
UNVERIFIED, the harness's honest state, not to a silent pass.

GC x representation-selection matrix

scripts/gc_repsel_matrix.sh --arms all --pressure 8:

summary: PASS=379 UNVER=100 XFAIL=1 FAIL=0     (480 cells)
byte-exact vs node 26.5.1: 479/480

Baseline on main was PASS=359 UNVER=100 XFAIL=1 FAIL=0 over 460 cells. The
delta is exactly the 20 cells of the new corpus row, all PASS; UNVER, XFAIL
and FAIL are unchanged. The single XFAIL is the pre-existing #6976 entry.

The new row is live, not merely green — every requires=move arm relocated
objects on it (evac_minor: 66 cycles / 1 506 487 scavenged; default
(requires=scavenge): 50 cycles / 1 121 856 scavenged). Arm liveness across the
corpus: all seven requires=move arms at 23/24 copy-minor, default and
verify_evac at 14/24.

gc-ratchet (run locally, pinned-host profile)

run_gc_ratchet_baseline.sh --check on both arms — exit 0 for both.

result
gated (retention + GC accounting), base vs fix 72/72 medians bit-identical over 8 probes x 9 metrics
ungated wall_ms 8/8 probes differ, −0.66% … +0.62%
ungated rss_bytes 5/8 probes differ, −0.49% … +0.23%
correctness vs node v26.5.1 pass on all 8 probes, both arms

The ungated spread is the point: bit-identical gated counters are also what a
vacuous A/B looks like when a stale .a makes both arms the same binary, so a
nonzero ungated spread on every row is the evidence the binaries really differed.

Review response

CodeRabbit raised one Major finding — that Expr::WtfString should be added to
the same predicates. Rejected on IR evidence (operand_needs_root returns
true for WtfString, so it already takes the strictly stronger Root path,
and the Reload arm is unreachable behind it), but the drift it was worried
about is now a gate rather than a comment — see the third sabotage row. Full
reasoning in the thread.

Ralph Küpper added 5 commits July 31, 2026 06:21
#7114)

lower_exprs_rooted suppressed Expr::String from temp rooting -- correctly,
the handle global is a registered root -- but never applied the compensating
re-load that RootedOperands::reread has always applied. The register loaded
before the sibling operand's call was reused after it, so an evacuating minor
left the concat reading the string's pre-move address.

Route both helper families through one operand_protection() decision
(Root / Reload / Reuse) so the pair cannot drift again.

Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

String-literal operands are now reloaded after allocating sibling expressions when their registered storage can be rewritten by evacuating GC. Other operands use temporary roots or register reuse as appropriate. Regression tests cover concatenation, arrays, WTF-8 literals, and end-to-end GC stress.

Changes

String operand GC handling

Layer / File(s) Summary
Centralized operand protection
crates/perry-codegen/src/expr/temp_root.rs, crates/perry-codegen/src/lower_call/new.rs
OperandProtection selects temporary rooting, reloading, or register reuse. Rooted lowering re-derives immutable registered operands after collection points. WTF-8 and i18n literals remain on the temporary-root path.
Code generation regression coverage
crates/perry-codegen/tests/temp_root_operand_temporaries.rs
Tests cover reloads after allocating siblings, reuse without allocation, array literal storage, and temporary rooting for WTF-8 literals.
End-to-end GC validation
test-files/test_gap_gc_string_literal_operand_rooting.ts, test-parity/gc_repsel_corpus.txt, changelog.d/7116-string-literal-operand-stale-after-evacuation.md
The GC stress test exercises multiple operand shapes and allocation order. The corpus registers the case for default and evacuating GC configurations. The changelog records the fix and verification.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ExpressionLowering
  participant AllocatingSibling
  participant EvacuatingGC
  participant StringConcat
  ExpressionLowering->>ExpressionLowering: classify string literal as Reload
  ExpressionLowering->>AllocatingSibling: lower sibling expression
  AllocatingSibling->>EvacuatingGC: allocate and trigger collection
  EvacuatingGC-->>AllocatingSibling: relocate registered handles
  ExpressionLowering->>ExpressionLowering: re-lower string literal handle
  ExpressionLowering->>StringConcat: pass refreshed operands
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #7114 by re-deriving string-literal operands after collection points and adding targeted regression coverage.
Out of Scope Changes check ✅ Passed All changes support the linked issue through code, documentation, regression tests, and GC corpus registration.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly and concisely describes the primary fix for stale string-literal operands after a collection point.
Description check ✅ Passed The description provides a detailed summary, related issue, implementation details, test evidence, and performance impact, despite not using all template headings.
✨ 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/7114-string-literal-operand-stale-after-evacuation

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: 1

🤖 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/expr/temp_root.rs`:
- Around line 636-698: Update operand_needs_root, operand_is_reloadable,
expr_may_trigger_gc, and expr_is_inert_primitive to treat Expr::WtfString the
same as Expr::String. Preserve the existing protection and reload behavior so
WtfString operands use their GC-registered handle correctly when an allocating
sibling may collect.
🪄 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: 61504e59-7d50-4918-b7e9-66af52b90610

📥 Commits

Reviewing files that changed from the base of the PR and between ff85fd4 and eb747ad.

📒 Files selected for processing (6)
  • changelog.d/7116-string-literal-operand-stale-after-evacuation.md
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/tests/temp_root_operand_temporaries.rs
  • test-files/test_gap_gc_string_literal_operand_rooting.ts
  • test-parity/gc_repsel_corpus.txt

Comment thread crates/perry-codegen/src/expr/temp_root.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/perry-codegen/tests/temp_root_operand_temporaries.rs (1)

596-610: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Scope the IR assertions to the tested expression.

ir_for returns the complete module IR. The three .find() calls select the first matching operation in that module. If another generated function emits one of these calls, the test can pass without proving that the Expr::WtfString operand is rooted and re-read after its allocating sibling.

Extract the IR for the function containing this expression, or assert the operand-specific handle and expected call counts before comparing the order.

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

In `@crates/perry-codegen/tests/temp_root_operand_temporaries.rs` around lines 596
- 610, Scope the IR checks in the WtfString temporary-rooting test to the
generated function containing the tested expression instead of searching the
complete module returned by ir_for. Update the push, alloc, and get lookups so
they cannot match calls from unrelated functions, then preserve the required
push → allocating sibling → re-read ordering assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-codegen/tests/temp_root_operand_temporaries.rs`:
- Around line 596-610: Scope the IR checks in the WtfString temporary-rooting
test to the generated function containing the tested expression instead of
searching the complete module returned by ir_for. Update the push, alloc, and
get lookups so they cannot match calls from unrelated functions, then preserve
the required push → allocating sibling → re-read ordering assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 16814ab6-cd7d-4b45-9d25-291de1f7302d

📥 Commits

Reviewing files that changed from the base of the PR and between eb747ad and 68e7c5f.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/tests/temp_root_operand_temporaries.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/expr/temp_root.rs

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Thanks — this landed in the right place, but the conclusion doesn't hold for the current code, and I have IR evidence rather than an argument. I've taken the part that is real and gated it.

Rejected: Expr::WtfString is not unrooted today. It is rooted more strongly than Expr::String.

operand_protection resolves in this order:

if !collects                     { return Reuse }
if operand_needs_root(ctx, expr) { return Root }
if operand_is_reloadable(expr)   { return Reload }
Reuse

operand_needs_root starts with expr_is_known_non_pointer_shadow_value, whose WtfString case falls through to the _ => false arm (expr/shadow_slot.rs), and then returns !matches!(expr, Expr::String(_))true for WtfString. So a WTF-8 literal takes Root, which supplies all three properties on its own (liveness, a rewritten location, and the call-time value). Reload supplies only the last two and borrows liveness from the handle global. Adding WtfString to operand_is_reloadable would therefore be a downgrade, not a fix — it is unreachable behind the Root arm unless operand_needs_root is also changed.

Verified in the emitted IR, not by reading: the new test wtf8_literal_operand_is_rooted_not_merely_reused compiles <lone surrogate> + <allocating numeric> and asserts js_gc_temp_root_pushjs_object_allocjs_gc_temp_root_get in that order. It passes.

The same reasoning covers Expr::I18nString, which is likewise not suppressed and likewise takes Root.

Rejected: expr_may_trigger_gc / expr_is_inert_primitive. Both are deliberately one-sided — their own docs say a wrong false is a use-after-free and a wrong true costs two runtime calls. WtfString currently answers conservatively (_ => true / _ => false). Flipping them would be sound in isolation, but expr_is_inert_primitive is also the whitelist behind loop_purity::loop_may_allocate, so changing it widens a loop fast path — a behaviour change outside a rooting fix, and this repo has a documented history of "more type visibility" PRs waking latent fast paths (#6377#6328). Not in this PR.

Accepted: the drift hazard is real, so it is now a gate rather than a comment.

You are right that the asymmetry is where a future edit gets this wrong — and the dangerous edit is half-closing it: adding a literal form to operand_needs_root's suppression list without also adding it to operand_is_reloadable drops it to Reuse, which is #7114 for that form. Nothing in the suite caught that. It does now:

sabotage result
!matches!(expr, Expr::String(_) | Expr::WtfString(_)) in operand_needs_root only wtf8_literal_operand_is_rooted_not_merely_reused FAILS; the other 10 tests pass

operand_is_reloadable's doc now states why the sibling literal forms are absent and points at that test, so the next reader doesn't "fix" the asymmetry by suppressing them.

Gates re-run after the change (crates/*/src delta since the measured binaries is 0 non-comment lines, so matrix and ratchet carry; the compiler was rebuilt and the probe re-diffed against the oracle anyway):

  • codegen contract suite 11/11, including the new gate
  • probe byte-exact vs node v26.5.1 with the rebuilt compiler
  • GC x repsel matrix --arms all --pressure 8: PASS=379 UNVER=100 XFAIL=1 FAIL=0 over 480 cells
  • gc-ratchet pinned-host --check: exit 0 on both arms; 72/72 gated counters bit-identical to pristine main, with 8/8 ungated rows showing a nonzero spread (the proof the two binaries genuinely differed)

@proggeramlug
proggeramlug merged commit 57f9dc3 into main Jul 31, 2026
7 checks passed
@proggeramlug
proggeramlug deleted the fix/7114-string-literal-operand-stale-after-evacuation branch July 31, 2026 05:23
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Post-merge note, for anyone grepping this PR for gate evidence.

gc-ratchet was run, and the results are in the PR body above (§ "gc-ratchet
(run locally, pinned-host profile)", alongside the matrix result at
PASS=379 UNVER=100 XFAIL=1 FAIL=0 over 480 cells). Re-stating the numbers here
so they are in the timeline and not only in an edited body:

  • run_gc_ratchet_baseline.sh --check, pinned_host profile, on the machine the
    baseline is pinned to (perry-macos.fritz.box, Apple M1) — exit 0 on both
    arms
    .
  • Gated (retention + GC accounting): 72/72 medians bit-identical between
    ff85fd483 and ff85fd483+this change, over 8 probes x 9 metrics.
  • Ungated: wall_ms differs on 8/8 probes (−0.66% … +0.62%), rss_bytes on 5/8
    (−0.49% … +0.23%). That spread is the point — bit-identical gated counters are
    also what a vacuous A/B looks like when a stale .a makes both arms the same
    binary. Fingerprints confirm it independently: perry sha256 2df0831b… vs
    86d9575a…, libperry_runtime.a byte-identical 143b184c… (correct: this is
    a codegen-only change, so the archive should match and perry should not).
  • correctness=pass vs node v26.5.1 on all 8 probes, both arms.

Re-confirmed after the merge, on merged main's own compiler
(crates/*/src byte-identical to the measured tree): gc-ratchet: OK, 96 gated
rows ok/improvement, 0 regressions, quiet host at cpu_active=5.4%.

One CodeRabbit finding arrived at 05:03:58, a minute after the last commit, and
was not addressed before the merge: the #7114 IR assertions searched
whole-module IR, so an ordering .find() could in principle be satisfied by
another function's IR. Fixed in #7120 — assertions scoped to the function under
test and pinned to exact opcode counts, with the narrowing itself made
falsifiable and four sabotage arms re-run against the scoped form.

proggeramlug added a commit that referenced this pull request Jul 31, 2026
…7120)

* test(gc): scope the #7114 IR assertions to the function under test (#7116 review)

ir_for returns the WHOLE module, so a bare .find() over it answers with
whichever function comes first in the file. Every ordering assertion in the
#7114 section is now sliced to @main via function_ir/init_ir and paired with an
exact count of the operand-specific opcode inside that slice, so 'this is the
operand I meant' is a property of the test rather than of module layout.

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

* test(gc): make the IR-slice narrowing itself falsifiable

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

---------

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.

String literal operand is not GC-rooted across an allocating call in the same expression (stale handle after evacuation)

1 participant