Skip to content

Add PHPStan rules that find static state leaking between requests - #35

Merged
austinderrick merged 6 commits into
mainfrom
phpstan-static-state-rules
Nov 13, 2026
Merged

austinderrick merged 6 commits into
mainfrom
phpstan-static-state-rules

Conversation

@austinderrick

Copy link
Copy Markdown
Collaborator

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: a static $var inside 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(), a terminating() callback, a RequestReceived listener, or this plugin's own reset lists. The rules skip writes made while the worker boots, test code, values put back in a finally block, WeakMap properties, 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.php runs PHPStan on phpstan/fixtures and 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.
  • The fixtures report exactly the expected lines on a case-sensitive layout too: a git checkout of the plugin in a stock Winter 1.3 install, with the standalone PHPStan phar. extension.neon scans classes/ and attributes/ so PHPStan finds them without Composer.
  • The new code passes PHPStan at level 8 (without Larastan, which CI adds) and PHPCS. The rest of the suite matches main.
  • On a large Winter application, the rule found every static that its resetWorkerState() methods protect when those resets were removed.

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.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@github-actions
github-actions Bot dismissed their stale review November 13, 2026 15:05

Superseded by an approving review of 5591c62.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 try block, so an uninitialized typed-static read—or any other failure before entering try—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.

@austinderrick
austinderrick merged commit f69f228 into main Nov 13, 2026
9 checks passed
@austinderrick
austinderrick deleted the phpstan-static-state-rules branch November 13, 2026 15:07
@austinderrick austinderrick mentioned this pull request Nov 13, 2026
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.

1 participant