Skip to content

Hole-leak family: js_array_get_f64's Set/Map arm skips hole translation — typeof hole is "number", String(hole) is "NaN", console.table renders NaN cells #9462

Description

@proggeramlug

An exhaustive audit of the if tag == A || tag == B { … } else { treat as C } fall-through shape (run for PR #9461) found six more live sites. Each was reproduced, not inferred. They are one family: a TAG_HOLE that escapes translation at its source, plus downstream ladders with no hole arm.

The leak source (fix this first)

  • array/indexing.rs:549 — the Set/Map arm of js_array_get_f64 returns a raw slot with no hole translation, unlike the array arm at :440. This is what makes the downstream sites reachable at all.

Downstream ladders with no hole arm

  • builtins/arithmetic.rs:778classify_value_typeof has no TAG_HOLE arm: typeof hole === "number" (node: "undefined").
  • value/to_string.rs:1357js_jsvalue_to_string renders a hole as "NaN" (node: "undefined").
  • param_type_guard.rs:658,684 — the OP_MAP/OP_SET arms have the same size-vs-used bound PR fix(runtime): inspect decodes classes, holes and promise state (#9415); Array.from(str) keeps lone surrogates (#9431) #9461 fixed in the formatter, and no hole arm, where OP_ARRAY/OP_TUPLE are correct. Not unsafe — it silently deopts a legitimate Map<string,number> parameter after any .delete(), which is a performance leak from a correctness-shaped hole.

console.table (two distinct mechanisms)

  • builtins/table.rs:444,456console.table([[1,,3]]) renders the hole cell as NaN. The sibling primitives-only branch at :467 already has the TAG_HOLE skip; the two array-of-arrays branches were missed. Note a cell-only fix still will not match node — node omits the hole's column entirely, so the header derivation needs the same treatment.
  • builtins/table.rs:546const o={a:1,b:2}; delete o.a; console.table(o) prints b | NaN. Different mechanism: object_key_names drops tombstoned keys from a Vec without preserving slot indices, then indexes fields by compacted position. format_object_as_json and both console.rs sites iterate 0..key_count and continue on a non-string key — table.rs is the outlier.

Why this family keeps producing bugs

Tag ambiguity in perry is by design (0x7FFC is both undefined and TAG_HOLE; 0x7FFE both INT32 and class-ref), and tombstone deletes (#9331, default-on) made TAG_HOLE common in live data. Every value-decoding ladder written before that is a candidate. This family has now produced #9398 (SIGSEGV), #9415 (NaN holes / integer classes), #9409 (split("")), and these six.

Verified clean — do not re-audit

value/truthy.rs (hole → falsy), json/stringify.rs (hole → null, correct per §25.5.2), child_process/v8_serde.rs, structured clone, thread.rs, napi_typeof, array sort/join/iter paths.

Verification bar

Fix the leak source first, then check which downstream symptoms become unreachable — the typeof/String sites may still be reachable through other hole producers, so each needs its own repro: a .ts fixture byte-compared to node --experimental-strip-types, demonstrated failing on unfixed origin/main, covering typeof, String(), template interpolation, console.table on holey rows and tombstoned objects, and a Map-typed function parameter that keeps its fast path after .delete() (assert via the deopt counter, not timing).

Found during the #9415 audit (PR #9461).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions