Skip to content

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

Description

@vibbow

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

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions