Skip to content

fix(strings): (int) over a numeric string caps like PHP instead of wrapping - #1066

Open
Guikingone wants to merge 3 commits into
mainfrom
fix/int-cast-numeric-string-cap
Open

Guikingone wants to merge 3 commits into
mainfrom
fix/int-cast-numeric-string-cap

Conversation

@Guikingone

Copy link
Copy Markdown
Collaborator

Found while investigating #953, which lists (int)$s as one of the paths into the unbounded
__rt_cstr buffer. The buffer is a separate problem; on the way there, the values turned out
to be wrong.

PHP applies two different rules

A float value is reduced modulo 2^64. A numeric string is capped. elephc was routing
the string through the float rule.

(int)1e19      // php: -8446744073709551616   — the float rule, and elephc agrees
(int)"1e19"    // php:  9223372036854775807   — the STRING rule

__rt_php_float_to_int implements the float rule correctly and is untouched; __rt_str_to_int
just should not have been calling it.

What was wrong

expression PHP before
(int)"1e19" 9223372036854775807 -8446744073709551616
(int)"-1e19" -9223372036854775808 8446744073709551616
(int)"1.8e19" 9223372036854775807 -446744073709551616
(int)"1e100" 9223372036854775807 0
(int)"1e308" 9223372036854775807 0
(int)str_repeat("1", 310) 0 9223372036854775807
(int)("-" . str_repeat("1", 310)) 0 -9223372036854775808

The wrapping cases are the ones that matter: (int)$_GET['page'] on an oversized value produced
a large negative number instead of PHP_INT_MAX, with no warning.

The last two are the reverse failure, and they sit on the integer-form path: strtoll
saturates a 310-digit integer to PHP_INT_MAX, but PHP classifies a string whose value
overflows the double as IS_DOUBLE, and INF casts to 0. So the non-finite check has to run
before the integer/float form choice — that string is integer-form.

The fix

  • Non-finite parsed double → 0, checked first, for either form.
  • Float-form → cap. AArch64 needs no bounds of its own, because fcvtzs saturates by
    definition, which is exactly PHP's cap for every finite double. x86_64's cvttsd2si reports
    the "integer indefinite" pattern instead — already correct for a negative overflow, so only a
    positive one is turned into PHP_INT_MAX.
  • Integer-form → unchanged, strtoll's exact value.

That last point is load-bearing and has its own fixture: 2^53 + 1 is exactly representable as
an integer but not as a double, so capping the parsed double instead of taking strtoll's
answer would round (int)"9007199254740993" down by one.

Verification

  • 23 shapes byte-identical to PHP 8.5.10, spanning the int64 boundary (…807, …808, …809),
    the finite/INF boundary (309 vs 310 digits), both signs, leading whitespace, and float-form
    spellings.
  • The same values through intval(), settype(), mixed-array arithmetic and loose comparison.
  • Both fixtures fail without the fix.
  • x86_64 assembles clean for x86_64-unknown-linux-gnu; --lib 1680, strings:: 392 and
    operators:: 127 pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr

@github-actions github-actions Bot added area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Sep 17, 2026
@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previous documentation requirement is now fully satisfied and no new actionable issue remains.

Fix All in Claude CodeFindings

  1. P2 Runtime semantics lack documentation
Fix with agent prompt
### Issue 1
src/codegen_support/runtime/numeric.rs:undefined-84
This adds a separate numeric-string conversion helper and changes string casts to cap values instead of applying the float-value wrapping rule. The repository requires compiler-internal runtime changes to be documented in the relevant `docs/internals/` page. Please document why numeric strings use the capping helper while float values retain modulo-2^64 conversion; this requirement must be satisfied before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR corrects numeric-string-to-integer conversion so finite overflow is capped to PHP’s integer range rather than routed through the float-value wrapping rule.

  • Adds a shared, target-aware capping helper for AArch64 and x86_64.
  • Handles non-finite parsed values before selecting integer-form or float-form conversion.
  • Preserves exact integer-form values that cannot be represented precisely as doubles.
  • Adds regression coverage for overflow boundaries, very large strings, and both target implementations.
  • Documents why numeric strings and float values require different conversion helpers.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Numeric string] --> B[Parse with strtod and strtoll]
    B --> C{Parsed double finite?}
    C -- No --> D[Return 0]
    C -- Yes --> E{strtod consumed more input?}
    E -- No: integer form --> F[Return exact strtoll value]
    E -- Yes: float form --> G[Call numeric-string cap helper]
    G --> H{Target}
    H -- AArch64 --> I[fcvtzs saturation]
    H -- x86_64 --> J[cvttsd2si and correct positive overflow]
    I --> K[Return capped integer]
    J --> K
Loading

Reviews (4) · Last reviewed commit: "docs(internals): record the two double-t..."

@Guikingone

Copy link
Copy Markdown
Collaborator Author

The one red check here is not this change. Curl Codegen Tests (macos-aarch64 4/4) fails on curl_monitor_excludes_write_callback_cpu_from_network_wait, a timing assertion, and it has now failed twice in a row on this runner with the same shape:

run 1:  control 372625 ns → in-callback 7815417 ns
run 2:  control 432417 ns → in-callback 7418999 ns

against WAIT_SLACK_NS = 5_000_000. The defect that assertion guards produced 133.9 ms against a 1.2 ms control — 18x further out than either sample here. This is loopback scheduling jitter on a shared runner, not billed callback CPU: the burn is ~130 ms and would show as that.

This PR touches __rt_str_to_int and adds __rt_php_float_to_int_cap; nothing on the curl monitoring path. Build & Test is the aggregation gate and fails only because that shard did.

#1024 is exactly this: it raises the bound to 20 ms — better than 2x over these samples, still ~6.6x below the gap the defect produces — and, because widening a tolerance can only move a test toward vacuous, it adds an assertion that the in-callback burn measured from the capture exceeds the slack by 2x, so a fully misbilled callback can never satisfy the comparison. Its comment already cites the 7.80 ms sample from run 1; run 2's 7.42 ms is a third data point.

Re-running does not help — it has now been re-run once and reproduced. #1024 should land first, then this one goes green on a rebase.

…apping

PHP applies two DIFFERENT rules, and the string path was using the wrong one. A
float VALUE is reduced modulo 2^64 -- `(int)1e19` is negative in PHP too, and
`__rt_php_float_to_int` is right to do that. A numeric STRING is CAPPED. Routing
the string through the wrapping helper produced silently wrong arithmetic on a very
common operation:

    (int)"1e19"     php: 9223372036854775807   was: -8446744073709551616
    (int)"-1e19"    php: -9223372036854775808  was:  8446744073709551616
    (int)"1.8e19"   php: 9223372036854775807   was:  -446744073709551616
    (int)"1e100"    php: 9223372036854775807   was:  0
    (int)"1e308"    php: 9223372036854775807   was:  0

The reverse failure sat on the integer-form path. `strtoll` saturates a 310-digit
integer to PHP_INT_MAX, but PHP classifies a string whose value overflows the double
as IS_DOUBLE, and INF casts to 0:

    (int)str_repeat("1", 310)   php: 0   was: 9223372036854775807

so the non-finite check has to come BEFORE the integer/float form choice -- that
string is integer-form.

AArch64 needs no bounds of its own: `fcvtzs` saturates by definition, which is
exactly PHP's cap for every finite double. x86_64's `cvttsd2si` reports the "integer
indefinite" pattern instead, which is already correct for a negative overflow; only
a positive one has to be turned into PHP_INT_MAX.

The integer-form parse is left alone, and one fixture guards that: 2^53+1 is exactly
representable as an integer but not as a double, so capping the parsed double instead
of taking `strtoll`'s answer would round it down by one.

23 shapes verified byte-identical to PHP 8.5.10, and the same values through
`intval()`, `settype()`, mixed-array arithmetic and loose comparison. Both fixtures
fail without the fix. x86_64 assembles clean for x86_64-unknown-linux-gnu.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
CI caught the first version of this fix with an invariant I had not noticed:
`test_runtime_float_consumers_call_the_shared_helper` requires every runtime
float-to-int consumer to route through `__rt_php_float_to_int` rather than
open-code the conversion, because a bare `fcvtzs` / `cvttsd2si` is where the
per-target `(int)NAN` and `$a[INF]` divergence came from -- the two disagree
with each other on NaN and on overflow.

The fix inlined exactly such a conversion. It was guarded (non-finite handled
first, so the hardware difference could not show), but the invariant is worth
more than the guard: the next person to add a numeric-string consumer would
have had a second open-coded copy to get right.

So the cap becomes `__rt_php_float_to_int_cap`, beside the wrapping helper it
deliberately is not. Both PHP rules now live in `runtime::numeric`, named, with
the reason each exists, and `__rt_str_to_int` calls one of them.

Two tests pin it: that `__rt_str_to_int` reaches the cap through the helper and
contains no bare conversion, and that the helper checks non-finite BEFORE
capping -- capping first would answer PHP_INT_MAX for `(int)"1e309"" instead
of 0.

Same 23 shapes still byte-identical to PHP 8.5.10; math 92, --lib 1680 pass.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
@Guikingone
Guikingone force-pushed the fix/int-cast-numeric-string-cap branch from ead76a3 to 68c83e2 Compare September 18, 2026 13:10
emitter.instruction("ret"); // return the PHP integer value in x9
}

/// Emits the `__rt_php_float_to_int_cap` runtime helper for the active target.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Runtime semantics lack documentation

This adds a separate numeric-string conversion helper and changes string casts to cap values instead of applying the float-value wrapping rule. The repository requires compiler-internal runtime changes to be documented in the relevant docs/internals/ page. Please document why numeric strings use the capping helper while float values retain modulo-2^64 conversion; this requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/codegen_support/runtime/numeric.rs
Line: 84

Comment:
**Runtime semantics lack documentation**

This adds a separate numeric-string conversion helper and changes string casts to cap values instead of applying the float-value wrapping rule. The repository requires compiler-internal runtime changes to be documented in the relevant `docs/internals/` page. Please document why numeric strings use the capping helper while float values retain modulo-2^64 conversion; this requirement must be satisfied before merging.

**Context Used:** AGENTS.md ([source](https://github.com/illegalstudio/elephc/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

…separate

Raised in review. The runtime page listed `__rt_php_float_to_int` among the
array/scalar entry points and said nothing about the capping sibling this PR
adds, so the reason a numeric string does not use the wrapping helper was only
in the code.

Documents the rule table -- a float VALUE reduces modulo 2^64, a numeric STRING
caps -- with php-src's answers for `(int)1e19` and `(int)"1e19"`, and the reason
both helpers check for NaN and the infinities before converting: `fcvtzs`
saturates while `cvttsd2si` reports its indefinite pattern, which is what made a
bare conversion at a call site produce a per-target `(int)NAN`.

Values verified against host PHP 8.5.10.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
@Guikingone

Copy link
Copy Markdown
Collaborator Author

Addressed in 37225c6c3a. The runtime page listed __rt_php_float_to_int among the array/scalar entry points and said nothing about the capping sibling, so the reason a numeric string does not use the wrapping helper lived only in the code.

It now documents the rule table and why the split exists:

source rule helper (int) of 1e19
a float VALUE reduce modulo 2^64 __rt_php_float_to_int -8446744073709551616
a numeric STRING CAP at the integer range __rt_php_float_to_int_cap 9223372036854775807

plus the reason both helpers check NaN and the infinities before converting: fcvtzs saturates by definition while cvttsd2si answers with the indefinite pattern, which is what made a bare conversion at a call site produce a per-target (int)NAN. All four values verified against host PHP 8.5.10.

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

Labels

area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant