Skip to content

chore: merge train 235 (v0.5.1614) - #10788

Merged
proggeramlug merged 3 commits into
mainfrom
train235r
Sep 20, 2026
Merged

proggeramlug merged 3 commits into
mainfrom
train235r

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train 235 — a memory-safety fix, released as v0.5.1614.

Contents

PR Change
#10785 fix(codegen,runtime): canonicalise NaNs read out of an ArrayBuffer so user bytes cannot forge a boxed value — fixes #10779
#10781 perf(codegen): unary + proves a Number by construction, with no operand condition

#10785 contains the whole perf chain as ancestry; #10731/#10746/#10752 landed as v0.5.1611 and #10766/#10774/#10767/#10776 as v0.5.1613, so only these two commits are unique and only these two were picked.

This is a pointer forged from caller-controlled bytes

A double whose bit pattern falls inside the NaN-box tag window, written into an ArrayBuffer and read back through a float view, decoded as its payload rather than as the NaN it is — and Number.isNaN then answered false, so the one defensive check a program would actually write agreed the value was fine.

Reproduced on main (v0.5.1611) before queueing, and re-checked against this train's own pinned artifacts after:

bits node 26.5.1 v0.5.1611 (before) v0.5.1614 (this train)
0x7FF9_0000_0000_0001 number / isNaN=true typeof=string / false number / true
0x7FFD_0000_0000_0001 number / isNaN=true typeof=object / false number / true
0x7FFF_0000_0000_0001 number / isNaN=true typeof=string / false number / true
0x7FFE_0000_1234_5678 number / isNaN=true number / false number / true

typeof === "string" on bytes the caller wrote means the runtime was prepared to read that payload as a StringHeader*. The author measures 80 of 144 probe patterns diverging on base, 32 of them SIGSEGV, and seeded GC stress with PERRY_GC_FROMSPACE_SCAN_ABORT=1 surviving 0 of 10 seeds on base against 10 of 10 on the fix. Any path that moves a double out of binary data reaches it: a file, a socket, WASM memory, a GPU buffer, a C or Rust struct.

Why the fix goes on the read, and not somewhere cheaper

A field is a conduit, not a source. The only way a tag-band NaN enters the system is an ArrayBuffer float read, so canonicalising there is sufficient — numeric_fields then cannot hold one. Perry already enforces the same invariant for Array<number> on the store side (array/header.rs:841); typed arrays are the one class where it has to be on the read.

The author's rejected-alternatives table is the part worth keeping, because each entry is a trap a later reader would otherwise walk back into:

  • move the tag band — no NaN-free window exists in NaN space, either sign
  • fix it at the decode0x7FFE…12345678 is simultaneously a valid int32 box and a valid NaN; undecidable there
  • JSC's ±2⁴⁹ offset — charges every double, not only NaNs
  • canonicalise only in-band NaNsunsound: a signalling NaN quiets into the band (0x7FF7… * 1 forged a StringHeader* and SIGSEGV'd), and fneg/fabs move negative payload NaNs in

Two behavioural notes, stated rather than buried

NaN payload bits are no longer preserved through a JS number — 101 of 144 cases differ in bits only, nothing semantic. Node preserves them; this now matches JSC and SpiderMonkey. Spec-permitted, and unavoidable under any sound design, but it is a real difference from the oracle.

Two rows regress, and neither is one where perry beats node: h += a[k&255] inline tier 28 → 30, and the Float32Array inline tier 41 → 46. The second is +12% on a path that already loses to node (41 against 15.9). The Float64Array element read moves −0.006%, and the eleven #10777/#10761 parity rows are flat at 0.

PERRY_NANBOX_CANON=0 reproduces base exactly, which is the right arm-distinctness control. Per CLAUDE.md's knob kill-policy it now needs a CI arm exercising its off state, or a plan to delete it after one release of soak — flagged on the PR.

Two "missing" insertions, both correct absences

The line-level check flagged five lines in #10785, both artefacts of train 233 landing after that branch was cut: lower_lru_cache_subclass_init (0 occurrences on main) and a gc_runtime_root_holders.json entry for crates/perry-stdlib/src/commander.rs (file absent from main). Restoring either would have referenced something that no longer exists; gc_runtime_root_holders.py passes on this tree.

Validation

Assembled on afd77dbe30; both source heads asserted fresh; no attribution trailers. Nine cheap gates, cargo check --workspace --all-targets under -D warnings, all five pinned artifacts byte-identical before and after the sweep, six unit suites with an empty failing set, and lint complete at 6-of-6 with nothing outside the known-red public-baseline step.

Gap sweep at PERRY_RUN_TIMEOUT=30, nine areas weighted at the fix's blast radius — every read of a double out of binary data — every area asserted live, zero unexplained regressions:

gc_ 54   json 43   buffer 15   numeric 15   typed_ 12
number 10   dataview 5   math 2   arraybuffer 1

perry-bot and others added 3 commits September 20, 2026 08:21
…nd condition (#10777)

`expr_numeric_by_construction` required `rec(operand)` for `Pos`, the same as
for `Neg` and `BitNot`. That was not a soundness guard, it was a missed proof.

Unary `+` is ToNumber, which either completes holding a Number or throws: a
BigInt and a Symbol both throw a TypeError, an object goes through ToPrimitive
and then ToNumber again, `undefined` is NaN, and NaN is a Number. A throw
stores no value, so the store-universe question this fixpoint asks is vacuous
on that path — there is no input for which `+x` finishes holding something
other than a Number.

`Neg` and `BitNot` keep their operand condition, because ToNumeric is
BigInt-preserving: `-1n` is `-1n` and `~1n` is `-2n`, neither a Number.

The missed proof left the ACCUMULATOR unproven, so its add kept a
per-iteration tag test:

  const v = +o.a;  for (…) h += v     20 -> 9 Ir/iteration
  const v = +a[0]; for (…) h += v     20 -> 9   (Float64Array)

which is exactly where `o.a * 1` and `o.a - 0` already sat. node is 7.03 and
7.50 on the same fixtures, bun 4.27 and 4.48, so this closes the
perry-versus-perry gap and does not reach parity.
… user bytes cannot forge a boxed value (#10779)

A double whose bit pattern falls inside the NaN-box tag window read back as its
payload instead of the NaN it is. `Number.isNaN` then reported false, so the one
defensive check a program would use agreed the value was fine.

It is not a wrong number. 80 of 144 probe patterns diverge on base and 32 of
them SIGSEGV: 0x7FF9... reads back with `typeof === "string"` and 0x7FFD... as
`[object Object]` — a pointer forged out of user-controlled bytes. Seeded GC
stress with FROMSPACE_SCAN_ABORT=1 survives 0 of 10 seeds on base and 10 of 10
here.

A field is a conduit, not a source: the only way a tag-band NaN enters is an
ArrayBuffer float read, so canonicalising those is sufficient. Perry already
enforces the same invariant for Array<number> on the store side
(array/header.rs:841); typed arrays are the one class where it has to be on the
read.

Rejected, with reasons recorded in the PR: moving the tag band (no NaN-free
window exists in NaN space, either sign); fixing it at the decode (0x7FFE...
is simultaneously a valid int32 box and a valid NaN); JSC's +/-2^49 offset
(charges every double rather than only NaNs); canonicalising at the raw-to-boxed
boundary (a perry value IS a double, so that boundary is not a syntactic site);
and canonicalising only in-band NaNs, which is unsound — a signalling NaN quiets
INTO the band, and fneg/fabs move negative payload NaNs in.

  Float64Array element read      6.04 instr   -0.006%
  every #10777 and #10761 row                  0
  h += a[k&255] inline tier      28 -> 30
  Float32Array inline tier       41 -> 46

No row where perry beats node regresses.

NaN payload bits are no longer preserved through a JS number: 101 of 144 cases
differ in bits only, nothing semantic. node preserves them; this matches JSC and
SpiderMonkey. It is spec-permitted and unavoidable under any sound design.
@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 381b1023-8f71-454f-a4a8-cf0dde71d66b

📥 Commits

Reviewing files that changed from the base of the PR and between afd77db and 76aebd2.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (20)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10777-unary-pos-numeric.md
  • changelog.d/10779-nanbox-canonicalise.md
  • crates/perry-codegen/src/collectors/ptr_shape_numeric.rs
  • crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs
  • crates/perry-codegen/src/expr/index_get_claim_tests.rs
  • crates/perry-codegen/src/expr/masked_window.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/nanbox_inline.rs
  • crates/perry-codegen/src/expr/proven_view_access.rs
  • crates/perry-codegen/src/expr/ta_param_f64_read.rs
  • crates/perry-codegen/src/lower_call/buffer_intrinsic.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/buffer/dataview.rs
  • crates/perry-runtime/src/buffer/numeric.rs
  • crates/perry-runtime/src/typedarray/mod.rs
  • crates/perry/src/commands/compile/build_cache.rs
  • crates/perry/src/commands/compile/object_cache.rs
 ___________________________________________________________________
< Your tests are like unicorns: frequently referenced, rarely seen. >
 -------------------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

correctness: a Float64Array element whose bits land in the NaN-box window reads back as its payload integer, and Number.isNaN then reports false

2 participants