Repository navigation
Tracing JIT with global register allocation corrupts UTF-8 scanning #24206
Description
Description
Disclosure: this report and its reduction were prepared with AI assistance. The commands and results below were executed locally.
Tracing JIT on Alpine Linux aarch64 produces an incorrect UTF-8 character map for valid input. The reproducer below is standalone PHP: it needs no Composer packages, framework, PHPUnit, database, or network.
This was reduced from Symfony Mime 7.4.13's CharacterStream after observing corrupted MIME Subject headers. The unmodified Symfony encoder produced imm=C3=3Fdiatdiate instead of imm=C3=A9diate for immédiate. The standalone reduction isolates the wrong character map before MIME encoding or decoding.
All three literal input strings are valid UTF-8. The lookup table has been reduced to the bytes used in those inputs. Save the following as repro.php:
<?php
// Reduced from Symfony Mime v7.4.13 CharacterStream (MIT).
// Copyright (c) Fabien Potencier and Symfony contributors.
// Standalone diagnostic: tracing JIT must not change valid UTF-8 into invalid input.
final class Utf8Scanner
{
private const UTF8_LENGTH_MAP = [
"\x20" => 1, "\x46" => 1, "\x53" => 1, "\x61" => 1, "\x63" => 1, "\x64" => 1,
"\x65" => 1, "\x67" => 1, "\x68" => 1, "\x69" => 1, "\x6c" => 1, "\x6d" => 1,
"\x6e" => 1, "\x6f" => 1, "\x72" => 1, "\x73" => 1, "\x74" => 1, "\x75" => 1,
"\x80" => 0, "\x93" => 0, "\xa7" => 0, "\xa8" => 0, "\xa9" => 0, "\xc3" => 2,
"\xe2" => 3,
];
/** @var array{p: array<int, int>, i: array<int, true>} */
public array $map = ['p' => [], 'i' => []];
public function scan(string $string, int $startOffset, string &$ignoredChars): int
{
$strlen = \strlen($string);
$charPos = \count($this->map['p']);
$foundChars = 0;
$invalid = false;
for ($i = 0; $i < $strlen; ++$i) {
$char = $string[$i];
$size = self::UTF8_LENGTH_MAP[$char];
if (0 == $size) {
/* char is invalid, we must wait for a resync */
$invalid = true;
continue;
}
if ($invalid) {
/* We mark the chars as invalid and start a new char */
$this->map['p'][$charPos + $foundChars] = $startOffset + $i;
$this->map['i'][$charPos + $foundChars] = true;
++$foundChars;
$invalid = false;
}
if (($i + $size) > $strlen) {
$ignoredChars = substr($string, $i);
break;
}
for ($j = 1; $j < $size; ++$j) {
$char = $string[$i + $j];
if ($char > "\x7F" && $char < "\xC0") {
// Valid - continue parsing
} else {
/* char is invalid, we must wait for a resync */
$invalid = true;
continue 2;
}
}
/* Ok we got a complete char here */
$this->map['p'][$charPos + $foundChars] = $startOffset + $i + $size;
$i += $j - 1;
++$foundChars;
}
return $foundChars;
}
}
$inputs = ['règlement', 'échéance – Société Française', 'régularisation immédiate'];
foreach ($inputs as $input) {
$scanner = new Utf8Scanner();
$ignored = '';
$scanner->scan($input, 0, $ignored);
if ($scanner->map['i']) {
echo "VALID UTF8 MARKED INVALID: ", json_encode($scanner->map), "\n";
exit(1);
}
}
echo "PASS\n";Run:
php -d opcache.enable_cli=1 \
-d opcache.file_update_protection=0 \
-d opcache.jit_buffer_size=128M \
-d opcache.jit=1255 \
-d opcache.jit_hot_loop=41 repro.phpopcache.file_update_protection=0 avoids skipping compilation of a freshly saved reproducer. The explicit hot-loop threshold makes the failure reproducible independently of preceding application/test execution.
Expected output and exit status:
PASS
Exit status: 0.
Actual output:
VALID UTF8 MARKED INVALID: {"p":[1,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26],"i":{"19":true}}
Exit status: 1. The invalid marker appears while scanning the third string, régularisation immédiate.
Controls and repeatability
Using the same script, PHP build, and command, changing only opcache.jit:
| JIT setting | Fresh processes tested | Result |
|---|---|---|
disable |
20 | 20 pass |
1255 |
20 | 20 fail |
1205 (function JIT) |
20 | 20 pass |
Additional individual checks: 1254 also fails; 1055 and 1155 pass. This points toward tracing JIT with global register allocation, but I have not identified the faulty compiler instruction. Replacing $i += $j - 1 with the equivalent $i += $size - 1 after successful continuation-byte validation did not remove the failure.
Possibly related: #22115 and its open fix #22132. I have not tested that patch or established that this is the same defect. Please treat this as an additional concrete reproducer if it is a duplicate.
PHP Version
PHP 8.5.11 (cli) (built: Sep 25 2026 15:57:29) (NTS)
Copyright (c) The PHP Group
Built by Alpine Linux aports
Zend Engine v4.5.11, Copyright (c) Zend Technologies
with Zend OPcache v8.5.11, Copyright (c), by Zend Technologies
The same corruption was also reproduced with PHP 8.5.10 on aarch64. Other architectures and an upstream development build have not been tested.
Operating System
Alpine Linux 3.24.2, aarch64, in a local Docker container. The original symptom was first observed on a Linux ARM64 CI runner.
Reproducer attribution
Reduced from Symfony Mime v7.4.13 CharacterStream, MIT licensed. The diagnosis and reduction used AI assistance; the commands and results above were executed locally.
Original MIT license notice
Copyright (c) 2010-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.
Activity
mercierj commentedon Oct 8, 2026
Confirmed with an independent build: the same standalone reproducer also fails in the official
php:8.5.11-cliimage on aarch64, not only the Alpine aports build.Public image digest:
sha256:01a109229f4465bc9ef042d9198f09a4d9da7775a825dbd8572dcdbd8756c4d3.Using the script from the issue body:
docker run --rm --network none \ --mount "type=bind,src=$PWD/repro.php,dst=/repro.php,readonly" \ php:8.5.11-cli \ php -d opcache.enable_cli=1 -d opcache.file_update_protection=0 \ -d opcache.jit_buffer_size=128M -d opcache.jit=1255 \ -d opcache.jit_hot_loop=41 /repro.phpResult: the same invalid marker at index 19, exit status 1. Changing only
opcache.jit=1255toopcache.jit=disableproducesPASS, exit status 0.AI-assisted follow-up; these additional commands were executed locally.
ndossche commentedon Oct 8, 2026
The issue isn't aarch64 specific, and doesn't seem related to #22115 on first sight
ndossche commentedon Oct 8, 2026
This doesn't seem right:
Why on earth type_only ?? I have a suspicion but I need to double check...
ndossche commentedon Oct 8, 2026
Inside the JITted loop,
$sizeis in a register.At the start of each loop iteration, the compiled code doesn't take
$sizefrom that register. It reads$sizefrom memory.That's because
$sizefrom before the loop isn't a single type ([null, long]) so it cannot be in a register there.But at the end of the loop iteration, right when it backedges, JIT doesn't write this back from register into memory... It's kept type-only.
And I believe the reason is that there's
ZREG_STOREmissing on the back-edge value.PoC fix, if possible please try this out:
[-]Tracing JIT with global register allocation corrupts UTF-8 scanning on aarch64[/-][+]Tracing JIT with global register allocation corrupts UTF-8 scanning[/+]mercierj commentedon Oct 8, 2026
Thanks @ndossche, I tested your exact PoC patch from #24206 (comment) and it fixes both the standalone reproducer and the Symfony MIME corruption in my tests.
I built PHP 8.5.11 from the source tarball bundled in the official
php:8.5.11-cliimage on Linux aarch64, saved the unpatched CLI binary, applied only your patch, and rebuilt with the same configuration:The patch applied with a 24-line offset, without changes to the patch. Each result below is from 20 fresh PHP processes:
12551255imm=C3=3Fdiatdiatedisabledisable12051205Both binaries were run with:
php -n -d opcache.enable_cli=1 -d opcache.file_update_protection=0 \ -d opcache.jit_buffer_size=128M -d opcache.jit=1255 \ -d opcache.jit_hot_loop=41 repro.phpOnly
opcache.jitchanged for the controls.opcache_get_status(false)['jit']confirmedenabled=true,on=true,kind=5,opt_level=5for both binaries with1255.The Symfony check alternates an ASCII subject and the original French subject containing
régularisation immédiate, for 100 iterations per process, and compares the decoded Subject to the exact input. No Symfony source changes were made.I also ran the bundled
ext/opcache/tests/jit/reg_alloc*.phpttests withopcache.jit=1255and a 128M JIT buffer: 23 passed, 0 failed, 1 skipped (32-bit-only test), with both binaries.I have not tested other architectures or the full PHP test suite. This follow-up was prepared with AI assistance; all builds and test results above were executed locally.
damonsson commentedon Oct 11, 2026
Confirming this on x86_64 too, with a shorter reproducer that fails at the default JIT thresholds (no
opcache.jit_hot_loopoverride).We hit it in production with Symfony Mime 8.1.7. Mail subjects with "ś" were encoded as
nowo=C5=3Fciinstead ofnowo=C5=9Bci. In both affected CLI runs it started with the third mail. The subject is encoded about four times per mail, so the loop crosses the defaultjit_hot_loop(61) around the third mail.Expected
int(-1), gotint(4). The lead byte"\xC5"is taken with the length of the previous character (1), so its continuation byte at offset 4 looks like a stray byte. This matches the missing back-edge store of$sizedescribed above. With 30 warm-up calls it passes, from about 60 on it fails every time.Results over 20 fresh processes per setting (PHP 8.5.10 ZTS):
opcache.jitdisable,1205(function),1055,1155int(-1)1254(tracing),1255int(4)The failure (
int(4)withtracing,int(-1)with the JIT disabled) also reproduces on:php8.5-cli 8.5.4-0ubuntu1.3--disable-all --enable-cli). Unpatched it fails as above. With the PoC patch from @ndossche above (applies with a 24-line offset) it passes 20/20 withtracing,1255,1155anddisable. The reproducer from the issue body and a Symfony Mime 8.1.7 subject round trip behave the same: fail unpatched, pass patched.x86_64 (AMD EPYC 4345P), Linux 7.0, Ubuntu 26.04.
Workaround until a release, in case it helps others: blacklist the affected method before it is first traced.
opcache.jit=1155(tracing with local register allocation) also avoids it.The diagnosis and the reduction were done with AI assistance. All commands and results above were run locally.