[php-src] Issue #22727: `RETURN` opcode for `return <multi-line conditional expression>;` is attributed to a line inside a never-executed branch, causing false-positive code coverage

[email protected] (sebastianbergmann) Tue, 14 Jul 2026 11:54:09 +0000
Newsgroups php.bugs
Message-ID <DrNvTQPdI6QlcyO0ikKvPxCv4FEIzMZ8904lOX188vI@main.internal.php.net>
Issue: https://github.com/php/php-src/issues/22727
Author: sebastianbergmann

### Description

When a `return` statement's expression is a multi-line conditional expression (`match`, ternary, ...),
the `ZEND_RETURN` and `ZEND_VERIFY_RETURN_TYPE` opcodes are not attributed to the line of the
`return` statement. Instead, they are attributed to whatever `CG(zend_lineno)` happens to be after
the expression has been compiled: the line of the last leaf of the last branch of the expression.

Because *every* branch of the conditional expression jumps to these shared opcodes, executing *any*
branch executes an opcode that is attributed to a line inside the *last* branch — even when that
branch was never executed. Line-based code coverage tools then report a line of a never-executed
branch as executed.

The following self-contained script reproduces the problem (requires Xdebug for the coverage part
only; the compiler defect itself is visible with phpdbg alone, see below):

#### `mre.php`
```php
<?php declare(strict_types=1);

function unescape(string $character): string
{
    return match ($character) {
        'n'     => "\n",
        default => throw new InvalidArgumentException(
            'Unknown escape sequence: ' .
            $character,
        ),
    };
}

xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);

unescape('n');

$coverage = xdebug_get_code_coverage()[__FILE__];

xdebug_stop_code_coverage();

ksort($coverage);

foreach ($coverage as $line => $status) {
    printf("%2d: %2d\n", $line, $status);
}
```

`unescape('n')` executes only the `'n'` arm on line 6. The `default` arm (lines 7–10) is never
executed.

Resulted in this output (`1` = executed, `-1` = executable but not executed, `-2` = dead code):

```
 6:  1
 7: -1
 9:  1
12: -2
16:  1
18:  1
```

Line 9, `$character,`, the last operand of the string concatenation inside the never-executed
`default` arm, is reported as executed.

But I expected this output instead:

```
 6:  1
 7: -1
 9: -1
12: -2
16:  1
18:  1
```

## Analysis

### Unoptimized bytecode (PHP 8.5.8, phpdbg)

`phpdbg -e mre.php`, then `print func unescape` (the `EXT_*` opcodes are phpdbg instrumentation
artifacts and not relevant):

```
unescape:
     ; (lines=21, args=1, vars=1, tmps=5)
     ; mre.php:3-12
L0003 0000 CV0($character) = RECV 1
L0006 0001 EXT_STMT
L0006 0002 T1 = IS_IDENTICAL CV0($character) string("n")
L0006 0003 JMPNZ T1 0005
L0006 0004 JMP 0007
L0006 0005 T2 = QM_ASSIGN string("\n")
L0006 0006 JMP 0016                                          ; 'n' arm jumps to the shared epilogue
L0007 0007 V3 = NEW 1 string("InvalidArgumentException")
L0009 0008 T4 = CONCAT string("Unknown escape sequence: ") CV0($character)
L0009 0009 SEND_VAL_EX T4 1
L0009 0010 EXT_FCALL_BEGIN
L0007 0011 DO_FCALL                                          ; correctly re-stamped to the call site line
L0009 0012 EXT_FCALL_END
L0009 0013 THROW V3
L0009 0014 T2 = QM_ASSIGN bool(true)
L0009 0015 JMP 0016
L0009 0016 VERIFY_RETURN_TYPE T2                             ; WRONG: should be L0005
L0009 0017 RETURN T2                                         ; WRONG: should be L0005
L0012 0018 EXT_STMT
L0012 0019 VERIFY_RETURN_TYPE
L0012 0020 RETURN null
```

Opcodes 0016 (`VERIFY_RETURN_TYPE`) and 0017 (`RETURN`) implement the `return` statement on
**line 5**, but they are attributed to **line 9**: the line of the last leaf compiled inside the
`match` expression (`$character` inside the `default` arm). The `JMP` of the `'n'` arm (op 0006)
targets op 0016, so executing the `'n'` arm executes two opcodes attributed to line 9, and line 9
is reported as covered.

The cause is that `zend_compile_return()` (and `zend_emit_return_type_check()`) emit their opcodes
via `zend_emit_op()`, which stamps `opline->lineno = CG(zend_lineno)` and `CG(zend_lineno)` has
been advanced by `zend_compile_expr()` to the line of the last-compiled leaf of the return
expression. `zend_compile_stmt()` did set `CG(zend_lineno)` to the `return` statement's line
before compiling it, but that value is overwritten while the expression is compiled and never
restored.

### What it should be

The compiler already restores line numbers in a comparable situation: `DO_FCALL` (op 0011 above)
is stamped with the line of the call site (`L0007`), not with the line of the last multi-line
argument. The `return` statement's opcodes should be treated the same way: stamped with the line
of the `return` statement itself:

```
L0005 0016 VERIFY_RETURN_TYPE T2
L0005 0017 RETURN T2
```

A possible fix is to remember the statement's line number (which `zend_compile_stmt()` has already
placed in `CG(zend_lineno)`) at the top of `zend_compile_return()` and restore it before emitting
`ZEND_VERIFY_RETURN_TYPE` / `ZEND_RETURN`.

With that attribution, the coverage output for the reproducing script becomes the expected one:
line 5 is reported as executed (correct: the `return` statement did execute) and line 9 stays
`-1` (correct: the `default` arm did not execute).

### Optimized bytecode (does not change the picture)

`opcache.opt_debug_level=0x30000` shows that the optimizer restructures the function (jump
threading, dead `JMP` removal), but the line attribution of the surviving `VERIFY_RETURN_TYPE` /
`RETURN` opcodes is unchanged. Running the reproducing script with
`-d opcache.enable=1 -d opcache.enable_cli=1` produces the identical (wrong) coverage output, so
this is purely a compiler issue, not an optimizer issue:

```
unescape:
     ; (lines=13, args=1, vars=1, tmps=3)
     ; (after optimizer)
0000 CV0($character) = RECV 1
0001 T1 = IS_IDENTICAL CV0($character) string("n")
0002 JMPZ T1 0005
0003 T1 = QM_ASSIGN string("\n")
0004 JMP 0011
0005 V2 = NEW 1 string("InvalidArgumentException")
0006 T3 = FAST_CONCAT string("Unknown escape sequence: ") CV0($character)
0007 SEND_VAL_EX T3 1
0008 DO_FCALL
0009 THROW V2
0010 T1 = QM_ASSIGN bool(true)
0011 VERIFY_RETURN_TYPE T1
0012 RETURN T1
```

(The `CONCAT` becoming `FAST_CONCAT` and, in variants using `sprintf()`, the compile-time
`sprintf()`-to-concatenation transformation new in PHP 8.5, are unrelated to this issue; the
problem reproduces identically with plain concatenation and on the unoptimized bytecode.)

### Xdebug reports truthfully

This is not an Xdebug bug. Xdebug's line coverage marks a line as executed when an opcode whose
`lineno` is that line is executed. For `unescape('n')` the executed opcodes are 0000–0006 and
0016–0017; the latter two carry `lineno` 9, so Xdebug correctly aggregates line 9 as executed.
Xdebug faithfully reports the line attribution it is given by the compiler. (PCOV behaves the
same, for the same reason.)

### Not specific to `match`

The same happens with any multi-line conditional expression as the `return` operand, e.g. a
ternary:

```php
function f(bool $b): string
{
    return $b ?
        'true' :
        sprintf(
            'false: %s',
            'x',
        );
}
```

`f(true)` executes only the true branch, yet line 9 (`'x',` inside the never-executed else branch)
is reported as executed, because `VERIFY_RETURN_TYPE`/`RETURN` are again stamped with the last
leaf's line:

```
 5:  1
 6:  1
 9:  1     <-- inside the never-executed else branch
```

The `match`-with-throwing-`default`-arm pattern is simply the most common real-world way to hit
this. It also reproduces with the real `ZEND_MATCH` jump-table opcode (five or more arms), not
just with the `IS_IDENTICAL` lowering shown above.

The problem disappears when the expression is assigned to a temporary variable first
(`$r = match (...) {...}; return $r;`).

### Real-world impact

Line-based code coverage tools consume this attribution and cannot correct it. In
phpunit/php-code-coverage, static analysis maps *every* line of a `match` arm's body to one
branch, and marks all lines of a branch as covered as soon as the coverage driver reports any one
of them as executed (this compensates for multi-line statements where only one line carries
opcodes). The single spuriously-executed line 9 therefore paints the *entire* never-executed
`default` arm (lines 7–10) green in the coverage report, while the sibling arms are correctly
shown as uncovered. Users see a never-executed `throw` reported as covered by their test suite.
(A corresponding issue is being filed with sebastianbergmann/php-code-coverage, referencing this
one.)

Related earlier report about the same class of line-attribution problem, for stack traces:
php/php-src#8810 ("Wrong line number reported in case of a multiline parameter list").


### PHP Version

```plain
PHP 8.5.8 (cli) (built: Jul 1 2026) — also verified with Zend OPcache 8.5.8 enabled and with
Xdebug 3.5.3.
```

### Operating System

Fedora 44 (PHP from Remi's RPM repository)