Repository navigation
Tracing JIT with global register allocation corrupts UTF-8 scanning #24206
Description
Activity
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.PHP 8.5.11 (cli) (built: Oct 6 2026 01:23:13) (NTS) Copyright (c) The PHP Group Built by https://github.com/docker-library/php Zend Engine v4.5.11, Copyright (c) Zend Technologies with Zend OPcache v8.5.11, Copyright (c), by Zend TechnologiesUsing 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.
The issue isn't aarch64 specific, and doesn't seem related to #22115 on first sight
This doesn't seem right:
exit_5: 0040/0038/11 CV0($string):string CV1($startOffset):int(type_only) CV3($strlen):int(r12:1) CV4($charPos):int(type_only) CV5($foundChars):int(type_only) CV6($invalid):bool CV7($i):int(rcx:1) CV8($char):string CV9($size):int(rdx:1) CV10($j):int(type_only)
Why on earth type_only ?? I have a suspicion but I need to double check...
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'sZREG_STOREmissing on the back-edge value.PoC fix, if possible please try this out:
diff --git a/ext/opcache/jit/zend_jit_trace.c b/ext/opcache/jit/zend_jit_trace.c index 5aa62d2e087..9e53faaf0e0 100644 --- a/ext/opcache/jit/zend_jit_trace.c +++ b/ext/opcache/jit/zend_jit_trace.c @@ -3290,6 +3290,7 @@ static zend_jit_reg_var* zend_jit_trace_allocate_registers(zend_jit_trace_rec *t RA_REG_FLAGS(def) |= ZREG_LOAD; RA_REG_FLAGS(use) |= ZREG_STORE; } else { + /* trace-entry value */ use = phi->sources[0]; if (zend_jit_var_supports_reg(ssa, use)) { ZEND_ASSERT(!RA_HAS_REG(use)); @@ -3298,6 +3299,8 @@ static zend_jit_reg_var* zend_jit_trace_allocate_registers(zend_jit_trace_rec *t count++; } else { RA_REG_FLAGS(def) |= ZREG_LOAD; + /* back-edge value */ + RA_REG_FLAGS(phi->sources[1]) |= ZREG_STORE; } } } else if (RA_HAS_REG(use)) {
- changed the title
[-]Tracing JIT with global register allocation corrupts UTF-8 scanning on aarch64[/-][+]Tracing JIT with global register allocation corrupts UTF-8 scanning[/+]on 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:./configure --disable-all --enable-cli --disable-cgi --disable-phpdbg --enable-mbstring --disable-mbregex make -j4
The patch applied with a 24-line offset, without changes to the patch. Each result below is from 20 fresh PHP processes:
Test JIT Unpatched Patched Standalone script from the issue 125520/20 fail, invalid marker at index 19 20/20 pass Symfony Mime 7.4.13 Subject encode/decode round-trip 125520/20 fail, imm=C3=3Fdiatdiate20/20 pass Standalone script disable20/20 pass 20/20 pass Symfony MIME round-trip disable20/20 pass 20/20 pass Standalone script 120520/20 pass 20/20 pass Symfony MIME round-trip 120520/20 pass 20/20 pass Both 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.
Reacted by Nora Dossche- linked a pull request that will close this issueFix GH-24206: Tracing JIT loses loop-carried CV when its loop PHI is reloaded from memory #24209
on Oct 8, 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.<?php // Walks a UTF-8 string character by character (the loop shape of // symfony/mime CharacterStream::getUtf8CharPositions). Returns the offset of // the first byte it takes for a stray continuation byte, or -1 if the string // is valid UTF-8. function firstStray(string $s): int { $len = ['a' => 1, 'b' => 1, 'c' => 1, 'n' => 1, 'o' => 1, 'w' => 1, "\xC5" => 2, "\x9B" => 0]; for ($i = 0, $n = strlen($s); $i < $n; ++$i) { $char = $s[$i]; $size = $len[$char]; if ($size === 0) { return $i; } for ($j = 1; $j < $size; ++$j) { $char = $s[$i + $j]; if (!($char > "\x7F" && $char < "\xC0")) { return -2; } } $i += $j - 1; } return -1; } // Make the loop hot on ASCII-only input; default opcache.jit_hot_loop is 61. for ($k = 0; $k < 100; ++$k) { firstStray('abc'); } var_dump(firstStray("now\xC5\x9Bc")); // "nowśc" is valid UTF-8: expected int(-1)
php -n -d opcache.enable_cli=1 -d opcache.file_update_protection=0 -d opcache.jit=tracing repro.php
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.jitResult disable,1205(function),1055,115520/20 int(-1)1254(tracing),125520/20 int(4)The failure (
int(4)withtracing,int(-1)with the JIT disabled) also reproduces on:- PHP 8.5.4 NTS, Ubuntu 26.04 package
php8.5-cli 8.5.4-0ubuntu1.3 - PHP 8.5.10 and 8.5.11 ZTS, static builds from pkg.henderkes.com
- PHP 8.5.11 NTS built from the php.net tarball (
--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_blacklist((new ReflectionMethod(Symfony\Component\Mime\CharacterStream::class, 'getUtf8CharPositions')) ->getClosure(new Symfony\Component\Mime\CharacterStream('')));
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.
- PHP 8.5.4 NTS, Ubuntu 26.04 package
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
CharacterStreamafter observing corrupted MIME Subject headers. The unmodified Symfony encoder producedimm=C3=3Fdiatdiateinstead ofimm=C3=A9diateforimmé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: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=0avoids 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:
Exit status: 0.
Actual output:
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:disable12551205(function JIT)Additional individual checks:
1254also fails;1055and1155pass. This points toward tracing JIT with global register allocation, but I have not identified the faulty compiler instruction. Replacing$i += $j - 1with the equivalent$i += $size - 1after 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
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