Skip to content

Tracing JIT with global register allocation corrupts UTF-8 scanning #24206

Description

@mercierj

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.php

opcache.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

  1. mercierj commented on Oct 8, 2026

    @mercierj
    Author

    Confirmed with an independent build: the same standalone reproducer also fails in the official php:8.5.11-cli image 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 Technologies
    

    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.php

    Result: the same invalid marker at index 19, exit status 1. Changing only opcache.jit=1255 to opcache.jit=disable produces PASS, exit status 0.

    AI-assisted follow-up; these additional commands were executed locally.

  2. ndossche commented on Oct 8, 2026

    @ndossche
    Member

    The issue isn't aarch64 specific, and doesn't seem related to #22115 on first sight

  3. ndossche commented on Oct 8, 2026

    @ndossche
    Member

    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...

  4. ndossche commented on Oct 8, 2026

    @ndossche
    Member

    Inside the JITted loop, $size is in a register.
    At the start of each loop iteration, the compiled code doesn't take $size from that register. It reads $size from memory.
    That's because $size from 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_STORE missing 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)) {
    
  5. 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
  6. mercierj commented on Oct 8, 2026

    @mercierj
    Author

    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-cli image 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 1255 20/20 fail, invalid marker at index 19 20/20 pass
    Symfony Mime 7.4.13 Subject encode/decode round-trip 1255 20/20 fail, imm=C3=3Fdiatdiate 20/20 pass
    Standalone script disable 20/20 pass 20/20 pass
    Symfony MIME round-trip disable 20/20 pass 20/20 pass
    Standalone script 1205 20/20 pass 20/20 pass
    Symfony MIME round-trip 1205 20/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.php

    Only opcache.jit changed for the controls. opcache_get_status(false)['jit'] confirmed enabled=true, on=true, kind=5, opt_level=5 for both binaries with 1255.

    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*.phpt tests with opcache.jit=1255 and 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.

  7. damonsson commented on Oct 11, 2026

    @damonsson

    Confirming this on x86_64 too, with a shorter reproducer that fails at the default JIT thresholds (no opcache.jit_hot_loop override).

    We hit it in production with Symfony Mime 8.1.7. Mail subjects with "ś" were encoded as nowo=C5=3Fci instead of nowo=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 default jit_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), got int(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 $size described 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.jit Result
    disable, 1205 (function), 1055, 1155 20/20 int(-1)
    1254 (tracing), 1255 20/20 int(4)

    The failure (int(4) with tracing, 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 with tracing, 1255, 1155 and disable. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions