Skip to content

fix(codegen): Buffer.readFloatLE does not compile on main — give fcmp an operand type (#10779 follow-up) - #10789

Closed
proggeramlug wants to merge 3 commits into
PerryTS:mainfrom
proggeramlug:fix/10779-fcmp-operand-type
Closed

proggeramlug wants to merge 3 commits into
PerryTS:mainfrom
proggeramlug:fix/10779-fcmp-operand-type

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

⚠️ Buffer.readFloatLE and readFloatBE do not compile on main right now

#10779's NaN canonicaliser landed in merge train 235 (v0.5.1614). The follow-up fix below did not, because I found the break after that PR was already closed. Any module calling Buffer.readFloatLE or readFloatBE currently fails to compile:

'%r1' defined with type 'float' but expected 'double'

This is a hard codegen failure, not a wrong value. The program compiles and prints 1.5 on the commit before the canonicaliser.

canonicalize_lane_f32 (nanbox_inline.rs:63) is reached from buffer_intrinsic.rs:428 on the Buffer.readFloat* path and emits an fcmp on an f32 lane — but LlBlock::fcmp renders its operand type as double unconditionally (inst.rs:197), and the in-process LLVM builder hardcodes "double" too (dialect/mod.rs:1356).

Fix: LlInst::FCmp carries its operand type. fcmp() keeps its double signature and delegates to a new fcmp_ty(), so no existing call site changes — one construction site and two .. patterns. Three regression tests, each sabotage-proven: flipping F32 back to DOUBLE fails naming left: "double", right: "float".

Why nothing caught it

The read-shape harness used for #10779 contained readDoubleLE and no readFloat*, and none of the 153 programs in the suites calls one. The guard that PR added therefore had zero coverage on one of its two arms — untested by construction rather than by oversight.

The harness is now 54 shapes including readFloatLE, readFloatBE and readDoubleBE. On the pre-fix runtime those first two SIGSEGV, which is the witness that should have existed before the original PR was opened.

Also included: NaNs arriving from native code

The remaining raw float source. expr_numeric_by_construction has no Expr::Call arm, so an FFI return can never enter numeric_fields and #10777's precondition was already discharged — but it is a raw source on its own account, and it is reachable.

Witnessed with a real perry.nativeLibrary whose staticlib returns f64::from_bits(0x7FFE_0000_1234_5678) and f32::from_bits(0x7FFFFFFF):

  • before: the double return prints 305419896 with Number.isNaN false; the float return SIGSEGVs — the forged StringHeader* is dereferenced
  • after: NaN, NaN, and 2.5 for the unchanged control, plus NaN through an object field

Closed at the source: the C float return before its fpext; the C double return only when the manifest declares F64, because that arm also serves perry's own double ABI where the value already is a NaN box and canonicalising would destroy every tag; and load_pod_field_native for F64/F32 fields — that last one guarded but not witnessed, as no PerryPod fixture was built.

Performance

Every #10777 and #10761 row is unchanged. The one typed-array row where perry beats node — h += p[k & 255] through a typed parameter — is 13 against node's 14.10, unchanged: a guard that had taken it to 16 was dropped, since every correctness result is identical without it.

Rows that do pay (m_f64_const 47→49, m_f64_idx 28→30, m_f32_idx 41→46) all lose to node by 1.7×–4.5× already, so the cost is invisible there. Making the width-8 canonicalisation cheaper is recorded as its own change with its own measurement.

Gates

perry-runtime 4074/0 (--test-threads=1), perry-codegen 1653/0, perry-hir 472/0. 144-probe: 0 crashes, 0 semantic diffs, 101 bits-only. 54 read shapes: 1 diff, the pre-existing Float64Array.prototype.toString() throw. 27-case cross-module witness: 0 diffs. FFI witness 4/4. cargo fmt --check, check_file_size.sh, gc_runtime_root_holders.py, local_binding_type_audit.py all clean. manifest_consistency is red and fails identically on base — pre-existing.

Clippy, with a methodology correction worth recording. A whole-workspace run reported a difference; it was an artefact. Clippy only warns on units it actually rebuilds, and cargo clean -p had left the two arms with different cached sets, so they compiled different target lists. The earlier "clippy identical" claim on the original PR carried the same hazard and merely happened to agree. Since the diff is entirely within perry-codegen, the comparison that means anything is crate-scoped: cargo clean -p perry-codegen on each arm, then clippy --release --all-targets344 warnings on base, 344 on this branch, tables identical.

https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ

Summary by CodeRabbit

  • Bug Fixes
    • Fixed compilation for Buffer.readFloatLE and Buffer.readFloatBE.
    • Corrected float comparisons to use the appropriate operand type.
    • Prevented native floating-point NaN values from being misinterpreted as invalid references.
    • Normalized NaN values from native floats, external returns, and floating-point record fields for safer handling.
    • Preserved valid double-precision runtime values during NaN normalization.

perry-bot and others added 3 commits September 20, 2026 09:47
… emitted IR LLVM rejects (PerryTS#10779)

`LlBlock::fcmp` rendered its operand type as `double` unconditionally, and the
in-process LLVM builder hardcoded `"double"` to match. `canonicalize_lane_f32`,
added in the previous commit for `Buffer.readFloatLE`/`readFloatBE`, passes a
`float`, so it emitted

    %r2 = fcmp uno double %r1, %r1        ; %r1 is a float

and the module was rejected with

    '%r1' defined with type 'float' but expected 'double'

Every program calling `readFloatLE` or `readFloatBE` failed to COMPILE. This is
a hard codegen failure, not a wrong value, and it is a regression introduced by
the previous commit rather than a pre-existing defect.

`LlInst::FCmp` now carries its operand type. `fcmp()` keeps its `double`
signature and delegates to a new `fcmp_ty()`, so no existing call site changes;
both the text renderer and the in-process builder read the type from the
instruction. One construction site, two `..` patterns.

Why nothing caught it: the read-shape harness covered `readDoubleLE` (the f64
helper) and no `readFloat*`, and none of the 153 realsuite/rungs/clisuite
programs calls one — the f32 arm of a two-arm guard had no coverage by
construction. Three unit tests now pin it, each sabotage-proven:

  * f32_canonicalisation_compares_as_float_not_double — flipping `F32` back to
    `DOUBLE` fails it with `left: "double", right: "float"`.
  * f64_canonicalisation_compares_as_double — so the f32 case cannot be "fixed"
    by widening both.
  * both_select_the_canonical_quiet_nan_on_the_nan_arm — pins
    0x7FF8000000000000 on the is-NaN arm and the original value on the other.

`readFloatLE`/`readFloatBE`/`readDoubleBE` are also added to the read-shape
harness; on the pre-fix runtime the two readFloat rows SIGSEGV.

The `#[rustfmt::skip]` on the `I::FCmp` match arm keeps the five-field pattern
on one line: `dialect/mod.rs` sits against the 2000-line file-size gate.

Claude-Session: https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ
…rom an ArrayBuffer (PerryTS#10779)

A C function returning a `double` or a `float` is the same class of source as a
`Float64Array` lane: raw native bits that become a JS value with no conversion.
Witnessed with a real `perry.nativeLibrary` whose staticlib returns
`f64::from_bits(0x7FFE_0000_1234_5678)` and `f32::from_bits(0x7FFF_FFFF)`:

  before: the f64 return prints 305419896 with `Number.isNaN` false, and the
          f32 return SEGFAULTS — it widens to 0x7FFF_FFFF_E000_0000, a forged
          StringHeader*, which is then dereferenced.
  after:  both are NaN, and a control function returning 2.5 is unchanged.

Three sources closed, at the source rather than at the consumer:

  * the C `float` return, before its `fpext`;
  * the C `double` return, but ONLY when the manifest descriptor is `F64`.
    That arm also serves Perry's own double-based ABI, where the returned
    double ALREADY IS a NaN box — canonicalising it would destroy every tag it
    carries. `JsValue` and a missing descriptor are left untouched.
  * `load_pod_field_native` for `F64`/`F32` fields: a POD record's backing
    memory is a native struct, written by C, by Rust, or by a previous native
    store, so its float fields can hold any NaN. The integer field reps cannot
    be NaN and pay nothing. This one is guarded on the same reasoning as its
    neighbour but is NOT witnessed — no PerryPod fixture was built.

This does NOT change PerryTS#10777's precondition either way:
`expr_numeric_by_construction` has no `Expr::Call` arm, so an FFI return can
never make a `numeric_fields` slot. The precondition was already discharged by
the ArrayBuffer sources; this closes the hazard on its own account.

Cost: zero on every measured row. The per-op table is unchanged from the
previous commit — +2 on two Float64Array read shapes that already lose to node,
+5 on the Float32Array shape, and +0 on every PerryTS#10777 and PerryTS#10761 row, including
`h += p[k & 255]` through a typed parameter, which stays at 13 instructions
against node's 14.10.

Claude-Session: https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ
@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: bfe0aa7c-eff2-4c1c-ad87-50518d5be386

📥 Commits

Reviewing files that changed from the base of the PR and between 1afa961 and 09932e7.

📒 Files selected for processing (7)
  • changelog.d/10779-fcmp-operand-type.md
  • crates/perry-codegen/src/block.rs
  • crates/perry-codegen/src/dialect/mod.rs
  • crates/perry-codegen/src/expr/nanbox_inline.rs
  • crates/perry-codegen/src/expr/pod_record.rs
  • crates/perry-codegen/src/inst.rs
  • crates/perry-codegen/src/lower_call/extern_func.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Native floating-point codegen

Layer / File(s) Summary
Typed FCmp pipeline
crates/perry-codegen/src/inst.rs, crates/perry-codegen/src/block.rs, crates/perry-codegen/src/dialect/mod.rs
LlInst::FCmp now stores its operand type. fcmp_ty supports explicit types, while fcmp keeps the double default. Rendering and typed instruction construction use the stored type.
Native NaN canonicalization
crates/perry-codegen/src/expr/nanbox_inline.rs, crates/perry-codegen/src/expr/pod_record.rs, crates/perry-codegen/src/lower_call/extern_func.rs
F32 and F64 POD fields and native returns now use type-specific NaN canonicalization. F64 return canonicalization applies only to manifest-declared F64 values.
Canonicalization validation and changelog
crates/perry-codegen/src/expr/nanbox_inline.rs, changelog.d/10779-fcmp-operand-type.md
Tests verify F32 and F64 comparison types and canonical quiet NaN selection. The changelog records the codegen and NaN handling changes.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.92% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 6 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary codegen fix: adding an operand type to fcmp so Buffer.readFloatLE and related paths compile. It is specific and concise enough despite the additional NaN handl…
Description check ✅ Passed The description provides a detailed summary, concrete changes, related issue reference, verification results, performance impact, and validation status. It does not use every template heading or check…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 76.92% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 6 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • 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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train 238 (#10794) as v0.5.16171a4fa6507e. Closing #10779 stays closed; this is its follow-up.

Your commits are on main unmodified. Thank you for catching this — Buffer.readFloatLE had not compiled on main since v0.5.1614, and that train was mine.

Verified independently before and after, with pinned binaries rather than from the PR description:

v0.5.1613 v0.5.1616 (main) v0.5.1617 node
readFloatLE / readFloatBE / readDoubleLE / readDoubleBE 1.5 2 compile errors 1.5 / −2.25 / 3.125 / −0.5 identical
f32 NaN via readFloatLE typeof=number isNaN=true identical

That last row is the arm that had zero coverage, so it is the one I most wanted to see.

Your "why nothing caught it" is the most valuable part of the PR and it is quoted in the train body so it reaches the release notes: the #10779 read-shape harness contained readDoubleLE and no readFloat*, and none of the 153 programs in the suites calls one — so the guard that PR added had zero coverage on one of its two arms, untested by construction rather than by oversight. The harness going to 54 shapes, with the first two SIGSEGVing on the pre-fix runtime, is the witness that should have existed before the original PR opened.

Two things I noted rather than changed:

  • The deliberate asymmetry on the C double return — canonicalised only when the manifest declares F64, because that arm also serves perry's own double ABI where the value already is a NaN box — is exactly the kind of reasoning a later reader would "simplify" into a bug. It is in the train body.
  • load_pod_field_native is guarded but not witnessed, as you say, since no PerryPod fixture was built. Worth a fixture when one is cheap; not blocking.

Validation: seven gap areas aimed at every path that moves a float out of bytes (gc_ 54, buffer 15, numeric 15, typed_ 12, native 11, dataview 5, float 1), zero unexplained regressions, all five pinned artifacts byte-identical before and after, six unit suites with an empty failing set. Both compiler-output suites also pass on this tree with failed_workloads: [] — a gate my driver had reported as covered while only running a different subcommand of the same script, which this train fixes.

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.

2 participants