Skip to content

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

Description

@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 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:

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. See sebastianbergmann/php-code-coverage#1257 for details.

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

PHP Version

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)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions