Tracing JIT: writes to a parent's private property land in a child's shadowing public property
- Ngôn ngữ chính
- C
- Star
- 40.4k
- Fork
- 8.1k
- Merge trung bình
- 2 ngày 13 giờ
- Pull request đã merge (30 ngày)
- 96
Mô tả
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 ofwithHeader()ran as expected.- The parent's private
$headerscontains onlyaccept. TheHostentry written by
updateHostFromUri()is gone (correctly unset bywithHeader()), but the replacement
hostentry was never stored there. - The child's public
$headerscontains"host":["example.com"]— the value wrapped in an
array, which is the formwithHeader()produces, not the plain string the caller
assigned. That write went to the wrong slot. - The
acceptiteration of the very same loop, through the very samewithHeader(), 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
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Đánh giá
Issue này chưa được đánh giá.