Skip to content

Commit bdb4580

Browse files
committed
Merge pull request #6327 from phpstan/turbo-trusted-types
Strip run-time type checks from PHPStan's own code with a turbo optimizer pass
2 parents b293f3c + 71fb593 commit bdb4580

16 files changed

Lines changed: 654 additions & 5 deletions

File tree

‎.github/workflows/phar.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@ jobs:
303303
- name: "Forked-worker exit (exitImmediately() ends a child whose teardown would wedge)"
304304
run: php -d extension="$RUNNER_TEMP/turbo-ext/modules/phpstan_turbo.so" turbo-ext/tests/exit-immediately.php
305305

306+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
307+
run: php -d extension="$RUNNER_TEMP/turbo-ext/modules/phpstan_turbo.so" -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
308+
306309
- name: "Signature parity (reflect native classes against the PHP twins)"
307310
run: php -d extension="$RUNNER_TEMP/turbo-ext/modules/phpstan_turbo.so" turbo-ext/tests/signature-parity.php
308311

@@ -494,6 +497,9 @@ jobs:
494497
- name: "Forked-worker exit (exitImmediately() ends a child whose teardown would wedge)"
495498
run: php -d extension="$PWD/turbo-ext/phpstan_turbo.so" turbo-ext/tests/exit-immediately.php
496499

500+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
501+
run: php -d extension="$PWD/turbo-ext/phpstan_turbo.so" -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
502+
497503
- name: "Signature parity (reflect native classes against the PHP twins)"
498504
run: php -d extension="$PWD/turbo-ext/phpstan_turbo.so" turbo-ext/tests/signature-parity.php
499505

@@ -607,6 +613,9 @@ jobs:
607613
- name: "Forked-worker exit (exitImmediately() ends a child whose teardown would wedge)"
608614
run: docker exec alpine-build php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/exit-immediately.php
609615

616+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
617+
run: docker exec alpine-build php -d extension=/work/turbo-ext/phpstan_turbo.so -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
618+
610619
- name: "Signature parity (reflect native classes against the PHP twins)"
611620
run: docker exec alpine-build php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/signature-parity.php
612621

@@ -948,6 +957,10 @@ jobs:
948957
shell: bash
949958
run: php -d extension="$TURBO_DLL" turbo-ext/tests/exit-immediately.php
950959

960+
- name: "Trusted types (the optimizer pass drops type checks under the prefix only)"
961+
shell: bash
962+
run: php -d extension="$TURBO_DLL" -d opcache.enable_cli=1 turbo-ext/tests/trusted-types.php
963+
951964
- name: "Signature parity (reflect native classes against the PHP twins)"
952965
shell: bash
953966
run: php -d extension="$TURBO_DLL" turbo-ext/tests/signature-parity.php

‎bin/phpstan‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use Symfony\Component\Console\Helper\ProgressBar;
3030
require_once __DIR__ . '/../src/Turbo/TurboProcessRestarter.php';
3131
TurboProcessRestarter::restartIfSuitable($_SERVER['argv']);
3232
TurboExtensionEnabler::enableIfLoaded();
33+
TurboExtensionEnabler::trustOwnTypesIfSuitable($_SERVER['argv']);
3334

3435
$analysisStartTime = microtime(true);
3536

‎build/phpstan.neon‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ parameters:
154154
identifier: shipmonk.deadMethod
155155
message: '#^Unused PHPStan\\Turbo\\TurboProcessRestarter::restartIfSuitable#'
156156
reportUnmatched: false
157+
-
158+
# called from bin/phpstan before the autoloader, outside the analysed paths
159+
identifier: shipmonk.deadMethod
160+
message: '#^Unused PHPStan\\Turbo\\TurboExtensionEnabler::trustOwnTypesIfSuitable#'
161+
reportUnmatched: false
157162
-
158163
# the turbo extension shadows this seam class; the nullable returns
159164
# are the contract of the pair, the PHP twin honestly never hits

‎src/Turbo/TurboDiagnoseExtension.php‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Turbo;
44

5+
use Phar;
56
use PHPStan\Command\Output;
67
use PHPStan\DependencyInjection\AutowiredService;
78
use PHPStan\Diagnose\DiagnoseExtension;
@@ -64,9 +65,28 @@ public function print(Output $output): void
6465
'<info>Turbo worker binary:</info> %s',
6566
$workerBinaryLine,
6667
));
68+
$output->writeLineFormatted(sprintf(
69+
'<info>Turbo trusted types:</info> %s',
70+
$this->describeTrustedTypes(),
71+
));
6772
$output->writeLineFormatted('');
6873
}
6974

75+
private function describeTrustedTypes(): string
76+
{
77+
if (TurboExtensionEnabler::isTrustingOwnTypes()) {
78+
return 'on (PHPStan\'s own argument and return type checks are dropped; --debug keeps them)';
79+
}
80+
if (!TurboExtensionEnabler::isActive()) {
81+
return 'off (extension inactive)';
82+
}
83+
if (Phar::running(false) === '') {
84+
return 'off (not running from a phar)';
85+
}
86+
87+
return 'off (--debug, or OPcache is not active)';
88+
}
89+
7090
private function describeStatus(?string $workerBinary): string
7191
{
7292
if (!TurboExtensionEnabler::isLoaded()) {

‎src/Turbo/TurboExtensionEnabler.php‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use function dirname;
99
use function extension_loaded;
1010
use function file_get_contents;
11+
use function in_array;
1112
use function is_file;
1213
use function json_decode;
1314
use function phpversion;
@@ -21,12 +22,14 @@ final class TurboExtensionEnabler
2122
* version is the short SHA of the last commit touching turbo-ext/src/,
2223
* enforced by the phar.yml turbo-version job.
2324
*/
24-
public const EXPECTED_EXTENSION_VERSION = 'dee1d70';
25+
public const EXPECTED_EXTENSION_VERSION = 'e598750';
2526

2627
private static bool $typeCombinatorCacheEnabled = false;
2728

2829
private static bool $enabled = false;
2930

31+
private static bool $trustingOwnTypes = false;
32+
3033
public static function isLoaded(): bool
3134
{
3235
return extension_loaded('phpstan_turbo');
@@ -159,4 +162,57 @@ public static function enableIfLoaded(): void
159162
self::$enabled = true;
160163
}
161164

165+
/**
166+
* Whether the extension drops the engine's argument and return type
167+
* checks from PHPStan's own code in this process, see
168+
* trustOwnTypesIfSuitable().
169+
*/
170+
public static function isTrustingOwnTypes(): bool
171+
{
172+
return self::$trustingOwnTypes;
173+
}
174+
175+
/**
176+
* PHPStan's code is verified by PHPStan itself at the strictest level, so
177+
* the engine's run-time checks of its parameter and return types re-check
178+
* what analysis already proved — at about 8% of the analysis CPU: a
179+
* class-typed parameter costs a class lookup and an instanceof on every
180+
* call, a typed return the same on the way out. With the extension active
181+
* and PHPStan running from a phar, its optimizer pass (TrustedTypes.cpp)
182+
* drops those checks from the code compiled out of the phar. Nothing else
183+
* is touched: extensions, bootstrap files and the analysed project keep
184+
* their checks, including on what they receive from PHPStan and return to
185+
* it — a check sits in the callee.
186+
*
187+
* What is lost is the TypeError at the boundary when such code passes a
188+
* wrong value into PHPStan: it surfaces later, deeper. That is why --debug
189+
* keeps the checks — the "run with --debug" advice on internal errors then
190+
* yields the original error. PHPUnit never gets here, so the test suites
191+
* of PHPStan and of extensions always run fully checked.
192+
*
193+
* Must run right after enableIfLoaded(), before the Composer autoloader
194+
* and preload.php are compiled: the pass rewrites scripts as they are
195+
* compiled, so whatever was compiled earlier keeps its checks.
196+
*
197+
* @param list<string> $argv
198+
*/
199+
public static function trustOwnTypesIfSuitable(array $argv): void
200+
{
201+
if (!self::$enabled) {
202+
return;
203+
}
204+
if (in_array('--debug', $argv, true)) {
205+
return;
206+
}
207+
if (!class_exists('Phar', false)) {
208+
return;
209+
}
210+
$pharPath = Phar::running(false);
211+
if ($pharPath === '') {
212+
return;
213+
}
214+
215+
self::$trustingOwnTypes = Runtime::trustTypesUnder('phar://' . $pharPath . '/');
216+
}
217+
162218
}

‎src/Turbo/TurboProcessRestarter.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ final class TurboProcessRestarter
6262

6363
private const OPCACHE_MAX_ACCELERATED_FILES_LIMIT = 20000;
6464

65+
/** PHP's default opcache.optimization_level, pinned so the optimizer (and the extension's pass in it) always runs */
66+
private const OPCACHE_OPTIMIZATION_LEVEL = '0x7FFEBFFF';
67+
6568
/** The php.ini directives resolveOpcacheArgs() reacts to */
6669
private const OPCACHE_INI_INPUTS = [
6770
'opcache.file_cache_only',
@@ -296,6 +299,10 @@ private static function getOpcacheArgs(): array
296299
* - opcache.save_comments is pinned on: stripping doc comments (a common
297300
* web tuning) breaks annotation readers in the project code the
298301
* extensions bootstrap, which worked with OPcache dormant.
302+
* - opcache.optimization_level is pinned to PHP's default: the
303+
* extension's pass dropping PHPStan's own type checks
304+
* (TurboExtensionEnabler::trustOwnTypesIfSuitable()) runs inside the
305+
* optimizer, which a php.ini can switch off entirely.
299306
*
300307
* Two configurations are left alone entirely — no OPcache entries at all,
301308
* so the restarted process runs with what the php.ini says, as before:
@@ -336,6 +343,7 @@ public static function resolveOpcacheArgs(array $ini): array
336343
'opcache.max_file_size=0',
337344
'opcache.file_cache=',
338345
'opcache.save_comments=1',
346+
'opcache.optimization_level=' . self::OPCACHE_OPTIMIZATION_LEVEL,
339347
'opcache.memory_consumption=' . $memory,
340348
'opcache.interned_strings_buffer=' . $internedStrings,
341349
'opcache.max_accelerated_files=' . $files,

‎tests/PHPStan/Turbo/TurboProcessRestarterTest.php‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ final class TurboProcessRestarterTest extends PHPStanTestCase
2020
'opcache.max_file_size=0',
2121
'opcache.file_cache=',
2222
'opcache.save_comments=1',
23+
'opcache.optimization_level=0x7FFEBFFF',
2324
'opcache.memory_consumption=256',
2425
'opcache.interned_strings_buffer=64',
2526
'opcache.max_accelerated_files=20000',
@@ -101,9 +102,9 @@ public function testResolveOpcacheArgs(array $ini, array $expected): void
101102
private static function withSizes(int $memory, int $interned, int $files): array
102103
{
103104
$args = self::STOCK_ARGS;
104-
$args[9] = 'opcache.memory_consumption=' . $memory;
105-
$args[10] = 'opcache.interned_strings_buffer=' . $interned;
106-
$args[11] = 'opcache.max_accelerated_files=' . $files;
105+
$args[10] = 'opcache.memory_consumption=' . $memory;
106+
$args[11] = 'opcache.interned_strings_buffer=' . $interned;
107+
$args[12] = 'opcache.max_accelerated_files=' . $files;
107108

108109
return $args;
109110
}
@@ -124,13 +125,21 @@ public static function dataResolveOpcacheRestartNeeded(): iterable
124125
'opcache.max_file_size' => '0',
125126
'opcache.file_cache' => '',
126127
'opcache.save_comments' => '1',
128+
'opcache.optimization_level' => '0x7FFEBFFF',
127129
'opcache.memory_consumption' => '256',
128130
'opcache.interned_strings_buffer' => '64',
129131
'opcache.max_accelerated_files' => '20000',
130132
];
131133

132134
yield 'already in effect' => [$inEffect, false];
133135

136+
// the optimizer switched off in php.ini would also switch off the
137+
// extension's trusted-types pass, which runs inside it
138+
yield 'optimizer disabled' => [
139+
['opcache.optimization_level' => '0'] + $inEffect,
140+
true,
141+
];
142+
134143
// php.ini spellings the ini parser leaves as-is versus its "1"/"" for booleans
135144
yield 'already in effect, php.ini spellings' => [
136145
['opcache.enable' => 'On', 'opcache.validate_timestamps' => '', 'opcache.jit' => 'Disable'] + $inEffect,

‎turbo-ext/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ their threads, so PHP's full teardown can wedge in a fork-unsafe extension's
8888
module shutdown (ext-grpc without `grpc.enable_fork_support`); a forked child
8989
that does not exec() must `_exit()` instead.
9090

91+
`Runtime::trustTypesUnder()` is the one entry point that changes how PHPStan's
92+
own code runs rather than replacing it: it arms an opcache optimizer pass
93+
(`TrustedTypes.cpp`) that drops the engine's argument and return type checks
94+
from scripts compiled under the given prefix — the running phar, passed by
95+
`TurboExtensionEnabler::trustOwnTypesIfSuitable()`. PHPStan's code is verified
96+
by PHPStan, so those checks re-check what analysis proved, at about 8% of the
97+
analysis CPU. Nothing outside the prefix is touched, and a check sits in the
98+
callee, so extensions and bootstrapped code keep checking what PHPStan hands
99+
them; what goes is the TypeError at the boundary when *they* pass PHPStan a
100+
wrong value — a `--debug` run keeps the checks for exactly that. Float-coercing
101+
signatures, typed variadics and typed property writes stay checked
102+
(`TrustedTypes.cpp` explains each); `tests/trusted-types.php` pins the
103+
behaviour.
104+
91105
The extension is version-pinned (`TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION`);
92106
a mismatched extension is ignored. There is no runtime kill switch — the only
93107
way to run without it is not to load it.

‎turbo-ext/analysis-stub.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public static function enablePharForkGuard(string $pharPath): void
2323
{
2424
}
2525

26+
public static function trustTypesUnder(string $prefix): bool
27+
{
28+
}
29+
2630
public static function exitImmediately(): never
2731
{
2832
}

‎turbo-ext/config.w32‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ if (PHP_PHPSTAN_TURBO != "no") {
2929
// directory by splitting on backslashes only — with forward slashes the
3030
// objects compile flat while the link list expects the subpath
3131
EXTENSION("phpstan_turbo",
32-
"src\\main.cpp src\\support.cpp src\\ArenaCache.cpp src\\CombinationsHelper.cpp src\\ConditionalExpressionHolder.cpp src\\ExpressionResultStorage.cpp src\\ExpressionTypeHolder.cpp src\\NodeScanner.cpp src\\NodeTraverser.cpp src\\PharForkGuard.cpp src\\PhpFileCleaner.cpp src\\ScopeOps.cpp src\\SymbolFinderInFiles.cpp src\\TrinaryLogic.cpp src\\TypeCombinatorCache.cpp",
32+
"src\\main.cpp src\\support.cpp src\\ArenaCache.cpp src\\CombinationsHelper.cpp src\\ConditionalExpressionHolder.cpp src\\ExpressionResultStorage.cpp src\\ExpressionTypeHolder.cpp src\\NodeScanner.cpp src\\NodeTraverser.cpp src\\PharForkGuard.cpp src\\PhpFileCleaner.cpp src\\ScopeOps.cpp src\\SymbolFinderInFiles.cpp src\\TrinaryLogic.cpp src\\TrustedTypes.cpp src\\TypeCombinatorCache.cpp",
3333
true,
3434
flags);
3535
ADD_SOURCES(configure_module_dirname + "/src/parser",

0 commit comments

Comments
 (0)