Repository navigation
Description
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.
Reproduction (direct, no array/loop involved)
const dv = new DataView(new ArrayBuffer(8));
dv.setUint32(0, 0x7ffe0000, false);
dv.setUint32(4, 5, false);
const crafted: any = dv.getFloat64(0, false);
console.log(typeof crafted, Number.isNaN(crafted));Expected (Node 26.5.1)
number true
Actual (Perry, this build)
number false
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:
sso3 instanceof craftedNaN: true (Node/expected: throws TypeError)
newA instanceof craftedNaN: false (Node/expected: throws TypeError)
null instanceof craftedNaN: false (Node/expected: throws TypeError)
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.
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.