- Add BUILD_MODE_SAPI constant and related methods for SAPI-specific builds - Introduce sapiTargets, sapiEntryFile, and other SAPI-related configuration options - Enhance Android platform with allowTargetVersion parameter in buildPhpIncludePaths - Reorganize imports in CompilerBase with better grouping and ordering - Add isSapiBuild() and getSapiTargets() methods for SAPI detection and management - Update build mode logic to include SAPI in embed builds - Refine expression type checking with shorter namespace references - Add comprehensive SAPI build tests including target configuration scenarios - Improve class property access in tests with parent class traversal support - Standardize namespace separator handling in various compiler operations - Add proper escaping and formatting in generated C++ code strings - Implement SAPI-specific project parsing and validation rules - Update test assertions to handle new namespace separator format - Add error handling improvements for unsupported source conditions - Enhance type checker generatormaster
parent
43e85b8866
commit
3b33d79109
29 changed files with 2440 additions and 335 deletions
@ -0,0 +1,55 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\OfficialPhpSource; |
||||
|
||||
final class OfficialPhpSourceTest extends TestCase |
||||
{ |
||||
public function testDefaultCacheUsesTypePhpHomeDirectory(): void |
||||
{ |
||||
self::assertSame('/home/example/.typephp', OfficialPhpSource::defaultCacheDirectory('/home/example')); |
||||
} |
||||
|
||||
public function testVersionIsReadFromSourceHeader(): void |
||||
{ |
||||
$directory = sys_get_temp_dir() . '/typephp-source-version-' . bin2hex(random_bytes(6)); |
||||
mkdir($directory . '/main', 0777, true); |
||||
file_put_contents($directory . '/main/php_version.h', '#define PHP_VERSION "8.4.14"'); |
||||
try { |
||||
self::assertSame('8.4.14', OfficialPhpSource::version($directory)); |
||||
} finally { |
||||
unlink($directory . '/main/php_version.h'); |
||||
rmdir($directory . '/main'); |
||||
rmdir($directory); |
||||
} |
||||
} |
||||
|
||||
public function testCachedMinorReleaseIsUsedWithoutNetworkAccess(): void |
||||
{ |
||||
$directory = sys_get_temp_dir() . '/typephp-source-cache-' . bin2hex(random_bytes(6)); |
||||
foreach (['8.4.9', '8.4.25'] as $version) { |
||||
$source = $directory . '/src/php-' . $version; |
||||
foreach (['main', 'Zend', 'ext/standard'] as $child) { |
||||
mkdir($source . '/' . $child, 0777, true); |
||||
} |
||||
foreach (['configure', 'main/php.h', 'main/php_version.h', 'Zend/zend.h', 'ext/standard/fsock.h'] as $file) { |
||||
touch($source . '/' . $file); |
||||
} |
||||
} |
||||
try { |
||||
$manager = new OfficialPhpSource($directory, static function (string $message): void {}); |
||||
self::assertSame($directory . '/src/php-8.4.25', $manager->prepare('8.4')); |
||||
} finally { |
||||
$iterator = new \RecursiveIteratorIterator( |
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), |
||||
\RecursiveIteratorIterator::CHILD_FIRST, |
||||
); |
||||
foreach ($iterator as $entry) { |
||||
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||
} |
||||
rmdir($directory); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,105 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\SapiApplicationLinker; |
||||
|
||||
/** |
||||
* @internal |
||||
* @coversNothing |
||||
*/ |
||||
final class SapiApplicationLinkerTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp-sapi-linker-' . uniqid('', true); |
||||
mkdir($this->directory . '/php/sapi/cli', 0777, true); |
||||
file_put_contents( |
||||
$this->directory . '/php/sapi/cli/php_cli.c', |
||||
<<<'C' |
||||
#include "php.h" |
||||
static zend_result cli_seek_file_begin(zend_file_handle *file_handle, char *script_file) |
||||
{ |
||||
} |
||||
int main(int argc, char *argv[]) |
||||
{ |
||||
int ini_ignore = 0; |
||||
sapi_module_struct *sapi_module_ptr = &cli_sapi_module; |
||||
argv = save_ps_args(argc, argv); |
||||
cleanup_ps_args(argv); |
||||
} |
||||
C, |
||||
); |
||||
file_put_contents( |
||||
$this->directory . '/php/sapi/cli/php_cli_server.c', |
||||
<<<'C' |
||||
#include "php.h" |
||||
static void translate(void) |
||||
{ |
||||
php_sys_stat(buf, &sb); |
||||
php_sys_stat(buf, &sb); |
||||
} |
||||
C, |
||||
); |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
$iterator = new \RecursiveIteratorIterator( |
||||
new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), |
||||
\RecursiveIteratorIterator::CHILD_FIRST, |
||||
); |
||||
foreach ($iterator as $entry) { |
||||
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||
} |
||||
rmdir($this->directory); |
||||
} |
||||
|
||||
public function testCliAdapterPrependsEmbeddedEntryToSavedArguments(): void |
||||
{ |
||||
$entry = $this->directory . '/project/index.php'; |
||||
$linker = new SapiApplicationLinker( |
||||
$this->directory . '/php', |
||||
$this->directory . '/php-build', |
||||
$this->directory . '/libphpx.a', |
||||
[], |
||||
$this->directory . '/project-build', |
||||
['cli'], |
||||
$entry, |
||||
static function (string $message): void {}, |
||||
); |
||||
$method = new \ReflectionMethod($linker, 'generateSapiAdapters'); |
||||
$method->invoke($linker, 'sapi/cli/php_cli.lo', ''); |
||||
|
||||
$sources = glob($this->directory . '/project-build/cache/sapi/adapters/*/php_cli.c'); |
||||
self::assertIsArray($sources); |
||||
self::assertCount(1, $sources); |
||||
$code = (string) file_get_contents($sources[0]); |
||||
self::assertStringContainsString('typephp_saved_argv = argv;', $code); |
||||
self::assertStringContainsString( |
||||
'typephp_cli_prepare_arguments(&argc, &argv, OPTIONS, &typephp_application_argv)', |
||||
$code, |
||||
); |
||||
self::assertStringContainsString('typephp_cli_release_arguments(typephp_application_argv);', $code); |
||||
self::assertStringContainsString('cleanup_ps_args(typephp_saved_argv);', $code); |
||||
self::assertStringNotContainsString('php_getopt(', $code); |
||||
self::assertStringNotContainsString('typephp_entry_script', $code); |
||||
|
||||
$serverSources = glob($this->directory . '/project-build/cache/sapi/adapters/*/php_cli_server.c'); |
||||
self::assertIsArray($serverSources); |
||||
self::assertCount(1, $serverSources); |
||||
$serverCode = (string) file_get_contents($serverSources[0]); |
||||
self::assertSame(2, substr_count($serverCode, 'typephp_cli_server_stat(buf, &sb)')); |
||||
self::assertStringContainsString('typephp_embedded_path_kind(path)', $serverCode); |
||||
self::assertStringNotContainsString('php_sys_stat(buf, &sb)', $serverCode); |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\SapiBuildConfiguration; |
||||
|
||||
final class SapiBuildConfigurationTest extends TestCase |
||||
{ |
||||
public function testTargetsAcceptSingleBothAndLists(): void |
||||
{ |
||||
self::assertSame(['cli'], SapiBuildConfiguration::parseTargets('cli')); |
||||
self::assertSame(['cli', 'fpm'], SapiBuildConfiguration::parseTargets('both')); |
||||
self::assertSame(['fpm', 'cli'], SapiBuildConfiguration::parseTargets(['fpm', 'cli', 'fpm'])); |
||||
self::assertSame(['cli', 'fpm'], SapiBuildConfiguration::parseTargets('cli, fpm')); |
||||
} |
||||
|
||||
public function testInvalidTargetIsRejected(): void |
||||
{ |
||||
$this->expectException(\InvalidArgumentException::class); |
||||
$this->expectExceptionMessage('Expected cli, fpm, or both'); |
||||
SapiBuildConfiguration::parseTargets('apache'); |
||||
} |
||||
} |
||||
@ -0,0 +1,102 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Tests\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\PhpSourceExtensionIndex; |
||||
use TypePhp\Build\SapiExtensionConfiguration; |
||||
use TypePhp\Build\SapiExtensionRequirements; |
||||
|
||||
/** |
||||
* @internal |
||||
* @coversNothing |
||||
*/ |
||||
final class SapiExtensionRequirementsTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp-sapi-ext-' . bin2hex(random_bytes(6)); |
||||
mkdir($this->directory, 0777, true); |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
if (is_dir($this->directory)) { |
||||
exec('rm -rf ' . escapeshellarg($this->directory)); |
||||
} |
||||
} |
||||
|
||||
public function testComposerRequireExtensionsAreNormalizedAndSorted(): void |
||||
{ |
||||
mkdir($this->directory . '/vendor/package/name', 0777, true); |
||||
file_put_contents($this->directory . '/vendor/autoload.php', '<?php'); |
||||
$embedded = $this->directory . '/vendor/package/name/file.php'; |
||||
file_put_contents($embedded, '<?php'); |
||||
file_put_contents($this->directory . '/composer.json', json_encode([ |
||||
'require' => [ |
||||
'php' => '^8.4', |
||||
'ext-Zend-OPcache' => '*', |
||||
'ext-pdo_mysql' => '*', |
||||
], |
||||
'require-dev' => ['ext-xdebug' => '*'], |
||||
], JSON_THROW_ON_ERROR)); |
||||
|
||||
self::assertSame( |
||||
['opcache', 'pdo-mysql'], |
||||
SapiExtensionRequirements::fromEmbeddedVendorFiles([$embedded]), |
||||
); |
||||
} |
||||
|
||||
public function testComposerOutsideEmbeddedVendorIsIgnored(): void |
||||
{ |
||||
file_put_contents($this->directory . '/composer.json', json_encode([ |
||||
'require' => ['ext-curl' => '*'], |
||||
], JSON_THROW_ON_ERROR)); |
||||
$application = $this->directory . '/index.php'; |
||||
file_put_contents($application, '<?php'); |
||||
|
||||
self::assertSame([], SapiExtensionRequirements::fromEmbeddedVendorFiles([$application])); |
||||
} |
||||
|
||||
public function testPhpSourceConfigureSwitchesFollowExtensionConfig(): void |
||||
{ |
||||
mkdir($this->directory . '/ext/curl', 0777, true); |
||||
mkdir($this->directory . '/ext/mbstring', 0777, true); |
||||
file_put_contents($this->directory . '/ext/curl/config.m4', 'PHP_ARG_WITH([curl], [curl])'); |
||||
file_put_contents($this->directory . '/ext/mbstring/config.m4', 'PHP_ARG_ENABLE([mbstring], [mb])'); |
||||
|
||||
self::assertSame( |
||||
['--with-curl', '--enable-mbstring'], |
||||
SapiExtensionConfiguration::configureOptions( |
||||
$this->directory, |
||||
['curl', 'Core', 'mbstring'], |
||||
), |
||||
); |
||||
} |
||||
|
||||
public function testPhpSourceStubsProvideFunctionAndClassOwnership(): void |
||||
{ |
||||
mkdir($this->directory . '/ext/example', 0777, true); |
||||
file_put_contents($this->directory . '/ext/example/example.stub.php', <<<'PHP' |
||||
<?php |
||||
namespace { |
||||
function example_open(): void {} |
||||
} |
||||
namespace Example { |
||||
class Client {} |
||||
} |
||||
PHP); |
||||
|
||||
$index = PhpSourceExtensionIndex::forSource($this->directory); |
||||
self::assertSame('example', $index->functionExtension('example_open')); |
||||
self::assertSame('example', $index->classExtension('Example\Client')); |
||||
} |
||||
} |
||||
@ -0,0 +1,238 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
final class OfficialPhpSource |
||||
{ |
||||
private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100'; |
||||
private readonly \Closure $output; |
||||
|
||||
/** @param callable(string):void $output */ |
||||
public function __construct( |
||||
private readonly string $cacheDirectory, |
||||
callable $output, |
||||
) { |
||||
$this->output = \Closure::fromCallable($output); |
||||
} |
||||
|
||||
public static function defaultCacheDirectory(?string $home = null): string |
||||
{ |
||||
$home ??= getenv('HOME') ?: ''; |
||||
if ($home === '') { |
||||
throw new \RuntimeException('Cannot determine the PHP source cache because HOME is not set'); |
||||
} |
||||
return rtrim($home, '/\\') . '/.typephp'; |
||||
} |
||||
|
||||
public function prepare(string $requestedVersion): string |
||||
{ |
||||
$cached = $this->findCachedSource($requestedVersion); |
||||
if ($cached !== null) { |
||||
return $cached; |
||||
} |
||||
$release = $this->resolveRelease($requestedVersion); |
||||
$this->mkdir($this->cacheDirectory); |
||||
$lock = fopen($this->cacheDirectory . '/download.lock', 'c+'); |
||||
if ($lock === false || !flock($lock, LOCK_EX)) { |
||||
throw new \RuntimeException('Unable to lock the PHP source cache: ' . $this->cacheDirectory); |
||||
} |
||||
try { |
||||
return $this->prepareLocked($release); |
||||
} finally { |
||||
flock($lock, LOCK_UN); |
||||
fclose($lock); |
||||
} |
||||
} |
||||
|
||||
private function findCachedSource(string $requestedVersion): ?string |
||||
{ |
||||
if (preg_match('/^(8\.(?:4|5))(?:\.0)?$/', $requestedVersion, $match) === 1) { |
||||
$pattern = $this->cacheDirectory . '/src/php-' . $match[1] . '.*'; |
||||
} elseif (preg_match('/^8\.(?:4|5)\.\d+$/', $requestedVersion) === 1) { |
||||
$pattern = $this->cacheDirectory . '/src/php-' . $requestedVersion; |
||||
} else { |
||||
return null; |
||||
} |
||||
$candidates = []; |
||||
foreach (glob($pattern, GLOB_ONLYDIR) ?: [] as $source) { |
||||
if ($this->isComplete($source)) { |
||||
$candidates[] = $source; |
||||
} |
||||
} |
||||
usort($candidates, static fn(string $left, string $right): int => version_compare( |
||||
substr(basename($right), 4), |
||||
substr(basename($left), 4), |
||||
)); |
||||
return $candidates[0] ?? null; |
||||
} |
||||
|
||||
/** @return array{version:string,filename:string,sha256:string,url:string} */ |
||||
private function resolveRelease(string $requestedVersion): array |
||||
{ |
||||
if (preg_match('/^(8\.(?:4|5))(?:\.0)?$/', $requestedVersion, $match) === 1) { |
||||
$branch = $match[1]; |
||||
$exactVersion = null; |
||||
} elseif (preg_match('/^(8\.(?:4|5))\.\d+$/', $requestedVersion, $match) === 1) { |
||||
$branch = $match[1]; |
||||
$exactVersion = $requestedVersion; |
||||
} else { |
||||
throw new \InvalidArgumentException( |
||||
"Unsupported PHP source version `{$requestedVersion}`. Expected 8.4, 8.5, or a stable patch release.", |
||||
); |
||||
} |
||||
|
||||
$data = json_decode( |
||||
$this->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))), |
||||
true, |
||||
flags: JSON_THROW_ON_ERROR, |
||||
); |
||||
if (!is_array($data)) { |
||||
throw new \RuntimeException('Invalid release list returned by PHP.net'); |
||||
} |
||||
if ($exactVersion === null) { |
||||
$versions = array_keys($data); |
||||
usort($versions, static fn(string $left, string $right): int => version_compare($right, $left)); |
||||
$exactVersion = current(array_values(array_filter( |
||||
$versions, |
||||
static fn(string $version): bool => preg_match('/^' . preg_quote($branch, '/') . '\.\d+$/', $version) === 1, |
||||
))) ?: null; |
||||
} |
||||
if ($exactVersion === null || !isset($data[$exactVersion]) || !is_array($data[$exactVersion])) { |
||||
throw new \RuntimeException("PHP {$requestedVersion} was not found in the official release list"); |
||||
} |
||||
foreach ($data[$exactVersion]['source'] ?? [] as $source) { |
||||
$filename = (string) ($source['filename'] ?? ''); |
||||
$sha256 = (string) ($source['sha256'] ?? ''); |
||||
if (str_ends_with($filename, '.tar.xz') && $sha256 !== '') { |
||||
return [ |
||||
'version' => $exactVersion, |
||||
'filename' => $filename, |
||||
'sha256' => $sha256, |
||||
'url' => 'https://www.php.net/distributions/' . rawurlencode($filename), |
||||
]; |
||||
} |
||||
} |
||||
throw new \RuntimeException("PHP {$exactVersion} has no verified tar.xz source archive"); |
||||
} |
||||
|
||||
/** @param array{version:string,filename:string,sha256:string,url:string} $release */ |
||||
private function prepareLocked(array $release): string |
||||
{ |
||||
$sourceDirectory = $this->cacheDirectory . '/src'; |
||||
$this->mkdir($sourceDirectory); |
||||
$source = $sourceDirectory . '/php-' . $release['version']; |
||||
if ($this->isComplete($source)) { |
||||
return $source; |
||||
} |
||||
$archiveDirectory = $this->cacheDirectory . '/archives'; |
||||
$this->mkdir($archiveDirectory); |
||||
$archive = $archiveDirectory . '/' . $release['filename']; |
||||
if (!is_file($archive) || hash_file('sha256', $archive) !== $release['sha256']) { |
||||
($this->output)('Downloading PHP ' . $release['version'] . ' source from php.net'); |
||||
$temporary = $archive . '.part-' . bin2hex(random_bytes(6)); |
||||
try { |
||||
$this->downloadFile($release['url'], $temporary); |
||||
if (hash_file('sha256', $temporary) !== $release['sha256']) { |
||||
throw new \RuntimeException('PHP source archive SHA-256 verification failed'); |
||||
} |
||||
if (!rename($temporary, $archive)) { |
||||
throw new \RuntimeException("Unable to cache PHP source archive: {$archive}"); |
||||
} |
||||
} finally { |
||||
if (is_file($temporary)) { |
||||
@unlink($temporary); |
||||
} |
||||
} |
||||
} |
||||
if (is_dir($source) && !$this->isComplete($source)) { |
||||
throw new \RuntimeException("Incomplete PHP source cache must be removed: {$source}"); |
||||
} |
||||
($this->output)('Extracting PHP ' . $release['version'] . ' source into ' . $sourceDirectory); |
||||
$this->run(['tar', '-xJf', $archive, '-C', $sourceDirectory]); |
||||
if (!$this->isComplete($source)) { |
||||
throw new \RuntimeException("PHP source archive did not produce a complete source tree: {$source}"); |
||||
} |
||||
return $source; |
||||
} |
||||
|
||||
private function isComplete(string $source): bool |
||||
{ |
||||
foreach (['configure', 'main/php.h', 'main/php_version.h', 'Zend/zend.h', 'ext/standard/fsock.h'] as $file) { |
||||
if (!is_file($source . '/' . $file)) { |
||||
return false; |
||||
} |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
public static function version(string $source): string |
||||
{ |
||||
$header = rtrim($source, '/\\') . '/main/php_version.h'; |
||||
$contents = is_file($header) ? file_get_contents($header) : false; |
||||
if (!is_string($contents) |
||||
|| preg_match('/^#define\s+PHP_VERSION\s+"([^"]+)"/m', $contents, $match) !== 1) { |
||||
throw new \RuntimeException("Cannot determine PHP source version from {$header}"); |
||||
} |
||||
return $match[1]; |
||||
} |
||||
|
||||
private function downloadText(string $url): string |
||||
{ |
||||
$context = stream_context_create(['http' => ['timeout' => 30, 'user_agent' => 'TypePHP/tpc']]); |
||||
$contents = @file_get_contents($url, false, $context); |
||||
if (is_string($contents)) { |
||||
return $contents; |
||||
} |
||||
$curl = trim((string) shell_exec('command -v curl 2>/dev/null')); |
||||
if ($curl === '') { |
||||
throw new \RuntimeException("Unable to download {$url}; enable allow_url_fopen or install curl"); |
||||
} |
||||
return $this->capture([$curl, '--fail', '--location', '--retry', '3', $url]); |
||||
} |
||||
|
||||
private function downloadFile(string $url, string $target): void |
||||
{ |
||||
$curl = trim((string) shell_exec('command -v curl 2>/dev/null')); |
||||
if ($curl !== '') { |
||||
$this->run([$curl, '--fail', '--location', '--retry', '3', '--output', $target, $url]); |
||||
return; |
||||
} |
||||
$contents = $this->downloadText($url); |
||||
if (file_put_contents($target, $contents) === false) { |
||||
throw new \RuntimeException("Unable to write {$target}"); |
||||
} |
||||
} |
||||
|
||||
/** @param list<string> $command */ |
||||
private function run(array $command): void |
||||
{ |
||||
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes); |
||||
if (!is_resource($process) || proc_close($process) !== 0) { |
||||
throw new \RuntimeException('Command failed: ' . implode(' ', $command)); |
||||
} |
||||
} |
||||
|
||||
/** @param list<string> $command */ |
||||
private function capture(array $command): string |
||||
{ |
||||
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); |
||||
if (!is_resource($process)) { |
||||
throw new \RuntimeException('Unable to run command: ' . implode(' ', $command)); |
||||
} |
||||
$stdout = stream_get_contents($pipes[1]); |
||||
$stderr = stream_get_contents($pipes[2]); |
||||
fclose($pipes[1]); |
||||
fclose($pipes[2]); |
||||
if (proc_close($process) !== 0) { |
||||
throw new \RuntimeException(trim($stderr)); |
||||
} |
||||
return $stdout; |
||||
} |
||||
|
||||
private function mkdir(string $directory): void |
||||
{ |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new \RuntimeException("Cannot create PHP source cache directory: {$directory}"); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,92 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\Node\Stmt; |
||||
use PhpParser\ParserFactory; |
||||
|
||||
final class PhpSourceExtensionIndex |
||||
{ |
||||
/** @var array<string, self> */ |
||||
private static array $instances = []; |
||||
|
||||
/** @var array<string, string> */ |
||||
private array $functions = []; |
||||
|
||||
/** @var array<string, string> */ |
||||
private array $classes = []; |
||||
|
||||
public static function forSource(string $sourceDirectory): self |
||||
{ |
||||
return self::$instances[$sourceDirectory] ??= new self($sourceDirectory); |
||||
} |
||||
|
||||
public function functionExtension(string $function): ?string |
||||
{ |
||||
return $this->functions[strtolower(ltrim($function, '\\'))] ?? null; |
||||
} |
||||
|
||||
public function classExtension(string $class): ?string |
||||
{ |
||||
return $this->classes[strtolower(ltrim($class, '\\'))] ?? null; |
||||
} |
||||
|
||||
private function __construct(string $sourceDirectory) |
||||
{ |
||||
$parser = (new ParserFactory())->createForNewestSupportedVersion(); |
||||
foreach (glob($sourceDirectory . '/ext/*') ?: [] as $extensionDirectory) { |
||||
if (!is_dir($extensionDirectory)) { |
||||
continue; |
||||
} |
||||
$extension = basename($extensionDirectory); |
||||
foreach (glob($extensionDirectory . '/*.stub.php') ?: [] as $stub) { |
||||
try { |
||||
$statements = $parser->parse((string) file_get_contents($stub)); |
||||
} catch (\Throwable) { |
||||
continue; |
||||
} |
||||
if (is_array($statements)) { |
||||
$this->indexStatements($statements, '', $extension); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** @param list<Node\Stmt> $statements */ |
||||
private function indexStatements(array $statements, string $namespace, string $extension): void |
||||
{ |
||||
foreach ($statements as $statement) { |
||||
if ($statement instanceof Stmt\Namespace_) { |
||||
$this->indexStatements( |
||||
$statement->stmts, |
||||
$statement->name?->toString() ?? '', |
||||
$extension, |
||||
); |
||||
continue; |
||||
} |
||||
if ($statement instanceof Stmt\Function_) { |
||||
$this->functions[$this->qualified($namespace, $statement->name->toString())] = $extension; |
||||
continue; |
||||
} |
||||
if ($statement instanceof Stmt\ClassLike && $statement->name !== null) { |
||||
$this->classes[$this->qualified($namespace, $statement->name->toString())] = $extension; |
||||
continue; |
||||
} |
||||
if (property_exists($statement, 'stmts') && is_array($statement->stmts)) { |
||||
$this->indexStatements($statement->stmts, $namespace, $extension); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private function qualified(string $namespace, string $name): string |
||||
{ |
||||
return strtolower($namespace === '' ? $name : $namespace . '\\' . $name); |
||||
} |
||||
} |
||||
@ -0,0 +1,358 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
final class SapiApplicationLinker |
||||
{ |
||||
/** @param \Closure(string):void $output */ |
||||
public function __construct( |
||||
private readonly string $phpSourceDirectory, |
||||
private readonly string $phpBuildDirectory, |
||||
private readonly string $phpxArchive, |
||||
/** @var array<string, string> */ |
||||
private readonly array $sapiArchives, |
||||
private readonly string $buildDirectory, |
||||
private readonly array $targets, |
||||
private readonly ?string $entryFile, |
||||
private readonly \Closure $output, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* @param list<string> $projectObjects |
||||
* @param array<string, string> $outputs |
||||
*/ |
||||
public function link(array $projectObjects, array $outputs): string |
||||
{ |
||||
$makefile = $this->phpBuildDirectory . '/Makefile'; |
||||
if (!is_file($makefile)) { |
||||
throw new \RuntimeException("Private PHP Makefile does not exist: {$makefile}"); |
||||
} |
||||
$variables = $this->readMakeVariables($makefile, [ |
||||
'PHP_CLI_OBJS', 'PHP_FPM_OBJS', 'EXTRA_LIBS', 'CXX', |
||||
]); |
||||
$cxx = trim($variables['CXX'] ?? '') ?: 'c++'; |
||||
$binaryObjects = implode(' ', [...$projectObjects, $this->phpxArchive]); |
||||
$extraLibraries = trim(($variables['EXTRA_LIBS'] ?? '') . ' -lgmp -lgmpxx -lmpfr'); |
||||
|
||||
$cliObjects = $variables['PHP_CLI_OBJS'] ?? ''; |
||||
$fpmObjects = $variables['PHP_FPM_OBJS'] ?? ''; |
||||
$adapterMakefile = $this->generateSapiAdapters($cliObjects, $fpmObjects); |
||||
$adapterDirectory = $this->adapterDirectory(); |
||||
if (in_array('cli', $this->targets, true)) { |
||||
$cliObjects = $this->replaceObject( |
||||
$cliObjects, |
||||
'sapi/cli/php_cli.lo', |
||||
$adapterDirectory . '/php_cli.lo', |
||||
); |
||||
$cliObjects = $this->replaceObject( |
||||
$cliObjects, |
||||
'sapi/cli/php_cli_server.lo', |
||||
$adapterDirectory . '/php_cli_server.lo', |
||||
); |
||||
} |
||||
if (in_array('fpm', $this->targets, true)) { |
||||
$fpmObjects = $this->replaceObject( |
||||
$fpmObjects, |
||||
'sapi/fpm/fpm/fpm_main.lo', |
||||
$adapterDirectory . '/fpm_main.lo', |
||||
); |
||||
} |
||||
$this->run([ |
||||
'make', '-f', $adapterMakefile, '-j1', |
||||
...array_map( |
||||
fn (string $target): string => 'typephp-' . $target . '-adapter', |
||||
$this->targets, |
||||
), |
||||
], $this->phpBuildDirectory); |
||||
|
||||
foreach ($this->targets as $target) { |
||||
$output = $outputs[$target] ?? throw new \RuntimeException("Missing {$target} output path"); |
||||
$linkOutput = $this->absoluteOutputPath($output); |
||||
$runtimeArchive = $this->sapiArchives[$target] |
||||
?? throw new \RuntimeException("Missing cached PHP {$target} runtime archive"); |
||||
$this->mkdir(dirname($linkOutput)); |
||||
$arguments = [ |
||||
'make', '-j1', |
||||
'CC=' . $cxx, |
||||
'CXX=' . $cxx, |
||||
'PHP_BINARY_OBJS=' . $binaryObjects, |
||||
'PHP_GLOBAL_OBJS=', |
||||
'EXTRA_LIBS=' . $extraLibraries, |
||||
]; |
||||
if ($target === 'cli') { |
||||
$arguments[] = 'PHP_CLI_OBJS=' . $this->onlyEntryObjects( |
||||
$cliObjects, |
||||
['php_cli.lo', 'php_cli_server.lo'], |
||||
) |
||||
. ' ' . $runtimeArchive; |
||||
$arguments[] = 'SAPI_CLI_PATH=' . $linkOutput; |
||||
$arguments[] = 'cli'; |
||||
} else { |
||||
$arguments[] = 'PHP_FPM_OBJS=' . $this->onlyEntryObjects($fpmObjects, ['fpm_main.lo']) |
||||
. ' ' . $runtimeArchive; |
||||
$arguments[] = 'SAPI_FPM_PATH=' . $linkOutput; |
||||
$arguments[] = 'fpm'; |
||||
} |
||||
($this->output)('Linking self-contained PHP ' . strtoupper($target) . ': ' . $output); |
||||
$this->run($arguments, $this->phpBuildDirectory); |
||||
if (!is_executable($linkOutput)) { |
||||
throw new \RuntimeException("PHP {$target} output was not generated: {$output}"); |
||||
} |
||||
} |
||||
return $outputs['cli'] ?? $outputs['fpm']; |
||||
} |
||||
|
||||
private function absoluteOutputPath(string $output): string |
||||
{ |
||||
if ($output === '') { |
||||
throw new \RuntimeException('SAPI output path cannot be empty'); |
||||
} |
||||
if ($output[0] === '/' || preg_match('/^[A-Za-z]:[\\\\\/]/', $output) === 1) { |
||||
return $output; |
||||
} |
||||
$directory = getcwd(); |
||||
if ($directory === false) { |
||||
throw new \RuntimeException('Cannot resolve the SAPI application output directory'); |
||||
} |
||||
return $directory . DIRECTORY_SEPARATOR . $output; |
||||
} |
||||
|
||||
private function generateSapiAdapters(string $cliObjects, string $fpmObjects): string |
||||
{ |
||||
$directory = $this->adapterDirectory(); |
||||
$this->mkdir($directory); |
||||
$targets = []; |
||||
$rules = []; |
||||
if (in_array('cli', $this->targets, true)) { |
||||
if ($this->entryFile === null) { |
||||
throw new \RuntimeException('The CLI SAPI target requires an entry file'); |
||||
} |
||||
$source = $directory . '/php_cli.c'; |
||||
$object = $directory . '/php_cli.lo'; |
||||
$serverSource = $directory . '/php_cli_server.c'; |
||||
$serverObject = $directory . '/php_cli_server.lo'; |
||||
$code = $this->readSource('/sapi/cli/php_cli.c'); |
||||
$needle = "static zend_result cli_seek_file_begin(zend_file_handle *file_handle, char *script_file)\n{\n"; |
||||
$replacement = $needle |
||||
. "\tif (typephp_embedded_file_exists(script_file)) {\n" |
||||
. "\t\tzend_stream_init_filename(file_handle, script_file);\n" |
||||
. "\t\tfile_handle->primary_script = 1;\n" |
||||
. "\t\treturn SUCCESS;\n\t}\n"; |
||||
$code = $this->patchOnce($code, $needle, $replacement, 'CLI script opener'); |
||||
$code = $this->patchCliArguments($code); |
||||
$code = $this->insertDeclaration($code, true); |
||||
$this->writeIfChanged($source, $code); |
||||
$serverCode = $this->readSource('/sapi/cli/php_cli_server.c'); |
||||
$statCall = 'php_sys_stat(buf, &sb)'; |
||||
if (substr_count($serverCode, $statCall) !== 2) { |
||||
throw new \RuntimeException('Cannot apply embedded path checks to the selected PHP CLI Server source'); |
||||
} |
||||
$serverCode = str_replace($statCall, 'typephp_cli_server_stat(buf, &sb)', $serverCode); |
||||
$serverCode = $this->insertDeclaration($serverCode, false, true); |
||||
$this->writeIfChanged($serverSource, $serverCode); |
||||
$targets[] = 'typephp-cli-adapter'; |
||||
$rules[] = "typephp-cli-adapter: {$object} {$serverObject}\n"; |
||||
$rules[] = "{$object}: {$source}\n" |
||||
. "\t\$(LIBTOOL) --tag=CC --mode=compile \$(CC) -Isapi/cli/ -I\$(top_srcdir)/sapi/cli/ " |
||||
. "\$(COMMON_FLAGS) \$(CFLAGS_CLEAN) \$(EXTRA_CFLAGS) -c {$source} -o {$object}\n"; |
||||
$rules[] = "{$serverObject}: {$serverSource}\n" |
||||
. "\t\$(LIBTOOL) --tag=CC --mode=compile \$(CC) -Isapi/cli/ -I\$(top_srcdir)/sapi/cli/ " |
||||
. "\$(COMMON_FLAGS) \$(CFLAGS_CLEAN) \$(EXTRA_CFLAGS) -c {$serverSource} -o {$serverObject}\n"; |
||||
} |
||||
if (in_array('fpm', $this->targets, true)) { |
||||
if (trim($fpmObjects) === '') { |
||||
throw new \RuntimeException('The private PHP build was not configured with FPM support'); |
||||
} |
||||
$source = $directory . '/fpm_main.c'; |
||||
$object = $directory . '/fpm_main.lo'; |
||||
$code = $this->readSource('/sapi/fpm/fpm/fpm_main.c'); |
||||
$needle = '(real_path = tsrm_realpath(script_path_translated, NULL)) == NULL'; |
||||
$replacement = '(real_path = typephp_embedded_file_exists(script_path_translated) ' |
||||
. '? estrdup(script_path_translated) : tsrm_realpath(script_path_translated, NULL)) == NULL'; |
||||
$code = $this->patchOnce($code, $needle, $replacement, 'FPM path check'); |
||||
$code = $this->insertDeclaration($code); |
||||
$this->writeIfChanged($source, $code); |
||||
$targets[] = 'typephp-fpm-adapter'; |
||||
$rules[] = "typephp-fpm-adapter: {$object}\n"; |
||||
$rules[] = "{$object}: {$source}\n" |
||||
. "\t\$(LIBTOOL) --tag=CC --mode=compile \$(CC) -Isapi/fpm/ -I\$(top_srcdir)/sapi/fpm/ " |
||||
. '$(COMMON_FLAGS) $(CFLAGS_CLEAN) $(EXTRA_CFLAGS) -I$(top_srcdir)/sapi/fpm ' |
||||
. '-I$(top_srcdir)/sapi/fpm/fpm ' |
||||
. "-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1 -c {$source} -o {$object}\n"; |
||||
} |
||||
$path = $directory . '/adapters.mk'; |
||||
$body = 'include ' . $this->phpBuildDirectory . '/Makefile' . PHP_EOL |
||||
. '.PHONY: ' . implode(' ', $targets) . PHP_EOL |
||||
. implode(PHP_EOL, $rules); |
||||
AtomicFile::write($path, $body); |
||||
return $path; |
||||
} |
||||
|
||||
private function adapterDirectory(): string |
||||
{ |
||||
$runtime = hash('sha256', $this->phpSourceDirectory . "\0" . $this->phpBuildDirectory); |
||||
return $this->buildDirectory . '/cache/sapi/adapters/' . substr($runtime, 0, 16); |
||||
} |
||||
|
||||
private function insertDeclaration( |
||||
string $code, |
||||
bool $cliArguments = false, |
||||
bool $cliServerStat = false, |
||||
): string { |
||||
$needle = '#include "php.h"'; |
||||
$declarations = 'extern int typephp_embedded_file_exists(const char *path);'; |
||||
if ($cliArguments) { |
||||
$declarations .= PHP_EOL |
||||
. 'extern int typephp_cli_prepare_arguments(int *argc, char ***argv, ' |
||||
. 'const void *options, char ***allocated_argv);' . PHP_EOL |
||||
. 'extern void typephp_cli_release_arguments(char **allocated_argv);'; |
||||
} |
||||
if ($cliServerStat) { |
||||
$declarations .= PHP_EOL . <<<'C' |
||||
extern int typephp_embedded_path_kind(const char *path); |
||||
static int typephp_cli_server_stat(const char *path, zend_stat_t *statbuf) |
||||
{ |
||||
int kind = typephp_embedded_path_kind(path); |
||||
if (kind == 0) { |
||||
return php_sys_stat(path, statbuf); |
||||
} |
||||
memset(statbuf, 0, sizeof(*statbuf)); |
||||
statbuf->st_mode = kind == 2 ? (S_IFDIR | 0555) : (S_IFREG | 0444); |
||||
statbuf->st_nlink = 1; |
||||
return 0; |
||||
} |
||||
C; |
||||
} |
||||
$code = preg_replace( |
||||
'/' . preg_quote($needle, '/') . '/', |
||||
$needle . PHP_EOL . $declarations, |
||||
$code, |
||||
1, |
||||
$replacements, |
||||
); |
||||
if (!is_string($code) || $replacements !== 1) { |
||||
throw new \RuntimeException('Cannot apply PHP header include patch to the selected PHP source'); |
||||
} |
||||
return $code; |
||||
} |
||||
|
||||
private function patchCliArguments(string $code): string |
||||
{ |
||||
$setup = "\targv = save_ps_args(argc, argv);\n"; |
||||
$code = $this->patchOnce( |
||||
$code, |
||||
$setup, |
||||
$setup |
||||
. "\tchar **typephp_saved_argv = argv;\n" |
||||
. "\tchar **typephp_application_argv = NULL;\n" |
||||
. "\tif (typephp_cli_prepare_arguments(&argc, &argv, OPTIONS, &typephp_application_argv) == FAILURE) {\n" |
||||
. "\t\tcleanup_ps_args(typephp_saved_argv);\n" |
||||
. "\t\treturn FAILURE;\n" |
||||
. "\t}\n", |
||||
'CLI argument setup', |
||||
); |
||||
|
||||
$cleanup = "\tcleanup_ps_args(argv);\n"; |
||||
return $this->patchOnce( |
||||
$code, |
||||
$cleanup, |
||||
"\ttypephp_cli_release_arguments(typephp_application_argv);\n" |
||||
. "\tcleanup_ps_args(typephp_saved_argv);\n", |
||||
'CLI argument cleanup', |
||||
); |
||||
} |
||||
|
||||
private function readSource(string $suffix): string |
||||
{ |
||||
$path = $this->phpSourceDirectory . $suffix; |
||||
$code = is_file($path) ? file_get_contents($path) : false; |
||||
if (!is_string($code)) { |
||||
throw new \RuntimeException("Cannot read PHP SAPI source: {$path}"); |
||||
} |
||||
return $code; |
||||
} |
||||
|
||||
private function patchOnce(string $code, string $needle, string $replacement, string $description): string |
||||
{ |
||||
if (substr_count($code, $needle) !== 1) { |
||||
throw new \RuntimeException("Cannot apply {$description} patch to the selected PHP source"); |
||||
} |
||||
return str_replace($needle, $replacement, $code); |
||||
} |
||||
|
||||
private function replaceObject(string $objects, string $original, string $replacement): string |
||||
{ |
||||
$parts = preg_split('/\s+/', trim($objects)) ?: []; |
||||
$index = array_search($original, $parts, true); |
||||
if ($index === false) { |
||||
throw new \RuntimeException("Cannot locate PHP SAPI object {$original}"); |
||||
} |
||||
$parts[$index] = $replacement; |
||||
return implode(' ', $parts); |
||||
} |
||||
|
||||
/** @param list<string> $basenames */ |
||||
private function onlyEntryObjects(string $objects, array $basenames): string |
||||
{ |
||||
$matches = []; |
||||
foreach (preg_split('/\s+/', trim($objects)) ?: [] as $object) { |
||||
if (in_array(basename($object), $basenames, true)) { |
||||
$matches[basename($object)] = $object; |
||||
} |
||||
} |
||||
foreach ($basenames as $basename) { |
||||
if (!isset($matches[$basename])) { |
||||
throw new \RuntimeException("Cannot locate patched PHP SAPI entry object {$basename}"); |
||||
} |
||||
} |
||||
return implode(' ', array_map( |
||||
static fn (string $basename): string => $matches[$basename], |
||||
$basenames, |
||||
)); |
||||
} |
||||
|
||||
/** @param list<string> $names @return array<string, string> */ |
||||
private function readMakeVariables(string $makefile, array $names): array |
||||
{ |
||||
$contents = (string) file_get_contents($makefile); |
||||
$values = []; |
||||
foreach ($names as $name) { |
||||
if (preg_match('/^' . preg_quote($name, '/') . '[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) { |
||||
$values[$name] = trim($match[1]); |
||||
} |
||||
} |
||||
return $values; |
||||
} |
||||
|
||||
/** @param list<string> $command */ |
||||
private function run(array $command, string $directory): void |
||||
{ |
||||
($this->output)('$ ' . implode(' ', array_map('escapeshellarg', $command))); |
||||
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $directory); |
||||
if (!is_resource($process) || proc_close($process) !== 0) { |
||||
throw new \RuntimeException('Command failed: ' . implode(' ', $command)); |
||||
} |
||||
} |
||||
|
||||
private function mkdir(string $directory): void |
||||
{ |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new \RuntimeException("Cannot create directory: {$directory}"); |
||||
} |
||||
} |
||||
|
||||
private function writeIfChanged(string $path, string $contents): void |
||||
{ |
||||
if (is_file($path) && file_get_contents($path) === $contents) { |
||||
return; |
||||
} |
||||
AtomicFile::write($path, $contents); |
||||
} |
||||
} |
||||
@ -0,0 +1,37 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
final class SapiBuildConfiguration |
||||
{ |
||||
/** @return list<string> */ |
||||
public static function parseTargets(string|array $value): array |
||||
{ |
||||
$values = is_array($value) ? $value : preg_split('/\s*,\s*/', trim($value)); |
||||
if (!is_array($values)) { |
||||
$values = []; |
||||
} |
||||
$targets = []; |
||||
foreach ($values as $target) { |
||||
if (!is_string($target) || trim($target) === '') { |
||||
throw new \InvalidArgumentException('`sapi` entries must be non-empty strings'); |
||||
} |
||||
$target = strtolower(trim($target)); |
||||
if ($target === 'both') { |
||||
array_push($targets, 'cli', 'fpm'); |
||||
continue; |
||||
} |
||||
if (!in_array($target, ['cli', 'fpm'], true)) { |
||||
throw new \InvalidArgumentException( |
||||
"Invalid SAPI target `{$target}`. Expected cli, fpm, or both.", |
||||
); |
||||
} |
||||
$targets[] = $target; |
||||
} |
||||
$targets = array_values(array_unique($targets)); |
||||
if ($targets === []) { |
||||
throw new \InvalidArgumentException('`sapi` must select cli, fpm, or both'); |
||||
} |
||||
return $targets; |
||||
} |
||||
} |
||||
@ -0,0 +1,71 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
final class SapiExtensionConfiguration |
||||
{ |
||||
/** Extensions compiled unconditionally by php-src. */ |
||||
private const array CORE_EXTENSIONS = [ |
||||
'core', 'date', 'hash', 'json', 'pcre', 'random', 'reflection', 'spl', 'standard', |
||||
]; |
||||
|
||||
/** |
||||
* @param list<string> $extensions |
||||
* @return list<string> |
||||
*/ |
||||
public static function configureOptions(string $phpSourceDirectory, array $extensions): array |
||||
{ |
||||
$options = []; |
||||
foreach (SapiExtensionRequirements::merge($extensions) as $extension) { |
||||
if (in_array($extension, self::CORE_EXTENSIONS, true)) { |
||||
continue; |
||||
} |
||||
if ($extension === 'opcache') { |
||||
$options[] = '--enable-opcache'; |
||||
continue; |
||||
} |
||||
$directory = $phpSourceDirectory . '/ext/' . str_replace('-', '_', $extension); |
||||
if (!is_dir($directory)) { |
||||
throw new \RuntimeException("PHP extension `{$extension}` is not bundled with php-src and cannot be linked into the SAPI runtime"); |
||||
} |
||||
$option = self::findConfigureOption($directory, $extension); |
||||
// An extension directory without a configure switch is part of the |
||||
// core build and needs no extra argument. |
||||
if ($option !== null) { |
||||
$options[] = $option; |
||||
} |
||||
} |
||||
return array_values(array_unique($options)); |
||||
} |
||||
|
||||
private static function findConfigureOption(string $directory, string $extension): ?string |
||||
{ |
||||
foreach ([$directory . '/config0.m4', $directory . '/config.m4'] as $file) { |
||||
if (!is_file($file)) { |
||||
continue; |
||||
} |
||||
$contents = (string) file_get_contents($file); |
||||
if (preg_match_all( |
||||
'/PHP_ARG_(WITH|ENABLE)\s*\(\s*\[?([A-Za-z0-9_-]+)\]?/i', |
||||
$contents, |
||||
$matches, |
||||
PREG_SET_ORDER, |
||||
) === false) { |
||||
continue; |
||||
} |
||||
foreach ($matches as $match) { |
||||
if (SapiExtensionRequirements::normalize($match[2]) !== $extension) { |
||||
continue; |
||||
} |
||||
return ($match[1] === 'WITH' ? '--with-' : '--enable-') . $match[2]; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
@ -0,0 +1,89 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
final class SapiExtensionRequirements |
||||
{ |
||||
/** |
||||
* @param list<string> ...$groups |
||||
* @return list<string> |
||||
*/ |
||||
public static function merge(array ...$groups): array |
||||
{ |
||||
$extensions = []; |
||||
foreach ($groups as $group) { |
||||
foreach ($group as $extension) { |
||||
$name = self::normalize($extension); |
||||
if ($name !== '') { |
||||
$extensions[$name] = true; |
||||
} |
||||
} |
||||
} |
||||
$result = array_keys($extensions); |
||||
sort($result, SORT_STRING); |
||||
return $result; |
||||
} |
||||
|
||||
/** @param list<string> $files @return list<string> */ |
||||
public static function fromEmbeddedVendorFiles(array $files): array |
||||
{ |
||||
$manifests = []; |
||||
foreach ($files as $file) { |
||||
$vendor = self::vendorRoot($file); |
||||
if ($vendor !== null) { |
||||
$manifest = dirname($vendor) . DIRECTORY_SEPARATOR . 'composer.json'; |
||||
if (is_file($manifest)) { |
||||
$manifests[$manifest] = true; |
||||
} |
||||
} |
||||
} |
||||
$extensions = []; |
||||
foreach (array_keys($manifests) as $manifest) { |
||||
$decoded = json_decode((string) file_get_contents($manifest), true); |
||||
if (!is_array($decoded)) { |
||||
throw new \RuntimeException("Invalid Composer manifest: {$manifest}"); |
||||
} |
||||
$require = $decoded['require'] ?? []; |
||||
if (!is_array($require)) { |
||||
throw new \RuntimeException("Composer `require` must be an object: {$manifest}"); |
||||
} |
||||
foreach (array_keys($require) as $package) { |
||||
if (is_string($package) && str_starts_with(strtolower($package), 'ext-')) { |
||||
$extensions[] = substr($package, 4); |
||||
} |
||||
} |
||||
} |
||||
return self::merge($extensions); |
||||
} |
||||
|
||||
public static function normalize(string $extension): string |
||||
{ |
||||
$extension = strtolower(trim($extension)); |
||||
if (str_starts_with($extension, 'ext-')) { |
||||
$extension = substr($extension, 4); |
||||
} |
||||
$extension = str_replace(' ', '-', $extension); |
||||
return match ($extension) { |
||||
'zend-opcache', 'opcache' => 'opcache', |
||||
default => str_replace('_', '-', $extension), |
||||
}; |
||||
} |
||||
|
||||
private static function vendorRoot(string $file): ?string |
||||
{ |
||||
$directory = is_dir($file) ? $file : dirname($file); |
||||
while ($directory !== '' && $directory !== dirname($directory)) { |
||||
if (basename($directory) === 'vendor' && is_file($directory . '/autoload.php')) { |
||||
return $directory; |
||||
} |
||||
$directory = dirname($directory); |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
@ -0,0 +1,25 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
final readonly class SapiPhpBuild |
||||
{ |
||||
public function __construct( |
||||
public string $sourceDirectory, |
||||
public string $buildDirectory, |
||||
public string $prefix, |
||||
public string $phpxArchive, |
||||
/** @var array<string, string> */ |
||||
public array $sapiArchives, |
||||
public string $version, |
||||
/** @var list<string> */ |
||||
public array $enabledExtensions = [], |
||||
) { |
||||
} |
||||
} |
||||
@ -0,0 +1,368 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use TypePhp\Installer\PhpBuildConfiguration; |
||||
|
||||
final class SapiPhpBuilder |
||||
{ |
||||
private readonly \Closure $output; |
||||
|
||||
/** @param callable(string):void $output */ |
||||
public function __construct( |
||||
private readonly string $phpxSourceDirectory, |
||||
callable $output, |
||||
) { |
||||
$this->output = \Closure::fromCallable($output); |
||||
} |
||||
|
||||
/** |
||||
* @param list<string> $targets |
||||
* @param list<string> $requiredExtensions |
||||
*/ |
||||
public function prepare( |
||||
string $phpVersion, |
||||
array $targets, |
||||
int $jobs, |
||||
array $requiredExtensions = [], |
||||
): SapiPhpBuild { |
||||
if (PHP_OS_FAMILY !== 'Linux' && PHP_OS_FAMILY !== 'Darwin') { |
||||
throw new \RuntimeException('Self-contained SAPI builds currently require Linux or macOS'); |
||||
} |
||||
if ($targets === [] || array_diff($targets, ['cli', 'fpm']) !== []) { |
||||
throw new \InvalidArgumentException('SAPI targets must contain cli, fpm, or both'); |
||||
} |
||||
$source = (new OfficialPhpSource( |
||||
OfficialPhpSource::defaultCacheDirectory(), |
||||
$this->output, |
||||
))->prepare($phpVersion); |
||||
$sourceVersion = OfficialPhpSource::version($source); |
||||
$baseOptions = $this->sourceConfigureOptions($source); |
||||
$requiredExtensions = SapiExtensionRequirements::merge($requiredExtensions); |
||||
$extensionOptions = SapiExtensionConfiguration::configureOptions($source, $requiredExtensions); |
||||
// Build both official executable SAPIs once. Individual projects can |
||||
// then select either archive without configuring or compiling php-src. |
||||
$runtimeTargets = ['cli', 'fpm']; |
||||
$identity = [ |
||||
$sourceVersion, |
||||
$baseOptions, |
||||
$runtimeTargets, |
||||
PHP_OS_FAMILY, |
||||
php_uname('m'), |
||||
getenv('CC') ?: '', |
||||
getenv('CXX') ?: '', |
||||
filemtime($source . '/configure'), |
||||
]; |
||||
$compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR)); |
||||
$cacheDirectory = OfficialPhpSource::defaultCacheDirectory() . '/sapi'; |
||||
$cached = $this->findCompatibleRuntime( |
||||
$cacheDirectory, |
||||
$sourceVersion, |
||||
$compatibility, |
||||
$requiredExtensions, |
||||
); |
||||
if ($cached !== null) { |
||||
($this->output)('Reusing private PHP runtime with extensions: ' |
||||
. ($requiredExtensions === [] ? 'core' : implode(', ', $requiredExtensions))); |
||||
return $cached; |
||||
} |
||||
|
||||
// Keep the original no-extension key stable so runtimes created by an |
||||
// earlier TypePHP version can be adopted without rebuilding php-src. |
||||
$fingerprintInput = $extensionOptions === [] ? $identity : [...$identity, $extensionOptions]; |
||||
$fingerprint = substr(hash('sha256', json_encode($fingerprintInput, JSON_THROW_ON_ERROR)), 0, 16); |
||||
$root = $cacheDirectory . '/php-' |
||||
. $sourceVersion . '-' . $fingerprint; |
||||
$this->mkdir($root); |
||||
$lock = fopen($root . '/build.lock', 'c+'); |
||||
if ($lock === false || !flock($lock, LOCK_EX)) { |
||||
throw new \RuntimeException("Unable to lock SAPI runtime cache: {$root}"); |
||||
} |
||||
try { |
||||
$build = $root . '/build'; |
||||
$prefix = $root . '/install'; |
||||
$this->mkdir($build); |
||||
$this->mkdir($prefix . '/lib/conf.d'); |
||||
|
||||
$options = PhpBuildConfiguration::deriveSapi( |
||||
[...$baseOptions, ...$extensionOptions], |
||||
$prefix, |
||||
$runtimeTargets, |
||||
); |
||||
$configured = $build . '/.typephp-configure.json'; |
||||
$configuration = json_encode($options, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); |
||||
if (!is_file($build . '/Makefile') || @file_get_contents($configured) !== $configuration) { |
||||
($this->output)('Configuring private PHP ' . $sourceVersion . ' SAPI runtime'); |
||||
$this->run([$source . '/configure', ...$options], $build); |
||||
AtomicFile::write($configured, $configuration); |
||||
} |
||||
|
||||
$php = $prefix . '/bin/php'; |
||||
$fpm = $prefix . '/sbin/php-fpm'; |
||||
$needsInstall = !is_executable($php) || !is_executable($fpm); |
||||
if ($needsInstall) { |
||||
($this->output)('Building private PHP runtime (cached across application builds)'); |
||||
$this->run(['make', '-j' . max(1, $jobs)], $build); |
||||
$this->run(['make', 'install'], $build); |
||||
} |
||||
|
||||
$sapiArchives = $this->buildSapiArchives($build, $root, $jobs); |
||||
|
||||
$phpxBuild = $root . '/phpx-build'; |
||||
$phpxArchive = $phpxBuild . '/lib/libphpx.a'; |
||||
if (!is_file($phpxArchive)) { |
||||
($this->output)('Building static PHPX runtime'); |
||||
$this->run([ |
||||
'cmake', |
||||
'-S', $this->phpxSourceDirectory . '/sapi-static', |
||||
'-B', $phpxBuild, |
||||
'-DCMAKE_BUILD_TYPE=Release', |
||||
'-DPHPX_ROOT=' . $this->phpxSourceDirectory, |
||||
'-DPHPX_PHP_PREFIX=' . $prefix, |
||||
], $root); |
||||
$this->run([ |
||||
'cmake', '--build', $phpxBuild, '--parallel', (string) max(1, $jobs), |
||||
], $root); |
||||
} |
||||
if (!is_executable($php) || !is_file($phpxArchive)) { |
||||
throw new \RuntimeException('Private SAPI runtime build did not produce PHP CLI and libphpx.a'); |
||||
} |
||||
$enabledExtensions = $this->detectEnabledExtensions($php); |
||||
$missing = array_values(array_diff($requiredExtensions, $enabledExtensions)); |
||||
if ($missing !== []) { |
||||
throw new \RuntimeException('Private PHP runtime is missing required extensions after build: ' . implode(', ', $missing)); |
||||
} |
||||
AtomicFile::write($root . '/runtime.json', json_encode([ |
||||
'version' => $sourceVersion, |
||||
'compatibility' => $compatibility, |
||||
'requested_extensions' => $requiredExtensions, |
||||
'enabled_extensions' => $enabledExtensions, |
||||
'configure_options' => $options, |
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL); |
||||
return new SapiPhpBuild( |
||||
$source, |
||||
$build, |
||||
$prefix, |
||||
$phpxArchive, |
||||
$sapiArchives, |
||||
$sourceVersion, |
||||
$enabledExtensions, |
||||
); |
||||
} finally { |
||||
flock($lock, LOCK_UN); |
||||
fclose($lock); |
||||
} |
||||
} |
||||
|
||||
/** @param list<string> $requiredExtensions */ |
||||
private function findCompatibleRuntime( |
||||
string $cacheDirectory, |
||||
string $sourceVersion, |
||||
string $compatibility, |
||||
array $requiredExtensions, |
||||
): ?SapiPhpBuild { |
||||
$matches = []; |
||||
foreach (glob($cacheDirectory . '/php-' . $sourceVersion . '-*') ?: [] as $root) { |
||||
$manifest = $root . '/runtime.json'; |
||||
if (!is_file($manifest)) { |
||||
continue; |
||||
} |
||||
$metadata = json_decode((string) file_get_contents($manifest), true); |
||||
if (!is_array($metadata) || ($metadata['compatibility'] ?? null) !== $compatibility) { |
||||
continue; |
||||
} |
||||
$enabled = SapiExtensionRequirements::merge( |
||||
is_array($metadata['enabled_extensions'] ?? null) ? $metadata['enabled_extensions'] : [], |
||||
); |
||||
if (array_diff($requiredExtensions, $enabled) !== []) { |
||||
continue; |
||||
} |
||||
$runtime = $this->runtimeFromRoot($root, $sourceVersion, $enabled); |
||||
if ($runtime !== null) { |
||||
$matches[] = [$runtime, count($enabled), filemtime($manifest) ?: 0]; |
||||
} |
||||
} |
||||
usort($matches, static fn (array $left, array $right): int => $left[1] <=> $right[1] ?: $right[2] <=> $left[2]); |
||||
return $matches[0][0] ?? null; |
||||
} |
||||
|
||||
/** @param list<string> $enabledExtensions */ |
||||
private function runtimeFromRoot( |
||||
string $root, |
||||
string $version, |
||||
array $enabledExtensions, |
||||
): ?SapiPhpBuild { |
||||
$build = $root . '/build'; |
||||
$prefix = $root . '/install'; |
||||
$phpxArchive = $root . '/phpx-build/lib/libphpx.a'; |
||||
$archives = [ |
||||
'cli' => $root . '/lib/libphp-cli.a', |
||||
'fpm' => $root . '/lib/libphp-fpm.a', |
||||
]; |
||||
if (!is_executable($prefix . '/bin/php') |
||||
|| !is_file($phpxArchive) |
||||
|| !is_file($archives['cli']) |
||||
|| !is_file($archives['fpm']) |
||||
|| !is_file($build . '/Makefile') |
||||
) { |
||||
return null; |
||||
} |
||||
$source = OfficialPhpSource::defaultCacheDirectory() . '/src/php-' . $version; |
||||
return new SapiPhpBuild( |
||||
$source, |
||||
$build, |
||||
$prefix, |
||||
$phpxArchive, |
||||
$archives, |
||||
$version, |
||||
$enabledExtensions, |
||||
); |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private function detectEnabledExtensions(string $php): array |
||||
{ |
||||
$process = proc_open( |
||||
[$php, '-n', '-r', 'echo json_encode(get_loaded_extensions());'], |
||||
[0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], |
||||
$pipes, |
||||
); |
||||
if (!is_resource($process)) { |
||||
throw new \RuntimeException("Unable to inspect private PHP extensions: {$php}"); |
||||
} |
||||
$stdout = stream_get_contents($pipes[1]); |
||||
$stderr = stream_get_contents($pipes[2]); |
||||
fclose($pipes[1]); |
||||
fclose($pipes[2]); |
||||
$status = proc_close($process); |
||||
$decoded = is_string($stdout) ? json_decode($stdout, true) : null; |
||||
if ($status !== 0 || !is_array($decoded)) { |
||||
throw new \RuntimeException("Unable to inspect private PHP extensions: {$php}" . (is_string($stderr) && trim($stderr) !== '' ? PHP_EOL . trim($stderr) : '')); |
||||
} |
||||
return SapiExtensionRequirements::merge(array_values(array_filter($decoded, 'is_string'))); |
||||
} |
||||
|
||||
/** @return array<string, string> */ |
||||
private function buildSapiArchives(string $build, string $root, int $jobs): array |
||||
{ |
||||
$libraryDirectory = $root . '/lib'; |
||||
$this->mkdir($libraryDirectory); |
||||
$archives = [ |
||||
'cli' => $libraryDirectory . '/libphp-cli.a', |
||||
'fpm' => $libraryDirectory . '/libphp-fpm.a', |
||||
]; |
||||
if (is_file($archives['cli']) && is_file($archives['fpm'])) { |
||||
return $archives; |
||||
} |
||||
// The normal PHP build creates every core and SAPI object. Archive |
||||
// them once, excluding the two entry objects that TypePHP patches to |
||||
// accept embedded primary scripts. |
||||
$this->run(['make', '-j' . max(1, $jobs)], $build); |
||||
$variables = $this->readMakeVariables($build . '/Makefile', [ |
||||
'PHP_GLOBAL_OBJS', 'PHP_CLI_OBJS', 'PHP_FPM_OBJS', |
||||
]); |
||||
foreach ([ |
||||
'cli' => ['PHP_CLI_OBJS', 'sapi/cli/php_cli.lo'], |
||||
'fpm' => ['PHP_FPM_OBJS', 'sapi/fpm/fpm/fpm_main.lo'], |
||||
] as $target => [$variable, $entryObject]) { |
||||
if (is_file($archives[$target])) { |
||||
continue; |
||||
} |
||||
$objects = $this->objectFiles( |
||||
($variables['PHP_GLOBAL_OBJS'] ?? '') . ' ' . ($variables[$variable] ?? ''), |
||||
$entryObject, |
||||
$build, |
||||
); |
||||
($this->output)('Caching PHP ' . strtoupper($target) . ' runtime: ' . $archives[$target]); |
||||
$temporary = $archives[$target] . '.part-' . bin2hex(random_bytes(6)); |
||||
$this->run(['ar', 'rcs', $temporary, ...$objects], $build); |
||||
if (!rename($temporary, $archives[$target])) { |
||||
@unlink($temporary); |
||||
throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]); |
||||
} |
||||
} |
||||
return $archives; |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private function objectFiles(string $value, string $excluded, string $build): array |
||||
{ |
||||
$objects = []; |
||||
foreach (preg_split('/\s+/', trim($value)) ?: [] as $object) { |
||||
if ($object === '' || $object === $excluded) { |
||||
continue; |
||||
} |
||||
$object = preg_replace('/\.lo$/', '.o', $object) ?? $object; |
||||
$path = str_starts_with($object, '/') ? $object : $build . '/' . $object; |
||||
if (!is_file($path)) { |
||||
throw new \RuntimeException("PHP build object does not exist: {$path}"); |
||||
} |
||||
$objects[] = $path; |
||||
} |
||||
if ($objects === []) { |
||||
throw new \RuntimeException('PHP build did not publish objects for the requested SAPI archive'); |
||||
} |
||||
return $objects; |
||||
} |
||||
|
||||
/** @param list<string> $names @return array<string, string> */ |
||||
private function readMakeVariables(string $makefile, array $names): array |
||||
{ |
||||
$contents = (string) file_get_contents($makefile); |
||||
$values = []; |
||||
foreach ($names as $name) { |
||||
if (preg_match('/^' . preg_quote($name, '/') . '[ \t]*=[ \t]*(.*)$/m', $contents, $match) === 1) { |
||||
$values[$name] = trim($match[1]); |
||||
} |
||||
} |
||||
return $values; |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private function sourceConfigureOptions(string $source): array |
||||
{ |
||||
$configNice = $source . '/config.nice'; |
||||
if (!is_file($configNice)) { |
||||
return ['--enable-zts', '--enable-mbstring', '--enable-sockets', '--with-zlib']; |
||||
} |
||||
$contents = (string) file_get_contents($configNice); |
||||
$contents = preg_replace('/^#!.*\n|^#.*\n/m', '', $contents) ?? $contents; |
||||
$contents = str_replace(["\\\r\n", "\\\n", '"$@"', "'$@'"], [' ', ' ', '', ''], $contents); |
||||
$options = PhpBuildConfiguration::parseShellWords($contents); |
||||
return array_values(array_filter($options, static fn (string $value): bool => str_starts_with($value, '--'))); |
||||
} |
||||
|
||||
/** @param list<string> $command */ |
||||
private function run(array $command, string $directory): void |
||||
{ |
||||
($this->output)('$ ' . implode(' ', array_map('escapeshellarg', $command))); |
||||
$logPath = rtrim($directory, '/\\') . '/.typephp-build.log'; |
||||
$log = fopen($logPath, 'ab'); |
||||
if ($log === false) { |
||||
throw new \RuntimeException("Unable to open SAPI build log: {$logPath}"); |
||||
} |
||||
fwrite($log, PHP_EOL . '$ ' . implode(' ', array_map('escapeshellarg', $command)) . PHP_EOL); |
||||
$process = proc_open($command, [STDIN, $log, $log], $pipes, $directory); |
||||
$status = is_resource($process) ? proc_close($process) : -1; |
||||
fclose($log); |
||||
if ($status !== 0) { |
||||
$contents = (string) @file_get_contents($logPath); |
||||
$lines = preg_split('/\R/', trim($contents)) ?: []; |
||||
$tail = implode(PHP_EOL, array_slice($lines, -40)); |
||||
throw new \RuntimeException('Command failed: ' . implode(' ', $command) . PHP_EOL . 'Build log: ' . $logPath . ($tail === '' ? '' : PHP_EOL . $tail)); |
||||
} |
||||
} |
||||
|
||||
private function mkdir(string $directory): void |
||||
{ |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new \RuntimeException("Cannot create SAPI build directory: {$directory}"); |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue