Repository navigation
Add PHPStan rules that find static state leaking between requests - #35
Conversation
phpstan/extension.neon adds winterOctane.staticPropertyNotReset, for a static property that request code writes and nothing clears before the next request, and winterOctane.staticVariable, for a static local inside a function. #[Winter\Octane\Attributes\WorkerScoped] marks a static that is safe to keep for the worker's life. tests/PHPStanRulesTest.php runs the rules on phpstan/fixtures.
…s out of the package
There was a problem hiding this comment.
Warning
Verdict: CHANGES NEEDED: see the findings below.
The new call-before-try and incremented-save fixtures address the exact examples from the previous review. The narrower statement window and expanded mutation tracking are improvements, but both exemptions still have reachable cases that hide retained request state.
1. A static-property read before try can still bypass cleanup
phpstan/FinallyRestoreCollector.php:176
isStraightLine() accepts static-property reads as unable to throw. Reading an uninitialized typed static property throws an Error, however:
// Declared as: private static ?Configuration $configuration; (no default)
self::$user = $user;
$configuration = self::$configuration;
try {
$this->work();
} finally {
self::$user = null;
}When configuration has not been initialized, the read throws before execution enters try. The user value remains for the next request, but the collector exempts its assignment because both preceding statements pass this check.
Conservatively stop the window at static-property reads unless their safety is established, or require the request-state write to be inside try. Add an uninitialized typed-static read fixture alongside the throwing-call regression.
2. Mutating an offset of a saved array still qualifies as restoration
phpstan/FinallyRestoreCollector.php:151
The new mutation helper explicitly ignores array-offset targets, even when their root is the saved local variable:
$previous = self::$context;
try {
$previous['user'] = $user;
self::$context = ['temporary' => true];
} finally {
self::$context = $previous;
}Starting with an empty context, this leaves the first request's user in self::$context for the next request. Nevertheless, $previous has a mutation count of one, so the collector treats the cleanup as restoration and suppresses the property writes.
Count writes and unsets through array offsets as mutations of their root local variable, and add a saved-array regression fixture.
Automated review of 9b8beab0a31f72749372def3c8026feaadc55d32 by openai/gpt-6.1-sol via OpenRouter. Request a review to run it again.
… accept FilterTokenParser::$blockSalt A reset that unsets one array offset no longer covers the whole property. A finally block now counts only when it clears the target or restores a value saved before the try block, and only for writes up to the end of that try block. Winter core added FilterTokenParser::$blockSalt (wintercms/winter#1595), a counter that keeps compiled filter block names unique.
…, and only for a saved value assigned once A write before the try block counts only when the statements between it and the try block are plain expressions, with no return, throw, exit or branch. A local variable counts as the saved value only when the function assigns it once, from the property. The collector now reads whole method and function bodies, so it is split into a method and a function collector.
…set() and list() as changes to a saved value A call between a write and its try block can throw, and then the finally block never runs, so only assignments of a variable, a constant or a static property count as straight-line code. Incrementing, decrementing, unsetting or destructuring into the saved variable now disqualifies it as the restored value.
… offset writes as changes to a saved value Any statement between a write and its try block can throw or return, even a read of an uninitialized typed static, so only writes inside the try block are covered. Writing to an offset or a property of the saved local variable now disqualifies it as the restored value.
Superseded by an approving review of 5591c62.
There was a problem hiding this comment.
Tip
Verdict: APPROVED: no blocking concerns from this pass.
Both findings from the previous review are addressed:
- Finally-based exemptions now apply only to writes inside the
tryblock, so an uninitialized typed-static read—or any other failure before enteringtry—cannot hide an uncleared write. - Mutation tracking now follows array-offset and object-property targets back to their root local variable, preventing a changed saved value from qualifying as restoration.
The new before-try and saved-array fixtures exercise these cases and require diagnostics at the unsafe writes. These changes resolve the earlier blocking concerns; my verdict changes to approval.
Automated review of 5591c62c0e9a24b36a60a40aa6f255e0d0223a28 by openai/gpt-6.1-sol via OpenRouter. Request a review to run it again.
Adds PHPStan rules that find static state a worker would carry from one request to the next. A project turns them on by including
phpstan/extension.neon:winterOctane.staticPropertyNotReset: request code writes a static property, and nothing clears it before the next request.winterOctane.staticVariable: astatic $varinside a function, which nothing outside the function can clear.A static property counts as cleared when one of these clears it: a plugin's
resetWorkerState(), aterminating()callback, aRequestReceivedlistener, or this plugin's own reset lists. The rules skip writes made while the worker boots, test code, values put back in afinallyblock,WeakMapproperties, and values computed only from code.#[Winter\Octane\Attributes\WorkerScoped('reason')]marks a static that is safe to keep. The README's new "Finding leaks with PHPStan" section covers the details.Testing
tests/PHPStanRulesTest.phpruns PHPStan onphpstan/fixturesand checks that it reports exactly the lines marked// expect:. The test fails when an expectation is wrong or when the plugin's reset lists are not read.extension.neonscansclasses/andattributes/so PHPStan finds them without Composer.main.resetWorkerState()methods protect when those resets were removed.