php / php/php-src

Tracing JIT: writes to a parent's private property land in a child's shadowing public property

未关闭
#23,679 0 条评论 0 个 reaction 已指派 1 人 在 GitHub 查看

@iliaal 已经在做这个了。

开始于 2026年9月13日。

  • #23683 来自 @iliaal —— 未关闭
Bug Category: JIT Status: Verified
主要语言
C
星标
40.4k
派生
8.1k
平均合并
2 天 13 小时
30 天内合并 PR
96

描述

Description

I discovered an unexcepted behaviour for guzzlehttp/psr7 on production server.

I'm not quite sure what happened, so I asked Claude to analyais the issue.

The following code:

php -n -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M repro.php
<?php

trait MessageTrait
{
    private $headers = [];
    private $headerNames = [];

    public function hasHeader($h): bool
    {
        return isset($this->headerNames[strtolower($h)]);
    }

    public function getHeader($h): array
    {
        $h = strtolower($h);
        if (!isset($this->headerNames[$h])) {
            return [];
        }
        $h = $this->headerNames[$h];

        return $this->headers[$h];
    }

    public function getHeaderLine($h): string
    {
        return implode(', ', $this->getHeader($h));
    }

    public function withHeader($h, $v)
    {
        $v = [$v];
        $normalized = strtolower($h);

        $new = clone $this;
        if (isset($new->headerNames[$normalized])) {
            unset($new->headers[$new->headerNames[$normalized]]);   // hits the PRIVATE slot
        }
        $new->headerNames[$normalized] = $h;
        $new->headers[$h] = $v;                                     // hits the PUBLIC slot

        return $new;
    }

    private function setHeaders(array $headers): void
    {
        $this->headerNames = $this->headers = [];
        foreach ($headers as $h => $v) {
            $normalized = strtolower((string) $h);
            $this->headerNames[$normalized] = $h;
            $this->headers[$h] = [$v];
        }
    }
}

class Request
{
    use MessageTrait;

    private $uri;

    public function __construct(string $uri = '', array $headers = [])
    {
        $this->uri = $uri;
        $this->setHeaders($headers);
        if (!isset($this->headerNames['host'])) {
            $this->updateHostFromUri();
        }
    }

    public function withUri(string $uri)
    {
        $new = clone $this;
        $new->uri = $uri;
        $new->updateHostFromUri();

        return $new;
    }

    private function updateHostFromUri(): void
    {
        $host = $this->uri;
        if ($host === '') {
            return;
        }
        if (isset($this->headerNames['host'])) {
            $header = $this->headerNames['host'];
        } else {
            $header = 'Host';
            $this->headerNames['host'] = 'Host';
        }
        $this->headers = [$header => [$host]] + $this->headers;
    }
}

class SubRequest extends Request
{
    public $headers = [];   // shadows Request's private $headers

    public function build()
    {
        $request = clone $this;
        $request = $request->withUri('example.com');
        foreach ($this->headers as $k => $v) {
            $request = $request->withHeader($k, $v);
        }

        return $request;
    }
}

for ($i = 0; $i < 50000; $i++) {
    // The same call sites are exercised by BOTH the parent and the subclass.
    (new Request('example.com'))->withHeader('host', 'example.com')->getHeaderLine('Host');

    $sub = new SubRequest();
    $sub->headers = ['host' => 'example.com', 'accept' => 'application/json'];
    $built = $sub->build();

    try {
        $line = $built->getHeaderLine('Host');
    } catch (Throwable $e) {
        echo "FAILED at iteration $i\n  ", get_class($e), ': ', $e->getMessage(), "\n";
        $r = new ReflectionClass(Request::class);
        echo '  private Request::$headers     = ', json_encode($r->getProperty('headers')->getValue($built)), "\n";
        echo '  private Request::$headerNames = ', json_encode($r->getProperty('headerNames')->getValue($built)), "\n";
        echo '  public  SubRequest::$headers  = ', json_encode($built->headers), "\n";
        exit(1);
    }
    if ($line !== 'example.com') {
        echo "WRONG VALUE at $i: ", var_export($line, true), "\n";
        exit(1);
    }
}
echo "OK\n";

Resulted in this output:

Fails at iteration 21:

Warning: Undefined array key "host" in repro.php on line 37
FAILED at iteration 21
  TypeError: Request::getHeader(): Return value must be of type array, null returned
  private Request::$headers     = {"accept":["application\/json"]}
  private Request::$headerNames = {"host":"host","accept":"accept"}
  public  SubRequest::$headers  = {"host":["example.com"],"accept":"application\/json"}

But I expected this output instead:

OK

Analysis (by claude)

Walking SubRequest::build() for the failing iteration, the expected end state of the
parent's private $headers is:

['host' => ['example.com'], 'accept' => ['application/json']]

updateHostFromUri() first writes ['Host' => ['example.com']], then
withHeader('host', ...) unsets Host and writes host, then withHeader('accept', ...)
writes accept.

What actually happens:

  • $headerNames — private in the parent and not shadowed — is fully correct, including
    'host' => 'host'. So the bookkeeping half of withHeader() ran as expected.
  • The parent's private $headers contains only accept. The Host entry written by
    updateHostFromUri() is gone (correctly unset by withHeader()), but the replacement
    host entry was never stored there.
  • The child's public $headers contains "host":["example.com"] — the value wrapped in an
    array, which is the form withHeader() produces, not the plain string the caller
    assigned. That write went to the wrong slot.
  • The accept iteration of the very same loop, through the very same withHeader(), stored
    correctly into the private slot.

So a single execution of withHeader() performed its unset against the parent's private
property and its assignment against the child's public property. hasHeader('Host') then
returns true (reading the intact $headerNames) while getHeader('Host') reads the
private $headers, finds nothing, and returns null from a function declared : array.

Both classes flow through the same withHeader()/getHeader() call sites here, which
appears to be necessary: the parent and the child have to share the call site.

Control experiments

Same machine, same build, 20 runs each, one variable changed at a time:

Variable changed Reproduced
opcache.jit=tracing 20 / 20
opcache.jit=function 0 / 20
opcache.jit=disable 0 / 20
opcache.enable_cli=0 (OPcache off) 0 / 20
Rename SubRequest::$headers to $hdrs so it no longer shadows, nothing else changed 0 / 20

The rename is the decisive control: it removes the property shadowing and changes nothing
else about the program, and the failure disappears completely.

All testing was done on PHP 8.5.10; I make no claim about earlier branches either way.

PHP Version
* **PHP 8.5.10** (cli), NTS, Visual C++ 2022, x64, Windows 11 — reproduces
* **PHP 8.5.10** (fpm-fcgi), Linux — reproduces; this is where it was first hit
* Zend Engine v4.5.10, Zend OPcache v8.5.10
Operating System

No response

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

评估

这个 Issue 还没有评估数据。

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。