1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Turbo;
4:
5: use Phar;
6: use function dirname;
7: use function file_get_contents;
8: use function get_cfg_var;
9: use function is_dir;
10: use function is_file;
11: use function is_string;
12: use function php_uname;
13: use function sprintf;
14: use function strpos;
15: use const PHP_DEBUG;
16: use const PHP_MAJOR_VERSION;
17: use const PHP_MINOR_VERSION;
18: use const PHP_OS_FAMILY;
19: use const PHP_VERSION_ID;
20: use const PHP_ZTS;
21:
22: /**
23: * Locates the distributed turbo extension binary matching the current runtime
24: * so it can be loaded via `-d extension=` — into spawned worker processes
25: * (ProcessHelper), or into the restarted main process (the turbo row of
26: * PHPStan\Process\Restart\ExtensionRegistry) whose pcntl_fork()ed workers then
27: * inherit it.
28: *
29: * The binaries are committed to the phpstan/phpstan repository next to
30: * phpstan.phar (turbo-ext/<platform>/phpstan_turbo-<minor>.so, .dll on
31: * Windows) by the phar.yml commit job, so they only exist for phar-based
32: * installations. Each is a thin per-PHP-version library loading the
33: * platform's shared core from its own directory (phpstan_turbo_core.so;
34: * phpstan_turbo_core.dll and phpstan_turbo_core-zts.dll on Windows, whose
35: * thread-safe PHP is a different DLL to link against), so both must be
36: * there —
37: * a source checkout loads its locally built extension through php.ini
38: * instead. Only non-debug builds for PHP >= MINIMUM_PHP_VERSION_ID are
39: * shipped; a ZTS variant (-zts filename
40: * suffix) exists for Windows, where XAMPP, WampServer and Scoop default
41: * to thread-safe PHP, and for Linux, where the official Docker images are
42: * thread-safe from 8.6 on (and the php:*-zts ones before) — a thread-safe
43: * PHP on macOS finds no binary and runs without the extension. Workers run the regular
44: * entrypoint, so TurboExtensionEnabler still gates activation on the
45: * expected extension version.
46: *
47: * bin/phpstan loads this class before the Composer autoloader (for the
48: * process restart), so it must only call functions native to PHP 7.4 - the
49: * symfony polyfills are not registered yet. PreAutoloadFilesTest guards this.
50: */
51: final class TurboExtensionSelector
52: {
53:
54: /**
55: * The oldest PHP the extension is built for — the phar.yml turbo-compile
56: * matrix. Older runtimes have no binary to find, so they skip the lookup
57: * (and with it the process restart) entirely.
58: */
59: public const MINIMUM_PHP_VERSION_ID = 80200;
60:
61: /** @api */
62: public const EXTENSION_PATH_INI = 'phpstan.turboExtensionPath';
63:
64: /**
65: * The extension path this process was given through -d — by the restart,
66: * or by ProcessHelper when spawned as a worker. Null when the extension
67: * came from the php.ini or is not loaded at all.
68: */
69: public static function getRestartExtensionPath(): ?string
70: {
71: $path = get_cfg_var(self::EXTENSION_PATH_INI);
72: if (!is_string($path) || $path === '') {
73: return null;
74: }
75:
76: return $path;
77: }
78:
79: /**
80: * The extension path a spawned worker needs as `-d extension=`, along with
81: * `-d` EXTENSION_PATH_INI with the same path. Null when the worker gets the
82: * extension from the php.ini it inherits, or when there is no extension.
83: *
84: * @api
85: */
86: public static function findExtensionForWorkers(?string $pharPath = null): ?string
87: {
88: if (TurboExtensionEnabler::isLoaded()) {
89: $restartPath = self::getRestartExtensionPath();
90: if ($restartPath !== null) {
91: // loaded through a -d flag — the restart's own, or the one
92: // ProcessHelper gave this process when spawning it as a worker:
93: // spawned workers do not inherit command-line -d flags, so they
94: // need it passed explicitly
95: return $restartPath;
96: }
97:
98: // loaded through php.ini — workers inherit the ini file
99: return null;
100: }
101:
102: return self::findExtension($pharPath);
103: }
104:
105: /**
106: * Whether PHPStan runs from a bare copy of phpstan.phar, without the
107: * turbo-ext/ directory the phpstan/phpstan Composer package ships next to
108: * it, on a runtime the extension is built for, and no php.ini loads the
109: * extension either.
110: */
111: public static function isMissingNextToPhar(): bool
112: {
113: $platformDirectory = self::getPlatformDirectoryNextToPhar();
114: if ($platformDirectory === null) {
115: return false;
116: }
117: if (TurboExtensionEnabler::isLoaded()) {
118: return false;
119: }
120:
121: return !is_dir(dirname($platformDirectory));
122: }
123:
124: /**
125: * Locates the distributed extension binary for the current platform —
126: * present only next to a phar-based installation.
127: *
128: * Another program that loads PHPStan's phar, Rector for example, passes
129: * the path to that phar: Phar::running() only knows the phar that runs.
130: */
131: public static function findExtension(?string $pharPath = null): ?string
132: {
133: $platformDirectory = self::getPlatformDirectoryNextToPhar($pharPath);
134: if ($platformDirectory === null) {
135: return null;
136: }
137:
138: $file = sprintf(
139: '%s/phpstan_turbo-%d.%d%s.%s',
140: $platformDirectory,
141: PHP_MAJOR_VERSION,
142: PHP_MINOR_VERSION,
143: (bool) PHP_ZTS ? '-zts' : '',
144: PHP_OS_FAMILY === 'Windows' ? 'dll' : 'so',
145: );
146: if (!is_file($file)) {
147: return null;
148: }
149:
150: // without its core next to it, PHP would warn at startup that it
151: // cannot load the extension
152: if (!is_file(dirname($file) . '/' . self::resolveCoreFileName(PHP_OS_FAMILY, (bool) PHP_ZTS))) {
153: return null;
154: }
155:
156: return $file;
157: }
158:
159: /**
160: * The directory of turbo-ext/ next to the running phar that holds the
161: * binaries for this platform, whether it exists or not. Null when no
162: * binary is built for this runtime: PHP older than the minimum, a debug
163: * build, a run from source, or a platform without binaries.
164: */
165: private static function getPlatformDirectoryNextToPhar(?string $pharPath = null): ?string
166: {
167: if (PHP_VERSION_ID < self::MINIMUM_PHP_VERSION_ID) {
168: return null;
169: }
170: if ((bool) PHP_DEBUG) {
171: return null;
172: }
173:
174: $pharPath ??= Phar::running(false);
175: if ($pharPath === '') {
176: return null;
177: }
178:
179: $platform = self::resolvePlatformDirectory(PHP_OS_FAMILY, php_uname('m'), self::isMusl());
180: if ($platform === null) {
181: return null;
182: }
183:
184: return sprintf('%s/turbo-ext/%s', dirname($pharPath), $platform);
185: }
186:
187: /**
188: * The shared core the extension binary loads, in the same directory.
189: */
190: public static function resolveCoreFileName(string $osFamily, bool $zts): string
191: {
192: if ($osFamily === 'Windows') {
193: return $zts ? 'phpstan_turbo_core-zts.dll' : 'phpstan_turbo_core.dll';
194: }
195:
196: // one core serves the thread-safe and the non-thread-safe extension:
197: // it reaches the engine's globals through the pointers the extension
198: // hands it, never through TSRM itself
199: return 'phpstan_turbo_core.so';
200: }
201:
202: public static function resolvePlatformDirectory(string $osFamily, string $machine, bool $isMusl): ?string
203: {
204: if ($osFamily === 'Darwin') {
205: // arm64 (Apple Silicon) only - there is no Intel build. An x86_64
206: // PHP under Rosetta reports x86_64 here as well, and cannot load
207: // the arm64 binary either.
208: return $machine === 'arm64' ? 'macos-arm64' : null;
209: }
210: if ($osFamily === 'Windows') {
211: return $machine === 'AMD64' || $machine === 'x86_64' ? 'windows-x86_64' : null;
212: }
213: if ($osFamily !== 'Linux') {
214: return null;
215: }
216:
217: $architecture = $machine === 'aarch64' ? 'arm64' : $machine;
218: if ($architecture !== 'x86_64' && $architecture !== 'arm64') {
219: return null;
220: }
221:
222: return sprintf('linux-%s-%s', $isMusl ? 'musl' : 'gnu', $architecture);
223: }
224:
225: /**
226: * libc has no PHP constant; this checks what loader is actually
227: * mapped into the running process rather than whether a musl loader
228: * merely exists somewhere on disk — a glibc host with musl-tools
229: * installed has one too, and would otherwise be misdetected as musl.
230: */
231: public static function isMusl(): bool
232: {
233: return self::resolveIsMusl(@file_get_contents('/proc/self/maps'), is_file('/etc/alpine-release'));
234: }
235:
236: public static function resolveIsMusl(string|false $selfMaps, bool $hasAlpineRelease): bool
237: {
238: if ($selfMaps !== false) {
239: // strpos() rather than str_contains(): this runs before the Composer
240: // autoloader, so the symfony polyfill is not loaded yet and
241: // str_contains() does not exist on PHP < 8.0
242: return strpos($selfMaps, '/ld-musl-') !== false;
243: }
244:
245: // no procfs to inspect (non-Linux, or a sandboxed container without
246: // /proc) — Alpine's own musl PHP packages are still worth detecting
247: return $hasAlpineRelease;
248: }
249:
250: }
251: