Skip to content

fix(codegen): root the URL constructor's coerced string across base lowering (Layer 1) - #7453

Merged
proggeramlug merged 2 commits into
mainfrom
fix/layer1-url-ctor-rooting
Aug 5, 2026
Merged

fix(codegen): root the URL constructor's coerced string across base lowering (Layer 1)#7453
proggeramlug merged 2 commits into
mainfrom
fix/layer1-url-ctor-rooting

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Layer 1 of the engine plan (#7294) — "the remaining question is not build the mechanism but where is it still not used". This is one of those sites, found by scanning for sequential lower_expr calls with no rooted variant nearby and then confirming against emitted IR.

The window

js_url_coerce_string returns a raw StringHeader pointer, not a NaN-boxed value, so nothing else keeps it alive. In new URL(input, base) two collection points stood between it and its use — lowering base runs arbitrary user code, and the second coercion allocates whenever base is not already a string.

Emitted IR for new URL("/p" + i, mk()), before:

%r23 = call i64 @js_url_coerce_string(double %r22)
%r24 = call double @perry_fn_url_root_ts__mk()      ; user call — may collect
%r25 = call i64 @js_url_coerce_string(double %r24)  ; may collect
%r26 = call i64 @js_url_new_with_base(i64 %r23, i64 %r25)

after:

%r23 = call i64 @js_url_coerce_string(double %r22)
%r24 = call i32 @js_gc_temp_root_push(i64 %r23)     ; root BEFORE the window
%r25 = call double @perry_fn_url_root_ts__mk()
%r26 = call i64 @js_url_coerce_string(double %r25)
%r27 = call i64 @js_gc_temp_root_get(i32 %r24)      ; re-read AFTER it
%r28 = call i64 @js_url_new_with_base(i64 %r27, i64 %r26)
call void @js_gc_temp_root_truncate(i32 %r24)

The ordering is the fix, not the presence of a root: pushing after the coercion would have rooted an already-stale pointer, which is the shape #7192 and #7184 both had.

UrlPatternNew held its NaN-boxed input in a bare register across the same window and now lowers both operands through lower_exprs_rooted.

Evidence, and its limit stated plainly

This is latent and I did not produce a failing repro. The value is only wrong if a collection lands inside the window, and at that moment there is nothing for the collector to find — which is exactly why gc-rooting-invariant.md makes the static IR the instrument for this class and why #7154's from-space scan only ever caught offenders whose targets had already died. The IR above is the evidence.

The repository's --stale-registers checker did not flag this: its heap-source heuristic recognises string handles and slot loads, not a raw pointer returned by a coercion helper. That is a coverage gap in the checker rather than an absence of the bug, and it is why this was found by reading rather than by the gate.

Verification

  • Repro exits 0 and prints ok, including under PERRY_GC_HEAP_LIMIT=8 PERRY_GEN_GC_EVACUATE=1 PERRY_GC_FORCE_EVACUATE=1
  • 11/11 new URL / URLPattern gap tests byte-identical to the pinned Node oracle
  • cargo check -p perry-codegen clean, cargo fmt --check clean

Stacked on the corpus repair in #7452 — without it the dominance gate cannot see any of this class, since its corpus had zero root stores.

Ralph Küpper added 2 commits August 5, 2026 16:37
…owering

`js_url_coerce_string` returns a RAW StringHeader pointer, not a
NaN-boxed value, so nothing else keeps it alive. In `new URL(input, base)`
two collection points then stood between it and its use: lowering `base`
runs arbitrary user code, and the second coercion allocates whenever
`base` is not already a string. An evacuating cycle in either window left
the pointer forwarded and `js_url_new_with_base` parsing freed bytes.

Emitted IR before:

  %r23 = call js_url_coerce_string(...)
  %r24 = call perry_fn__mk()            ; user call, may collect
  %r25 = call js_url_coerce_string(...) ; may collect
  %r26 = call js_url_new_with_base(%r23, %r25)

and after:

  %r23 = call js_url_coerce_string(...)
  %r24 = call js_gc_temp_root_push(%r23)
  %r25 = call perry_fn__mk()
  %r26 = call js_url_coerce_string(%r25)
  %r27 = call js_gc_temp_root_get(%r24)
  %r28 = call js_url_new_with_base(%r27, %r26)
  call js_gc_temp_root_truncate(%r24)

UrlPatternNew held its NaN-boxed input in a bare register across the same
window; it now lowers both operands through lower_exprs_rooted.

Latent, as every member of this class is: the value is only wrong if a
collection lands inside the window, and at collection time there is
nothing for the collector to find. The evidence is the IR, per
docs/src/internals/gc-rooting-invariant.md.
@proggeramlug
proggeramlug merged commit 0dbdd2f into main Aug 5, 2026
1 check was pending
@proggeramlug
proggeramlug deleted the fix/layer1-url-ctor-rooting branch August 5, 2026 14:37
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fe106f46-e5f3-49bb-87a5-7c65878796e8

📥 Commits

Reviewing files that changed from the base of the PR and between 45b9f79 and ce4eea7.

📒 Files selected for processing (2)
  • changelog.d/7453-url-ctor-rooting.md
  • crates/perry-codegen/src/expr/url_main.rs
✨ 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/layer1-url-ctor-rooting

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 added a commit that referenced this pull request Aug 5, 2026
…7454)

* fix(gc-check): recognise js_url_coerce_string as a heap-value source

ALLOC_RE covers fresh-string producers by naming convention. There are
exactly three `coerce -> *mut StringHeader` helpers in the runtime;
`js_string_coerce` and `js_jsvalue_to_string_coerce` are matched by
existing patterns, but `js_url_coerce_string` reads `url_coerce_string`
after the `js_` prefix and matched none of them.

That gap is why the checker did not flag #7453. On the pre-fix IR the
widened matcher reports it, and reports it as MOVING:

  source (alloc): %r23 = call i64 @js_url_coerce_string(double %r22)
  stale use     : %r26 = call i64 @js_url_new_with_base(i64 %r23, %r25)
  between       : js_url_coerce_string, perry_fn_url_root_ts__mk
  MOVING        : YES via perry_fn_url_root_ts__mk

Measured, per the extrapolated-suffix warning in the same list: the
symbol was verified to exist before the pattern was added, stale-register
uses on that IR go 3 -> 4 (the new one is the bug), and both gated arms
over the 144-module corpus stay at 0 violations with 40/40 seeded
violations caught.

* docs: changelog fragment for #7454

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 5, 2026
First production lowering on the layer 1 discipline, and it closed a
window the hand-fix in #7453 left behind.

FnCtx has no interior mutability -- ctx.block() needs &mut -- so the
borrow-carrying Raw from #7459 cannot be built on it: root(self) would
need a second borrow while the handle holds the first. The shape that
works against a &mut-only emitter is the combinator, which is what the
runtime settled on for layer 3 (RuntimeHandle::across_*): never hand out
an unrooted handle at all. call_rooted emits the collecting call and
roots its result in one step.

Emitted window, #7453 vs now:

  before                          after
  coerce url                      coerce url
  root url                        root url
  mk()          <- user call      mk()
  coerce base   <- UNROOTED       coerce base
  read url                        root base      <- closed
  new_with_base(url, base_raw)    read url
                                  read base
                                  new_with_base(read, read)

base_ptr was live and unrooted across js_gc_temp_root_get in the
hand-written version. Small window, but a window -- and I did not see it
when writing that fix by hand, which is the argument for the API.

11/11 URL gap tests byte-identical to node; repro clean under
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1.
proggeramlug added a commit that referenced this pull request Aug 5, 2026
* feat(codegen): migrate UrlNew onto the Layer 1 rooting API

First production lowering on the layer 1 discipline, and it closed a
window the hand-fix in #7453 left behind.

FnCtx has no interior mutability -- ctx.block() needs &mut -- so the
borrow-carrying Raw from #7459 cannot be built on it: root(self) would
need a second borrow while the handle holds the first. The shape that
works against a &mut-only emitter is the combinator, which is what the
runtime settled on for layer 3 (RuntimeHandle::across_*): never hand out
an unrooted handle at all. call_rooted emits the collecting call and
roots its result in one step.

Emitted window, #7453 vs now:

  before                          after
  coerce url                      coerce url
  root url                        root url
  mk()          <- user call      mk()
  coerce base   <- UNROOTED       coerce base
  read url                        root base      <- closed
  new_with_base(url, base_raw)    read url
                                  read base
                                  new_with_base(read, read)

base_ptr was live and unrooted across js_gc_temp_root_get in the
hand-written version. Small window, but a window -- and I did not see it
when writing that fix by hand, which is the argument for the API.

11/11 URL gap tests byte-identical to node; repro clean under
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1.

* docs: changelog fragment for 7461

---------

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

* fix(codegen): root the URLSearchParams receiver across the name lowering

Six arms held a raw heap pointer across arbitrary user code. Same shape as
#7453, found by scanning url_main.rs for a raw pointer bound before a
lower_expr and used after it, then confirmed in emitted IR:

  %r22 = and i64 %r21, 281474976710655         ; p_ptr, raw heap pointer
  %r23 = call double @perry_fn_sp_root_ts__mk()  ; user call, can collect
  %r24 = call i64 @js_url_search_params_get(i64 %r22, double %r23)

Moving the unbox below the lowering would not fix it: p_v is the same
pointer with a NaN-box tag on it, and a tagged pointer in an SSA register
is exactly as invisible to the collector. Both operands are now lowered
through lower_exprs_rooted and the receiver is unboxed from the RELOADED
value:

  %r22 = call i32 @js_gc_temp_root_push(i64 %r21)   ; root before
  %r23 = call double @perry_fn_sp_root_ts__mk()
  %r24 = call i64 @js_gc_temp_root_get(i32 %r22)    ; re-read after
  %r26 = and i64 %r24, 281474976710655              ; unbox the reload
  %r27 = call i64 @js_url_search_params_get(i64 %r26, double %r23)
  call void @js_gc_temp_root_truncate(i32 %r22)     ; release after use

Arms: Get, Has, Set, Append, Delete, GetAll.

The guard is released after the consuming call, not before -- the consumer
allocates while reading these values. An earlier draft bound it to
_operand_guard, which compiled clean and never emitted the truncate; in a
loop that grows the temp-root stack without bound.

12/12 URL + URLSearchParams gap tests byte-identical to node; repro clean
under PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1.

* docs: changelog fragment for 7462

---------

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.

1 participant