[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_