diff --git a/src/Package/Target/php.php b/src/Package/Target/php.php index a4ae5c56..b2dd3af2 100644 --- a/src/Package/Target/php.php +++ b/src/Package/Target/php.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Package\Target; use Package\Target\php\frankenphp; +use Package\Target\php\pgo; use Package\Target\php\unix; use Package\Target\php\windows; use StaticPHP\Artifact\ArtifactCache; @@ -48,6 +49,7 @@ class php extends TargetPackage use unix; use windows; use frankenphp; + use pgo; /** @var string[] Supported major PHP versions */ public const array SUPPORTED_MAJOR_VERSIONS = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']; diff --git a/src/Package/Target/php/frankenphp.php b/src/Package/Target/php/frankenphp.php index 4c428623..2af52702 100644 --- a/src/Package/Target/php/frankenphp.php +++ b/src/Package/Target/php/frankenphp.php @@ -88,10 +88,13 @@ trait frankenphp $libs .= ' -lgcov'; } + $extraLdProgram = clean_spaces((string) getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS_PROGRAM')); $env = [ 'CGO_ENABLED' => '1', 'CGO_CFLAGS' => clean_spaces($cflags), - 'CGO_LDFLAGS' => "{$package->getLibExtraLdFlags()} {$staticFlags} {$config['ldflags']} {$libs}", + 'CGO_LDFLAGS' => clean_spaces("{$package->getLibExtraLdFlags()} {$staticFlags} {$config['ldflags']} {$libs} {$extraLdProgram}"), + // cgo strips flags not on its safe allowlist; widen it + 'CGO_LDFLAGS_ALLOW' => '-Wl,-z,.*|-Wl,--.*|-flto.*|-fprofile-.*', 'XCADDY_GO_BUILD_FLAGS' => '-buildmode=pie ' . '-ldflags \"-linkmode=external ' . $extLdFlags . ' ' . '-X \'github.com/caddyserver/caddy/v2/modules/caddyhttp.ServerHeader=FrankenPHP Caddy\' ' . diff --git a/src/Package/Target/php/pgo.php b/src/Package/Target/php/pgo.php new file mode 100644 index 00000000..40be88e6 --- /dev/null +++ b/src/Package/Target/php/pgo.php @@ -0,0 +1,127 @@ +isInstrument() && !$pgo->isCsInstrument()) { + return; + } + foreach (PgoContext::SHUTDOWN_PATCHES as $dir => $patch) { + $cwd = SOURCE_PATH . '/' . $dir; + if (!is_dir($cwd)) { + continue; + } + if (!SourcePatcher::patchFile($patch, $cwd)) { + throw new WrongUsageException("PGO --phase=instrument: patch {$patch} failed to apply in {$cwd}"); + } + logger()->info("PGO --phase=instrument: applied {$patch}"); + } + } + + #[ConditionalOn(PgoContext::class)] + #[AfterStage('php', [self::class, 'configureForUnix'], 'php')] + #[PatchDescription('Patch libtool to passthrough -fcs-profile-* for context-sensitive PGO')] + public function pgoPatchLibtoolForCsInstrument(PgoContext $pgo): void + { + if (!$pgo->isCsInstrument()) { + return; + } + $libtool = SOURCE_PATH . '/php-src/libtool'; + if (!is_file($libtool)) { + return; + } + $contents = (string) file_get_contents($libtool); + if (str_contains($contents, '-fcs-profile-*')) { + return; + } + $patched = str_replace('-fprofile-*|-F*', '-fprofile-*|-fcs-profile-*|-F*', $contents); + if ($patched === $contents) { + logger()->warning('PGO --phase=cs-instrument: could not patch libtool for -fcs-profile-* passthrough'); + return; + } + file_put_contents($libtool, $patched); + logger()->info('PGO --phase=cs-instrument: patched libtool for -fcs-profile-* passthrough'); + } + + #[ConditionalOn(PgoContext::class)] + #[BeforeStage('php', [self::class, 'configureForUnix'], 'php')] + public function pgoApplyConfigureFlags(PgoContext $pgo): void + { + $sapis = $pgo->trainableSapis(); + if ($sapis === []) { + return; + } + $pgo->applyEnvFor($sapis[0]); + } + + #[ConditionalOn(PgoContext::class)] + #[BeforeStage('php', [self::class, 'makeCliForUnix'], 'php')] + public function pgoBeforeMakeCli(PgoContext $pgo, TargetPackage $package): void + { + $this->pgoBeforeSapiMake($pgo, $package, 'cli'); + } + + #[ConditionalOn(PgoContext::class)] + #[BeforeStage('php', [self::class, 'makeCgiForUnix'], 'php')] + public function pgoBeforeMakeCgi(PgoContext $pgo, TargetPackage $package): void + { + $this->pgoBeforeSapiMake($pgo, $package, 'cgi'); + } + + #[ConditionalOn(PgoContext::class)] + #[BeforeStage('php', [self::class, 'makeFpmForUnix'], 'php')] + public function pgoBeforeMakeFpm(PgoContext $pgo, TargetPackage $package): void + { + $this->pgoBeforeSapiMake($pgo, $package, 'fpm'); + } + + #[ConditionalOn(PgoContext::class)] + #[BeforeStage('php', [self::class, 'makeMicroForUnix'], 'php')] + public function pgoBeforeMakeMicro(PgoContext $pgo, TargetPackage $package): void + { + $this->pgoBeforeSapiMake($pgo, $package, 'micro'); + } + + #[ConditionalOn(PgoContext::class)] + #[BeforeStage('php', [self::class, 'makeEmbedForUnix'], 'php')] + public function pgoBeforeMakeEmbed(PgoContext $pgo, TargetPackage $package): void + { + $this->pgoBeforeSapiMake($pgo, $package, 'embed'); + } + + #[ConditionalOn(PgoContext::class)] + #[BeforeStage('php', [self::class, 'buildFrankenphpForUnix'], 'php')] + public function pgoBeforeBuildFrankenphp(PgoContext $pgo): void + { + $pgo->applyEnvFor('frankenphp'); + logger()->info("PGO {$pgo->mode}: applying flags for frankenphp"); + } + + private function pgoBeforeSapiMake(PgoContext $pgo, TargetPackage $package, string $sapi): void + { + $resolved = $pgo->resolveSapi($sapi); + if (!in_array($resolved, $pgo->trainableSapis(), true)) { + return; + } + shell()->cd($package->getSourceDir())->exec('make clean'); + $pgo->applyEnvFor($sapi); + logger()->info("PGO {$pgo->mode}: applying flags for {$sapi}"); + } +} diff --git a/src/Package/Target/php/unix.php b/src/Package/Target/php/unix.php index e1052fbd..b227071a 100644 --- a/src/Package/Target/php/unix.php +++ b/src/Package/Target/php/unix.php @@ -138,7 +138,7 @@ trait unix $this->seekPhpSrcLogFileOnException(fn () => shell()->cd($package->getSourceDir())->setEnv([ 'CFLAGS' => getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS'), 'CPPFLAGS' => "-I{$package->getIncludeDir()}", - 'LDFLAGS' => "-L{$package->getLibDir()} " . getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS'), + 'LDFLAGS' => "-L{$package->getLibDir()}", 'LIBS' => $vars['EXTRA_LIBS'] ?? '', ])->exec("{$cmd} {$args} {$static_extension_str}"), $package->getSourceDir()); } @@ -220,6 +220,7 @@ trait unix #[Stage] public function makeCliForUnix(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void { + $start = microtime(true); InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('make cli')); $concurrency = $builder->concurrency; $vars = $this->makeVars($installer); @@ -230,11 +231,13 @@ trait unix $builder->deployBinary("{$package->getSourceDir()}/sapi/cli/php", BUILD_BIN_PATH . '/php'); $package->setOutput('Binary path for cli SAPI', BUILD_BIN_PATH . '/php'); + InteractiveTerm::success('Built SAPI: ' . ConsoleColor::green('php-cli'), true, $start); } #[Stage] public function makeCgiForUnix(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void { + $start = microtime(true); InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('make cgi')); $concurrency = $builder->concurrency; $vars = $this->makeVars($installer); @@ -245,11 +248,13 @@ trait unix $builder->deployBinary("{$package->getSourceDir()}/sapi/cgi/php-cgi", BUILD_BIN_PATH . '/php-cgi'); $package->setOutput('Binary path for cgi SAPI', BUILD_BIN_PATH . '/php-cgi'); + InteractiveTerm::success('Built SAPI: ' . ConsoleColor::green('php-cgi'), true, $start); } #[Stage] public function makeFpmForUnix(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void { + $start = microtime(true); InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('make fpm')); $concurrency = $builder->concurrency; $vars = $this->makeVars($installer); @@ -260,12 +265,14 @@ trait unix $builder->deployBinary("{$package->getSourceDir()}/sapi/fpm/php-fpm", BUILD_BIN_PATH . '/php-fpm'); $package->setOutput('Binary path for fpm SAPI', BUILD_BIN_PATH . '/php-fpm'); + InteractiveTerm::success('Built SAPI: ' . ConsoleColor::green('php-fpm'), true, $start); } #[Stage] #[PatchDescription('Patch phar extension for micro SAPI to support compressed phar')] public function makeMicroForUnix(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void { + $start = microtime(true); $phar_patched = false; try { if ($installer->isPackageResolved('ext-phar')) { @@ -298,6 +305,7 @@ trait unix file_put_contents($dst, substr(file_get_contents($dst), 0, $offset)); } $package->setOutput('Binary path for micro SAPI', $dst); + InteractiveTerm::success('Built SAPI: ' . ConsoleColor::green('php-micro'), true, $start); } finally { if ($phar_patched) { SourcePatcher::unpatchMicroPhar(); @@ -308,6 +316,7 @@ trait unix #[Stage] public function makeEmbedForUnix(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void { + $start = microtime(true); InteractiveTerm::setMessage('Building php: ' . ConsoleColor::yellow('make embed')); $shared_exts = array_filter( $installer->getResolvedPackages(), @@ -321,22 +330,43 @@ trait unix $root = BUILD_ROOT_PATH; $sed_prefix = SystemTarget::getTargetOS() === 'Darwin' ? 'sed -i ""' : 'sed -i'; + $vars = $this->makeVars($installer); + $makeArgs = $this->makeVarsToArgs($vars); shell()->cd($package->getSourceDir()) - ->setEnv($this->makeVars($installer)) + ->setEnv($vars) ->exec("{$sed_prefix} \"s|^EXTENSION_DIR = .*|EXTENSION_DIR = /" . basename(BUILD_MODULES_PATH) . '|" Makefile') - ->exec("make -j{$builder->concurrency} INSTALL_ROOT={$root} install-sapi {$install_modules} install-build install-headers install-programs"); + ->exec("make -j{$builder->concurrency} {$makeArgs} INSTALL_ROOT={$root} install-sapi {$install_modules} install-build install-headers install-programs"); + + // install-modules deref'd libtool's `$ext.so → $ext-X.so` symlink for each built-with-php ext; restore them. + $release = null; + if (preg_match('/-release\s+(\S+)/', (string) getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS'), $m)) { + $release = $m[1]; + foreach ($shared_exts as $ext) { + $name = $ext->getExtensionName(); + $u = BUILD_MODULES_PATH . "/{$name}.so"; + $v = BUILD_MODULES_PATH . "/{$name}-{$release}.so"; + if (file_exists($v) && file_exists($u) && !is_link($u)) { + unlink($u); + symlink(basename($v), $u); + } + } + } // ------------- SPC_CMD_VAR_PHP_EMBED_TYPE=shared ------------- - // process libphp.so for shared embed + // INSTALL_IT for embed copies through libtool's symlink, leaving only unversioned libphp.{so,dylib} — rename and symlink back so shared exts can `-lphp`. (static libphp.a is never versioned, even with -release.) $suffix = SystemTarget::getTargetOS() === 'Darwin' ? 'dylib' : 'so'; $libphp_so = "{$package->getLibDir()}/libphp.{$suffix}"; if (file_exists($libphp_so)) { - // rename libphp.so if -release is set - if (SystemTarget::getTargetOS() === 'Linux') { - $this->processLibphpSoFile($libphp_so, $installer); + if ($release !== null) { + $versioned = "{$package->getLibDir()}/libphp-{$release}.{$suffix}"; + if (file_exists($versioned)) { + @unlink($versioned); + } + rename($libphp_so, $versioned); + symlink(basename($versioned), $libphp_so); + $libphp_so = $versioned; } - // deploy $builder->deployBinary($libphp_so, $libphp_so, false); $package->setOutput('Library path for embed SAPI', $libphp_so); } @@ -345,6 +375,9 @@ trait unix $increment_files = $diff->getChangedFiles(); $files = []; foreach ($increment_files as $increment_file) { + if (is_link($increment_file) || !file_exists($increment_file)) { + continue; + } $builder->deployBinary($increment_file, $increment_file, false); $files[] = basename($increment_file); } @@ -352,6 +385,11 @@ trait unix $package->setOutput('Built shared extensions', implode(', ', $files)); } + // phpize needs prefix patched whether libphp is .a or .so + $package->runStage([$this, 'patchUnixEmbedScripts']); + + InteractiveTerm::success('Built SAPI: ' . ConsoleColor::green('php-embed'), true, $start); + // ------------- SPC_CMD_VAR_PHP_EMBED_TYPE=static ------------- // process libphp.a for static embed @@ -362,9 +400,6 @@ trait unix $libphp_a = "{$package->getLibDir()}/libphp.a"; shell()->exec("{$ar} -t {$libphp_a} | grep '\\.a$' | xargs -n1 {$ar} d {$libphp_a}"); UnixUtil::exportDynamicSymbols($libphp_a); - - // deploy embed php scripts - $package->runStage([$this, 'patchUnixEmbedScripts']); } #[Stage] @@ -397,8 +432,15 @@ trait unix try { logger()->debug('Building shared extensions...'); foreach ($shared_extensions as $extension) { - InteractiveTerm::setMessage('Building shared PHP extension: ' . ConsoleColor::yellow($extension->getName())); - $extension->buildShared(); + $ext_start = microtime(true); + InteractiveTerm::setMessage('Building shared extension: ' . ConsoleColor::yellow($extension->getName())); + try { + $extension->buildShared(); + } catch (\Throwable $e) { + InteractiveTerm::error('Building shared extension failed: ' . ConsoleColor::red($extension->getName())); + throw $e; + } + InteractiveTerm::success('Built shared extension: ' . ConsoleColor::green($extension->getName()), true, $ext_start); } } finally { // restore php-config @@ -682,77 +724,6 @@ trait unix return $php; } - /** - * Rename libphp.so to libphp-.so if -release is set in LDFLAGS. - */ - private function processLibphpSoFile(string $libphpSo, PackageInstaller $installer): void - { - $ldflags = getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS') ?: ''; - $libDir = BUILD_LIB_PATH; - $modulesDir = BUILD_MODULES_PATH; - $realLibName = 'libphp.so'; - $cwd = getcwd(); - - if (preg_match('/-release\s+(\S+)/', $ldflags, $matches)) { - $release = $matches[1]; - $realLibName = "libphp-{$release}.so"; - $libphpRelease = "{$libDir}/{$realLibName}"; - if (!file_exists($libphpRelease) && file_exists($libphpSo)) { - rename($libphpSo, $libphpRelease); - } - if (file_exists($libphpRelease)) { - chdir($libDir); - if (file_exists($libphpSo)) { - unlink($libphpSo); - } - symlink($realLibName, 'libphp.so'); - shell()->exec(sprintf( - 'patchelf --set-soname %s %s', - escapeshellarg($realLibName), - escapeshellarg($libphpRelease) - )); - } - if (is_dir($modulesDir)) { - chdir($modulesDir); - foreach ($installer->getResolvedPackages(PhpExtensionPackage::class) as $ext) { - if (!$ext->isBuildShared()) { - continue; - } - $name = $ext->getName(); - $versioned = "{$name}-{$release}.so"; - $unversioned = "{$name}.so"; - $src = "{$modulesDir}/{$versioned}"; - $dst = "{$modulesDir}/{$unversioned}"; - if (is_file($src)) { - rename($src, $dst); - shell()->exec(sprintf( - 'patchelf --set-soname %s %s', - escapeshellarg($unversioned), - escapeshellarg($dst) - )); - } - } - } - chdir($cwd); - } - - $target = "{$libDir}/{$realLibName}"; - if (file_exists($target)) { - [, $output] = shell()->execWithResult('readelf -d ' . escapeshellarg($target)); - $output = implode("\n", $output); - if (preg_match('/SONAME.*\[(.+)]/', $output, $sonameMatch)) { - $currentSoname = $sonameMatch[1]; - if ($currentSoname !== basename($target)) { - shell()->exec(sprintf( - 'patchelf --set-soname %s %s', - escapeshellarg(basename($target)), - escapeshellarg($target) - )); - } - } - } - } - /** * Make environment variables for php make. * This will call SPCConfigUtil to generate proper LDFLAGS and LIBS for static linking. @@ -762,16 +733,28 @@ trait unix $config = new SPCConfigUtil(['libs_only_deps' => true])->config($installer->getAvailableResolvedPackageNames()); $static = ApplicationContext::get(ToolchainInterface::class)->isStatic() ? '-all-static' : ''; $pie = SystemTarget::getTargetOS() === 'Linux' ? '-pie' : ''; + $lib = BUILD_LIB_PATH; // Append SPC_EXTRA_LIBS to libs for dynamic linking support (e.g., X11) $extra_libs = getenv('SPC_EXTRA_LIBS') ?: ''; $libs = trim($config['libs'] . ' ' . $extra_libs); + // libtool input (libphp.la). `make EXTRA_LDFLAGS=…` cmdline overrides fully replace the Makefile value, so re-include $config['ldflags'] for -L paths. + $extra_ldflags = clean_spaces($config['ldflags'] . ' ' . getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS')); + if (getenv('SPC_CMD_VAR_PHP_EMBED_TYPE') === 'shared' + && !str_contains($extra_ldflags, '-avoid-version') + && !preg_match('/-release\s+\S+/', $extra_ldflags)) { + $extra_ldflags = trim($extra_ldflags . ' -avoid-version -module'); + } + + $extra_ldflags_program_env = getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS_PROGRAM') ?: ''; + $extra_ldflags_program = clean_spaces("-L{$lib} {$static} {$pie} {$extra_ldflags_program_env}"); + return array_filter([ 'EXTRA_CFLAGS' => getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS'), 'EXTRA_CXXFLAGS' => getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_CXXFLAGS'), - 'EXTRA_LDFLAGS_PROGRAM' => deduplicate_flags(getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS') . " {$config['ldflags']} {$static} {$pie}"), - 'EXTRA_LDFLAGS' => $config['ldflags'], + 'EXTRA_LDFLAGS' => $extra_ldflags, + 'EXTRA_LDFLAGS_PROGRAM' => $extra_ldflags_program, 'EXTRA_LIBS' => $libs, ]); } diff --git a/src/StaticPHP/Command/CraftCommand.php b/src/StaticPHP/Command/CraftCommand.php index f390e433..23320725 100644 --- a/src/StaticPHP/Command/CraftCommand.php +++ b/src/StaticPHP/Command/CraftCommand.php @@ -9,7 +9,10 @@ use StaticPHP\Doctor\Doctor; use StaticPHP\Exception\ValidationException; use StaticPHP\Package\PackageBuilder; use StaticPHP\Package\PackageInstaller; +use StaticPHP\Registry\PackageLoader; +use StaticPHP\Util\DependencyResolver; use StaticPHP\Util\FileSystem; +use StaticPHP\Util\Pgo\PgoContext; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Yaml\Exception\ParseException; @@ -21,6 +24,8 @@ class CraftCommand extends BaseCommand public function configure(): void { $this->addArgument('craft', null, 'Path to craft.yml file', WORKING_DIR . '/craft.yml'); + $this->addOption('libs-only', null, null, 'Build only the libraries needed by the configured extensions (skip PHP and SAPI build).'); + PgoContext::registerOptions($this); } public function handle(): int @@ -83,23 +88,67 @@ class CraftCommand extends BaseCommand FileSystem::resetDir(SOURCE_PATH); } + $pgo = $this->getOption('libs-only') ? null : PgoContext::tryFromInput($this->input, $craft['sapi'], $build_options); + $starttime = microtime(true); // run installer $installer = new PackageInstaller($build_options); ApplicationContext::get(PackageBuilder::class)->setArgument('extensions', implode(',', $craft['extensions'])); - $installer->addBuildPackage('php'); + + if ($this->getOption('libs-only')) { + $with_suggests = (bool) ($craft['build-options']['with-suggests'] ?? false); + $libs = $this->resolveLibsForExtensions($craft, $with_suggests); + if ($libs === []) { + $this->output->writeln('No libraries needed for the configured extensions; nothing to do.'); + return static::SUCCESS; + } + foreach ($libs as $lib) { + $installer->addBuildPackage($lib); + } + } else { + $installer->addBuildPackage('php'); + } $installer->run(true); $usedtime = round(microtime(true) - $starttime, 1); + $tag = $pgo !== null ? " (PGO {$pgo->mode})" : ''; $this->output->writeln("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - $this->output->writeln("✔ BUILD SUCCESSFUL ({$usedtime} s)"); + $this->output->writeln("✔ BUILD SUCCESSFUL{$tag} ({$usedtime} s)"); $this->output->writeln("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + if ($pgo !== null && $pgo->isInstrument()) { + $this->output->writeln("Next: exercise the instrumented binary, then re-run craft with --pgo to consume {$pgo->profileRoot}."); + } + $installer->printBuildPackageOutputs(); return static::SUCCESS; } + /** @return list library package names transitively required by the configured extensions */ + private function resolveLibsForExtensions(array $craft, bool $include_suggests): array + { + $exts = array_merge($craft['extensions'], $craft['shared-extensions'] ?? []); + $ext_pkgs = array_map(fn ($x) => "ext-{$x}", $exts); + $extra = $craft['packages'] ?? []; + + $resolved = DependencyResolver::resolve( + array_merge($ext_pkgs, $extra), + include_suggests: $include_suggests, + ); + + $libs = []; + foreach ($resolved as $pkg_name) { + if (str_starts_with($pkg_name, 'ext-') || !PackageLoader::hasPackage($pkg_name)) { + continue; + } + if (PackageLoader::getPackage($pkg_name)->getType() === 'library') { + $libs[] = $pkg_name; + } + } + return $libs; + } + /** * Validate and parse craft.yml file to array. * diff --git a/src/StaticPHP/Package/PackageInstaller.php b/src/StaticPHP/Package/PackageInstaller.php index 750ddbaf..61233511 100644 --- a/src/StaticPHP/Package/PackageInstaller.php +++ b/src/StaticPHP/Package/PackageInstaller.php @@ -215,7 +215,7 @@ class PackageInstaller if (!$is_to_build && $should_use_binary) { // install binary if ($this->interactive) { - InteractiveTerm::indicateProgress('Installing package: ' . ConsoleColor::yellow($package->getName())); + InteractiveTerm::indicateProgress('Installing ' . $this->kindLabel($package) . ': ' . ConsoleColor::yellow($package->getName())); } try { // Start tracking for binary installation @@ -227,17 +227,17 @@ class PackageInstaller // Stop tracking on error $this->tracker?->stopTracking(); if ($this->interactive) { - InteractiveTerm::finish('Installing binary package failed: ' . ConsoleColor::red($package->getName()), false); + InteractiveTerm::finish('Installing ' . $this->kindLabel($package) . ' failed: ' . ConsoleColor::red($package->getName()), false); echo PHP_EOL; } throw $e; } if ($this->interactive) { - InteractiveTerm::finish('Installed binary package: ' . ConsoleColor::green($package->getName()) . ($status === SPC_STATUS_ALREADY_INSTALLED ? ' (already installed, skipped)' : '')); + InteractiveTerm::finish('Installed ' . $this->kindLabel($package) . ': ' . ConsoleColor::green($package->getName()) . ($status === SPC_STATUS_ALREADY_INSTALLED ? ' (already installed, skipped)' : '')); } } elseif ($is_to_build && $has_build_stage || $has_source && $has_build_stage) { if ($this->interactive) { - InteractiveTerm::indicateProgress('Building package: ' . ConsoleColor::yellow($package->getName())); + InteractiveTerm::indicateProgress('Building ' . $this->kindLabel($package) . ': ' . ConsoleColor::yellow($package->getName())); } try { // Start tracking for build @@ -260,13 +260,13 @@ class PackageInstaller // Stop tracking on error $this->tracker?->stopTracking(); if ($this->interactive) { - InteractiveTerm::finish('Building package failed: ' . ConsoleColor::red($package->getName()), false); + InteractiveTerm::finish('Building ' . $this->kindLabel($package) . ' failed: ' . ConsoleColor::red($package->getName()), false); echo PHP_EOL; } throw $e; } if ($this->interactive) { - InteractiveTerm::finish('Built package: ' . ConsoleColor::green($package->getName()) . ($status === SPC_STATUS_ALREADY_BUILT ? ' (already built, skipped)' : '')); + InteractiveTerm::finish('Built ' . $this->kindLabel($package) . ': ' . ConsoleColor::green($package->getName()) . ($status === SPC_STATUS_ALREADY_BUILT ? ' (already built, skipped)' : '')); } } } @@ -568,6 +568,16 @@ class PackageInstaller return null; } + private function kindLabel(Package $package): string + { + return match (true) { + $package instanceof PhpExtensionPackage => 'extension', + $package instanceof TargetPackage => 'target', + $package instanceof LibraryPackage => 'library', + default => 'package', + }; + } + /** * @param Package[] $packages */ diff --git a/src/StaticPHP/Package/PhpExtensionPackage.php b/src/StaticPHP/Package/PhpExtensionPackage.php index 2919e227..8fb63ece 100644 --- a/src/StaticPHP/Package/PhpExtensionPackage.php +++ b/src/StaticPHP/Package/PhpExtensionPackage.php @@ -12,6 +12,7 @@ use StaticPHP\Exception\WrongUsageException; use StaticPHP\Runtime\SystemTarget; use StaticPHP\Toolchain\ToolchainManager; use StaticPHP\Toolchain\ZigToolchain; +use StaticPHP\Util\FileSystem; use StaticPHP\Util\GlobalEnvManager; use StaticPHP\Util\SPCConfigUtil; @@ -326,16 +327,29 @@ class PhpExtensionPackage extends Package { // phpize Makefile's _SHARED_LIBADD line misses our static archives — splice them in $package->patchSharedLibAdd(); + $extra_ldflags = (string) getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS'); + $makeArgs = $extra_ldflags !== '' ? 'EXTRA_LDFLAGS=' . escapeshellarg($extra_ldflags) : ''; shell()->cd($package->getSourceDir()) ->setEnv($env) ->exec('make clean') - ->exec("make -j{$builder->concurrency}") - ->exec('make install'); + ->exec("make -j{$builder->concurrency} {$makeArgs}") + ->exec("make install {$makeArgs}"); + + // install-modules deref'd libtool's `$ext.so → $ext-X.so` symlink into two regular files; restore the symlink. + if (preg_match('/-release\s+(\S+)/', $extra_ldflags, $m)) { + $name = $package->getExtensionName(); + $unversioned = BUILD_MODULES_PATH . "/{$name}.so"; + $versioned = BUILD_MODULES_PATH . "/{$name}-{$m[1]}.so"; + if (file_exists($versioned) && file_exists($unversioned) && !is_link($unversioned)) { + unlink($unversioned); + symlink(basename($versioned), $unversioned); + } + } } public function patchSharedLibAdd(): void { - $config = (new SPCConfigUtil())->getExtensionConfig($this); + $config = new SPCConfigUtil()->getExtensionConfig($this); [$staticLibs, $sharedLibs] = $this->splitLibsIntoStaticAndShared($config['libs']); $lstdcpp = str_contains($sharedLibs, '-l:libstdc++.a') ? '-l:libstdc++.a' @@ -353,7 +367,7 @@ class PhpExtensionPackage extends Package $current = trim($m[2]); $merged = clean_spaces("{$current} {$staticLibs} {$lstdcpp}"); $merged = deduplicate_flags($merged); - \StaticPHP\Util\FileSystem::replaceFileRegex( + FileSystem::replaceFileRegex( $makefile, '/^(.*_SHARED_LIBADD\s*=.*)$/m', $prefix . $merged @@ -389,8 +403,10 @@ class PhpExtensionPackage extends Package $this->runStage([$this, 'configureForUnix'], ['env' => $env]); $this->runStage([$this, 'makeForUnix'], ['env' => $env]); - // process *.so file - $soFile = BUILD_MODULES_PATH . '/' . $this->getExtensionName() . '.so'; + // libtool's -release X gives $name-X.so as the real file + $soFile = BUILD_MODULES_PATH . '/' . $this->getExtensionName() + . (preg_match('/-release\s+(\S+)/', (string) getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS'), $m) ? "-{$m[1]}" : '') + . '.so'; if (!file_exists($soFile)) { throw new ValidationException("Extension {$this->getExtensionName()} build failed: {$soFile} not found", validation_module: "Extension {$this->getExtensionName()} build"); } diff --git a/src/StaticPHP/Registry/Registry.php b/src/StaticPHP/Registry/Registry.php index 8f206c30..69c5a474 100644 --- a/src/StaticPHP/Registry/Registry.php +++ b/src/StaticPHP/Registry/Registry.php @@ -89,14 +89,17 @@ class Registry self::$current_registry_name = $registry_name; try { - // Load composer autoload if specified (for external registries with their own dependencies) + // resolve autoload manually — path-repo installs have no vendor/, FileSystem::fullpath would throw if (isset($data['autoload']) && is_string($data['autoload'])) { - $autoload_path = FileSystem::fullpath($data['autoload'], dirname($registry_file)); + $base = dirname($registry_file); + $autoload_path = FileSystem::isRelativePath($data['autoload']) + ? rtrim($base, '/') . DIRECTORY_SEPARATOR . $data['autoload'] + : $data['autoload']; if (file_exists($autoload_path)) { logger()->debug("Loading external autoload from: {$autoload_path}"); require_once $autoload_path; } else { - logger()->warning("Autoload file not found: {$autoload_path}"); + logger()->warning("Registry autoload not present, relying on consumer autoloader: {$autoload_path}"); } } diff --git a/src/StaticPHP/Util/InteractiveTerm.php b/src/StaticPHP/Util/InteractiveTerm.php index 0570f31c..1842cce4 100644 --- a/src/StaticPHP/Util/InteractiveTerm.php +++ b/src/StaticPHP/Util/InteractiveTerm.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace StaticPHP\Util; use StaticPHP\DI\ApplicationContext; +use Symfony\Component\Console\Helper\Helper; use Symfony\Component\Console\Helper\ProgressIndicator; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; @@ -27,14 +28,19 @@ class InteractiveTerm } } - public static function success(string $message, bool $indent = false): void + public static function success(string $message, bool $indent = false, ?float $start_time = null): void { $no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false; $output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput(); + if ($start_time !== null) { + $message .= ' (' . Helper::formatTime(microtime(true) - $start_time) . ')'; + } if ($output->isVerbose()) { logger()->info(strip_ansi_colors($message)); } else { - $output->writeln(($no_ansi ? 'strip_ansi_colors' : 'strval')(ConsoleColor::green(($indent ? ' ' : '') . '✔ ') . $message)); + // wipe the current indicator line so our persistent ✔ line doesn't get appended to a spinner + $clear = (self::$indicator !== null && $output->isDecorated() && !$no_ansi) ? "\x0D\x1B[2K" : ''; + $output->writeln($clear . ($no_ansi ? 'strip_ansi_colors' : 'strval')(ConsoleColor::green(($indent ? ' ' : '') . '✔ ') . $message)); logger()->debug(strip_ansi_colors($message)); } } diff --git a/src/StaticPHP/Util/Pgo/PgoContext.php b/src/StaticPHP/Util/Pgo/PgoContext.php new file mode 100644 index 00000000..99f447f3 --- /dev/null +++ b/src/StaticPHP/Util/Pgo/PgoContext.php @@ -0,0 +1,267 @@ + + */ + public const array TRAINABLE = [ + 'cli' => 'build-cli', + 'micro' => 'build-micro', + 'cgi' => 'build-cgi', + 'fpm' => 'build-fpm', + 'embed' => 'build-embed', + 'frankenphp' => 'build-frankenphp', + ]; + + public const array SHUTDOWN_PATCHES = [ + 'php-src' => 'spc_pgo_flush_php_main.patch', + 'frankenphp' => 'spc_pgo_flush_frankenphp.patch', + ]; + + /** @var list */ + private array $trainableSapis = []; + + public function __construct( + public readonly string $mode, + public readonly string $profileRoot, + ) { + if (!in_array($mode, [self::MODE_INSTRUMENT, self::MODE_CS_INSTRUMENT, self::MODE_USE], true)) { + throw new WrongUsageException("PgoContext: unknown mode '{$mode}'"); + } + } + + public static function registerOptions(BaseCommand $cmd): void + { + $cmd->addOption('pgi', null, null, 'PGO instrument pass: build with -fprofile-generate so the resulting binary writes .profraw on shutdown.'); + $cmd->addOption('cs-pgi', null, null, 'PGO context-sensitive instrument pass: -fprofile-use= + -fcs-profile-generate. Requires a prior --pgi/--pgo cycle.'); + $cmd->addOption('pgo', null, null, 'PGO use pass: merge the collected .profraw into .profdata, then rebuild with -fprofile-use.'); + } + + /** + * @param array $sapis + * @param array $build_options + */ + public static function tryFromInput(InputInterface $input, array $sapis, array &$build_options): ?self + { + $modes = array_filter(['pgi', 'cs-pgi', 'pgo'], fn ($m) => (bool) $input->getOption($m)); + if (count($modes) > 1) { + throw new WrongUsageException('--pgi, --cs-pgi, and --pgo are mutually exclusive'); + } + $picked = array_values($modes)[0] ?? null; + if ($picked === null) { + return null; + } + $mode = match ($picked) { + 'pgi' => self::MODE_INSTRUMENT, + 'cs-pgi' => self::MODE_CS_INSTRUMENT, + 'pgo' => self::MODE_USE, + }; + $ctx = new self($mode, BUILD_ROOT_PATH . '/pgo-data'); + $ctx->setTrainableSapis($sapis); + + match ($mode) { + self::MODE_INSTRUMENT => $ctx->setupInstrument(), + self::MODE_CS_INSTRUMENT => $ctx->setupCsInstrument(), + self::MODE_USE => $ctx->mergeProfiles(), + }; + + if ($ctx->isInstrument() || $ctx->isCsInstrument()) { + $build_options['no-strip'] = true; + } + ApplicationContext::set(self::class, $ctx); + return $ctx; + } + + public function isInstrument(): bool + { + return $this->mode === self::MODE_INSTRUMENT; + } + + public function isCsInstrument(): bool + { + return $this->mode === self::MODE_CS_INSTRUMENT; + } + + public function isUse(): bool + { + return $this->mode === self::MODE_USE; + } + + /** + * @param list $sapis + */ + public function setTrainableSapis(array $sapis): void + { + $resolved = []; + foreach ($sapis as $sapi) { + $r = $this->resolveSapi($sapi); + if (!in_array($r, $resolved, true)) { + $resolved[] = $r; + } + } + if ($resolved === []) { + throw new WrongUsageException( + 'PGO: no trainable SAPI selected; supply one of ' . implode(', ', array_keys(self::TRAINABLE)) + ); + } + $this->trainableSapis = $resolved; + } + + /** @return list */ + public function trainableSapis(): array + { + return $this->trainableSapis; + } + + /** + * Static-embed mode links libphp.a into frankenphp, sharing a single binary + * and profdata. Shared-embed keeps them separate. + */ + public function resolveSapi(string $sapi): string + { + if ($sapi === 'embed' && getenv('SPC_CMD_VAR_PHP_EMBED_TYPE') === 'static') { + return 'frankenphp'; + } + return $sapi; + } + + public function rawDir(string $sapi): string + { + return $this->profileRoot . '/' . $sapi; + } + + public function csRawDir(string $sapi): string + { + return $this->profileRoot . '/cs-' . $sapi; + } + + public function profDataFile(string $sapi): string + { + return $this->profileRoot . '/' . $sapi . '.profdata'; + } + + public function cflagsFor(string $sapi): string + { + $sapi = $this->resolveSapi($sapi); + if ($this->mode === self::MODE_USE && !is_file($this->profDataFile($sapi))) { + return ''; + } + return match ($this->mode) { + self::MODE_INSTRUMENT => '-fprofile-generate=' . $this->rawDir($sapi) + . ' -fprofile-update=atomic', + self::MODE_CS_INSTRUMENT => '-fprofile-use=' . $this->profDataFile($sapi) + . ' -fcs-profile-generate=' . $this->csRawDir($sapi) + . ' -fprofile-update=atomic' + . ' -Wno-error=profile-instr-unprofiled' + . ' -Wno-error=profile-instr-out-of-date' + . ' -Wno-backend-plugin', + self::MODE_USE => '-fprofile-use=' . $this->profDataFile($sapi) + . ' -Wno-error=profile-instr-unprofiled' + . ' -Wno-error=profile-instr-out-of-date' + . ' -Wno-backend-plugin', + default => throw new WrongUsageException("PgoContext: unreachable mode '{$this->mode}'"), + }; + } + + public function ldflagsFor(string $sapi): string + { + $resolved = $this->resolveSapi($sapi); + $flags = $this->cflagsFor($sapi); + $patterns = ['/\s*-Wno-error=\S+/', '/\s*-Wno-backend-plugin/']; + if ($resolved === 'frankenphp') { + $patterns[] = '/\s*-fprofile-use=\S+/'; + $patterns[] = '/\s*-fcs-profile-generate=\S+/'; + } + return trim((string) preg_replace($patterns, '', $flags)); + } + + public function applyEnvFor(string $sapi): void + { + self::overwritePgoFlags('SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS', $this->cflagsFor($sapi)); + self::overwritePgoFlags('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS_PROGRAM', $this->ldflagsFor($sapi)); + } + + public function setupInstrument(): void + { + FileSystem::removeDir($this->profileRoot); + FileSystem::createDir($this->profileRoot); + foreach ($this->trainableSapis as $sapi) { + FileSystem::createDir($this->rawDir($sapi)); + } + } + + public function setupCsInstrument(): void + { + foreach ($this->trainableSapis as $sapi) { + if (!is_file($this->profDataFile($sapi))) { + throw new WrongUsageException( + "PGO --phase=cs-instrument: missing {$sapi}.profdata; run --phase=instrument and --phase=use first" + ); + } + FileSystem::createDir($this->csRawDir($sapi)); + } + } + + public function mergeProfiles(): void + { + if (trim((string) shell_exec('command -v llvm-profdata 2>/dev/null')) === '') { + throw new WrongUsageException('PGO --phase=use: llvm-profdata not on PATH'); + } + foreach ($this->trainableSapis as $sapi) { + $this->mergeSapi($sapi); + } + } + + private function mergeSapi(string $sapi): void + { + $raws = glob($this->rawDir($sapi) . '/*.profraw') ?: []; + $csRaws = glob($this->csRawDir($sapi) . '/*.profraw') ?: []; + if ($raws === [] && $csRaws === []) { + if ($sapi === 'frankenphp') { + logger()->warning( + 'PGO --phase=use: no .profraw for frankenphp (cgo-glue PGO will be skipped); ' . + 'run --phase=instrument, exercise frankenphp longer, then re-run --phase=use' + ); + return; + } + throw new WrongUsageException( + "PGO --phase=use: no .profraw for {$sapi}; run --phase=instrument, exercise the binary, then re-run --phase=use" + ); + } + $out = $this->profDataFile($sapi); + $inputs = array_merge($raws, $csRaws); + $argv = implode(' ', array_map('escapeshellarg', $inputs)); + shell()->exec('llvm-profdata merge --failure-mode=warn -output=' . escapeshellarg($out) . ' ' . $argv); + if (!is_file($out) || filesize($out) === 0) { + throw new WrongUsageException("PGO --phase=use: empty merge output for {$sapi}"); + } + logger()->info("PGO merged {$sapi}: " . filesize($out) . ' bytes'); + } + + private static function overwritePgoFlags(string $var, string $append): void + { + $cur = (string) getenv($var); + $cur = preg_replace('/\s*-f(cs-)?profile-(generate|use)=\S+/', '', $cur) ?? $cur; + $cur = preg_replace('/\s*-fprofile-update=atomic/', '', $cur) ?? $cur; + $cur = preg_replace('/\s*-Wno-error=profile-instr-\S+/', '', $cur) ?? $cur; + $cur = preg_replace('/\s*-Wno-backend-plugin/', '', $cur) ?? $cur; + f_putenv($var . '=' . trim(trim($cur) . ' ' . $append)); + } +} diff --git a/src/StaticPHP/Util/PkgConfigUtil.php b/src/StaticPHP/Util/PkgConfigUtil.php index d5b03757..ba3026aa 100644 --- a/src/StaticPHP/Util/PkgConfigUtil.php +++ b/src/StaticPHP/Util/PkgConfigUtil.php @@ -67,7 +67,7 @@ class PkgConfigUtil public static function getCflags(string $pkg_config_str): string { // get other things - $result = self::execWithResult("pkg-config --static --cflags-only-other {$pkg_config_str}"); + $result = self::execWithResult("pkg-config --static --cflags {$pkg_config_str}"); return trim($result); } diff --git a/src/globals/patch/spc_pgo_flush_frankenphp.patch b/src/globals/patch/spc_pgo_flush_frankenphp.patch new file mode 100644 index 00000000..7c58faff --- /dev/null +++ b/src/globals/patch/spc_pgo_flush_frankenphp.patch @@ -0,0 +1,15 @@ +--- a/frankenphp.c ++++ b/frankenphp.c +@@ -1254,6 +1254,12 @@ + + go_frankenphp_shutdown_main_thread(); + ++ /* spc-pgo: explicit profile flush so the cgo-instrumented frankenphp ++ * still writes .profraw on Go-runtime exit (which bypasses libc atexit). ++ * Weak symbol → no-op in non-PGO builds. */ ++ { extern int __llvm_profile_write_file(void) __attribute__((weak)); ++ if (__llvm_profile_write_file) __llvm_profile_write_file(); } ++ + return NULL; + } + diff --git a/src/globals/patch/spc_pgo_flush_php_main.patch b/src/globals/patch/spc_pgo_flush_php_main.patch new file mode 100644 index 00000000..6bf905bf --- /dev/null +++ b/src/globals/patch/spc_pgo_flush_php_main.patch @@ -0,0 +1,12 @@ +--- a/main/main.c ++++ b/main/main.c +@@ -2563,6 +2563,9 @@ + #endif + + zend_observer_shutdown(); ++ ++ { extern int __llvm_profile_write_file(void) __attribute__((weak)); ++ if (__llvm_profile_write_file) __llvm_profile_write_file(); } + } + /* }}} */ +