- Implement configureProxyEnvironment function to export CLI proxy for child processes - Add --sapi option supporting embed, cli, fpm targets with comma-separated lists - Introduce --php-builder option for building PHP from source with extensions and ZTS - Update documentation for new SAPI and PHP builder configuration options - Add PeclExtensionSource class for managing PECL extension downloads - Modify build configuration to support PHP builder and multiple SAPI targets - Update compiler base with new build mode validation and SAPI handling - Enhance native build configuration with PHP builder specific library paths - Add comprehensive test coverage for new SAPI and PHP builder featuresmaster
parent
e16c787f44
commit
ceeb1da3f9
27 changed files with 1117 additions and 253 deletions
@ -0,0 +1,59 @@ |
||||
<?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\PhpBuilderConfiguration; |
||||
|
||||
/** |
||||
* @internal |
||||
* @coversNothing |
||||
*/ |
||||
final class PhpBuilderConfigurationTest extends TestCase |
||||
{ |
||||
public function testParsesYamlMapping(): void |
||||
{ |
||||
$configuration = PhpBuilderConfiguration::fromYaml([ |
||||
'zts' => 'on', |
||||
'extensions' => ['swoole', 'ext-mongodb', 'swoole'], |
||||
]); |
||||
|
||||
self::assertTrue($configuration->zts); |
||||
self::assertSame(['mongodb', 'swoole'], $configuration->extensions); |
||||
} |
||||
|
||||
public function testParsesCommandLineSyntax(): void |
||||
{ |
||||
$configuration = PhpBuilderConfiguration::fromCommandLine( |
||||
'extensions: [curl, mbstring]; zts: off;', |
||||
); |
||||
|
||||
self::assertFalse($configuration->zts); |
||||
self::assertSame(['curl', 'mbstring'], $configuration->extensions); |
||||
} |
||||
|
||||
public function testRejectsLegacyListOfMappingsYamlShape(): void |
||||
{ |
||||
$this->expectException(\InvalidArgumentException::class); |
||||
$this->expectExceptionMessage('`php-builder` must be a mapping'); |
||||
|
||||
PhpBuilderConfiguration::fromYaml([ |
||||
['zts' => true], |
||||
['extensions' => []], |
||||
]); |
||||
} |
||||
|
||||
public function testRejectsUnknownOption(): void |
||||
{ |
||||
$this->expectException(\InvalidArgumentException::class); |
||||
$this->expectExceptionMessage('Unknown `php-builder` option'); |
||||
|
||||
PhpBuilderConfiguration::fromYaml(['sapi' => ['embed']]); |
||||
} |
||||
} |
||||
@ -1,24 +1,24 @@ |
||||
<?php |
||||
|
||||
namespace TypePhpTest\Build; |
||||
namespace TypePhp\Tests\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\SapiBuildConfiguration; |
||||
|
||||
final class SapiBuildConfigurationTest extends TestCase |
||||
{ |
||||
public function testTargetsAcceptSingleBothAndLists(): void |
||||
public function testTargetsAcceptEmbedCliFpmAndLists(): void |
||||
{ |
||||
self::assertSame(['embed'], SapiBuildConfiguration::parseTargets('embed')); |
||||
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')); |
||||
self::assertSame(['embed', 'cli', 'fpm'], SapiBuildConfiguration::parseTargets('embed, cli, fpm')); |
||||
} |
||||
|
||||
public function testInvalidTargetIsRejected(): void |
||||
{ |
||||
$this->expectException(\InvalidArgumentException::class); |
||||
$this->expectExceptionMessage('Expected cli, fpm, or both'); |
||||
$this->expectExceptionMessage('Expected embed, cli, or fpm'); |
||||
SapiBuildConfiguration::parseTargets('apache'); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,112 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use TypePhp\Http\Downloader; |
||||
|
||||
final class PeclExtensionSource |
||||
{ |
||||
private readonly \Closure $output; |
||||
private readonly Downloader $downloader; |
||||
|
||||
/** @param callable(string):void $output */ |
||||
public function __construct( |
||||
private readonly string $cacheDirectory, |
||||
callable $output, |
||||
?string $proxy = null, |
||||
) { |
||||
$this->output = \Closure::fromCallable($output); |
||||
$this->downloader = new Downloader($proxy); |
||||
} |
||||
|
||||
/** @return array{directory:string,version:string} */ |
||||
public function prepare(string $extension): array |
||||
{ |
||||
$extension = SapiExtensionRequirements::normalize($extension); |
||||
if (preg_match('/^[a-z][a-z0-9_-]*$/D', $extension) !== 1) { |
||||
throw new \InvalidArgumentException("Invalid PECL extension name `{$extension}`"); |
||||
} |
||||
|
||||
$version = trim($this->downloader->downloadText( |
||||
'https://pecl.php.net/rest/r/' . rawurlencode($extension) . '/stable.txt', |
||||
)); |
||||
if (preg_match('/^[0-9][A-Za-z0-9._-]*$/D', $version) !== 1) { |
||||
throw new \RuntimeException("PECL extension `{$extension}` has no stable source release"); |
||||
} |
||||
|
||||
$root = $this->cacheDirectory . '/pecl/' . $extension . '-' . $version; |
||||
if ($this->isComplete($root)) { |
||||
return ['directory' => $root, 'version' => $version]; |
||||
} |
||||
$this->mkdir($this->cacheDirectory . '/pecl'); |
||||
$lock = fopen($this->cacheDirectory . '/pecl/download.lock', 'c+'); |
||||
if ($lock === false || !flock($lock, LOCK_EX)) { |
||||
throw new \RuntimeException('Unable to lock the PECL source cache'); |
||||
} |
||||
try { |
||||
if ($this->isComplete($root)) { |
||||
return ['directory' => $root, 'version' => $version]; |
||||
} |
||||
if (is_dir($root)) { |
||||
throw new \RuntimeException("Incomplete PECL source cache must be removed: {$root}"); |
||||
} |
||||
$archives = $this->cacheDirectory . '/archives'; |
||||
$this->mkdir($archives); |
||||
$archive = $archives . '/' . $extension . '-' . $version . '.tgz'; |
||||
if (!is_file($archive)) { |
||||
($this->output)("Downloading PECL extension {$extension}-{$version}"); |
||||
$temporary = $archive . '.part-' . bin2hex(random_bytes(6)); |
||||
try { |
||||
$this->downloader->downloadFile( |
||||
'https://pecl.php.net/get/' . rawurlencode($extension . '-' . $version) . '.tgz', |
||||
$temporary, |
||||
); |
||||
if (!rename($temporary, $archive)) { |
||||
throw new \RuntimeException("Unable to cache PECL archive: {$archive}"); |
||||
} |
||||
} finally { |
||||
if (is_file($temporary)) { |
||||
@unlink($temporary); |
||||
} |
||||
} |
||||
} |
||||
($this->output)("Extracting PECL extension {$extension}-{$version}"); |
||||
$this->run(['tar', '-xzf', $archive, '-C', $this->cacheDirectory . '/pecl']); |
||||
if (!$this->isComplete($root)) { |
||||
throw new \RuntimeException("PECL archive did not produce extension source: {$root}"); |
||||
} |
||||
return ['directory' => $root, 'version' => $version]; |
||||
} finally { |
||||
flock($lock, LOCK_UN); |
||||
fclose($lock); |
||||
} |
||||
} |
||||
|
||||
private function isComplete(string $directory): bool |
||||
{ |
||||
return is_dir($directory) |
||||
&& (is_file($directory . '/config.m4') || is_file($directory . '/config0.m4')); |
||||
} |
||||
|
||||
/** @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)); |
||||
} |
||||
} |
||||
|
||||
private function mkdir(string $directory): void |
||||
{ |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new \RuntimeException("Cannot create PECL source cache directory: {$directory}"); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,143 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use Symfony\Component\Yaml\Yaml; |
||||
|
||||
final readonly class PhpBuilderConfiguration |
||||
{ |
||||
/** |
||||
* @param list<string> $extensions |
||||
*/ |
||||
public function __construct( |
||||
public bool $zts, |
||||
public array $extensions, |
||||
) { |
||||
} |
||||
|
||||
public static function fromYaml(mixed $value): self |
||||
{ |
||||
if (!is_array($value) || ($value !== [] && array_is_list($value))) { |
||||
throw new \InvalidArgumentException('`php-builder` must be a mapping'); |
||||
} |
||||
return self::fromArray($value); |
||||
} |
||||
|
||||
public static function fromCommandLine(string $value): self |
||||
{ |
||||
$value = trim($value); |
||||
if ($value === '') { |
||||
throw new \InvalidArgumentException('Option --php-builder requires a non-empty configuration'); |
||||
} |
||||
|
||||
try { |
||||
$parsed = Yaml::parse(self::semicolonSeparatedYaml($value)); |
||||
} catch (\Throwable $exception) { |
||||
throw new \InvalidArgumentException( |
||||
'Invalid --php-builder configuration: ' . $exception->getMessage(), |
||||
previous: $exception, |
||||
); |
||||
} |
||||
return self::fromYaml($parsed); |
||||
} |
||||
|
||||
/** @param array<string, mixed> $config */ |
||||
private static function fromArray(array $config): self |
||||
{ |
||||
$unknown = array_diff(array_keys($config), ['zts', 'extensions']); |
||||
if ($unknown !== []) { |
||||
throw new \InvalidArgumentException( |
||||
'Unknown `php-builder` option' . (count($unknown) > 1 ? 's' : '') |
||||
. ': ' . implode(', ', array_map(static fn (string $key): string => "`{$key}`", $unknown)), |
||||
); |
||||
} |
||||
$extensions = self::stringList($config['extensions'] ?? [], 'php-builder.extensions', true); |
||||
$extensions = SapiExtensionRequirements::merge($extensions); |
||||
|
||||
return new self( |
||||
self::boolean($config['zts'] ?? false), |
||||
$extensions, |
||||
); |
||||
} |
||||
|
||||
/** @return list<string> */ |
||||
private static function stringList(mixed $value, string $name, bool $allowEmpty = false): array |
||||
{ |
||||
$values = is_string($value) ? preg_split('/\s*,\s*/', trim($value)) : $value; |
||||
if (!is_array($values)) { |
||||
throw new \InvalidArgumentException("`{$name}` must be a string or list"); |
||||
} |
||||
$result = []; |
||||
foreach ($values as $entry) { |
||||
if (!is_string($entry) || trim($entry) === '') { |
||||
throw new \InvalidArgumentException("Each `{$name}` entry must be a non-empty string"); |
||||
} |
||||
$result[] = strtolower(trim($entry)); |
||||
} |
||||
if (!$allowEmpty && $result === []) { |
||||
throw new \InvalidArgumentException("`{$name}` must not be empty"); |
||||
} |
||||
return $result; |
||||
} |
||||
|
||||
private static function boolean(mixed $value): bool |
||||
{ |
||||
if (is_bool($value)) { |
||||
return $value; |
||||
} |
||||
if ($value === 1 || $value === 0) { |
||||
return (bool) $value; |
||||
} |
||||
if (is_string($value)) { |
||||
return match (strtolower(trim($value))) { |
||||
'on', 'true', 'yes', '1' => true, |
||||
'off', 'false', 'no', '0' => false, |
||||
default => throw new \InvalidArgumentException('`php-builder.zts` must be on or off'), |
||||
}; |
||||
} |
||||
throw new \InvalidArgumentException('`php-builder.zts` must be on or off'); |
||||
} |
||||
|
||||
private static function semicolonSeparatedYaml(string $value): string |
||||
{ |
||||
$result = ''; |
||||
$quote = null; |
||||
$depth = 0; |
||||
$escaped = false; |
||||
foreach (str_split($value) as $character) { |
||||
if ($escaped) { |
||||
$result .= $character; |
||||
$escaped = false; |
||||
continue; |
||||
} |
||||
if ($character === '\\' && $quote === '"') { |
||||
$result .= $character; |
||||
$escaped = true; |
||||
continue; |
||||
} |
||||
if (($character === '"' || $character === "'") && ($quote === null || $quote === $character)) { |
||||
$quote = $quote === null ? $character : null; |
||||
$result .= $character; |
||||
continue; |
||||
} |
||||
if ($quote === null) { |
||||
if ($character === '[' || $character === '{') { |
||||
$depth++; |
||||
} elseif ($character === ']' || $character === '}') { |
||||
$depth--; |
||||
} elseif ($character === ';' && $depth === 0) { |
||||
$result .= "\n"; |
||||
continue; |
||||
} |
||||
} |
||||
$result .= $character; |
||||
} |
||||
return preg_replace('/\n[ \t]+/', "\n", trim($result)) ?? trim($result); |
||||
} |
||||
} |
||||
@ -0,0 +1,94 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
final class PhpBuilderSource |
||||
{ |
||||
private readonly \Closure $output; |
||||
|
||||
/** @param callable(string):void $output */ |
||||
public function __construct( |
||||
private readonly string $cacheDirectory, |
||||
callable $output, |
||||
private readonly ?string $proxy = null, |
||||
) { |
||||
$this->output = \Closure::fromCallable($output); |
||||
} |
||||
|
||||
/** |
||||
* @param list<string> $extensions |
||||
* @return array{source:string,external:array<string,string>} |
||||
*/ |
||||
public function prepare(string $officialSource, array $extensions): array |
||||
{ |
||||
$external = []; |
||||
$sources = []; |
||||
$pecl = new PeclExtensionSource($this->cacheDirectory, $this->output, $this->proxy); |
||||
foreach (SapiExtensionRequirements::merge($extensions) as $extension) { |
||||
$directoryName = str_replace('-', '_', $extension); |
||||
if (is_dir($officialSource . '/ext/' . $directoryName)) { |
||||
continue; |
||||
} |
||||
$release = $pecl->prepare($extension); |
||||
$external[$extension] = $release['version']; |
||||
$sources[$extension] = $release['directory']; |
||||
} |
||||
if ($external === []) { |
||||
return ['source' => $officialSource, 'external' => []]; |
||||
} |
||||
|
||||
ksort($external, SORT_STRING); |
||||
$version = OfficialPhpSource::version($officialSource); |
||||
$fingerprint = substr(hash('sha256', json_encode($external, JSON_THROW_ON_ERROR)), 0, 16); |
||||
$source = $this->cacheDirectory . '/php-builder-src/php-' . $version . '-' . $fingerprint; |
||||
$manifest = $source . '/.typephp-extensions.json'; |
||||
$metadata = json_encode($external, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) |
||||
. PHP_EOL; |
||||
if (is_file($source . '/configure') && @file_get_contents($manifest) === $metadata) { |
||||
return ['source' => $source, 'external' => $external]; |
||||
} |
||||
if (is_dir($source)) { |
||||
throw new \RuntimeException("Incomplete PHP builder source cache must be removed: {$source}"); |
||||
} |
||||
|
||||
$parent = dirname($source); |
||||
$this->mkdir($parent); |
||||
$temporary = $source . '.part-' . bin2hex(random_bytes(6)); |
||||
$this->mkdir($temporary); |
||||
($this->output)('Preparing php-src with external extensions: ' . implode(', ', array_keys($external))); |
||||
$this->run(['cp', '-a', rtrim($officialSource, '/\\') . '/.', $temporary]); |
||||
foreach ($sources as $extension => $extensionSource) { |
||||
$target = $temporary . '/ext/' . str_replace('-', '_', $extension); |
||||
$this->mkdir($target); |
||||
$this->run(['cp', '-a', rtrim($extensionSource, '/\\') . '/.', $target]); |
||||
} |
||||
$this->run(['sh', $temporary . '/buildconf', '--force'], $temporary); |
||||
AtomicFile::write($temporary . '/.typephp-extensions.json', $metadata); |
||||
if (!rename($temporary, $source)) { |
||||
throw new \RuntimeException("Unable to publish PHP builder source tree: {$source}"); |
||||
} |
||||
return ['source' => $source, 'external' => $external]; |
||||
} |
||||
|
||||
/** @param list<string> $command */ |
||||
private function run(array $command, ?string $directory = null): void |
||||
{ |
||||
$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 PHP builder source directory: {$directory}"); |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue