[php-src] Issue #24214: Function JIT (opcache.jit=1235) with the DFA pass disabled intermittently runs a bounded for loop past the end of its array
(PHP 8.5.11)
| From: | ballidev | Date: | Fri, 09 Oct 2026 11:29:56 +0000 |
| Subject: | [php-src] Issue #24214: Function JIT (opcache.jit=1235) with the DFA pass disabled intermittently runs a bounded for loop past the end of its array (PHP 8.5.11) |
||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-252952@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24214
Author: ballidev
**PHP version:** 8.5.11 (cli) (NTS), x86_64, Rocky Linux 9, packages from the Remi RPM repository
(built 22 Sep 2026), bundled opcache, no Xdebug.
**Summary**
With
opcache.jit=1235 and opcache.optimization_level=0x7FFEBFDF (the
default 0x7FFEBFFF with bit 0x20, the DFA pass, cleared), a small pure-PHP
function intermittently runs a bounded for loop past the end of its array. The loop is
```php
for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; ++$i) {
if ('variable' === $tokens[$i][0]) {
$tokens[$i][4] = true;
}
}
```
and the symptom is Warning: Undefined array key N with N counting up from 3 for as long
as the process is allowed to run, with a line such as ENTRY 192 (block 780 start 4585) - live
var 4580 written to stderr by the runtime beforehand (the numbers vary with the build). Left
unbounded, the loop keeps raising warnings indefinitely.
It is intermittent per process and depends on address-space layout: with ASLR disabled
(setarch -R) the attached file failed 0 of 500 runs, with ASLR enabled 8 of 500 (a
separate batch from the table below).
**Reproducer**
A standalone file (about 210 lines, core PHP and PCRE only, no extensions or autoloader) is attached
as repro.php and its full text is at the end of this report. It is a reduced copy of
RouteCompiler::compilePattern() from symfony/routing v8.1.8 (MIT licensed; the notice
is in the file header). Run it many times; one run is not a verdict:
```bash
for i in $(seq 1 500); do
php -n \
-d opcache.enable=1 -d opcache.enable_cli=1 \
-d opcache.jit=1235 -d opcache.jit_buffer_size=256M \
-d opcache.optimization_level=0x7FFEBFDF \
-d opcache.validate_timestamps=0 \
repro.php 1
echo "exit=$?"
done | sort | uniq -c
```
repro.php exits 0 and prints OK when fine; it exits 2 and prints
WARNING(2): Undefined array key 3 ... when the fault occurs (the script installs an
error handler that exits on the first warning, so nothing floods).
**Results** (PHP 8.5.11; the attached repro.php, 500 runs per row)
| opcache.jit | opcache.optimization_level | Runs failing |
| --------------- | ---------------------------- | ------------ |
| 1235 | 0x7FFEBFDF | 9 of 500 |
| 1235 | 0x7FFFBFDF | 3 of 500 |
| 1235 | 0x7FFEBFFF (default) | 0 of 500 |
| 1235 | 0x7FFFBFFF (all passes) | 0 of 500 |
| 1235 | 0 | 0 of 500 |
| 0 (off) | 0x7FFEBFDF | 0 of 500 |
| 1254 (tracing) | 0x7FFEBFDF | 0 of 500 |
| 1205 (function) | 0x7FFEBFDF | 0 of 500 |
Of the levels tested, those with bit 0x20 cleared and the other optimisation passes
still enabled produced failures; level 0 (all passes off) and every level with 0x20 set
produced none.
The same routine inside the complete routing library (a larger version of the reproducer, not
attached) failed more often, about 1 run in 4 at 1235 with 0x7FFEBFDF (23
of 100). There too, the levels tested with bit 0x20 cleared and the other passes
enabled (0x7FFEBFDF, 0x7FFFBFDF, 0x7FFEBF9F) produced 21 to 23 failures per 100 runs, and levels
with it set produced none, and jit_buffer_size 16M / 64M / 256M gave 22 / 16 / 23 of
100, so no consistent difference.
**Notes**
- The attached file fails about 1 run in 55 because it is a reduction, so use 500 runs or more.
- The level with bit 0x20 cleared is the workaround for a separate DFA-pass segfault
(GH-23644, which is listed in the PHP-8.5 NEWS for 8.5.12), so others on 8.5.11 may be using exactly
this combination.
- The 8.5.11 changelog lists no entry that matches.
- I have not tested 8.5.12 or master, so I do not know whether this also fails there, and I have not
bisected php-src commits.
**repro.php**
(full text follows)
```php
<?php
declare(strict_types=1);
/*
* Standalone reproducer: no framework, no autoloader, no extensions beyond the PHP core and PCRE.
*
* compilePattern() below is derived from RouteCompiler::compilePattern() of symfony/routing v8.1.8,
reduced to
* what the failing code path uses: the Route object is replaced by three plain arguments, host
handling and
* route defaults are dropped (no route here has a default). The token, regexp and utf8 loops are
unchanged.
*
* Copyright (c) 2004-present Fabien Potencier
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated
* documentation files (the "Software"), to deal in the Software without restriction,
including without
* limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the
* Software, and to permit persons to whom the Software is furnished to do so, subject to the
following
* conditions: The above copyright notice and this permission notice shall be included in all copies
or
* substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT
WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR
* PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* Run it many times under the ini below; one run is not a verdict (the fault is intermittent per
process).
* Exit 0 and "OK" is no fault; exit 2 is a PHP warning ("Undefined array key 3"
in the utf8 token loop), with
* a line such as "ENTRY 192 (block 780 start 4585) - live var 4580" written to stderr by
the runtime (the numbers
* vary with the build).
*
* php -n -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.jit=1235 -d
opcache.jit_buffer_size=256M \
* -d opcache.optimization_level=0x7FFEBFDF -d opcache.validate_timestamps=0 repro.php 1
*/
set_error_handler(static function (int $level, string $msg, string $file, int $line): never {
fwrite(STDOUT, "WARNING({$level}): {$msg} at {$file}:{$line}\n");
exit(2);
});
const SEPARATORS = '/,;.:-_~+*=@|';
const VARIABLE_MAXIMUM_LENGTH = 32;
/**
* @param array<string, string> $requirements
*
* @return array{regex: string, tokens: list<array<int, mixed>>, variables:
list<string>}
*/
function compilePattern(string $pattern, array $requirements, bool $needsUtf8): array
{
$tokens = [];
$variables = [];
$matches = [];
$pos = 0;
$defaultSeparator = '/';
$useUtf8 = preg_match('//u', $pattern);
preg_match_all('#\{(!)?([\w\x80-\xFF]+)\}#', $pattern, $matches, \PREG_OFFSET_CAPTURE
| \PREG_SET_ORDER);
foreach ($matches as $match) {
$important = $match[1][1] >= 0;
$varName = $match[2][0];
$precedingText = substr($pattern, $pos, $match[0][1] - $pos);
$pos = $match[0][1] + \strlen($match[0][0]);
if (!\strlen($precedingText)) {
$precedingChar = '';
} elseif ($useUtf8) {
preg_match('/.$/u', $precedingText, $precedingChar);
$precedingChar = $precedingChar[0];
} else {
$precedingChar = substr($precedingText, -1);
}
$isSeparator = '' !== $precedingChar && str_contains(SEPARATORS,
$precedingChar);
if ($isSeparator && $precedingText !== $precedingChar) {
$tokens[] = ['text', substr($precedingText, 0, -\strlen($precedingChar))];
} elseif (!$isSeparator && '' !== $precedingText) {
$tokens[] = ['text', $precedingText];
}
$regexp = $requirements[$varName] ?? null;
if (null === $regexp) {
$followingPattern = substr($pattern, $pos);
$nextSeparator = findNextSeparator($followingPattern, $useUtf8);
$regexp = \sprintf(
'[^%s%s]+',
preg_quote($defaultSeparator),
$defaultSeparator !== $nextSeparator && '' !== $nextSeparator ?
preg_quote($nextSeparator) : ''
);
if (('' !== $nextSeparator &&
!preg_match('#^\{[\w\x80-\xFF]+\}#', $followingPattern)) || '' ===
$followingPattern) {
$regexp .= '+';
}
} else {
if (!preg_match('//u', $regexp)) {
$useUtf8 = false;
}
$regexp = transformCapturingGroupsToNonCapturings($regexp);
}
if ($important) {
$token = ['variable', $isSeparator ? $precedingChar : '', $regexp,
$varName, false, true];
} else {
$token = ['variable', $isSeparator ? $precedingChar : '', $regexp,
$varName];
}
$tokens[] = $token;
$variables[] = $varName;
}
if ($pos < \strlen($pattern)) {
$tokens[] = ['text', substr($pattern, $pos)];
}
// find the first optional token (a route with no defaults has none)
$firstOptional = \PHP_INT_MAX;
// compute the matching regexp
$regexp = '';
for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; ++$i) {
$regexp .= computeRegexp($tokens, $i, $firstOptional);
}
$regexp = '{^' . $regexp . '$}sD';
// enable Utf8 matching if really required
if ($needsUtf8) {
$regexp .= 'u';
for ($i = 0, $nbToken = \count($tokens); $i < $nbToken; ++$i) {
if ('variable' === $tokens[$i][0]) {
$tokens[$i][4] = true;
}
}
}
return ['regex' => $regexp, 'tokens' => array_reverse($tokens),
'variables' => $variables];
}
function findNextSeparator(string $pattern, bool|int $useUtf8): string
{
if ('' == $pattern) {
return '';
}
if ('' === $pattern = preg_replace('#\{[\w\x80-\xFF]+\}#', '',
$pattern)) {
return '';
}
if ($useUtf8) {
preg_match('/^./u', $pattern, $pattern);
}
return str_contains(SEPARATORS, $pattern[0]) ? $pattern[0] : '';
}
/**
* @param list<array<int, mixed>> $tokens
*/
function computeRegexp(array $tokens, int $index, int $firstOptional): string
{
$token = $tokens[$index];
if ('text' === $token[0]) {
return preg_quote($token[1]);
}
if (0 === $index && 0 === $firstOptional) {
return \sprintf('%s(?P<%s>%s)?', preg_quote($token[1]), $token[3],
$token[2]);
}
$regexp = \sprintf('%s(?P<%s>%s)', preg_quote($token[1]), $token[3], $token[2]);
if ($index >= $firstOptional) {
$regexp = "(?:$regexp";
$nbTokens = \count($tokens);
if ($nbTokens - 1 == $index) {
$regexp .= str_repeat(')?', $nbTokens - $firstOptional - (0 === $firstOptional
? 1 : 0));
}
}
return $regexp;
}
function transformCapturingGroupsToNonCapturings(string $regexp): string
{
for ($i = 0; $i < \strlen($regexp); ++$i) {
if ('\\' === $regexp[$i]) {
++$i;
continue;
}
if ('(' !== $regexp[$i] || !isset($regexp[$i + 2])) {
continue;
}
if ('*' === $regexp[++$i] || '?' === $regexp[$i]) {
++$i;
continue;
}
$regexp = substr_replace($regexp, '?:', $i, 0);
++$i;
}
return $regexp;
}
$paths = [
'/things/{code}' => ['code' => '[A-Za-z0-9._-]{1,64}'],
'/things/{code}/a' => ['code' => '[A-Za-z0-9._-]{1,64}'],
'/things/{code}/b' => ['code' => '[A-Za-z0-9._-]{1,64}'],
'/health' => [],
'/items/{id}' => ['id' => '\d+'],
'/items/{id}/c' => ['id' => '\d+'],
'/items/{id}/c/{sub}/d' => ['id' => '\d+', 'sub'
=> '[1-9]\d*'],
'/items/{id}/e' => ['id' => '\d+'],
'/jobs/{uuid}' => ['uuid' =>
'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'],
];
$rounds = (int) ($argv[1] ?? 1);
for ($r = 0; $r < $rounds; ++$r) {
foreach ($paths as $path => $requirements) {
compilePattern($path, $requirements, true);
}
}
echo "OK\n";
```