Skip to content

gc-root-dominance is blind to the unrooted-register shape: the verbatim pre-#7453 code reports 0 violations in all three modes #7616

Description

@proggeramlug

gc_root_dominance_check.py reports the verbatim pre-#7453 code as clean in all three of its modes. The gate cannot see the bug class it was extended for.

Reproduction (measured, local)

Re-plant the exact code #7453 removed from expr/url_main.rs's Expr::UrlNew:

let url_ptr  = ctx.block().call(I64, "js_url_coerce_string", &[(DOUBLE, &url_v)]);
let base_v   = lower_expr(ctx, base)?;                                  // user code
let base_ptr = ctx.block().call(I64, "js_url_coerce_string", &[(DOUBLE, &base_v)]);  // allocates
ctx.block().call(I64, "js_url_new_with_base", &[(I64, &url_ptr), (I64, &base_ptr)]);

Emitted IR (test_compat_url_date_math, PERRY_RS4GC=0 PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0):

%r883 = call i64 @js_url_coerce_string(double %r882)
%r884 = load double, ptr @…_.str.17.handle
%r885 = call i64 @js_url_coerce_string(double %r884)      ; allocates
%r886 = call i64 @js_url_new_with_base(i64 %r883, i64 %r885)  ; %r883 is stale

Over the 18 test-files/ sources that exercise the URL lowerings:

checker mode sabotaged arm clean arm
--moving-only (dominance) 0 violations 0
--unrooted-allocas --moving-only 0 violations 0
--stale-registers --moving-only 3 3
--stale-registers (no --moving-only) 14, of which 11 sink=js_url_new_with_base 3

The two gated modes are structurally blind: dominance asks "is the root store late" and there is no root store; --unrooted-allocas asks "does this alloca have a root store" and the value is in an SSA register, not an alloca. --stale-registers is the mode whose help text does cover it — "report every register holding a GC value that is USED below a collection point without being re-read from a root (#7154)" — and it does see it, but only with --moving-only off.

Cause

js_url_coerce_string is in ALLOC_RE but not in POLL_CAPABLE_RUNTIME.

ALLOC_RE decides whether a register has a heap-value source; POLL_CAPABLE_RUNTIME decides whether the window around it is MOVING, which is what --moving-only gates on. #7453's own fix added url_coerce_string to ALLOC_RE — the code comment there says in as many words "That gap is why the checker did not flag #7453" — and stopped one list short, so the addition never made the shape catchable under the mode CI runs.

It is not an exotic callee: js_url_coerce_string (crates/perry-runtime/src/url/mod.rs:116) is a five-line wrapper whose body calls js_jsvalue_to_string, which is in POLL_CAPABLE_RUNTIME.

This is the dual of the hazard --audit-poll-capable already gates. That auditor catches an entry that names no symbol; nothing catches a real allocating symbol that is missing from the list, and a missing entry suppresses findings just as silently.

Measured effect of the one-line fix

Adding "js_url_coerce_string" to POLL_CAPABLE_RUNTIME:

  • --audit-poll-capable: still clean (55 entries, all real).
  • curated corpus (129 sources, 149 modules), --moving-only dominance: 0 violations, unchanged.
  • curated corpus, --stale-registers --moving-only: 23 before, 23 after — no change at all, because scripts/gc_root_dominance_corpus.sh's PATTERNS list has no URL prefix and the curated corpus therefore never compiles a expr/url_main.rs lowering. That is a second, smaller gap: the gate corpus does not exercise the module this bug lives in.
  • the sabotaged URL arm under --stale-registers --moving-only: 3 → 14, i.e. the bug becomes visible.

Not measured locally: the dependency-scale (zod) arm and its --max-stale 118 budget. That is the only reason this is a separate issue rather than a line in the Layer 1 template PR — widening a gate is its own change, and it has to measure both corpora before it lands.

Suggested scope

  1. Add js_url_coerce_string, then re-measure both corpora and re-pin --max-stale if it moves.
  2. Sweep ALLOC_RE's coercion family for the same one-list-short mistake (js_string_coerce*, js_jsvalue_to_string* and js_number_coerce are already in both; the question is which other ALLOC_RE alternatives are absent from POLL_CAPABLE_RUNTIME).
  3. Consider a third auditor: for every callee that both ALLOC_RE matches and the runtime shows re-entering JS or allocating, require it to be in POLL_CAPABLE_RUNTIME. That closes the direction --audit-poll-capable does not.

Found while migrating expr/url_main.rs onto the Layer 1 rooting API; the sabotage arm exists precisely to answer "does anything catch this".

Activity

  1. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Materially changed by #7663 (v0.5.1376), but not closed — and the reason is worth stating.

    This issue's finding was that the checker reports the pre-#7453 code as clean in all three of its modes. There is now a fourth: --statepoints, which reads the native root lowering (gc.statepoint "gc-live" relocation bundles) — the default since #7370 and the lowering the other three modes were structurally unable to see.

    That mode found 21 real hazards, and their census is dominated by exactly this issue's class: 10 string-literal handle globals held across a call and 7 unmasked receivers below a property GET, i.e. the value that goes stale is not the load's own register. #7667 has since fixed 14 of them and taken the ratchet to 7.

    Why this stays open: nobody has re-run this issue's specific reproducer — the re-planted Expr::UrlNew operand-to-operand shape — against the new mode. The classes overlap strongly but overlap is not coverage, and this issue exists precisely because a gate was believed to cover something it did not. The right close is a measurement: plant it, run --statepoints --moving-only, and record whether it is reported.

    One thing that shipped in the meantime and bears on the framing: the three original modes compile their corpus under PERRY_RS4GC=0 — the shadow lowering. docs/src/internals/gc-rooting-invariant.md now names that as a fourth blind spot in as many words: a green gc-root-dominance is evidence about a lowering that does not ship. So "clean in all three modes" was, in part, three modes answering a different question.

  2. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Measured, as requested. --statepoints --moving-only is blind to it too — and that is the more valuable finding. PR #7679.

    The reproducer was re-planted verbatim in expr/url_main.rs and the emitted IR confirmed register-for-register identical to what this issue quotes (%r883 … %r886). Over the 16 test-files/ sources that exercise the URL lowerings, both lowerings, corpora generated by the production pipeline:

    mode clean sabotaged
    --moving-only (dominance) 0 0
    --unrooted-allocas --moving-only 0 0
    --stale-registers --moving-only 2 2
    --statepoints --moving-only 2 2
    --stale-registers (unfiltered) 24 35
    --statepoints (unfiltered) 15 21

    Every gated arm blind; both unfiltered arms see it. So the mode added in #7663 to cover for the other three's blind spot has the same blind spot, for the reason this issue named: js_url_coerce_string is in ALLOC_RE but not in POLL_CAPABLE_RUNTIME, and --moving-only — which every gated arm runs, in all four modes — drops any window it cannot classify as MOVING. Adding that one name takes the sabotaged arms to 13 and 8 and leaves the clean arms at 2 and 2.

    Suggested scope items 1–3 are all in the PR. Item 3 (the third auditor) is --audit-poll-reach: it asserts not "every poll-capable symbol must be listed" — 297 exported symbols call one directly, which is a coverage change with its own hit count — but that the checker's two lists must not disagree about the same symbol. 77 found. It is a fixpoint, because the one-hop version reported 52, they were added, and re-running found 10 more.

    Shown able to fail three ways: deleting js_url_coerce_string reddens the audit; neutering the comment/string stripper reddens the decoy fixture; making the reach one-hop reddens the transitive fixture.

    Budgets, both corpora (item 1's "re-pin if it moves"): curated --stale-registers --moving-only 9 → 13 inside the pinned 39; dependency-scale 86 → 104 inside the pinned 118, deliberately not raised; native --statepoints --moving-only 7 → 11, re-pinned with all four new hits named individually — 3 in test_gap_array_splice_spread (#7453's shape in the spread lowering) and 1 more of the js_closure_get_capture_bits population. No hit disappeared.

    Closing on the measurement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions