[php-src] Issue #22855: Virtual property hook returns adjacent property value instead of calling getter (JIT + asymmetric visibility)
[email protected] (zhaohao19941221) Wed, 22 Jul 2026 03:15:36 +0000
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/22855
Author: zhaohao19941221
### Description
# Virtual property hook returns adjacent property value instead of calling getter (JIT + asymmetric visibility)
## Description
When a class has a `public protected(set)` property (with asymmetric visibility) immediately followed by a virtual property hook (get-only, no backing store), accessing the virtual property returns the **value of the preceding property** instead of invoking the getter. The preceding property is a `LoggerInterface` object, but the virtual property is typed `string`.
This only happens when OPcache + JIT is enabled. Disabling JIT (`opcache.jit=disable`) or replacing the virtual property hook with a regular method resolves the issue.
This appears to be a variant of GH-17376 that was fixed in PHP 8.4.6, but the fix does not cover this specific combination of virtual property hooks + asymmetric visibility.
## PHP Version
```
PHP 8.4.23 (cli) (built: Jul 3 2026 15:02:10) (NTS)
Copyright (c) The PHP Group
Built by Alpine Linux aports
Zend Engine v4.4.23, Copyright (c) Zend Technologies
with Zend OPcache v8.4.23, Copyright (c), by Zend Technologies
```
## Operating System
Alpine Linux (Docker container)
## OPcache/JIT Configuration
```ini
opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=100M
opcache.jit=1205
```
## Minimal Reproduction
```php
<?php
// file: repro.php
// Run with: php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.jit_buffer_size=100M -d opcache.jit=1205 repro.php
interface LoggerInterface {
public function info(string $msg): void;
}
class StdoutLogger implements LoggerInterface {
public function info(string $msg): void {
echo "[LOG] $msg\n";
}
}
class ActConfig
{
// Property with asymmetric visibility (no default value)
public protected(set) LoggerInterface $logger;
// Virtual property hook (get-only, no backing store)
public string $filename {
get => self::buildFilename($this->serviceType, $this->actId);
}
protected mixed $prevCfg = null;
public function __construct(
public protected(set) string $serviceType,
public protected(set) string $actId
) {
$this->logger = new StdoutLogger();
}
public static function buildFilename(string $serviceType, string $actId): string
{
return "/tmp/act_{$serviceType}_{$actId}.yaml";
}
public function test(): void
{
// This should print a string like "/tmp/act_aqtw_DI0DD9PU.yaml"
// But with JIT enabled, it returns the StdoutLogger object from $this->logger
$filename = $this->filename;
echo "Type: " . gettype($filename) . "\n";
echo "Value: " . (is_string($filename) ? $filename : get_class($filename)) . "\n";
}
}
$config = new ActConfig('aqtw', 'DI0DD9PU');
$config->test();
```
## Expected Output
```
Type: string
Value: /tmp/act_aqtw_DI0DD9PU.yaml
```
## Actual Output (with JIT enabled)
```
PHP Fatal error: Uncaught TypeError: dumpToYaml(): Argument #2 ($filename) must be of type ?string, StdoutLogger given
```
Or in our real-world scenario, passing `$this->filename` to a function expecting `?string` receives the `StdoutLogger` object that lives in `$this->logger` (the immediately preceding property slot).
## Analysis
The issue seems to be that the JIT-compiled code for virtual property access incorrectly resolves the property slot. Instead of calling the `get` hook, it reads the memory offset of the **adjacent declared property** (`$logger`).
Key conditions to trigger:
1. `public protected(set)` asymmetric visibility on the property **before** the virtual property
2. The virtual property has only a `get` hook (no backing store)
3. Constructor uses promoted properties (`public protected(set) string $serviceType`)
4. JIT is enabled
## Workaround
Replace the virtual property hook with a regular method:
```php
// Before (broken with JIT):
public string $filename {
get => self::buildFilename($this->serviceType, $this->actId);
}
// After (works correctly):
public function getFilename(): string
{
return self::buildFilename($this->serviceType, $this->actId);
}
```
Alternatively, disabling JIT resolves the issue:
```ini
opcache.jit=disable
```
## Related Issues
- GH-17376 — *Broken JIT polymorphism for property hooks added to child class* (fixed in 8.4.6, but does not cover this case)
- GH-15834 — *Segfault with hook "simple get" cache slot and minimal JIT* (fixed in 8.4.6)
- GH-20479 — *Hooked object properties overflow* (fixed in 8.5.3)
### PHP Version
```plain
PHP 8.4.23 (cli) (built: Jul 3 2026 10:03:08) (NTS)
Copyright (c) The PHP Group
Built by Ubuntu
Zend Engine v4.4.23, Copyright (c) Zend Technologies
with Zend OPcache v8.4.23, Copyright (c), by Zend Technologies
```
### Operating System
_No response_