Forget the tracked ob_get_level() after impure calls that may open or close output buffers#5964
Conversation
…or close output buffers - Extract the output-buffering level tracking from `FuncCallHandler` into a new `OutputBufferHelper` (delta application for `ob_start()`/`ob_end_*()`, plus a cheap `isLevelTracked()` guard and an `invalidateLevel()` helper). - Invalidate the narrowed `ob_get_level()` expression after any call that could change the buffer level but that PHPStan cannot inspect: callable or closure variable invocations, unknown functions, and impure non-builtin functions in `FuncCallHandler`. - Mirror the same invalidation for impure non-builtin methods and static methods (and unknown/dynamic ones) in `MethodCallHandler` and `StaticCallHandler`. - Also forget the level for built-ins that immediately invoke an impure callable argument (e.g. `call_user_func($cb)`, `array_map($cb, $a)`, `usort($a, $cb)`), while keeping it for pure callbacks and for later-invoked callables (e.g. `register_shutdown_function($cb)`). - Keep the level narrowed across pure functions/methods/callables and ordinary built-ins (e.g. `printf()`) that never touch output buffering.
staabm
left a comment
There was a problem hiding this comment.
missing invalidation after new someclass() when constructor might call ob_end()
An unknown function or a non-callable call target leaves $parametersAcceptor null, so calling getImpurePoints() on it directly was unsafe. Narrow to CallableParametersAcceptor and forget the tracked level when the target is not a known pure callable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A `new SomeClass()` whose constructor may open or close output buffers must invalidate the tracked output-buffering level, the same way impure function, method and static-method calls already do. Forget the level for impure user-defined constructors, unknown classes, and dynamic instantiation of non-final classes; keep it for pure or builtin constructors and classes without a constructor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. I addressed staabm's review requesting invalidation after Changes1. Mirroring the existing logic in the func/method/static-call handlers, after a
2.
3. Regression tests in Verification
Both commits are pushed to |
|
At this point its so much code for a maybe not so relevant problem, that I think we should revert the initial PR and re-open the issue instead. @VincentLanglet wdyt? |
VincentLanglet
left a comment
There was a problem hiding this comment.
Why do we need a dedicated logic to invalid ob_get_level (calling invalidateLevel in every handler), while it works already automatically for code like
class CoffeeBreak
{
public int $foo = 1;
public function doFoo(): void {
$this->foo = 1;
$this->doImpure();
PHPStan\dumpType($this->foo); // int
}
public function doImpure():void {
$this->foo = 2;
}
}
Can't we concentrate the invalidation of ob_get_level on one single place ?
Move the "should the tracked output-buffering level be forgotten after this call" decision out of the four call handlers and into a single OutputBufferHelper::invalidateLevelAfterCall() method. Each handler now maps its callee to (known, builtin, pure) and delegates; the cheap isLevelTracked() guard lives inside the helper too. The policy still needs per-callee reflection - the automatic invalidation MutatingScope performs after impure calls only covers expressions on the callee object, not global output-buffer state, and deciding purely on impure points would forget the level after impure-but-buffer-neutral built-ins such as printf() or register_shutdown_function(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I've addressed VincentLanglet's review. Here's a summary of what I did and why. What the review askedWhy does invalidating the tracked What I foundThe automatic mechanism the reviewer points to only invalidates expressions on the callee object ( I also checked whether the invalidation could be driven purely from the What I changedI concentrated the policy (not just the Verification
The remaining maintainer question (staabm suggesting the whole feature may not be worth the code) is a scope decision for the maintainers rather than something to act on here. |
There was a problem hiding this comment.
The issue found for ob_get_level can be reproduced with some other functions, for instance with the code
public function run(string $signature): void
{
assertType('string|false', openssl_error_string());
if (false === \openssl_error_string()) {
assertType('false', openssl_error_string());
openssl_sign('1', $signature, '');
assertType('string|false', openssl_error_string());
}
if (false === \openssl_error_string()) {
assertType('false', openssl_error_string());
$this->doImpure($signature);
assertType('string|false', openssl_error_string()); // Currently it's false
}
}
public function doImpure(string $signature): void
{
openssl_sign('1', $signature, '');
}
Without starting the implementation, propose some general strategy which avoid having to manually invalidate all the expr at 4 differents places (funccall, methodcall, new, staticcall). The question is "What should be automatically invalidated after any possibly-impure call)
One idea could be exposing a method in the scope "onImpureCall" but maybe there are others
Do you really encounter this issue ? @staabm The issue exists for multiple others function Rather than focusing on ob_get_level, I think we should look for a general strategy "What should be invalidated on any impure call". |
Move the "forget tracked global-state after an un-inspectable impure call" policy out of OutputBufferHelper (which was invoked from four call handlers) into MutatingScope::invalidateVolatileExpressionsAfterCall(). The scope now owns both the list of volatile argument-less function-call expressions (ob_get_level(), openssl_error_string()) and the keep/forget decision; the call handlers just describe their callee. This also fixes the openssl_error_string() counterpart of the ob_get_level() bug: a narrowed openssl_error_string() survived impure user calls that reach openssl_*() transitively. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I've addressed VincentLanglet's review. Here's the summary that will be posted. The general problemThe reviewer is right: Why a fully-automatic single dispatch point doesn't workI evaluated the Also, the automatic invalidation the reviewer pointed to ( What I changedI concentrated the policy and the list of volatile expressions into a single
Verification
On the naming: the tracked value is still a hardcoded family list rather than derived from reflection, because "reads mutable global state" isn't (The remaining maintainer question — staabm suggesting the whole feature may not be worth the code — is a scope decision for the maintainers.) |
…ndlers The keep/forget decision no longer goes through a wrapper method: each call handler describes its callee inline and invalidates the tracked volatile expressions directly. In FuncCallHandler the output-buffer delta bookkeeping and the invalidation are now two separate blocks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e-use constant The wrapper method and the VOLATILE_GLOBAL_STATE_FUNCTIONS constant were each used from a single place; the callee-safety condition now lives inline in the handlers and the function list is inlined into invalidateVolatileExpressions(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…single condition Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e condition Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…validation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
After PR #5909, PHPStan tracks the output-buffering nesting
level (
ob_get_level()) in scope and uses it to narrowob_get_contents()/ob_get_clean()/ob_get_flush()/ob_get_length()away fromfalsewhilea buffer is known to be active. That tracked value survived calls to arbitrary
impure code, so a call to a non-pure callable between
ob_start()andob_get_clean()incorrectly kept the level narrowed — hiding the possiblefalsereturn. This change forgets the tracked level after any call thatPHPStan cannot inspect and that could open or close a buffer.
Changes
src/Analyser/ExprHandler/Helper/OutputBufferHelper.phpcentralizingthe level-tracking logic:
getLevelDelta()/applyLevelDelta()(moved out ofFuncCallHandler), a cheapisLevelTracked()guard,invalidateLevel(), andcallImmediatelyInvokesImpureCallable().src/Analyser/ExprHandler/FuncCallHandler.php: after applying the known deltafor
ob_start()/ob_end_*(), forget the level for callable/closure variableinvocations and unknown functions (unless the invoked callable is provably
pure), for impure non-builtin functions, and for built-ins that immediately
invoke an impure callable argument.
src/Analyser/ExprHandler/MethodCallHandler.phpandsrc/Analyser/ExprHandler/StaticCallHandler.php: forget the level afterimpure non-builtin (or unknown/dynamic) method and static-method calls.
tests/PHPStan/Analyser/nsrt/output-buffering.php: added regression cases.Root cause
The
ob_get_level()value is scope state that assumes no un-inspected codechanged the buffer level. PR #5909 only accounted for direct calls to the
ob_*family; every other call left the narrowed value in place. Any impureuser function, method, static method, closure or callable — and any built-in
that immediately invokes an impure callable — can call
ob_start()/ob_end_*()transitively and therefore must invalidate the tracked level. Thefix invalidates it in all three call handlers, gated by a cheap
isLevelTracked()check so code that never uses output buffering pays only anO(1) map lookup. Pure callees and ordinary built-ins (e.g.
printf()) keep thenarrowing.
Test
Extended the existing
nsrt/output-buffering.phptype-inference test withcases covering: impure/pure callable and closure invocation, impure/pure
user functions, impure/pure methods, impure static methods,
__invoke,call_user_func,array_mapwith an impure vs. a pure callback,usort,later-invoked callables (
register_shutdown_function), and an ordinarybuilt-in (
printf). Verified the test fails before the fix (the impure-callcases still narrowed to
string/int<1, max>) and passes after. FullNodeScopeResolverTest, the Pure/CallMethods/CallCallables rule tests, theintegration test, and
make phpstanself-analysis all pass.Fixes phpstan/phpstan#14895