You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
runtime: JSX_NODE_CLASS_ID and RAW_JSON_CLASS_ID both 0xFFFF_00A0 — String(JSON.rawJSON(x)) takes the JSX path #7587
Same shape as #7576, which was ITERATOR_HELPER_CLASS_ID and STRING_ITERATOR_CLASS_ID both being 0xFFFF_0009 and which took out the entire TC39 iterator-helpers surface. Found by the agent that fixed #7576;
I verified it and traced the consequence.
It is live, not merely latent
value/to_string.rs:1144 discriminates on class_id alone — no other
condition:
let obj = ptr as*constcrate::object::ObjectHeader;if(*obj).class_id == crate::jsx::JSX_NODE_CLASS_ID{let html = crate::object::js_object_get_field(obj,0);returnjs_jsvalue_to_string(f64::from_bits(html.bits()));}
A JSON.rawJSON(...) object is allocated as js_object_alloc_null_proto(RAW_JSON_CLASS_ID, 1) (raw_json.rs:173) — one
field, payload in field 0. So String(JSON.rawJSON("123")) matches the JSX
arm and returns field 0.
Measured on main:
perry: String(r): 123 stringify: {"a":123}
node : TypeError (throws on String(r) -- rawJSON objects are null-prototype)
It returns a plausible-looking answer purely by coincidence of layout —
both types happen to keep their payload in field 0. That is the worst kind of
latent bug: it is taking the wrong branch today and the output only looks right
by accident. Any change to either type's field layout turns it into garbage or
a crash.
The JSON.stringify direction is currently masked — is_raw_json
(raw_json.rs:78) carries an additional condition, and JSON.stringify(<div/>)
correctly gives {} rather than emitting the node's stored markup unquoted. So
the exposure today is the to_string path; the rest is one guard away.
The fix, and the more important half
Reassigning one constant is a one-line change.
The part worth doing properly is the test.#7583 added iterator_class_ids_are_pairwise_distinct, which is good — but it is scoped to
the iterator family, so it cannot catch this pair, and it could not have
caught it. A distinctness test that enumerates one family is a gate that is
structurally unable to fail for the next family, which is CLAUDE.md's hazard 4
in miniature.
What is needed is a test over every reserved 0xFFFF_00xx class id in the
runtime, asserting pairwise distinctness across all families, so that the next
person who mints a constant by copying a neighbour and bumping the low byte
gets a red build instead of a silent cross-match years later. Both known
collisions were introduced exactly that way — each carrying a comment
describing where it sits relative to its neighbour.
Worth also checking whether the id space is documented anywhere central. Two
independent authors picking the same value twice suggests it is not.
Fixed in #7589 (v0.5.1334). RAW_JSON_CLASS_ID moves to 0xFFFF_00A1.
The gate the issue asked for is in: scripts/class_id_collisions.py, wired into lint. It scans every crate rather than enumerating a list, so a new constant is covered the moment it is written — an enumerated list could not have caught this pair and cannot catch the next one, because the failure it guards against is precisely forgetting that a constant exists.
It also caught a shape I had not anticipated, during its own development: perry-ext-events deliberately restates the runtime's ABORT_SIGNAL_CLASS_ID so it can recognise runtime AbortSignal objects. Same name, same type, intentional — and reporting it would have made the gate permanently red and therefore ignored. So the rule is different names on one value is a collision, while the same name on one value is a sanctioned mirror. That in turn exposed the opposite hazard, which the gate now also catches: one name carrying different values is mirror drift, and it is worse than a collision because each crate stays internally consistent while the type silently stops being recognised across the boundary.
Activity
fix(runtime): JSX and raw-JSON shared a class id; add a scanning gate (…
proggeramlug commentedon Aug 7, 2026
Fixed in #7589 (v0.5.1334).
RAW_JSON_CLASS_IDmoves to0xFFFF_00A1.The gate the issue asked for is in:
scripts/class_id_collisions.py, wired intolint. It scans every crate rather than enumerating a list, so a new constant is covered the moment it is written — an enumerated list could not have caught this pair and cannot catch the next one, because the failure it guards against is precisely forgetting that a constant exists.It also caught a shape I had not anticipated, during its own development:
perry-ext-eventsdeliberately restates the runtime'sABORT_SIGNAL_CLASS_IDso it can recognise runtime AbortSignal objects. Same name, same type, intentional — and reporting it would have made the gate permanently red and therefore ignored. So the rule is different names on one value is a collision, while the same name on one value is a sanctioned mirror. That in turn exposed the opposite hazard, which the gate now also catches: one name carrying different values is mirror drift, and it is worse than a collision because each crate stays internally consistent while the type silently stops being recognised across the boundary.Sabotage-verified with exit codes captured directly: collision → 1, mirror drift → 1, stale-scan floor → 2, clean → 0.