Skip to content

fix(runtime): root the normalize subject across form coercion - #8451

Closed
proggeramlug wants to merge 3 commits into
mainfrom
fix/8426-normalize-reentrant-gc
Closed

fix(runtime): root the normalize subject across form coercion#8451
proggeramlug wants to merge 3 commits into
mainfrom
fix/8426-normalize-reentrant-gc

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #8426.

Problem

js_string_normalize borrowed the subject string's inline WTF-8 payload, then coerced its form argument, then read the borrow:

let str_data = string_as_str(s);            // borrow of s's payload
...
crate::builtins::reject_symbol_to_string(form_value);
let form_ptr = crate::value::js_jsvalue_to_string(form_value);   // runs user toString
...
"NFC" => str_data.nfc().collect(),          // reads the stale borrow

The coercion is a collection point twice over:

  • an object form runs user toString, whose loop back-edge polls (default-on since fix(gc): make the moving-loop poll default ON in the code, not just the doc (#7690, #7682) #7721) run a moving minor — this is reachable from ordinary user code today;
  • an inline short-string form materializes onto the heap (js_string_materialize_to_heap), so even a plain s.normalize("NFC") allocates inside the window. That arm is latent — an alloc-point collection forces a conservative stack scan, which makes the copying minor ineligible — but it is the same window.

Either can evacuate a young subject. Rooting rewrites slots, never an already-materialized &str, so the normalization pass then reads retired from-space.

Fix

Coerce the form first, root the subject across the coercion in a RuntimeHandleScope, and take the borrow only from the address across_const hands back. Nothing in the coercion needs str_data, and nothing after it allocates through the GC before the result is built from an owned Rust String.

Both cfg arms (string-normalize on/off) read the re-derived borrow. The observable orderings are unchanged — ToString still runs before the form is validated, so a Symbol form throws TypeError rather than the invalid-form RangeError (#2782). The existing test_gap_2786_2880_2782_2789_string_semantics.ts still passes, and the new fixture pins both orderings explicitly.

Validation

Fault demonstrated before the fix, with test-files/test_issue_8426_normalize_reentrant.ts under the issue's knobs (PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 PERRY_GC_FORCE_EVACUATE=1):

[gc-fromspace-protect] FAULT: signal 10 at 0x20887000000
  This address is RETIRED FROM-SPACE. The evacuating minor moved or
  freed the object here and the holder kept the pre-collection address.
  block=0x20887000000 +0 retired_bytes=80 retired_by_minor=#4950
  last-known object: user_ptr=0x20887000008 obj_type=3 size=40

obj_type=3 is GC_TYPE_STRING — the subject. After the fix the same binary/knobs run clean (115,025 copying minors, 27,107 objects moved, exit 0) and stdout is byte-identical to Node 26.5.1 in both default and stress modes.

One note for anyone reproducing: the subject's construction matters. A += accumulator chain leaves its buffer outside the movable nursery, so a subject built that way never relocates and the fixture passes with or without the bug. The fixture uses join(""); a measurement pass confirmed join/slice/repeat/toLowerCase/substring subjects all evacuate mid-window while the += one does not. The fixture carries a comment saying so.

Gate

The .ts fixture only runs in the full tier and only faults under stress knobs, so it is a regression fixture, not a gate. The gate is a cargo-test-visible unit test, gc::tests::runtime_roots::string_normalize_form — it builds a form object whose toString forces a real copying minor and asserts three things together, so it cannot pass vacuously:

  1. the subject is nursery-resident (premise),
  2. the collection actually moved it (the window was live),
  3. the normalized bytes are the subject's, not the retired page's.

It runs under ProtectionModeGuard::PoisonOnly so a stale read is guaranteed to be detected rather than left to whatever the allocator recycled into the page.

Sabotage-checked: with the fix reverted and the test kept, it fails — SIGSEGV under poison, and a clean left: 16, right: 15 assertion without it. A green run means the detector works.

No version bump (maintainer bumps at merge).

Summary by CodeRabbit

  • Bug Fixes
    • Fixed String.prototype.normalize reliability when form coercion triggers memory cleanup.
    • Preserved expected behavior for default forms, invalid forms, and Symbol values.
    • Added regression coverage for repeated calls, normalization variants, and reentrant coercion scenarios.

@coderabbitai

coderabbitai Bot commented Aug 20, 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: b5815e8d-7592-4eaa-9fb8-ba165aaed2b1

📥 Commits

Reviewing files that changed from the base of the PR and between 3627657 and bbbb9c3.

📒 Files selected for processing (5)
  • changelog.d/8451-normalize-form-coercion-rooting.md
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/string_normalize_form.rs
  • crates/perry-runtime/src/string/compare.rs
  • test-files/test_issue_8426_normalize_reentrant.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • changelog.d/8451-normalize-form-coercion-rooting.md
  • test-files/test_issue_8426_normalize_reentrant.ts
  • crates/perry-runtime/src/gc/tests/runtime_roots/string_normalize_form.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/string/compare.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The change roots the subject string during String.prototype.normalize form coercion. It adds moving-GC regression tests and verifies normalization results, repeated calls, coercion ordering, and Symbol error behavior.

Changes

String normalization GC safety

Layer / File(s) Summary
Root subject during form coercion
crates/perry-runtime/src/string/compare.rs
js_string_normalize roots the subject across form coercion and borrows its payload after coercion completes.
Validate relocation and coercion behavior
crates/perry-runtime/src/gc/tests/runtime_roots/*, test-files/test_issue_8426_normalize_reentrant.ts, changelog.d/8451-normalize-form-coercion-rooting.md
Tests trigger moving collection during toString, verify live subject data and normalization results, and preserve coercion-before-validation behavior. The changelog records the fix.

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

Merge Risk: ⚪ Minimal · up to bbbb9

This change roots the subject string while form coercion can trigger garbage collection, preventing stale-string reads during normalization. No actionable merge-blocking risk remains after normal checks and review.

Possibly related issues

Possibly related PRs

  • PerryTS/perry#8439 — Addresses GC-safe string payload handling in the same runtime file.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary runtime fix: rooting the normalize subject across form coercion.
Description check ✅ Passed The description explains the problem, fix, issue reference, tests, validation results, regression coverage, and release metadata constraints.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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/8426-normalize-reentrant-gc

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 marked this pull request as ready for review August 20, 2026 05:35
Ralph Küpper added 3 commits August 20, 2026 10:14
`js_string_normalize` borrowed the subject string's inline WTF-8 payload
before coercing its `form` argument, then read that borrow afterwards.

The coercion is a collection point twice over: an inline short-string form
materializes onto the heap (so even `s.normalize("NFC")` allocates there),
and an object form runs user `toString`, whose loop back-edge polls run a
moving minor. Either can evacuate a young subject, and a `&str` taken
beforehand is a copy the collector cannot rewrite — rooting rewrites slots,
never already-materialized borrows. The normalization pass then read retired
from-space.

Coerce first, root the subject across the coercion with a RuntimeHandleScope,
and borrow only from the address `across_const` hands back. Both cfg arms of
the normalization match read the re-derived borrow. The observable orderings
are unchanged: ToString still runs before the form is validated, so a Symbol
form throws TypeError rather than the invalid-form RangeError (#2782).

Fixes #8426
`raw_handle_debt.py`'s per-module rule locks any unlisted runtime module at
zero bare `get_raw_{mut,const}_ptr` reads. The new test had three: two in
argument position (the closure and form-object pointers) and one post-call
reload (the subject's address after the coercion).

Convert them to the sanctioned combinators — `with_mut_ptr` for the argument
positions, `across_const` for the reload, which hands back the
post-collection address directly so the pre-call one is never nameable.
Ratchet returns to baseline 978 with no ceiling raised.

Re-ran the sabotage check after converting: with the fix reverted the test
still SIGSEGVs, so the conversion did not defang the regression it guards.
@proggeramlug
proggeramlug force-pushed the fix/8426-normalize-reentrant-gc branch from 3908ed7 to bbbb9c3 Compare August 20, 2026 08:19
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Superseded by #8483. The implementation in #8451 remains correct, but after #8481 landed it acquired the single inline-offset | perry-runtime: baseline 370, found 371 lint failure. I could not push the helper-only gate fix to #8451’s head branch, so I pushed the rebased change from my fork and opened #8483. The superseding PR changes only the test’s payload-pointer lookup to crate::string::string_data, preserves the rooting regression semantics, and credits @proggeramlug for the original work.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Superseded by #8483 — your fix rebased onto current main with one addition.

The blocker changed while this sat open: the original raw_handle_debt failure resolved itself as main moved, but #8481 landed a new string_payload_access_inventory ratchet that flagged the test's open-coded StringHeader payload offset (370 → 371). #8483 routes it through crate::string::string_data() instead, leaving the assertion unchanged.

Fork branch, so the gate fix could not be pushed here. Full validation is on #8483.

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.

runtime: js_string_normalize holds a payload borrow across user toString — moving GC can relocate the subject mid-call

1 participant