You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
max/min return wrong value if signed (negative) zero #20221
For 99.9% of applications, this output is actually what people would expect however: (instead of -0)
float(0)
int(0)
so I guess it would make sense to also change that.
This does not have any impact on calculations or comparisons, this is purely visual but can lead to unexpected behavior if the returned number is used as a string (e.g. substr, strtok,...) since there suddenly is an additional character in front of the numbers.
This is especially unexpected, when the float is the result of float arithmetics, which can minimally negative numbers in some cases
Every path keeps the first of two equal values except frameless min. The variadic loop replaces only on min_dval > Z_DVAL(args[i]), zend_hash_minmax only on compar(res, zv) > 0, and frameless max uses lhs >= rhs ? lhs : rhs. Frameless min uses lhs < rhs ? lhs : rhs, which takes the false branch on a tie and returns the second argument.
max agrees with itself across the forms, though keep-first stays order-dependent. max(0.0, -0.0) is +0 and max(-0.0, 0.0) is -0.
Changing frameless min to <= would line the paths up, and every form would still flip with argument order. Order-independence needs a sign check on equal doubles, giving min = -0 and max = +0 on a signed-zero tie.
That is the tie rule IEEE 754-2019 minimum/maximum use, and what Math.min/max implement. Would you want that, or is path agreement enough?
I hit a second form of this that isn't about signed zero and that the manual is explicit about, so it may help settle which fix to merge.
Two equal values of different types: the manual says the first one is returned ("If multiple values of different types evaluate as equal (e.g. 0 and 'abc') the first provided to the function will be returned"). With no extensions loaded, PHP 8.4 and 8.5 return the second:
Tested on 8.4.27 (two builds: Remi on AlmaLinux 10, Homebrew on macOS) and 8.5.12 (Homebrew). max() keeps the first; min() doesn't. The cause is the two-argument frameless fast path (ZEND_FRAMELESS_FUNCTION(min, 2) in ext/standard/array.c): on a tie its generic branch does zend_compare(lhs, rhs) < 0 ? lhs : rhs, so it returns rhs, while PHP_FUNCTION(min) only replaces on < 0 and keeps the first, and the frameless max uses >=. The same line is on master today. Before 8.4 there was no fast path, so the regular rule applied.
Two things make this one easy to miss and worth fixing on its own:
Which path a call takes depends on the environment. The compiler doesn't emit the fast path when an extension overrides zend_execute_internal (Xdebug, the New Relic agent), so the same script returns the float on one machine and the string on another, and under New Relic the first min() in a process returns the float and later ones the string. php -n is the reliable reproducer.
Returning the other argument changes the type, not just the sign. Real impact: Magento 2.4.8 computes a credit memo quantity as min(float, "1.0000") (the second value comes from the database) and passes the result to a strict_types method typed float. On PHP 8.4, refunds through the REST API fail with a TypeError whenever the requested quantity equals the invoiced quantity, which is nearly all of them (142 of 143 in one run); the same lines on PHP 8.2 worked for years. That took us a day to trace after a production upgrade.
#23505's change (<= in the frameless min) fixes this case as well as the signed-zero one. #20223 as written only changes the double/double branches and leaves the generic tie line alone, so it wouldn't.
Happy to test a patch on the Remi 8.4 build if that helps. Written with the help of an LLM (Claude); the tests and the reading of the source were done on my machines.
Activity
ndossche commentedon Oct 19, 2025
Versions prior to 8.4 output:
TimWolla commentedon Oct 19, 2025
Changing the order of the parameters
returns:
So this seems to be related to how
0.0and-0.0compare equal and thus the behavior is returning either the first or the last of all equal values.TimWolla commentedon Oct 19, 2025
Simpler reproducer:
TimWolla commentedon Oct 19, 2025
JavaScript:
TimWolla commentedon Oct 19, 2025
Full test case:
standard: Handle negative zero in `min()`/`max()`
eyupcanakman commentedon Jul 16, 2026
Every path keeps the first of two equal values except frameless
min. The variadic loop replaces only onmin_dval > Z_DVAL(args[i]),zend_hash_minmaxonly oncompar(res, zv) > 0, and framelessmaxuseslhs >= rhs ? lhs : rhs. Framelessminuseslhs < rhs ? lhs : rhs, which takes the false branch on a tie and returns the second argument.So only min changes with the call form:
max agrees with itself across the forms, though keep-first stays order-dependent.
max(0.0, -0.0)is +0 andmax(-0.0, 0.0)is -0.Changing frameless
minto<=would line the paths up, and every form would still flip with argument order. Order-independence needs a sign check on equal doubles, giving min = -0 and max = +0 on a signed-zero tie.That is the tie rule IEEE 754-2019 minimum/maximum use, and what Math.min/max implement. Would you want that, or is path agreement enough?
mrinella commentedon Oct 28, 2026
I hit a second form of this that isn't about signed zero and that the manual is explicit about, so it may help settle which fix to merge.
Two equal values of different types: the manual says the first one is returned ("If multiple values of different types evaluate as equal (e.g. 0 and 'abc') the first provided to the function will be returned"). With no extensions loaded, PHP 8.4 and 8.5 return the second:
Tested on 8.4.27 (two builds: Remi on AlmaLinux 10, Homebrew on macOS) and 8.5.12 (Homebrew).
max()keeps the first;min()doesn't. The cause is the two-argument frameless fast path (ZEND_FRAMELESS_FUNCTION(min, 2)in ext/standard/array.c): on a tie its generic branch doeszend_compare(lhs, rhs) < 0 ? lhs : rhs, so it returnsrhs, whilePHP_FUNCTION(min)only replaces on< 0and keeps the first, and the framelessmaxuses>=. The same line is on master today. Before 8.4 there was no fast path, so the regular rule applied.Two things make this one easy to miss and worth fixing on its own:
zend_execute_internal(Xdebug, the New Relic agent), so the same script returns the float on one machine and the string on another, and under New Relic the firstmin()in a process returns the float and later ones the string.php -nis the reliable reproducer.min(float, "1.0000")(the second value comes from the database) and passes the result to astrict_typesmethod typedfloat. On PHP 8.4, refunds through the REST API fail with a TypeError whenever the requested quantity equals the invoiced quantity, which is nearly all of them (142 of 143 in one run); the same lines on PHP 8.2 worked for years. That took us a day to trace after a production upgrade.#23505's change (
<=in the framelessmin) fixes this case as well as the signed-zero one. #20223 as written only changes the double/double branches and leaves the generic tie line alone, so it wouldn't.Happy to test a patch on the Remi 8.4 build if that helps. Written with the help of an LLM (Claude); the tests and the reading of the source were done on my machines.