Uh oh!
There was an error while loading. Please reload this page.
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>
phpstan-bot
commented
Jul 2, 2026
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 |
staabm
commented
Jul 2, 2026
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>
phpstan-bot
commented
Jul 2, 2026
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
VincentLanglet
commented
Jul 2, 2026
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>
phpstan-bot
commented
Jul 2, 2026
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.) |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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>
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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>
Uh oh!
There was an error while loading. Please reload this page.
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.Fixesphpstan/phpstan#14895