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:
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
Description
I discovered an unexcepted behaviour for
guzzlehttp/psr7on production server.I'm not quite sure what happened, so I asked Claude to analyais the issue.
The following code:
Resulted in this output:
But I expected this output instead:
Analysis (by claude)
Walking
SubRequest::build()for the failing iteration, the expected end state of theparent's private
$headersis:updateHostFromUri()first writes['Host' => ['example.com']], thenwithHeader('host', ...)unsetsHostand writeshost, thenwithHeader('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.$headerscontains onlyaccept. TheHostentry written byupdateHostFromUri()is gone (correctly unset bywithHeader()), but the replacementhostentry was never stored there.$headerscontains"host":["example.com"]— the value wrapped in anarray, which is the form
withHeader()produces, not the plain string the callerassigned. That write went to the wrong slot.
acceptiteration of the very same loop, through the very samewithHeader(), storedcorrectly into the private slot.
So a single execution of
withHeader()performed itsunsetagainst the parent's privateproperty and its assignment against the child's public property.
hasHeader('Host')thenreturns
true(reading the intact$headerNames) whilegetHeader('Host')reads theprivate
$headers, finds nothing, and returnsnullfrom a function declared: array.Both classes flow through the same
withHeader()/getHeader()call sites here, whichappears 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:
opcache.jit=tracingopcache.jit=functionopcache.jit=disableopcache.enable_cli=0(OPcache off)SubRequest::$headersto$hdrsso it no longer shadows, nothing else changedThe 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
Operating System
No response