From b893d865d1bf9a43514f24b062518f261b9de5c1 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Thu, 24 Sep 2026 13:11:28 +0800 Subject: [PATCH] feat(compiler): add CLI entry point and improve PHP builder functionality - Add --entry option to specify PHP entry file for CLI SAPI execution - Make --php-builder option optional with default empty configuration {} - Update documentation to reflect new --entry and optional --php-builder options - Add CompilerToolchain class to manage C/C++ compiler and archiver tools - Implement response file support for long object lists in archiving operations - Add progress tracking and detailed build stage reporting for PHP compilation - Support MSVC, GCC and Clang compiler toolchains with proper companion tools - Add test cases for new entry point and toolchain functionality - Adjust build pipeline to exclude entry file from source compilation - Improve build logging and error handling during compilation processes --- README-CN.md | 3 +- README.md | 3 +- completions/tpc.bash | 2 +- docs/en/COMPILER_CLI.md | 3 +- docs/zh-cn/COMPILER_CLI.md | 3 +- phpunit/src/Build/CompilerToolchainTest.php | 60 +++ .../src/Build/PhpBuilderConfigurationTest.php | 8 + phpunit/src/Build/SapiPhpBuilderTest.php | 119 ++++++ phpunit/src/CompilerBaseApiTest.php | 45 +++ src/Build/CompilerToolchain.php | 161 ++++++++ src/Build/PhpBuilderConfiguration.php | 2 +- src/Build/SapiPhpBuilder.php | 361 +++++++++++++++++- src/Build/SourcePipelineTrait.php | 54 +++ src/Metadata/Constants.php | 6 + src/Translator.php | 73 +++- 15 files changed, 869 insertions(+), 34 deletions(-) create mode 100644 phpunit/src/Build/CompilerToolchainTest.php create mode 100644 phpunit/src/Build/SapiPhpBuilderTest.php create mode 100644 src/Build/CompilerToolchain.php diff --git a/README-CN.md b/README-CN.md index 2f6412c7..b589ec2c 100644 --- a/README-CN.md +++ b/README-CN.md @@ -688,7 +688,8 @@ bin/tpc.php --wasm=browser app.php | `-o`, `--output ` | 输出文件名 | | `-m`, `--mode ` | 构建模式(默认 `bin`) | | `--sapi ` | 二进制使用的 SAPI(默认 `embed`,支持逗号分隔多个值) | -| `--php-builder <配置>` | 从 php-src 构建私有 PHP 运行时 | +| `--entry ` | CLI SAPI 启动时执行的 PHP 入口文件 | +| `--php-builder[=<配置>]` | 从 php-src 构建私有 PHP 运行时;省略配置时默认为 `{}` | | `-r`, `--run` | 构建成功后运行 | | `-j`, `--job ` | 并行编译任务数(默认 `4`) | | `-f`, `--force` | 不使用缓存,重新编译可复用 PHPX 对象 | diff --git a/README.md b/README.md index 0b6c077e..f268bb3d 100644 --- a/README.md +++ b/README.md @@ -755,7 +755,8 @@ Key options: | `-o`, `--output ` | Output file name | | `-m`, `--mode ` | Build mode (default `bin`) | | `--sapi ` | Binary SAPI target (default `embed`; comma-separated lists accepted) | -| `--php-builder ` | Build a private PHP runtime from php-src | +| `--entry ` | PHP entry file executed when the CLI SAPI starts | +| `--php-builder[=]` | Build a private PHP runtime from php-src; omitted config defaults to `{}` | | `-r`, `--run` | Run after a successful build | | `-j`, `--job ` | Parallel compile jobs (default `4`) | | `-f`, `--force` | Rebuild reusable PHPX objects instead of using the cache | diff --git a/completions/tpc.bash b/completions/tpc.bash index 4d22413e..bde7bfe5 100644 --- a/completions/tpc.bash +++ b/completions/tpc.bash @@ -144,7 +144,7 @@ _typephp_tpc() return ;; -* ) - COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --php-builder --sapi -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) + COMPREPLY=( $(compgen -W '--nano -O --optimize -o --output -h --help -v --version --profile --no-literal-strings --php-version --proxy -f --force -m --mode --php-builder --sapi --entry -r --run --debug -j --job --no-console --sanitize --cxx-std --march --compiler --target-platform --no-color --build-dir --dry -I --include-path -D --define --no-progress --lto --format -l --link-lib -L --link-path --full-static --wasm --wasm= --gen-python-helper --convert-python-to-php --output-dir --output-dir= --build-dir= --generate-completion=' -- "$current") ) return ;; esac diff --git a/docs/en/COMPILER_CLI.md b/docs/en/COMPILER_CLI.md index a01ce45a..1c590025 100644 --- a/docs/en/COMPILER_CLI.md +++ b/docs/en/COMPILER_CLI.md @@ -68,7 +68,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build | `-o`, `--output ` | Output file name. | | `-m`, `--mode ` | Build mode, default `bin`. | | `--sapi ` | SAPI used by a `bin` target; accepts a comma-separated list. Default: `embed`. | -| `--php-builder ` | Build PHP from php-src, for example `--php-builder='extensions: [swoole, mongodb]; zts: on'`. | +| `--entry ` | PHP entry file executed by the CLI SAPI; the CLI value overrides YAML `entry`. | +| `--php-builder[=]` | Build PHP from php-src; omitted config defaults to `{}`, for example `--php-builder='extensions: [swoole, mongodb]; zts: on'`. | | `-r`, `--run` | Run after a successful build. | | `-j`, `--job ` | Number of parallel compilation jobs, default `4`. | | `-f`, `--force` | Ignore the phpx misc object cache and force recompilation. | diff --git a/docs/zh-cn/COMPILER_CLI.md b/docs/zh-cn/COMPILER_CLI.md index 729c6336..805ce689 100644 --- a/docs/zh-cn/COMPILER_CLI.md +++ b/docs/zh-cn/COMPILER_CLI.md @@ -68,7 +68,8 @@ bin/tpc.php app.php --dry --build-dir /tmp/typephp-build | `-o`, `--output ` | 输出文件名。 | | `-m`, `--mode ` | 构建模式,默认 `bin`。 | | `--sapi ` | `bin` 目标使用的 SAPI,支持逗号分隔的多个值;默认 `embed`。 | -| `--php-builder <配置>` | 从 php-src 构建 PHP,例如 `--php-builder='extensions: [swoole, mongodb]; zts: on'`。 | +| `--entry ` | CLI SAPI 启动时执行的 PHP 入口文件;命令行配置覆盖 YAML 的 `entry`。 | +| `--php-builder[=<配置>]` | 从 php-src 构建 PHP;省略配置时默认为 `{}`,例如 `--php-builder='extensions: [swoole, mongodb]; zts: on'`。 | | `-r`, `--run` | 构建成功后运行。 | | `-j`, `--job ` | 并行编译任务数,默认 `4`。 | | `-f`, `--force` | 忽略 phpx misc 对象缓存,强制重新编译。 | diff --git a/phpunit/src/Build/CompilerToolchainTest.php b/phpunit/src/Build/CompilerToolchainTest.php new file mode 100644 index 00000000..54cf2c30 --- /dev/null +++ b/phpunit/src/Build/CompilerToolchainTest.php @@ -0,0 +1,60 @@ +cCompiler); + self::assertSame('/opt/cross/bin/aarch64-linux-gnu-g++', $toolchain->cxxCompiler); + self::assertSame('/opt/cross/bin/aarch64-linux-gnu-gcc-ar', $toolchain->archiver); + self::assertSame(CompilerToolchain::ARCHIVER_UNIX, $toolchain->archiverStyle); + } + + public function testDerivesVersionedClangCompanionTools(): void + { + $toolchain = CompilerToolchain::fromBackend(new Clang( + new Macos(), + '/opt/llvm/bin/clang++-18', + )); + + self::assertSame('/opt/llvm/bin/clang-18', $toolchain->cCompiler); + self::assertSame('/opt/llvm/bin/clang++-18', $toolchain->cxxCompiler); + self::assertSame('/opt/llvm/bin/llvm-ar-18', $toolchain->archiver); + } + + public function testSelectsMsvcLibraryManager(): void + { + $toolchain = CompilerToolchain::fromBackend(new Msvc(new Windows(), 'cl')); + + self::assertSame('cl', $toolchain->cCompiler); + self::assertSame('cl', $toolchain->cxxCompiler); + self::assertContains(strtolower(basename($toolchain->archiver)), ['lib', 'lib.exe']); + self::assertSame(CompilerToolchain::ARCHIVER_MSVC, $toolchain->archiverStyle); + } +} diff --git a/phpunit/src/Build/PhpBuilderConfigurationTest.php b/phpunit/src/Build/PhpBuilderConfigurationTest.php index ae9786d7..78c7e70e 100644 --- a/phpunit/src/Build/PhpBuilderConfigurationTest.php +++ b/phpunit/src/Build/PhpBuilderConfigurationTest.php @@ -38,6 +38,14 @@ final class PhpBuilderConfigurationTest extends TestCase self::assertSame(['curl', 'mbstring'], $configuration->extensions); } + public function testEmptyCommandLineConfigurationUsesMappingDefaults(): void + { + $configuration = PhpBuilderConfiguration::fromCommandLine(''); + + self::assertFalse($configuration->zts); + self::assertSame([], $configuration->extensions); + } + public function testRejectsLegacyListOfMappingsYamlShape(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/phpunit/src/Build/SapiPhpBuilderTest.php b/phpunit/src/Build/SapiPhpBuilderTest.php new file mode 100644 index 00000000..29c90576 --- /dev/null +++ b/phpunit/src/Build/SapiPhpBuilderTest.php @@ -0,0 +1,119 @@ +invoke($builder, implode("\n", [ + 'cc -c ext/json/json.c -o ext/json/json.lo -MMD', + "cc -c Zend/zend.c -o 'Zend/zend.lo' -MMD", + 'cc -c main/main.c -o "main/main.lo" -MMD', + 'cc -c ext/json/json.c -o ext/json/json.lo -MMD', + 'cc objects.o -o sapi/cli/php', + ]))); + } + + public function testAbbreviatesLongArchiverCommands(): void + { + $builder = new SapiPhpBuilder(__DIR__, static function (string $message): void {}); + $method = new \ReflectionMethod($builder, 'displayCommand'); + + self::assertSame( + "'ar' 'rcs' '/tmp/libphp.a' '@<6 object files>'", + $method->invoke($builder, [ + 'ar', + 'rcs', + '/tmp/libphp.a', + 'one.o', + 'two.o', + 'three.o', + 'four.o', + 'five.o', + 'six.o', + ]), + ); + } + + public function testBuildsArchiveCommandForSelectedToolchain(): void + { + $gcc = new SapiPhpBuilder( + __DIR__, + static function (string $message): void {}, + toolchain: new CompilerToolchain( + '/opt/gcc/bin/gcc', + '/opt/gcc/bin/g++', + '/opt/gcc/bin/gcc-ar', + CompilerToolchain::ARCHIVER_UNIX, + ), + ); + $method = new \ReflectionMethod($gcc, 'archiveCommand'); + self::assertSame( + ['/opt/gcc/bin/gcc-ar', 'rcs', '/tmp/libphp.a', '@/tmp/objects.rsp'], + $method->invoke($gcc, '/tmp/libphp.a', '@/tmp/objects.rsp'), + ); + + $msvc = new SapiPhpBuilder( + __DIR__, + static function (string $message): void {}, + toolchain: new CompilerToolchain('cl', 'cl', 'lib.exe', CompilerToolchain::ARCHIVER_MSVC), + ); + self::assertSame( + ['lib.exe', '/NOLOGO', '/OUT:C:\\tmp\\php.lib', '@C:\\tmp\\objects.rsp'], + $method->invoke($msvc, 'C:\\tmp\\php.lib', '@C:\\tmp\\objects.rsp'), + ); + } + + public function testReservesOneProgressStepUntilMakeActuallyFinishes(): void + { + $builder = new SapiPhpBuilder(__DIR__, static function (string $message): void {}); + $method = new \ReflectionMethod($builder, 'liveProgressValue'); + + self::assertSame(630, $method->invoke($builder, 634, 634)); + self::assertSame(512, $method->invoke($builder, 512, 634)); + self::assertSame(0, $method->invoke($builder, 1, 1)); + } + + public function testDetectsPostCompilationBuildStages(): void + { + $builder = new SapiPhpBuilder(__DIR__, static function (string $message): void {}); + $method = new \ReflectionMethod($builder, 'detectBuildStage'); + + self::assertSame( + 'Linking OPcache', + $method->invoke($builder, 'libtool --mode=link gcc -shared -o ext/opcache/opcache.la objects.o'), + ); + self::assertSame( + 'Linking PHP CLI', + $method->invoke($builder, 'libtool --mode=link gcc objects.o -o sapi/cli/php'), + ); + self::assertSame( + 'Linking PHP FPM', + $method->invoke($builder, 'libtool --mode=link gcc objects.o -o sapi/fpm/php-fpm'), + ); + self::assertSame('Generating phar.php', $method->invoke($builder, 'Generating phar.php')); + self::assertSame('Generating phar.phar', $method->invoke($builder, 'Generating phar.phar')); + } +} diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index fca5c266..d8175ac6 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -1013,6 +1013,35 @@ YAML); $this->invokeMethod('validateLoadedProjectConfiguration'); } + public function testCommandLineEntryConfiguresPhpBuilderCliSapi(): void + { + global $argv; + $source = $this->testDir . '/foo.php'; + $entry = $this->testDir . '/index.php'; + file_put_contents($source, "testDir); + $files = $compiler->getFiles($source); + $reflection = new \ReflectionClass($compiler); + $entryProperty = $reflection->getProperty('sapiEntryFile'); + $embeddedFilesProperty = (new \ReflectionClass(\TypePhp\Translator::class)) + ->getProperty('embeddedFiles'); + + $this->assertSame([$source], $files); + $this->assertSame(['cli'], $compiler->getSapiTargets()); + $this->assertTrue($compiler->isPhpBuilderBuild()); + $this->assertSame($entry, $entryProperty->getValue($compiler)); + $this->assertContains($entry, $embeddedFilesProperty->getValue($compiler)); + } + public function testPhpBuilderCliRequiresEntry(): void { $projectFile = $this->createProjectFile(<<<'YAML' @@ -1165,6 +1194,22 @@ YAML, 'myproject.yml', 'cli-output'); $this->assertSame('out_file', $targetProp->getValue($compiler)); } + public function testBarePhpBuilderOptionKeepsFollowingSourcePositional(): void + { + global $argv; + $source = $this->testDir . '/hello.php'; + file_put_contents($source, "testDir); + $method = (new \ReflectionClass($compiler))->getMethod('applyCommandLineArguments'); + $method->setAccessible(true); + $method->invoke($compiler); + + $this->assertSame(['compiler.php', '--php-builder={}', $source], $argv); + $this->assertTrue($compiler->isPhpBuilderBuild()); + } + public function testApplyCommandLineArgumentsDoesNotClearYamlRepeatableOptionsWhenCliAbsent(): void { $projectFile = $this->createProjectFile(<<<'YAML' diff --git a/src/Build/CompilerToolchain.php b/src/Build/CompilerToolchain.php new file mode 100644 index 00000000..0f331097 --- /dev/null +++ b/src/Build/CompilerToolchain.php @@ -0,0 +1,161 @@ +getCompilerCommand(); + [$words, $compilerIndex] = self::compilerWords($cxx); + $compiler = $words[$compilerIndex]; + + if ($backend instanceof Gcc) { + $cCompiler = self::replaceCompilerWord($words, $compilerIndex, self::gccCCompiler($compiler)); + $archiver = self::resolveCompanion($compiler, self::gccArchiverNames($compiler)); + return new self($cCompiler, $cxx, $archiver, self::ARCHIVER_UNIX); + } + if ($backend instanceof Clang) { + $cCompiler = self::replaceCompilerWord($words, $compilerIndex, self::clangCCompiler($compiler)); + $archiver = self::resolveCompanion($compiler, self::clangArchiverNames($compiler)); + return new self($cCompiler, $cxx, $archiver, self::ARCHIVER_UNIX); + } + if ($backend instanceof Msvc) { + $archiver = self::resolveCompanion($compiler, ['lib.exe', 'lib']); + return new self($cxx, $cxx, $archiver, self::ARCHIVER_MSVC); + } + throw new \RuntimeException('Unsupported PHP builder compiler toolchain: ' . $backend->getName()); + } + + /** @return array{list, int} */ + private static function compilerWords(string $command): array + { + $words = PhpBuildConfiguration::parseShellWords($command); + if ($words === []) { + throw new \RuntimeException('The compiler command is empty'); + } + for ($index = count($words) - 1; $index >= 0; --$index) { + $name = strtolower(preg_replace('/\.exe$/i', '', basename(str_replace('\\', '/', $words[$index]))) ?? ''); + if (preg_match('/(?:^|-)clang(?:\+\+|-cl)?(?:-\d+)?$|(?:^|-)(?:g\+\+|gcc|c\+\+|cl)(?:-\d+)?$/', $name) === 1) { + return [$words, $index]; + } + } + return [$words, 0]; + } + + /** @param list $words */ + private static function replaceCompilerWord(array $words, int $index, string $compiler): string + { + $words[$index] = $compiler; + if (count($words) === 1) { + return $compiler; + } + return implode(' ', array_map('escapeshellarg', $words)); + } + + private static function gccCCompiler(string $compiler): string + { + return self::replaceBasename($compiler, static function (string $name): string { + $name = preg_replace('/g\+\+/', 'gcc', $name, 1, $count) ?? $name; + if ($count === 0) { + $name = preg_replace('/c\+\+/', 'cc', $name, 1) ?? $name; + } + return $name; + }); + } + + private static function clangCCompiler(string $compiler): string + { + return self::replaceBasename( + $compiler, + static fn (string $name): string => preg_replace('/clang\+\+/', 'clang', $name, 1) ?? $name, + ); + } + + /** @return list */ + private static function gccArchiverNames(string $compiler): array + { + $name = basename(str_replace('\\', '/', $compiler)); + $gccAr = preg_replace('/g\+\+|gcc|c\+\+/', 'gcc-ar', $name, 1, $count) ?? $name; + if ($count === 0) { + $gccAr = 'gcc-ar'; + } + $plainAr = preg_replace('/g\+\+|gcc|c\+\+/', 'ar', $name, 1, $plainCount) ?? $name; + if ($plainCount === 0) { + $plainAr = 'ar'; + } + return array_values(array_unique([$gccAr, 'gcc-ar', $plainAr, 'ar'])); + } + + /** @return list */ + private static function clangArchiverNames(string $compiler): array + { + $name = strtolower(preg_replace('/\.exe$/i', '', basename(str_replace('\\', '/', $compiler))) ?? ''); + $suffix = preg_match('/clang(?:\+\+)?-(\d+)$/', $name, $match) === 1 ? '-' . $match[1] : ''; + $names = ['llvm-ar' . $suffix, 'llvm-ar']; + if (PHP_OS_FAMILY === 'Darwin') { + // Xcode ships the LLVM archiver as /usr/bin/ar on some releases. + $names[] = 'ar'; + } + return array_values(array_unique($names)); + } + + /** @param list $names */ + private static function resolveCompanion(string $compiler, array $names): string + { + $normalized = str_replace('\\', '/', $compiler); + $hasDirectory = str_contains($normalized, '/'); + if ($hasDirectory) { + $directory = dirname($normalized); + foreach ($names as $name) { + $resolved = ExecutableLocator::resolve($directory . DIRECTORY_SEPARATOR . $name); + if ($resolved !== null) { + return $resolved; + } + } + // An explicitly selected compiler directory defines the toolchain + // boundary. Do not silently take an unrelated archiver from PATH. + return $directory . DIRECTORY_SEPARATOR . $names[0]; + } + foreach ($names as $name) { + $resolved = ExecutableLocator::resolve($name); + if ($resolved !== null) { + return $resolved; + } + } + return $names[0]; + } + + private static function replaceBasename(string $path, callable $replace): string + { + $normalized = str_replace('\\', '/', $path); + $name = $replace(basename($normalized)); + if (!str_contains($normalized, '/')) { + return $name; + } + return dirname($normalized) . DIRECTORY_SEPARATOR . $name; + } +} diff --git a/src/Build/PhpBuilderConfiguration.php b/src/Build/PhpBuilderConfiguration.php index b9588e77..d059cfc1 100644 --- a/src/Build/PhpBuilderConfiguration.php +++ b/src/Build/PhpBuilderConfiguration.php @@ -33,7 +33,7 @@ final readonly class PhpBuilderConfiguration { $value = trim($value); if ($value === '') { - throw new \InvalidArgumentException('Option --php-builder requires a non-empty configuration'); + return self::fromYaml([]); } try { diff --git a/src/Build/SapiPhpBuilder.php b/src/Build/SapiPhpBuilder.php index 26bee646..bb7d0ad9 100644 --- a/src/Build/SapiPhpBuilder.php +++ b/src/Build/SapiPhpBuilder.php @@ -13,14 +13,22 @@ use TypePhp\Installer\PhpBuildConfiguration; final class SapiPhpBuilder { private readonly \Closure $output; + private readonly ?\Closure $progress; + private ?CompilerToolchain $activeToolchain = null; - /** @param callable(string):void $output */ + /** + * @param callable(string):void $output + * @param null|callable(int, int, string, bool):void $progress + */ public function __construct( private readonly string $phpxSourceDirectory, callable $output, private readonly ?string $proxy = null, + ?callable $progress = null, + private readonly ?CompilerToolchain $toolchain = null, ) { $this->output = \Closure::fromCallable($output); + $this->progress = $progress === null ? null : \Closure::fromCallable($progress); } /** @@ -40,6 +48,12 @@ final class SapiPhpBuilder if ($targets === [] || array_diff($targets, ['embed', 'cli', 'fpm']) !== []) { throw new \InvalidArgumentException('PHP builder SAPI targets must contain embed, cli, or fpm'); } + $this->activeToolchain = $this->toolchain ?? new CompilerToolchain( + getenv('CC') ?: 'cc', + getenv('CXX') ?: 'c++', + getenv('AR') ?: 'ar', + CompilerToolchain::ARCHIVER_UNIX, + ); $officialSource = (new OfficialPhpSource( OfficialPhpSource::defaultCacheDirectory(), $this->output, @@ -66,8 +80,9 @@ final class SapiPhpBuilder $externalExtensions, PHP_OS_FAMILY, php_uname('m'), - getenv('CC') ?: '', - getenv('CXX') ?: '', + $this->activeToolchain->cCompiler, + $this->activeToolchain->cxxCompiler, + $this->activeToolchain->archiver, filemtime($source . '/configure'), ]; $compatibility = hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR)); @@ -124,6 +139,7 @@ final class SapiPhpBuilder if ($needsInstall) { ($this->output)('Building private PHP runtime (cached across application builds)'); $this->run(['make', '-j' . max(1, $jobs)], $build); + ($this->output)('Installing private PHP runtime'); $this->run(['make', 'install'], $build); } @@ -325,7 +341,22 @@ final class SapiPhpBuilder ); ($this->output)('Caching PHP ' . strtoupper($target) . ' runtime: ' . $archives[$target]); $temporary = $archives[$target] . '.part-' . bin2hex(random_bytes(6)); - $this->run(['ar', 'rcs', $temporary, ...$objects], $build); + $responseFile = $archives[$target] . '.objects.rsp'; + AtomicFile::write( + $responseFile, + implode(PHP_EOL, array_map($this->quoteResponseFileArgument(...), $objects)) . PHP_EOL, + ); + try { + $this->run($this->archiveCommand($temporary, '@' . $responseFile), $build); + } catch (\RuntimeException) { + // GNU ar and llvm-ar accept @response files. Keep a fallback + // for older platform archivers while still abbreviating the + // displayed command so hundreds of object paths are not + // written to the user's terminal. + @unlink($temporary); + ($this->output)('Archiver response files are unavailable; retrying with direct arguments'); + $this->run($this->archiveCommand($temporary, ...$objects), $build); + } if (!rename($temporary, $archives[$target])) { @unlink($temporary); throw new \RuntimeException('Unable to store PHP SAPI archive: ' . $archives[$target]); @@ -388,17 +419,133 @@ final class SapiPhpBuilder /** @param list $command */ private function run(array $command, string $directory): void { - ($this->output)('$ ' . implode(' ', array_map('escapeshellarg', $command))); + ($this->output)('$ ' . $this->displayCommand($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; + + $pendingObjects = $this->pendingMakeObjects($command, $directory); + $pendingObjectSet = array_fill_keys($pendingObjects, true); + $isCmakeBuild = basename($command[0]) === 'cmake' && in_array('--build', $command, true); + $progressTotal = $pendingObjects !== [] ? count($pendingObjects) : ($isCmakeBuild ? 100 : 0); + $progressLabel = $pendingObjects !== [] ? 'Building PHP' : 'Building PHPX'; + $progressStage = $progressLabel; + $completed = 0; + $completedObjects = []; + if ($progressTotal !== 0 && $this->progress !== null) { + ($this->progress)(0, $progressTotal, $progressLabel, false); + } + + $process = proc_open($command, [ + STDIN, + ['pipe', 'w'], + ['pipe', 'w'], + ], $pipes, $directory, $this->processEnvironment()); + if (!is_resource($process)) { + fclose($log); + throw new \RuntimeException('Unable to start command: ' . implode(' ', $command)); + } + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $lineBuffers = [1 => '', 2 => '']; + $startedAt = microtime(true); + $lastHeartbeatAt = $startedAt; + $exitStatus = -1; + while (true) { + $read = []; + foreach ([1, 2] as $index) { + if (!feof($pipes[$index])) { + $read[] = $pipes[$index]; + } + } + if ($read !== []) { + $write = null; + $except = null; + @stream_select($read, $write, $except, 1, 0); + foreach ($read as $stream) { + $index = $stream === $pipes[1] ? 1 : 2; + while (($chunk = fread($stream, 8192)) !== false && $chunk !== '') { + fwrite($log, $chunk); + $lineBuffers[$index] .= $chunk; + $this->consumeProgressLines( + $lineBuffers[$index], + $pendingObjectSet, + $completedObjects, + $completed, + $progressTotal, + $progressLabel, + $progressStage, + $isCmakeBuild, + ); + } + } + } else { + usleep(100_000); + } + + $processStatus = proc_get_status($process); + $now = microtime(true); + if ($processStatus['running'] && $now - $lastHeartbeatAt >= 5.0) { + if ($progressTotal !== 0 && $this->progress !== null) { + ($this->progress)( + $this->liveProgressValue($completed, $progressTotal), + $progressTotal, + sprintf('%s (%ds)', $progressStage, (int) ($now - $startedAt)), + false, + ); + } else { + ($this->output)(sprintf( + 'Build command still running (%ds); output: %s', + (int) ($now - $startedAt), + $logPath, + )); + } + $lastHeartbeatAt = $now; + } + if (!$processStatus['running']) { + $exitStatus = $processStatus['exitcode']; + break; + } + } + foreach ([1, 2] as $index) { + $chunk = stream_get_contents($pipes[$index]); + if (is_string($chunk) && $chunk !== '') { + fwrite($log, $chunk); + $lineBuffers[$index] .= $chunk; + } + $this->consumeProgressLines( + $lineBuffers[$index], + $pendingObjectSet, + $completedObjects, + $completed, + $progressTotal, + $progressLabel, + $progressStage, + $isCmakeBuild, + true, + ); + fclose($pipes[$index]); + } + $closeStatus = proc_close($process); + if ($exitStatus < 0) { + $exitStatus = $closeStatus; + } + if ($progressTotal !== 0 && $this->progress !== null) { + if ($exitStatus === 0) { + $completed = $progressTotal; + } + ($this->progress)( + $completed, + $progressTotal, + $exitStatus === 0 ? $progressLabel . ' complete' : $progressLabel . ' failed', + true, + ); + } fclose($log); - if ($status !== 0) { + if ($exitStatus !== 0) { $contents = (string) @file_get_contents($logPath); $lines = preg_split('/\R/', trim($contents)) ?: []; $tail = implode(PHP_EOL, array_slice($lines, -40)); @@ -406,6 +553,204 @@ final class SapiPhpBuilder } } + private function quoteResponseFileArgument(string $argument): string + { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $argument) . '"'; + } + + /** @return list */ + private function archiveCommand(string $archive, string ...$objects): array + { + $toolchain = $this->activeToolchain ?? $this->toolchain; + if ($toolchain === null) { + throw new \LogicException('PHP builder toolchain was not initialized'); + } + if ($toolchain->archiverStyle === CompilerToolchain::ARCHIVER_MSVC) { + return [$toolchain->archiver, '/NOLOGO', '/OUT:' . $archive, ...$objects]; + } + return [$toolchain->archiver, 'rcs', $archive, ...$objects]; + } + + /** @param list $command */ + private function displayCommand(array $command): string + { + $program = strtolower(preg_replace('/\.exe$/i', '', basename(str_replace('\\', '/', $command[0]))) ?? ''); + if ((str_ends_with($program, 'ar') || $program === 'lib') && count($command) > 8) { + $objectCount = count($command) - 3; + return implode(' ', array_map('escapeshellarg', array_slice($command, 0, 3))) + . ' ' . escapeshellarg("@<{$objectCount} object files>"); + } + return implode(' ', array_map('escapeshellarg', $command)); + } + + /** @param list $command @return list */ + private function pendingMakeObjects(array $command, string $directory): array + { + if (basename($command[0]) !== 'make' + || array_filter(array_slice($command, 1), static fn (string $arg): bool => !str_starts_with($arg, '-')) !== [] + ) { + return []; + } + $process = proc_open( + [$command[0], '-n', '-j1'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $directory, + $this->processEnvironment(), + ); + if (!is_resource($process)) { + return []; + } + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + return []; + } + return $this->extractObjectTargets((string) $output . PHP_EOL . (string) $error); + } + + /** @return list */ + private function extractObjectTargets(string $output): array + { + preg_match_all( + '/(?:^|\s)-o\s+(?:\'([^\']+\.lo)\'|"([^"]+\.lo)"|([^\s\'";]+\.lo))(?=\s|$)/m', + $output, + $matches, + PREG_SET_ORDER, + ); + $targets = []; + foreach ($matches as $match) { + $target = $match[1] !== '' ? $match[1] : ($match[2] !== '' ? $match[2] : $match[3]); + $targets[$target] = true; + } + return array_keys($targets); + } + + /** + * @param array $pendingObjects + * @param array $completedObjects + */ + private function consumeProgressLines( + string &$buffer, + array $pendingObjects, + array &$completedObjects, + int &$completed, + int $total, + string $label, + string &$stage, + bool $isCmakeBuild, + bool $flush = false, + ): void { + $lines = preg_split('/\R/', $buffer); + if ($lines === false) { + return; + } + $buffer = $flush ? '' : (array_pop($lines) ?? ''); + foreach ($lines as $line) { + $nextStage = $this->detectBuildStage($line); + if ($nextStage !== null && $nextStage !== $stage) { + $stage = $nextStage; + if ($this->progress !== null && $total !== 0) { + ($this->progress)( + $this->liveProgressValue($completed, $total), + $total, + $stage, + false, + ); + } + } + if ($pendingObjects !== []) { + foreach ($this->extractObjectTargets($line) as $target) { + if (!isset($pendingObjects[$target]) || isset($completedObjects[$target])) { + continue; + } + $completedObjects[$target] = true; + ++$completed; + if ($this->progress !== null) { + if ($completed >= $total) { + $stage = 'Finishing PHP compilation'; + ($this->progress)( + $this->liveProgressValue($completed, $total), + $total, + $stage, + false, + ); + } else { + ($this->progress)( + $this->liveProgressValue($completed, $total), + $total, + $target, + false, + ); + } + } + } + } elseif ($isCmakeBuild && preg_match('/\[\s*(\d{1,3})%\]/', $line, $match) === 1) { + $next = min($total, (int) $match[1]); + if ($next > $completed) { + $completed = $next; + if ($this->progress !== null) { + ($this->progress)($this->liveProgressValue($completed, $total), $total, $label, false); + } + } + } + } + } + + private function liveProgressValue(int $completed, int $total): int + { + // Progressbar rounds to the nearest integer percentage, so total - 1 + // can still render as 100% for a large PHP build (633/634 = 99.84%). + // Reserve enough of the tail to keep every live update below 99.5%; + // the successful process exit is the only event allowed to show 100%. + $maximumLive = max(0, (int) ceil($total * 0.995) - 1); + return min($completed, $maximumLive); + } + + private function detectBuildStage(string $line): ?string + { + if (str_contains($line, '--mode=link')) { + if (preg_match('/(?:^|\s)-o\s+[^\s]*opcache(?:\.la|\.so)(?:\s|$)/', $line) === 1) { + return 'Linking OPcache'; + } + if (preg_match('#(?:^|\s)-o\s+(?:\'|")?sapi/cli/php(?:\'|"|\s|$)#', $line) === 1) { + return 'Linking PHP CLI'; + } + if (preg_match('#(?:^|\s)-o\s+(?:\'|")?sapi/fpm/php-fpm(?:\'|"|\s|$)#', $line) === 1) { + return 'Linking PHP FPM'; + } + if (preg_match('#(?:^|\s)-o\s+(?:\'|")?(?:libs/)?libphp(?:\.la|\.a)(?:\'|"|\s|$)#', $line) === 1) { + return 'Linking PHP Embed'; + } + } + if (str_starts_with($line, 'Generating phar.php')) { + return 'Generating phar.php'; + } + if (str_starts_with($line, 'Generating phar.phar')) { + return 'Generating phar.phar'; + } + return null; + } + + /** @return array|null */ + private function processEnvironment(): ?array + { + $toolchain = $this->activeToolchain ?? $this->toolchain; + if ($toolchain === null) { + return null; + } + $environment = getenv(); + if (!is_array($environment)) { + $environment = []; + } + $environment['CC'] = $toolchain->cCompiler; + $environment['CXX'] = $toolchain->cxxCompiler; + $environment['AR'] = $toolchain->archiver; + return $environment; + } + private function mkdir(string $directory): void { if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 971569f6..f3286fbd 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -479,6 +479,9 @@ trait SourcePipelineTrait // only after a YAML project has loaded all included configuration. $this->applyCommandLineArguments(); $this->validateLoadedProjectConfiguration(); + if ($this->sapiEntryFile !== null) { + $files = array_values(array_diff($files, [$this->sapiEntryFile])); + } $files = $this->excludeGeneratedLibraryStub($files); return $this->filterIgnoredFiles($files); } @@ -704,10 +707,61 @@ trait SourcePipelineTrait return; } try { + $phpBuildProgress = null; + $lastLineProgress = -1; + $lastLineProgressLabel = ''; $runtime = (new SapiPhpBuilder( $this->getPhpxDir(), fn (string $message) => $this->output($message, 'lightBlue'), $this->downloadProxy, + function ( + int $completed, + int $total, + string $detail, + bool $finished, + ) use (&$phpBuildProgress, &$lastLineProgress, &$lastLineProgressLabel): void { + if ($total === 0) { + return; + } + $isStage = preg_match( + '/^(?:Building|Finishing|Linking|Generating|Installing|PHP runtime)/', + $detail, + ) === 1; + $label = $isStage ? $detail : 'Building PHP'; + if ($this->noProgress) { + if ($completed !== $lastLineProgress || $label !== $lastLineProgressLabel || $finished) { + $percent = min(100, (int) floor($completed / $total * 100)); + $suffix = $detail === '' || $isStage + ? '' + : ' ' . $detail; + $this->output("[{$completed}/{$total}] {$percent}% {$label}{$suffix}", 'white'); + $lastLineProgress = $completed; + $lastLineProgressLabel = $label; + } + return; + } + if ($phpBuildProgress === null) { + $phpBuildProgress = new Progressbar(); + $phpBuildProgress->width(30) + ->barStyle([AnsiTerminal::FG_GREEN]) + ->percentageStyle([AnsiTerminal::TEXT_BOLD]) + ->labelStyle([AnsiTerminal::FG_CYAN]) + ; + } + // A dynamic "(31s)" suffix shifts the bar on every digit + // boundary, and shorter stage names leave bytes from the + // previous frame behind. Keep the TTY layout fixed; the + // elapsed heartbeat remains visible with --no-progress. + $ttyLabel = preg_replace('/ \(\d+s\)$/', '', $label) ?? $label; + $phpBuildProgress->renderInPlace($completed, $total, str_pad($ttyLabel, 26)); + if ($finished) { + echo PHP_EOL; + $phpBuildProgress = null; + $lastLineProgress = -1; + $lastLineProgressLabel = ''; + } + }, + CompilerToolchain::fromBackend($this->getCompilerBackend()), ))->prepare( $this->phpVersion, $this->sapiTargets, diff --git a/src/Metadata/Constants.php b/src/Metadata/Constants.php index b11f9297..8a84d55c 100644 --- a/src/Metadata/Constants.php +++ b/src/Metadata/Constants.php @@ -200,12 +200,18 @@ class Constants 'longPrefix' => 'php-builder', 'description' => 'Build PHP from source, e.g. "extensions: [curl]; zts: on"', 'required' => false, + 'defaultValue' => '{}', ], 'sapi' => [ 'longPrefix' => 'sapi', 'description' => 'PHP SAPI target: embed, cli, fpm, or a comma-separated list', 'required' => false, ], + 'entry' => [ + 'longPrefix' => 'entry', + 'description' => 'PHP entry file executed by the CLI SAPI', + 'required' => false, + ], 'run' => [ 'prefix' => 'r', 'longPrefix' => 'run', diff --git a/src/Translator.php b/src/Translator.php index 4de3a22c..2ad13dbe 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -363,11 +363,12 @@ class Translator extends Preprocessor { $path = null; for ($i = 1; $i < count($argv); $i++) { - if ($argv[$i] === '--proxy') { + if ($argv[$i] === '--proxy' || $argv[$i] === '--entry') { ++$i; continue; } - if (str_starts_with($argv[$i], '--proxy=')) { + if (str_starts_with($argv[$i], '--proxy=') + || str_starts_with($argv[$i], '--entry=')) { continue; } if ($argv[$i] !== '' && $argv[$i][0] !== '-') { @@ -439,7 +440,8 @@ class Translator extends Preprocessor ['-f, --force', 'Clear incremental caches and force a full rebuild'], ['-m, --mode ', 'Build mode: bin, lib, or ext (default: bin)'], ['--sapi ', 'PHP SAPI target: embed (default), CLI, and/or FPM'], - ['--php-builder ', 'Build a private PHP runtime from source'], + ['--entry ', 'PHP entry file executed by the CLI SAPI'], + ['--php-builder[=]', 'Build a private PHP runtime from source (default: {})'], ['-r, --run', 'Run the compiled binary after a successful build'], ['-j, --job ', 'Number of parallel compilation jobs (default: 4)'], ['--cxx-std ', 'C++ standard version (default: c++17)'], @@ -514,6 +516,12 @@ class Translator extends Preprocessor if ($this->climate->arguments->defined('sapi')) { $this->configureSapiTargets((string) $this->climate->arguments->get('sapi')); } + if ($this->climate->arguments->defined('entry')) { + $this->configureSapiEntry( + (string) $this->climate->arguments->get('entry'), + getcwd() ?: $this->rootPath, + ); + } if ($this->climate->arguments->defined('php-builder')) { try { $this->configurePhpBuilder(PhpBuilderConfiguration::fromCommandLine( @@ -1020,7 +1028,12 @@ class Translator extends Preprocessor for ($i = 1; $i < count($argv); $i++) { $arg = $argv[$i]; - if (preg_match('/^-([a-zA-Z])(.+)$/', $arg, $matches)) { + if ($arg === '--php-builder') { + // The configuration is optional, but CLImate otherwise treats + // the following positional source as this option's value. + // Explicit configurations use --php-builder="...". + $processed[] = '--php-builder={}'; + } elseif (preg_match('/^-([a-zA-Z])(.+)$/', $arg, $matches)) { $option = $matches[1]; $value = $matches[2]; $processed[] = "-{$option}"; @@ -2917,7 +2930,7 @@ CODE; $objectFiles, [$sapiObject, $internalFunctionsObject], )); - $this->linkNativeTarget($embedObjects, $outputs['embed']); + $this->linkNativeTarget($embedObjects, $outputs['embed'], false); } $runnableTargets = array_values(array_intersect($this->sapiTargets, ['embed', 'cli'])); @@ -2930,7 +2943,11 @@ CODE; } /** @param list $objectFiles */ - private function linkNativeTarget(array $objectFiles, string $targetFile): string + private function linkNativeTarget( + array $objectFiles, + string $targetFile, + bool $announceSuccess = true, + ): string { // Windows: add the .res resource file to the link @@ -2943,7 +2960,9 @@ CODE; if ($this->hasLinkCache($objectFiles, $targetFile)) { $this->climate->darkGray('[incremental] link cache: ' . $targetFile); - $this->climate->green('Build successful: ' . $targetFile); + if ($announceSuccess) { + $this->climate->green('Build successful: ' . $targetFile); + } return $targetFile; } @@ -2968,7 +2987,9 @@ CODE; } $this->writeLinkCache($objectFiles, $targetFile); - $this->climate->green('Build successful: ' . $targetFile); + if ($announceSuccess) { + $this->climate->green('Build successful: ' . $targetFile); + } return $targetFile; } @@ -4291,18 +4312,7 @@ CODE; } } if (array_key_exists('entry', $cfg)) { - if (!is_string($cfg['entry']) || trim($cfg['entry']) === '') { - $this->error('`entry` must be a non-empty PHP file path'); - } - $entryPath = $this->resolvePath($cfg['entry'], $projectDir, 'Entry path'); - $entry = realpath($entryPath); - if ($entry === false || !is_file($entry)) { - $this->error('Entry file does not exist: `' . $cfg['entry'] . '`'); - } - if (!FileScanner::isPhpFile($entry) || str_ends_with($entry, '.stub.php')) { - $this->error('`entry` must select an executable PHP file'); - } - $this->sapiEntryFile = $entry; + $this->configureSapiEntry($cfg['entry'], $projectDir); } if (!empty($cfg['sources'])) { @@ -4677,6 +4687,29 @@ CODE; // an internal Zend module and therefore has no dynamic get_module(). } + private function configureSapiEntry(mixed $value, string $baseDirectory): void + { + if (!is_string($value) || trim($value) === '') { + $this->error('`entry` must be a non-empty PHP file path'); + } + $value = trim($value); + $entryPath = $this->resolvePath($value, $baseDirectory, 'Entry path'); + $entry = realpath($entryPath); + if ($entry === false || !is_file($entry)) { + $this->error('Entry file does not exist: `' . $value . '`'); + } + if (!FileScanner::isPhpFile($entry) || str_ends_with($entry, '.stub.php')) { + $this->error('`entry` must select an executable PHP file'); + } + $this->sapiEntryFile = $entry; + if (!in_array($entry, $this->embeddedFiles, true)) { + $this->embeddedFiles[] = $entry; + } + if (!in_array($entry, $this->embeddedPhpFiles, true)) { + $this->embeddedPhpFiles[] = $entry; + } + } + private function configureSapiTargets(string|array $value): void { try {