- 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 |
<?php |
||||||
|
|
||||||
namespace TypePhpTest\Build; |
namespace TypePhp\Tests\Build; |
||||||
|
|
||||||
use PHPUnit\Framework\TestCase; |
use PHPUnit\Framework\TestCase; |
||||||
use TypePhp\Build\SapiBuildConfiguration; |
use TypePhp\Build\SapiBuildConfiguration; |
||||||
|
|
||||||
final class SapiBuildConfigurationTest extends TestCase |
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'], SapiBuildConfiguration::parseTargets('cli')); |
||||||
self::assertSame(['cli', 'fpm'], SapiBuildConfiguration::parseTargets('both')); |
|
||||||
self::assertSame(['fpm', 'cli'], SapiBuildConfiguration::parseTargets(['fpm', 'cli', 'fpm'])); |
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 |
public function testInvalidTargetIsRejected(): void |
||||||
{ |
{ |
||||||
$this->expectException(\InvalidArgumentException::class); |
$this->expectException(\InvalidArgumentException::class); |
||||||
$this->expectExceptionMessage('Expected cli, fpm, or both'); |
$this->expectExceptionMessage('Expected embed, cli, or fpm'); |
||||||
SapiBuildConfiguration::parseTargets('apache'); |
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