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