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
Activity
- added a commit that references this issue
on Aug 8, 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.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:mode clean sabotaged --moving-only(dominance)0 0 --unrooted-allocas --moving-only0 0 --stale-registers --moving-only2 2 --statepoints --moving-only2 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_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.
- added a commit that references this issue
on Aug 15, 2026
gc_root_dominance_check.pyreports 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'sExpr::UrlNew:Emitted IR (
test_compat_url_date_math,PERRY_RS4GC=0 PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0):Over the 18
test-files/sources that exercise the URL lowerings:--moving-only(dominance)--unrooted-allocas --moving-only--stale-registers --moving-only--stale-registers(no--moving-only)sink=js_url_new_with_baseThe two gated modes are structurally blind: dominance asks "is the root store late" and there is no root store;
--unrooted-allocasasks "does this alloca have a root store" and the value is in an SSA register, not an alloca.--stale-registersis 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-onlyoff.Cause
js_url_coerce_stringis inALLOC_REbut not inPOLL_CAPABLE_RUNTIME.ALLOC_REdecides whether a register has a heap-value source;POLL_CAPABLE_RUNTIMEdecides whether the window around it is MOVING, which is what--moving-onlygates on. #7453's own fix addedurl_coerce_stringtoALLOC_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 callsjs_jsvalue_to_string, which is inPOLL_CAPABLE_RUNTIME.This is the dual of the hazard
--audit-poll-capablealready 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"toPOLL_CAPABLE_RUNTIME:--audit-poll-capable: still clean (55 entries, all real).--moving-onlydominance: 0 violations, unchanged.--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.--stale-registers --moving-only: 3 → 14, i.e. the bug becomes visible.Not measured locally: the dependency-scale (
zod) arm and its--max-stale 118budget. 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
js_url_coerce_string, then re-measure both corpora and re-pin--max-staleif it moves.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).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.rsonto the Layer 1 rooting API; the sabotage arm exists precisely to answer "does anything catch this".