Skip to content

Do not infer by-ref use effects at invocations for a closure assigned to its own by-ref variable - #6818

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-08q3fet
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-08q3fet

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

With bleeding edge (closureSignaturesFromUsages), a self-referencing closure such as $fx = static function () use (&$fx) { … } saw $fx as null in its body whenever it reached itself in a way other than a direct $fx() call. The reported case is a nested closure that captures $fx and calls it. That produced false positives like "Trying to invoke null but it's not a callable."

The fix: a closure assigned to one of its own by-ref variables no longer gets by-ref markers. Its by-ref uses keep the creation-time fixpoint, which is the behaviour without the feature toggle.

Changes

  • src/Analyser/Generics/ClosureSignatureInference.php
    • getByRefUseMarkers() returns [] when the closure is assigned to one of its by-ref variables.
    • getAssignedClosures() takes a $nested flag (cached under a new NESTED_ASSIGNED_CLOSURES_ATTRIBUTE). With it, the scan also covers closures assigned inside nested function-likes. A closure created in another closure's body is observed through the enclosing function's frame, so the top-level-only map would miss it.
  • turbo-ext/src/ClosureSignatureInference.cpp and turbo-ext/src/generated/ClosureSignatureInference.h: the same change in the native mirror (new attribute string, private constant, nested parameter, self-assignment check). side-by-side.php pairs all methods, smoke.php passes, and the regression test passes with the extension loaded, including with the PHP change stashed.

Analogous cases, all fixed by the same check (each was null before):

  • nested closure capturing $fx by value or by reference
  • nested arrow function calling $fx()
  • call_user_func($fx)
  • $fx->__invoke()
  • aliasing ($alias = $fx; $alias();)
  • reading $fx without calling it
  • the same pattern inside another closure's body

Root cause

A closure written where nothing types it gets a marker per by-ref use. Its body is then entered with the by-ref variable's type at creation, joined with its types at the invocations that were seen. For $fx = function () use (&$fx) the creation state is before the assignment, so it is null. The only guard against this was syntactic: the body directly calling $fx(). Any other way of reaching the closure through $fx slipped past it.

I first tried extending that guard to follow captures into nested closures and arrow functions. It still missed aliasing, call_user_func, __invoke and plain reads. It also made mutual recursion through a captured variable less precise ((Closure)|null became null), so I dropped it in favour of checking the self-assignment directly.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15442.php: the reproducer from the issue, plus the nested arrow function, nested by-ref capture, call_user_func, __invoke, alias, read-only, passed-as-callback and inside-a-closure variants. A control checks that another closure's by-ref scalar is still followed at its invocations.
  • tests/PHPStan/Rules/Functions/CallCallablesRuleTest::testBug15442: no "Trying to invoke null" errors for the issue's code or the arrow-function variant.
  • Without the fix, the nsrt test fails on 7 assertions (null) and the rule test reports 2 errors.

Fixes phpstan/phpstan#15442

… to its own by-ref variable

- ClosureSignatureInference::getByRefUseMarkers() returns no markers for a
  closure that is the right side of an assignment to one of its by-ref uses
  (`$f = function () use (&$f)`). The body can reach the closure itself
  through that variable in ways the invocation tracking does not follow, so
  those by-ref uses keep the creation-time fixpoint. Before this, a body that
  only reached itself indirectly (a nested closure or arrow function capturing
  `$f`, an alias, `call_user_func($f)`, `$f->__invoke()`, or just reading `$f`)
  was entered with `$f` as it was before the assignment: `null`.
- getAssignedClosures() takes a `$nested` flag (cached under its own
  attribute) to also look inside nested function-likes. A closure created in
  another closure's body is observed through the enclosing function's frame.
- The turbo-ext C++ mirror (ClosureSignatureInference.cpp and its generated
  header) is updated the same way.
- Considered first: making the existing "body invokes its own by-ref
  variable" guard follow captures into nested closures and arrow functions.
  That fixes the reported nested case but misses aliasing, call_user_func,
  __invoke and plain reads. It also makes mutual recursion through a captured
  variable less precise, so I didn't use it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recursive function is analysed as null in some cases since v2.3.0

1 participant