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
- Add
js_url_coerce_string, then re-measure both corpora and re-pin --max-stale if it moves.
- 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).
- 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".
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".