- 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}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue