Description
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 ofjs_array_get_f64returns 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:778—classify_value_typeofhas noTAG_HOLEarm:typeof hole === "number"(node:"undefined").value/to_string.rs:1357—js_jsvalue_to_stringrenders a hole as"NaN"(node:"undefined").param_type_guard.rs:658,684— theOP_MAP/OP_SETarms have the samesize-vs-usedbound 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, whereOP_ARRAY/OP_TUPLEare correct. Not unsafe — it silently deopts a legitimateMap<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,456—console.table([[1,,3]])renders the hole cell asNaN. The sibling primitives-only branch at:467already has theTAG_HOLEskip; 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:546—const o={a:1,b:2}; delete o.a; console.table(o)printsb | NaN. Different mechanism:object_key_namesdrops tombstoned keys from aVecwithout preserving slot indices, then indexes fields by compacted position.format_object_as_jsonand bothconsole.rssites iterate0..key_countandcontinueon a non-string key —table.rsis 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).
Activity
fix(runtime): the hole-leak family — Set/Map raw reads, pop, typeof/S…