-
-
Notifications
You must be signed in to change notification settings - Fork 159
fix(runtime): root the normalize subject across form coercion #8483
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
proggeramlug
merged 5 commits into
PerryTS:main
from
proggeramlug:fix/8451-normalize-rooting-rebased
Aug 20, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e8e3a99
fix(runtime): root the normalize subject across form coercion
1c5c704
docs: changelog fragment for #8426
bbbb9c3
test(gc): use handle combinators in the normalize rooting test
a28b418
Merge branch 'src8451' into fix/8451-normalize-rooting-rebased
42d6fa9
test(runtime): use centralized string payload helper
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| ### Fixed | ||
|
|
||
| - `String.prototype.normalize` no longer holds a borrowed heap-string payload | ||
| across its `form` argument's `ToString` coercion. The coercion is a | ||
| collection point twice over — an object form runs user `toString` (whose | ||
| loop back-edge polls run a moving minor), and an inline short-string form | ||
| materializes onto the heap — so a young subject could be evacuated while | ||
| `js_string_normalize` held a `&str` into its pre-move address, after which | ||
| the normalization pass read retired from-space. The form is now coerced | ||
| first, the subject is rooted across the coercion, and the payload is | ||
| borrowed only from the post-collection address. 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`. (#8426) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
crates/perry-runtime/src/gc/tests/runtime_roots/string_normalize_form.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| use super::*; | ||
| use crate::arena::FromSpaceProtection; | ||
|
|
||
| /// #8426: `String.prototype.normalize` borrowed the subject string's inline | ||
| /// WTF-8 payload BEFORE coercing its `form` argument, then read that borrow | ||
| /// after the coercion had returned. | ||
| /// | ||
| /// 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. | ||
| /// | ||
| /// The test drives the object-form window, which is the one reachable from | ||
| /// user code today: `toString` forces a real copying minor. Three assertions | ||
| /// together, because any one alone can pass vacuously — (1) the subject was | ||
| /// young, (2) the collection actually MOVED it, so the window was live, and | ||
| /// (3) the normalized bytes are the subject's, not the retired page's. | ||
| /// | ||
| /// `PoisonOnly` makes failure certain rather than lucky. Without it, whether a | ||
| /// stale borrow is *detected* depends on what the allocator happened to | ||
| /// recycle into the retired page; with it, those bytes are guaranteed poison. | ||
| /// The cost is the failure mode: a regression faults inside the normalization | ||
| /// pass (poison is not valid WTF-8) rather than reaching the byte assertions | ||
| /// below, so a reintroduced #8426 shows up as a SIGSEGV naming this test. | ||
| /// That is deliberate — a gate that can pass vacuously is not a gate. | ||
| #[test] | ||
| fn normalize_form_coercion_must_not_strand_the_subject_payload() { | ||
| let _guard = CopyingNurseryTestGuard::new(0); | ||
| let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); | ||
| let _mode = crate::arena::ProtectionModeGuard::set(FromSpaceProtection::PoisonOnly); | ||
| register_runtime_handle_root_scanner_for_tests(); | ||
|
|
||
| // Decomposed "café-normalize": NFC must compose e+U+0301 into U+00E9, so a | ||
| // pass-through cannot be mistaken for a correct normalization. | ||
| const SUBJECT: &[u8] = "cafe\u{301}-normalize".as_bytes(); | ||
| const EXPECTED: &[u8] = "caf\u{e9}-normalize".as_bytes(); | ||
|
|
||
| let scope = RuntimeHandleScope::new(); | ||
| let subject = crate::string::js_string_from_bytes(SUBJECT.as_ptr(), SUBJECT.len() as u32); | ||
| // (1) premise: a heap string in the MOVABLE nursery. A `+=` accumulator | ||
| // buffer or a large string lives outside it and would never relocate, | ||
| // which would make every assertion below vacuous. | ||
| assert!( | ||
| crate::arena::pointer_in_nursery(subject as usize), | ||
| "test premise: the subject must be nursery-resident, or nothing moves" | ||
| ); | ||
| let subject_handle = scope.root_string_ptr(subject); | ||
| let before_addr = subject as usize; | ||
|
|
||
| // `{ toString() { <forces a copying minor>; return "NFC" } }` | ||
| let form = crate::object::js_object_alloc(0, 1); | ||
| let form_handle = scope.root_raw_mut_ptr(form); | ||
| let to_string = crate::closure::js_closure_alloc(normalize_form_force_minor_gc as *const u8, 0); | ||
| let to_string_handle = scope.root_raw_mut_ptr(to_string); | ||
| let key = crate::string::js_string_from_bytes(b"toString".as_ptr(), 8); | ||
| let key_handle = scope.root_string_ptr(key); | ||
| form_handle.with_mut_ptr::<crate::object::ObjectHeader, _>(|form_ptr| { | ||
| key_handle.with_const_ptr::<crate::StringHeader, _>(|key_ptr| { | ||
| crate::object::js_object_set_field_by_name( | ||
| form_ptr, | ||
| key_ptr, | ||
| to_string_handle.with_mut_ptr::<crate::closure::ClosureHeader, _>( | ||
| |to_string_ptr| crate::value::js_nanbox_pointer(to_string_ptr as i64), | ||
| ), | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| NORMALIZE_FORM_COERCIONS.with(|c| c.set(0)); | ||
| let before_collections = gc_collection_count(); | ||
| let form_value = form_handle.with_mut_ptr::<crate::object::ObjectHeader, _>(|form_ptr| { | ||
| crate::value::js_nanbox_pointer(form_ptr as i64) | ||
| }); | ||
| // Two combinators, no bare read (#7341): `with_const_ptr` hands the | ||
| // subject to `js_string_normalize`, which since the fix roots it itself | ||
| // (a self-rooting entry point), and `across_const` hands back its | ||
| // POST-collection address — the coercion inside moves it. | ||
| let (result, after_ptr) = subject_handle.across_const::<crate::StringHeader, _>(|| { | ||
| subject_handle.with_const_ptr::<crate::StringHeader, _>(|s| { | ||
| crate::string::js_string_normalize(s, form_value) | ||
| }) | ||
| }); | ||
|
|
||
| assert_eq!( | ||
| NORMALIZE_FORM_COERCIONS.with(|c| c.get()), | ||
| 1, | ||
| "the form's toString must have run exactly once" | ||
| ); | ||
| assert!( | ||
| gc_collection_count() > before_collections, | ||
| "test premise: the coercion must have collected" | ||
| ); | ||
| // (2) the subject really was evacuated inside the window — otherwise a | ||
| // stale borrow would still point at live bytes and this test could not | ||
| // distinguish the fix from the bug. | ||
| let after_addr = after_ptr as usize; | ||
| assert_ne!( | ||
| after_addr, before_addr, | ||
| "test premise: the copying minor must have MOVED the subject" | ||
| ); | ||
|
|
||
| // (3) the normalization read the subject's live bytes, not the retired page. | ||
| unsafe { | ||
| assert_eq!( | ||
| (*result).byte_len as usize, | ||
| EXPECTED.len(), | ||
| "normalized length must come from the live subject" | ||
| ); | ||
| let data = crate::string::string_data(result); | ||
| let bytes = std::slice::from_raw_parts(data, EXPECTED.len()); | ||
| assert_eq!( | ||
| bytes, EXPECTED, | ||
| "normalized bytes must be the subject's, not retired from-space" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| thread_local! { | ||
| static NORMALIZE_FORM_COERCIONS: Cell<u32> = const { Cell::new(0) }; | ||
| } | ||
|
|
||
| /// The form object's `toString`: forces a real copying minor — the moving | ||
| /// collection a user `toString`'s loop back-edge polls would run — then | ||
| /// returns the form name. | ||
| extern "C" fn normalize_form_force_minor_gc(_closure: *const crate::closure::ClosureHeader) -> f64 { | ||
| NORMALIZE_FORM_COERCIONS.with(|c| c.set(c.get() + 1)); | ||
| let _ = crate::gc::gc_collect_minor(); | ||
| test_string_value(b"NFC") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| // #8426: `String.prototype.normalize` must not hold a borrowed heap-string | ||
| // payload across the form argument's ToString coercion. | ||
| // | ||
| // The coercion is a collection point twice over: an inline short-string form | ||
| // materializes onto the heap, and an object form runs user `toString`, whose | ||
| // loop back-edge polls can run a moving minor. Either can evacuate the | ||
| // subject; a `&str` taken beforehand then points into from-space. | ||
| // | ||
| // The fix reorders the coercion ahead of the borrow, so this file also pins | ||
| // the two observable orderings that reorder must NOT change: ToString runs | ||
| // before the form is validated, and a Symbol form throws TypeError (§7.1.17) | ||
| // rather than the invalid-form RangeError (#2782). | ||
|
|
||
| // Build the subject at runtime so it is a young *nursery* heap string (>5 | ||
| // bytes, so not SSO) rather than a folded constant: "cafe" + U+0301 combining | ||
| // acute. `join` matters: a `+=` accumulator chain leaves its buffer outside the | ||
| // movable nursery, so a subject built that way never relocates and this test | ||
| // would pass whether or not the bug is present. | ||
| function buildSubject(tag: string): string { | ||
| const acute = String.fromCharCode(0x0301); | ||
| return ["caf", "e", acute, "-", tag].join(""); | ||
| } | ||
|
|
||
| // Churn hard enough to cross several loop back-edge safepoint polls: volume | ||
| // alone is not enough, the collector needs garbage to actually move. | ||
| function churn(): void { | ||
| let junk = ""; | ||
| const scraps: string[] = []; | ||
| for (let i = 0; i < 5000; i++) { | ||
| junk = junk + "x"; | ||
| if (i % 50 === 0) { | ||
| scraps.push(junk.slice(0, 8) + i); | ||
| } | ||
| } | ||
| if (junk.length !== 5000 || scraps.length !== 100) { | ||
| throw new Error("string churn was optimized away"); | ||
| } | ||
| } | ||
|
|
||
| // ---- 1. the bug: subject must survive a moving collection in the window ---- | ||
| let coercions = 0; | ||
| const subject = buildSubject("runtime"); | ||
| const reentrantForm = { | ||
| toString(): string { | ||
| coercions++; | ||
| churn(); | ||
| return "NFC"; | ||
| }, | ||
| }; | ||
| console.log("reentrant NFC =>", JSON.stringify(subject.normalize(reentrantForm as any))); | ||
| console.log("reentrant coercions =>", coercions); | ||
|
|
||
| // Decomposing form, same window — a different normalization pass over the | ||
| // same borrowed payload. | ||
| const subjectD = buildSubject("decompose"); | ||
| const reentrantFormD = { | ||
| toString(): string { | ||
| churn(); | ||
| return "NFD"; | ||
| }, | ||
| }; | ||
| const decomposed = subjectD.normalize(reentrantFormD as any); | ||
| console.log("reentrant NFD length =>", decomposed.length); | ||
| console.log("reentrant NFD roundtrip =>", JSON.stringify(decomposed.normalize("NFC"))); | ||
|
|
||
| // Repeat under sustained pressure: each call opens the window again. | ||
| let repeated = ""; | ||
| for (let i = 0; i < 20; i++) { | ||
| const s = buildSubject("iter" + i); | ||
| repeated = s.normalize({ | ||
| toString(): string { | ||
| churn(); | ||
| return "NFC"; | ||
| }, | ||
| } as any); | ||
| } | ||
| console.log("repeated last =>", JSON.stringify(repeated)); | ||
|
|
||
| // A plain string form is the *common* case and still allocates (an SSO form | ||
| // materializes onto the heap inside the coercion). | ||
| console.log("sso form =>", JSON.stringify(buildSubject("sso").normalize("NFC"))); | ||
|
|
||
| // ---- 2. ToString still runs BEFORE the form is validated ---- | ||
| let badCoercions = 0; | ||
| try { | ||
| buildSubject("bad").normalize({ | ||
| toString(): string { | ||
| badCoercions++; | ||
| churn(); | ||
| return "BAD"; | ||
| }, | ||
| } as any); | ||
| console.log("bad form => no throw"); | ||
| } catch (e: any) { | ||
| console.log("bad form =>", e.name); | ||
| } | ||
| console.log("bad form coercions =>", badCoercions); | ||
|
|
||
| // ---- 3. a Symbol form throws TypeError, not RangeError (#2782) ---- | ||
| try { | ||
| buildSubject("sym").normalize(Symbol("nope") as any); | ||
| console.log("symbol form => no throw"); | ||
| } catch (e: any) { | ||
| console.log("symbol form =>", e.name); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 306
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 11820
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 38676
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 15178
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 9769
Add the expected-output artifact.
test-parity/expected/test_issue_8426_normalize_reentrant.txtis missing. Add it with the normalized outputs, coercion counts,RangeErrorfor"BAD", andTypeErrorfor the Symbol form so the parity harness gates this test.🤖 Prompt for AI Agents