Repository navigation
gc-root-dominance is blind to the unrooted-register shape: the verbatim pre-#7453 code reports 0 violations in all three modes #7616
Description
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 staleOver 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-onlydominance: 0 violations, unchanged. - curated corpus,
--stale-registers --moving-only: 23 before, 23 after — no change at all, becausescripts/gc_root_dominance_corpus.sh'sPATTERNSlist has no URL prefix and the curated corpus therefore never compiles aexpr/url_main.rslowering. 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
- Add
js_url_coerce_string, then re-measure both corpora and re-pin--max-staleif it moves. - Sweep
ALLOC_RE's coercion family for the same one-list-short mistake (js_string_coerce*,js_jsvalue_to_string*andjs_number_coerceare already in both; the question is which otherALLOC_REalternatives are absent fromPOLL_CAPABLE_RUNTIME). - Consider a third auditor: for every callee that both
ALLOC_REmatches and the runtime shows re-entering JS or allocating, require it to be inPOLL_CAPABLE_RUNTIME. That closes the direction--audit-poll-capabledoes 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
refactor(codegen): migrate expr/url_main.rs onto the Layer 1 rooting …
proggeramlug commentedon Aug 9, 2026
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::UrlNewoperand-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.mdnow names that as a fourth blind spot in as many words: a greengc-root-dominanceis evidence about a lowering that does not ship. So "clean in all three modes" was, in part, three modes answering a different question.gc: teach gc-root-dominance the referent-with-no-name hole (#7616)
gc: teach gc-root-dominance the referent-with-no-name hole (#7616)
proggeramlug commentedon Aug 9, 2026
Measured, as requested.
--statepoints --moving-onlyis blind to it too — and that is the more valuable finding. PR #7679.The reproducer was re-planted verbatim in
expr/url_main.rsand the emitted IR confirmed register-for-register identical to what this issue quotes (%r883…%r886). Over the 16test-files/sources that exercise the URL lowerings, both lowerings, corpora generated by the production pipeline:--moving-only(dominance)--unrooted-allocas --moving-only--stale-registers --moving-only--statepoints --moving-only--stale-registers(unfiltered)--statepoints(unfiltered)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_stringis inALLOC_REbut not inPOLL_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_stringreddens 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-only9 → 13 inside the pinned 39; dependency-scale 86 → 104 inside the pinned 118, deliberately not raised; native--statepoints --moving-only7 → 11, re-pinned with all four new hits named individually — 3 intest_gap_array_splice_spread(#7453's shape in the spread lowering) and 1 more of thejs_closure_get_capture_bitspopulation. No hit disappeared.Closing on the measurement.
gc: teach gc-root-dominance the referent-with-no-name hole (#7616)
gc: teach gc-root-dominance the referent-with-no-name hole (#7616) (#…
fix(ci): list buffer/typed-array constructors as poll-capable (#8134)