Skip to content

fix(runtime): js_dynamic_mod preserves sign of zero (fmod semantics)#5999

Merged
proggeramlug merged 1 commit into
mainfrom
fix/t262-regr-mod-neg-zero
Jul 4, 2026
Merged

fix(runtime): js_dynamic_mod preserves sign of zero (fmod semantics)#5999
proggeramlug merged 1 commit into
mainfrom
fix/t262-regr-mod-neg-zero

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Problem

language/expressions/compound-assignment/mod-whitespace.js regressed: x %= -1 (with x == -1) yielded 0 where the spec wants -0.

js_dynamic_mod computed the remainder with the closed form a - (a / b).trunc() * b. That drops the sign of a zero result: -1 % -1 evaluates to -1.0 - (1.0 * -1.0) == +0.0. But JS % is C fmod, whose result takes the sign of the dividend, so -1 % -1 is -0. The same closed form also returned NaN for x % Infinity (should be x).

The bug surfaced once a compound assignment on a separately-declared int-valued local (var x; x = -1; x %= -1) started routing through js_dynamic_mod (the codegen fast path that carries the -0 correction isn't taken for this receiver shape).

Fix

Rust's f64 % f64 is fmod, so replace the closed form with a % b. It matches the spec exactly on:

  • sign of zero (-1 % -1 == -0, -0 % 5 == -0)
  • x % ±Infinity == x
  • ±Infinity % y == NaN, x % 0 == NaN

Verification

Built on an internal Linux sweep host:

  • mod-whitespace.js passes (all whitespace variants, -0 expected).
  • language/expressions/compound-assignment + language/expressions/modulus slices are 100% (487 cases, 0 runtime-fails).

Code-only (no version/changelog).

Summary by CodeRabbit

  • Bug Fixes
    • Improved JavaScript-style modulo behavior for floating-point numbers, including correct handling of signed zero, infinities, and zero division cases.
    • Results now align more closely with expected remainder behavior in edge cases.

js_dynamic_mod computed the remainder with the closed form
a - (a / b).trunc() * b. That drops the sign of a zero result:
-1 % -1 evaluates to -1.0 - (1.0 * -1.0) == +0.0, but JS % is C fmod,
whose result takes the sign of the dividend, so -1 % -1 is -0. The same
closed form also returned NaN for x % Infinity (should be x).

This surfaced as a regression once a compound assignment on a
separately-declared int-valued local (var x; x = -1; x %= -1) started
routing through js_dynamic_mod:

  language/expressions/compound-assignment/mod-whitespace.js

which asserts x %= -1 is -0.

Rust's f64 % f64 is fmod, so replace the closed form with a % b. It
matches the spec on the sign of zero, on x % Infinity == x, and on
Infinity % y / x % 0 == NaN.

Verified on an internal Linux sweep host: mod-whitespace passes; the
compound-assignment and modulus slices are 100% (0 runtime-fails).
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd444fe6-0d4e-4855-b039-fd945d6f4b52

📥 Commits

Reviewing files that changed from the base of the PR and between 95e4817 and 61a1889.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/value/dynamic_arith.rs

📝 Walkthrough

Walkthrough

The float modulo computation in js_dynamic_mod was changed from a trunc/division-based formula (a - (a / b).trunc() * b) to Rust's native % operator (a % b). An accompanying comment was updated to document JS-style remainder semantics for signed zero, infinity, and NaN cases.

Changes

Float Modulo Fix

Layer / File(s) Summary
Float modulo computation
crates/perry-runtime/src/value/dynamic_arith.rs
js_dynamic_mod's float path now computes a % b directly instead of the prior trunc/division formula, with a comment update describing JS-remainder semantics for signed zero, ±Infinity, and NaN.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Possibly related PRs

  • PerryTS/perry#5798: Both PRs modify the floating numeric path of js_dynamic_mod in the same file.
  • PerryTS/perry#5822: Both PRs adjust JavaScript % remainder semantics, including zero-handling behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the runtime fix to js_dynamic_mod and its sign-of-zero semantics.
Description check ✅ Passed The description includes the problem, fix, and verification, though it doesn't follow the exact template headings and omits a related issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage 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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/t262-regr-mod-neg-zero

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 proggeramlug merged commit 8489f99 into main Jul 4, 2026
16 of 17 checks passed
@proggeramlug proggeramlug deleted the fix/t262-regr-mod-neg-zero branch July 4, 2026 20:25
proggeramlug pushed a commit that referenced this pull request Jul 5, 2026
…essions

A test262 sweep of main at the #6008 tip regressed vs the #5979 tip
(parity 96.5% -> 95.8%, Temporal self-validated 99.1% -> 96.7%, 322
tests newly failing). git bisect (good 28948ea .. bad 76cc2b9,
witness = built-ins/Boolean + Number + Temporal.Duration parity) lands
on 849f297 "Improve Zod compatibility support" (#5874) as the first
bad commit: 98.3% witness parity at its parent-2 (4f5bc6a) drops to
93.9% at #5874 (Boolean 89%->67%, Temporal 100%->95%, self-validated
100%->95.1%).

#5874 was a 156-file monolithic squash that swept in unreviewed
codegen/runtime WIP + two 5.1MB binaries (constv, direct) + ~30 stray
tests/*.sh alongside the intended Zod work. Two swept changes dominate:

  - codegen/lower_call/property_get/number_string.rs gated the universal
    .toString() interception on is_numeric_expr || is_bigint_expr, so
    boxed built-in receivers (new String()/Number()/Date()) with a
    user-assigned built-in method bypassed the brand-check and stopped
    throwing TypeError (Boolean S15.6.4.2_A2_T1..T3) -> the "method is
    not a function" + SameValue clusters.
  - the runtime prototype-identity / class-registry / instanceof rewrite
    regressed Temporal subclass prototype resolution
    (Object.getPrototypeOf(result) === construct.prototype) -> the
    Temporal 159 + "(no output)" crash clusters.

This is the inverse patch of the #5874 payload applied to CURRENT main
(not a snapshot restore), so the later fleet merges are preserved:

  - #5991 (class X extends Promise): its promise_parent_runtime scaffold,
    emit_promise_subclass_init wiring, and new_helpers additions are
    kept (subclass executor resolves and .then sees the value).
  - #5999 (js_dynamic_mod fmod sign-of-zero): kept; only #5874s
    dynamic_number_operand double-coercion was dropped from the modulo
    path.
  - #5983 (external-http-client-pump dropped from stdlib full): untouched
    and links green.

new.rs / new_helpers.rs: reverting new.rs to its pre-#5874 form put it
at 2000 lines; applying #5991s +9 net lines on top pushed it to 2009,
over the 2000-line file-size gate. Moved three self-contained ctor
predicate helpers (effective_constructor_param_count,
local_constructor_symbol_exists, ctor_chain_uses_new_target) into the
new_helpers sibling to restore the split (new.rs -> 1939 lines).

Verified on an internal Linux sweep host: witness slice back to 98.3% /
self-validated 100% / Temporal.Duration 100%; fmt + file-size +
gc-store-site + addr-class audits clean. #5874 remains in history for
its author to re-land in reviewable pieces.
proggeramlug pushed a commit that referenced this pull request Jul 5, 2026
…main (preserves 5 later fixes)

The #5983 squash unknowingly re-landed force-rewound PR #5874 (Improve
Zod compatibility, 849f297, 161 files incl the constv/direct binaries
and a class-lowering change that regresses
temporal_subclass_capture_writeback_inner_class: 'class X extends
<param>' flips from static New to ClassExprFresh/NewDynamic and the
capture writeback is lost).

Inverse patch of the #5874 payload applied to CURRENT main via 3-way, so
the fleet merges that landed on top are preserved:
- #5991 (extends Promise): new.rs/new_helpers.rs taken from the prior
  working revert (unchanged by the 11 intervening merges); its
  promise_parent_runtime + emit_promise_subclass_init kept, #5874's
  map_set_default_super_kind scaffold dropped.
- #5999 (js_dynamic_mod fmod sign-of-zero): #5874's redundant
  dynamic_number_operand insertion removed, #5999's 'a % b' kept.
- #6004/#6005/#6007 (exotic-receiver dispatch, proxy get, seal on
  functions): 3-way removed ONLY #5874's hunks from
  native_call_method.rs/proxy.rs; the later fixes' additions stay.

Verified: temporal 6/6 (was 5/6 on main), 806 unchanged, stdlib links,
proxy e2e green, and behavioral probes for #5999 (mod -0), #5991
(promise subclass = 42), #6004/#6007 (exotic dispatch + fn seal) all
match node. Both stray binaries deleted. #5874 stays in history at
4500b5d for its author to re-land through review after fixing the
extends regression.
proggeramlug added a commit that referenced this pull request Jul 5, 2026
…essions (#6015)

A test262 sweep of main at the #6008 tip regressed vs the #5979 tip
(parity 96.5% -> 95.8%, Temporal self-validated 99.1% -> 96.7%, 322
tests newly failing). git bisect (good 28948ea .. bad 76cc2b9,
witness = built-ins/Boolean + Number + Temporal.Duration parity) lands
on 849f297 "Improve Zod compatibility support" (#5874) as the first
bad commit: 98.3% witness parity at its parent-2 (4f5bc6a) drops to
93.9% at #5874 (Boolean 89%->67%, Temporal 100%->95%, self-validated
100%->95.1%).

#5874 was a 156-file monolithic squash that swept in unreviewed
codegen/runtime WIP + two 5.1MB binaries (constv, direct) + ~30 stray
tests/*.sh alongside the intended Zod work. Two swept changes dominate:

  - codegen/lower_call/property_get/number_string.rs gated the universal
    .toString() interception on is_numeric_expr || is_bigint_expr, so
    boxed built-in receivers (new String()/Number()/Date()) with a
    user-assigned built-in method bypassed the brand-check and stopped
    throwing TypeError (Boolean S15.6.4.2_A2_T1..T3) -> the "method is
    not a function" + SameValue clusters.
  - the runtime prototype-identity / class-registry / instanceof rewrite
    regressed Temporal subclass prototype resolution
    (Object.getPrototypeOf(result) === construct.prototype) -> the
    Temporal 159 + "(no output)" crash clusters.

This is the inverse patch of the #5874 payload applied to CURRENT main
(not a snapshot restore), so the later fleet merges are preserved:

  - #5991 (class X extends Promise): its promise_parent_runtime scaffold,
    emit_promise_subclass_init wiring, and new_helpers additions are
    kept (subclass executor resolves and .then sees the value).
  - #5999 (js_dynamic_mod fmod sign-of-zero): kept; only #5874s
    dynamic_number_operand double-coercion was dropped from the modulo
    path.
  - #5983 (external-http-client-pump dropped from stdlib full): untouched
    and links green.

new.rs / new_helpers.rs: reverting new.rs to its pre-#5874 form put it
at 2000 lines; applying #5991s +9 net lines on top pushed it to 2009,
over the 2000-line file-size gate. Moved three self-contained ctor
predicate helpers (effective_constructor_param_count,
local_constructor_symbol_exists, ctor_chain_uses_new_target) into the
new_helpers sibling to restore the split (new.rs -> 1939 lines).

Verified on an internal Linux sweep host: witness slice back to 98.3% /
self-validated 100% / Temporal.Duration 100%; fmt + file-size +
gc-store-site + addr-class audits clean. #5874 remains in history for
its author to re-land in reviewable pieces.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Jul 5, 2026
…main (preserves 5 later fixes)

The #5983 squash unknowingly re-landed force-rewound PR #5874 (Improve
Zod compatibility, 849f297, 161 files incl the constv/direct binaries
and a class-lowering change that regresses
temporal_subclass_capture_writeback_inner_class: 'class X extends
<param>' flips from static New to ClassExprFresh/NewDynamic and the
capture writeback is lost).

Inverse patch of the #5874 payload applied to CURRENT main via 3-way, so
the fleet merges that landed on top are preserved:
- #5991 (extends Promise): new.rs/new_helpers.rs taken from the prior
  working revert (unchanged by the 11 intervening merges); its
  promise_parent_runtime + emit_promise_subclass_init kept, #5874's
  map_set_default_super_kind scaffold dropped.
- #5999 (js_dynamic_mod fmod sign-of-zero): #5874's redundant
  dynamic_number_operand insertion removed, #5999's 'a % b' kept.
- #6004/#6005/#6007 (exotic-receiver dispatch, proxy get, seal on
  functions): 3-way removed ONLY #5874's hunks from
  native_call_method.rs/proxy.rs; the later fixes' additions stay.

Verified: temporal 6/6 (was 5/6 on main), 806 unchanged, stdlib links,
proxy e2e green, and behavioral probes for #5999 (mod -0), #5991
(promise subclass = 42), #6004/#6007 (exotic dispatch + fn seal) all
match node. Both stray binaries deleted. #5874 stays in history at
4500b5d for its author to re-land through review after fixing the
extends regression.
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.

1 participant