Skip to content

catch block discards type info learned earlier in the try body, resets to pre-try state #388

Description

@anasgets111

Verified on latest main

  • I have confirmed this bug still occurs when built from the latest main branch.

PHPantom version / commit

phpantom_lsp 0.10.0

Installation method

Pre-built binary from GitHub Releases

Operating system

Linux x86_64

Editor

Zed

Bug description

A variable reassigned by an immediately-invoked by-reference closure keeps its correct type right up until a catch block, where it reverts to whatever it was before the try started.

process_try() in control_flow.rs resets the catch scope to a clone of the pre-try scope on entry:

// Merge pre-try scope (since the exception could have
// been thrown at any point in the try body) with the
// catch variable.
*scope = pre_try_scope.clone();

That comment's premise is right, the exception could fire after any statement, but the fix throws out everything learned in the try body, including the by-ref closure widening added for #329 (commit 238413f). A variable that's definitely reassigned before the throw still reads as its pre-try type inside catch.

Expected: catch scope accounts for assignments in the try body, not just the state before it.
Actual: catch scope is an exact copy of pre-try scope, so any assignment before the throw is invisible there.

Steps to reproduce

  1. Create a file with the following content:
<?php
function process(): void
{
    $path = null;

    try {
        (function () use (&$path): void {
            $path = 'a';
        })();

        throw new RuntimeException('boom');
    } catch (\Throwable $e) {
        unlink($path);
    }
}
  1. Open diagnostics for the file.
  2. Expected: no diagnostic on unlink($path). $path is provably 'a' by that point.
  3. Actual: type_mismatch_argument on unlink($path), "expects string, got null".

Error output or panic trace

N/A. No crash. This is a false diagnostic, not a panic.

.phpantom.toml

default / no config file

Additional context

Related: #329 ("Value is still treated as empty after a non-empty check"), fixed by commit 238413f, which taught process_by_ref_closure_capture() in assignment.rs to union a by-ref closure's assigned type into the outer variable when invocation can't be proven immediate. That fix works outside try/catch. It doesn't survive a catch block because process_try() discards the try-body scope wholesale instead of merging it with the pre-try snapshot.

Suggested fix direction: build the catch scope as a union of the pre-try type and the end-of-try type per variable, instead of a hard reset to pre-try:

catch_scope[$v] = union(pre_try_scope[$v], try_body_end_scope[$v])

Still sound. The exception could have fired at any point, so the true state is somewhere between those two snapshots. It stops discarding real information the try body already established, though.

Found in a Laravel app while debugging a false positive on a DB::transaction() by-ref closure capture checked in a catch block. The repro above strips out the framework. The bug isn't Laravel-specific.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions