Skip to content

Fix range() memory use, rand() and get_class() inference, and mt_rand(), rand() and round() signatures - #6531

Merged
ondrejmirtes merged 5 commits into
2.2.xfrom
stub-side-findings
Sep 22, 2026
Merged

ondrejmirtes merged 5 commits into
2.2.xfrom
stub-side-findings

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

These are bugs found while researching the throw type extensions for built-in functions. There is one commit per bug, and each adds a test that fails without it.

range() with a huge constant range allocated the whole array

RangeFunctionReturnTypeExtension called range() for constant arguments and only then checked the length.

  • range(0, 100000000) allocated 1.7 GB, and a large enough range exhausted the memory limit (the new test dies with a memory exhaustion fatal without the fix).
  • The length of a numeric range is now computed first. A range longer than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT gets the general non-empty-list<…> type straight away.
  • That also keeps the bounds for ranges range() itself refuses to create: range(0, 2000000000) was non-empty-list<int>, and is now non-empty-list<int<0, 2000000000>>.
  • Character ranges (at most 256 items) and zero, infinite or NAN arguments, for which range() throws before allocating anything, still go through range().

rand(5, 1) was inferred as never

rand() swaps its arguments when $min > $max, unlike mt_rand() and random_int(), which throw ValueError. It is now inferred as int<1, 5>.

mt_rand(1) and rand(1) were not reported

  • Cause: when no variant accepts the number of arguments, ParametersAcceptorSelector combines the variants into one whose parameters missing from some variant are optional. With the variants () and ($min, $max), a single argument was therefore accepted, although it throws ArgumentCountError.
  • Fix: CallToFunctionParametersRule now checks such a call against the variant closest to its argument count: Function mt_rand invoked with 1 parameter, 2 required.
    • This only applies when every argument is positional and not unpacked.
    • It only applies when the combined variant would accept the count, so mt_rand(1, 2, 3) keeps its 0-2 required message.
  • functionMap fixes: I listed every function whose variants leave such gaps and checked them against real PHP. Three entries were wrong and would have become false positives:
    • levenshtein() accepts each cost separately since PHP 8.0 (added to functionMap_php80delta.php);
    • the $flags / $all parameters of uopz_add_function() and uopz_del_function() are optional.
  • Also reported now, and correctly: stream_context_set_option() with 3 arguments (ValueError on PHP 8) and session_set_save_handler() with 3–5 arguments.

get_class() without arguments outside of a class was false on PHP 8

That's PHP 7 behaviour; PHP 8 throws Error.

  • In a function it is now never.
  • In top-level code (possibly included from a method) and in closures (possibly bound to an object) it is class-string.
  • PHP 7 keeps false.

round() rejected the modes 5–8 on PHP 8.4

PHP 8.4 added four rounding modes: RoundingMode cases, or their int values 5–8. It throws ValueError for any other int. functionMap_php84delta.php now types $mode as int<1, 8>|RoundingMode; before 8.4 it stays 1|2|3|4.

The full test suite and self-analysis pass. The version-dependent tests were also run locally on PHP 7.4.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7

ondrejmirtes and others added 5 commits September 22, 2026 14:11
RangeFunctionReturnTypeExtension called range() for constant arguments and
only then checked the length, so range(0, 100000000) allocated the whole
array (1.7 GB) and a large enough range exhausted the memory. The length of
a numeric range is now computed first, and a range longer than
ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT gets the general list type right
away. That also gives the list type to ranges range() itself refuses to
create, like range(0, 2000000000), which lost the bounds before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
rand() swaps the arguments when $min is greater than $max, so rand(5, 1)
returns an int between 1 and 5, but it was inferred as never like
random_int() and mt_rand(), which throw ValueError for such arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
When no variant of a function accepts the number of arguments, the variants
are combined into one whose parameters missing from some variant are
optional. mt_rand() and rand() have the variants () and ($min, $max), so
mt_rand(1) was accepted although it throws ArgumentCountError. Such a count
is now checked against the variant closest to it.

The functionMap was wrong for a few functions with such gaps: levenshtein()
accepts each cost separately since PHP 8.0, and the $flags and $all
parameters of uopz_add_function() and uopz_del_function() are optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
get_class() without arguments outside of a class returns false on PHP 7,
but PHP 8 throws Error instead. In a function it always throws, so it is
never now. Top-level code might be included from a method and a closure
bound to an object, where it returns the class name, so it is class-string
there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
PHP 8.4 added four rounding modes, available as RoundingMode cases or as
their int values 5-8, and throws ValueError for any other int. The signature
still allowed only 1|2|3|4 besides RoundingMode, so round($f, 0, 5) was
reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhb4ssXuKVWcVBcNFpsdn7
@ondrejmirtes
ondrejmirtes merged commit d0009af into 2.2.x Sep 22, 2026
560 of 562 checks passed
@ondrejmirtes
ondrejmirtes deleted the stub-side-findings branch September 22, 2026 12:27
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