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
INT32-tagged number is bit-identical to a class-ref for the same value: a crafted NaN whose payload equals a REGISTERED class id is indistinguishable from a real class reference #10601
Found while verifying a CodeRabbit finding on PR #10592 (class_ref_id validation for the instanceof dynamic-RHS classification, crates/perry-runtime/src/object/instanceof.rs). That fix
is correct and landed, but exposed a deeper issue this ticket is for.
Background
Perry's NaN-boxing reserves the tag band 0x7FFE_xxxx_xxxx_xxxx (top 16 bits) for INT32-tagged
class references — see class_constructor_ref_value/class_ref_id in crates/perry-runtime/src/object/native_module/class_ref_values.rs. A JS-visible number can carry
the exact same bit pattern: any signaling-NaN payload is a legal IEEE-754 double, and user code can
construct one directly via DataView.
typeof is correct, but Number.isNaN is wrong — Perry does not recognize this exact bit pattern as
NaN. This is consistent with the tag band being reserved for an internal representation (class refs)
rather than treated as a generic double.
A second, harder-to-pin-down symptom: context-dependent instanceof misclassification
With the class_ref_id validation fix from #10592 applied, the same crafted value used directly as an instanceof RHS correctly throws (Right-hand side of 'instanceof' is not an object), matching Node.
But inside test-files/test_gap_10479_instanceof_value_kinds.ts's full badRhs loop (a [string, any][] array literal with 7 entries, this crafted value as the 7th, iterated with for...of over two
nested loops), the SAME construction misbehaves:
This does not reproduce in a minimal 2-entry version of the same array/loop shape ([["{}", {}], ["craftedNaN", crafted]] misbehaves correctly / matches Node) — only the full ~200-line test file
context triggers it. That rules out a pure "array storage corrupts the NaN payload" theory (a minimal
array-storage repro round-trips the value fine) and points at something in the broader file — likely a
whole-program codegen specialization or inlining decision that changes how the dynamic instanceof/class_ref_id classification resolves R once enough distinct call shapes of x instanceof R exist in one compilation unit. Not root-caused further; flagging with the two data points
above (isolated 2-entry case: correct; full-file 7-entry case: wrong) for whoever picks this up.
Suspected area
crates/perry-runtime/src/object/instanceof.rs (js_instanceof_dynamic's RHS classification) and/or perry-codegen's handling of dynamic instanceof call sites when a function/module has many distinct
shapes of it — possibly PERRY_NO_AUTO_OPTIMIZE-sensitive (not checked either way this session).
Also worth checking whether Number.isNaN/general double round-tripping through DataView has a
broader gap for any payload in the 0x7FF8-0x7FFF band, independent of instanceof.
Impact
Low in practice — requires deliberately constructing a NaN payload via DataView to overlap Perry's
internal tag space, which is not something ordinary JS code does. Not currently known to break a
specific npm package.
Investigation result: diagnosed, not fixed (architectural)
Branch (base: fix/10478-10479-instanceof-value-kinds @ a1a9628f1c — the #10592 head that has class_ref_id): wip/10601-dataview-nan-instanceof, containing a test-files/issue_10601_repro_*.ts harness (six scripts, each with an inline writeup) that isolates every claim below.
The position-dependence is not codegen/IC/inlining — it's registered-class count
--print-hir on the full 204-line test_gap_10479_instanceof_value_kinds.ts confirms class ids are assigned sequentially from 1 in declaration order (lower_module starts start_class_id at 1; fresh_class() increments once per class lowered). Up through the point badRhs runs, five classes have been declared: K=1, A=2, B=3, Even=4, ShortString=5.
The DataView PoC's embedded id is 5. issue_10601_repro_class_id_confirm.ts proves the crafted value is being dispatched as ShortString's ref, not just landing on some registered id: on Perry it prints T F F for ("uri" instanceof craftedNaN), (newA instanceof craftedNaN), (null instanceof craftedNaN) — an exact match for ShortString[Symbol.hasInstance]("uri")=true, (object)=false, (null)=false (typeof v === "string" && v.length < 6), independently printed in the same file. In the minimal 2-entry repro only A (id 1) is ever declared, so id 5 is never registered and the value correctly falls through to TypeError — same binary, same code path, different runtime state. None of the codegen/IC/constant-folding candidates from the investigation brief apply; this reproduces or fails to reproduce purely as a function of how many classes had been declared before the check runs.
Root cause: INT32_TAG-boxed numbers and class refs are bit-identical
JSValue::int32() (crates/perry-runtime/src/value/jsvalue.rs): INT32_TAG | (value as u32 as u64). class_constructor_ref_value() (crates/perry-runtime/src/object/native_module/class_ref_values.rs): 0x7FFE_0000_0000_0000 | (class_id as u64 & 0xFFFF_FFFF).
These are the same formula. A plain boxed int32 numeric value n is bit-for-bit identical to the constructor ref for class id n. class_ref_id's is_class_id_registered check (#10592) rejects the overwhelming majority of crafted payloads — anything that doesn't happen to match a currently-registered id — but it cannot reject a payload that does match one, because there is no bit left in the encoding to say "this is genuinely a codegen-emitted ref" vs "this is just a number that happens to equal a registered id." That distinction doesn't exist in the wire format.
I checked whether this is reachable without DataView (issue_10601_repro_ordinary_int_no_collision.ts): a loop-accumulated int32 and an Int32Array element read, in the same 5-class context, both correctly throw on this build — ordinary numeric codegen paths apparently normalize through a real f64 conversion rather than the INT32_TAG bit-OR trick before a value becomes generically observable. So today this needs DataView's raw byte write to reach; it is not (as far as this session checked) reachable through ordinary arithmetic.
Why I didn't fix it
Full fix (extra discriminator bit in the class-ref encoding): ordinary INT32-boxed numbers never set bits 32+ (only 32 bits used), so an unused bit (e.g. bit 33, alongside the existing CLASS_PROTOTYPE_REF_FLAG at bit 32) could mark "genuinely a class ref." But the encoding is baked in directly (not through one shared constructor) at 10+ call sites across perry-codegen — expr/property_get.rs, expr/arrays_finds.rs, expr/dyn_extern_i18n.rs, stmt/let_stmt.rs, codegen/closure.rs, codegen/method_static.rs, codegen/static_fields.rs, codegen/helpers.rs, lower_call/new.rs (×2), lower_call/property_get/static_dispatch.rs — plus the runtime encoder, plus several INT32_TAG_TOP16_I64/INT32_TAG_I64 fast-path comparisons (expr/compare.rs, expr/index_get.rs, expr/index_set.rs, codegen/typed_abi.rs, codegen/typed_entry.rs) that would need auditing for whether they also assume the old 2-state encoding. crates/perry-runtime/src/value/tags.rs documents this exact space as "part of the wire-level contract... renumbering is an ABI break." This is a coordinated, ABI-affecting change across two crates needing full gap-suite validation, not a surgical diff.
Narrower "purify the NaN payload on DataView read" fix (the standard purifyNaN/PNaN technique): I built and ran it against the Node oracle before writing any code (issue_10601_repro_dataview_roundtrip_preserves_bits.ts) and it doesn't match Node's actual behavior. Node preserves the crafted value's exact bits through a getFloat64/setFloat64 round-trip (7ffe0000 5 survives unchanged) while still correctly reporting Number.isNaN() === true — V8 doesn't use NaN-boxing for its own tagging (HeapNumbers are boxed behind an ordinary tagged pointer), so it has no analogous ambiguity to resolve by canonicalizing. Squashing the payload to a canonical NaN in Perry would fix Number.isNaN for this exact repro but diverge from Node on any round-trip-sensitive case — trading the reported symptom for a different, currently-untested one. A real fix needs NaN classification to be correct without discarding the payload, which is the same registered/valid-id disambiguation problem #10592 solved for instanceof — but for every consumer of a NaN-boxed value (typeof, Number.isNaN, and implicitly everything else), not just one. Per the investigation brief, I did not implement this.
I did not touch class_ref_id/is_class_id_registered (#10592's fix is correct as-is and should not be weakened to paper over this).
Side note (not investigated further, not filed)
Float64Array element reads of the same crafted bytes hit a different, apparently V8-intrinsic quirk: Number.isNaN(new Float64Array(dv.buffer)[0]) is falseon Node itself for this payload (likely V8's own reserved "hole" NaN space), while a same-type Float64Array construction and plain element-to-element assignment both preserve the raw bits exactly. I didn't check what Perry currently does here since matching Node (however unintuitive) is the bar, not spec purity, and this looked like a distinct code path from the one in this issue.
What's next
Leaving this as diagnosed-not-fixed per the investigation brief's guidance for an architectural-scope finding. If a wire-format change to the class-ref encoding is ever undertaken for other reasons, folding in a genuine-class-ref discriminator bit at that time would close this for free.
changed the title [-]Crafted NaN payload (DataView) can collide with Perry's INT32-class-ref tag band, in a context-dependent way[/-][+]INT32-tagged number is bit-identical to a class-ref for the same value: a crafted NaN whose payload equals a REGISTERED class id is indistinguishable from a real class reference[/+]on Sep 18, 2026
Retitled to name the root cause established below rather than the symptom. Summary for anyone triaging: this is an encoding-level ambiguity in the NaN-boxed wire format, not a codegen or inline-cache interaction — my original framing (four named dispatch sites, position-dependent optimization) was wrong on both counts.
The practical consequence is that is_class_id_registered is the strongest check possible within the current encoding: it rejects unregistered ids, but a payload matching a live class id is genuinely indistinguishable from a codegen-emitted class ref. Closing that needs a discriminator bit, which tags.rs documents as an ABI-level contract — a deliberate change, not a patch.
Noted as explicitly NOT the fix: canonicalizing NaN on DataView read. Node preserves the crafted bits through a getFloat64/setFloat64 round-trip while still reporting Number.isNaN() === true, so purifying would trade this symptom for a fresh divergence from the oracle.
Fixed on main (verified on v0.5.1617). Both symptoms are gone.
Direct reproducer now gives number true, matching node — it was number false. The harder second symptom, the crafted value as the 7th badRhs entry in the nested for…of, was rebuilt exactly and all 21 lines match node.
Worth recording for whoever maintains the coverage: the in-tree gap test test_gap_10479_instanceof_value_kinds.ts no longer carries that 7th entry, so it does not cover the crafted-NaN case. It passes 81/81 on its own terms, but the case this issue found is only covered by this issue. If that case matters, it wants a fixture.
Activity
proggeramlug commentedon Sep 18, 2026
Investigation result: diagnosed, not fixed (architectural)
Branch (base:
fix/10478-10479-instanceof-value-kinds@a1a9628f1c— the #10592 head that hasclass_ref_id):wip/10601-dataview-nan-instanceof, containing atest-files/issue_10601_repro_*.tsharness (six scripts, each with an inline writeup) that isolates every claim below.The position-dependence is not codegen/IC/inlining — it's registered-class count
--print-hiron the full 204-linetest_gap_10479_instanceof_value_kinds.tsconfirms class ids are assigned sequentially from 1 in declaration order (lower_modulestartsstart_class_idat 1;fresh_class()increments once per class lowered). Up through the pointbadRhsruns, five classes have been declared:K=1, A=2, B=3, Even=4, ShortString=5.The DataView PoC's embedded id is 5.
issue_10601_repro_class_id_confirm.tsproves the crafted value is being dispatched asShortString's ref, not just landing on some registered id: on Perry it printsT F Ffor("uri" instanceof craftedNaN),(newA instanceof craftedNaN),(null instanceof craftedNaN)— an exact match forShortString[Symbol.hasInstance]("uri")=true, (object)=false, (null)=false(typeof v === "string" && v.length < 6), independently printed in the same file. In the minimal 2-entry repro onlyA(id 1) is ever declared, so id 5 is never registered and the value correctly falls through toTypeError— same binary, same code path, different runtime state. None of the codegen/IC/constant-folding candidates from the investigation brief apply; this reproduces or fails to reproduce purely as a function of how many classes had been declared before the check runs.Root cause:
INT32_TAG-boxed numbers and class refs are bit-identicalJSValue::int32()(crates/perry-runtime/src/value/jsvalue.rs):INT32_TAG | (value as u32 as u64).class_constructor_ref_value()(crates/perry-runtime/src/object/native_module/class_ref_values.rs):0x7FFE_0000_0000_0000 | (class_id as u64 & 0xFFFF_FFFF).These are the same formula. A plain boxed
int32numeric valuenis bit-for-bit identical to the constructor ref for class idn.class_ref_id'sis_class_id_registeredcheck (#10592) rejects the overwhelming majority of crafted payloads — anything that doesn't happen to match a currently-registered id — but it cannot reject a payload that does match one, because there is no bit left in the encoding to say "this is genuinely a codegen-emitted ref" vs "this is just a number that happens to equal a registered id." That distinction doesn't exist in the wire format.I checked whether this is reachable without
DataView(issue_10601_repro_ordinary_int_no_collision.ts): a loop-accumulatedint32and anInt32Arrayelement read, in the same 5-class context, both correctly throw on this build — ordinary numeric codegen paths apparently normalize through a real f64 conversion rather than theINT32_TAGbit-OR trick before a value becomes generically observable. So today this needsDataView's raw byte write to reach; it is not (as far as this session checked) reachable through ordinary arithmetic.Why I didn't fix it
Full fix (extra discriminator bit in the class-ref encoding): ordinary
INT32-boxed numbers never set bits 32+ (only 32 bits used), so an unused bit (e.g. bit 33, alongside the existingCLASS_PROTOTYPE_REF_FLAGat bit 32) could mark "genuinely a class ref." But the encoding is baked in directly (not through one shared constructor) at 10+ call sites acrossperry-codegen—expr/property_get.rs,expr/arrays_finds.rs,expr/dyn_extern_i18n.rs,stmt/let_stmt.rs,codegen/closure.rs,codegen/method_static.rs,codegen/static_fields.rs,codegen/helpers.rs,lower_call/new.rs(×2),lower_call/property_get/static_dispatch.rs— plus the runtime encoder, plus severalINT32_TAG_TOP16_I64/INT32_TAG_I64fast-path comparisons (expr/compare.rs,expr/index_get.rs,expr/index_set.rs,codegen/typed_abi.rs,codegen/typed_entry.rs) that would need auditing for whether they also assume the old 2-state encoding.crates/perry-runtime/src/value/tags.rsdocuments this exact space as "part of the wire-level contract... renumbering is an ABI break." This is a coordinated, ABI-affecting change across two crates needing full gap-suite validation, not a surgical diff.Narrower "purify the NaN payload on DataView read" fix (the standard
purifyNaN/PNaN technique): I built and ran it against the Node oracle before writing any code (issue_10601_repro_dataview_roundtrip_preserves_bits.ts) and it doesn't match Node's actual behavior. Node preserves the crafted value's exact bits through agetFloat64/setFloat64round-trip (7ffe0000 5survives unchanged) while still correctly reportingNumber.isNaN() === true— V8 doesn't use NaN-boxing for its own tagging (HeapNumbers are boxed behind an ordinary tagged pointer), so it has no analogous ambiguity to resolve by canonicalizing. Squashing the payload to a canonical NaN in Perry would fixNumber.isNaNfor this exact repro but diverge from Node on any round-trip-sensitive case — trading the reported symptom for a different, currently-untested one. A real fix needs NaN classification to be correct without discarding the payload, which is the same registered/valid-id disambiguation problem #10592 solved forinstanceof— but for every consumer of a NaN-boxed value (typeof,Number.isNaN, and implicitly everything else), not just one. Per the investigation brief, I did not implement this.I did not touch
class_ref_id/is_class_id_registered(#10592's fix is correct as-is and should not be weakened to paper over this).Side note (not investigated further, not filed)
Float64Array element reads of the same crafted bytes hit a different, apparently V8-intrinsic quirk:
Number.isNaN(new Float64Array(dv.buffer)[0])isfalseon Node itself for this payload (likely V8's own reserved "hole" NaN space), while a same-typeFloat64Arrayconstruction and plain element-to-element assignment both preserve the raw bits exactly. I didn't check what Perry currently does here since matching Node (however unintuitive) is the bar, not spec purity, and this looked like a distinct code path from the one in this issue.What's next
Leaving this as diagnosed-not-fixed per the investigation brief's guidance for an architectural-scope finding. If a wire-format change to the class-ref encoding is ever undertaken for other reasons, folding in a genuine-class-ref discriminator bit at that time would close this for free.
[-]Crafted NaN payload (DataView) can collide with Perry's INT32-class-ref tag band, in a context-dependent way[/-][+]INT32-tagged number is bit-identical to a class-ref for the same value: a crafted NaN whose payload equals a REGISTERED class id is indistinguishable from a real class reference[/+]proggeramlug commentedon Sep 18, 2026
Retitled to name the root cause established below rather than the symptom. Summary for anyone triaging: this is an encoding-level ambiguity in the NaN-boxed wire format, not a codegen or inline-cache interaction — my original framing (four named dispatch sites, position-dependent optimization) was wrong on both counts.
The practical consequence is that
is_class_id_registeredis the strongest check possible within the current encoding: it rejects unregistered ids, but a payload matching a live class id is genuinely indistinguishable from a codegen-emitted class ref. Closing that needs a discriminator bit, whichtags.rsdocuments as an ABI-level contract — a deliberate change, not a patch.Noted as explicitly NOT the fix: canonicalizing NaN on
DataViewread. Node preserves the crafted bits through agetFloat64/setFloat64round-trip while still reportingNumber.isNaN() === true, so purifying would trade this symptom for a fresh divergence from the oracle.proggeramlug commentedon Sep 20, 2026
Fixed on
main(verified on v0.5.1617). Both symptoms are gone.Direct reproducer now gives
number true, matching node — it wasnumber false. The harder second symptom, the crafted value as the 7thbadRhsentry in the nestedfor…of, was rebuilt exactly and all 21 lines match node.Worth recording for whoever maintains the coverage: the in-tree gap test
test_gap_10479_instanceof_value_kinds.tsno longer carries that 7th entry, so it does not cover the crafted-NaN case. It passes 81/81 on its own terms, but the case this issue found is only covered by this issue. If that case matters, it wants a fixture.