diff --git a/src/StaticPHP/Artifact/ArtifactCache.php b/src/StaticPHP/Artifact/ArtifactCache.php index cd9ad640..7626831a 100644 --- a/src/StaticPHP/Artifact/ArtifactCache.php +++ b/src/StaticPHP/Artifact/ArtifactCache.php @@ -282,6 +282,16 @@ class ArtifactCache logger()->debug("Removed binary cache entry for [{$artifact_name}] on platform [{$platform}]"); } + /** + * Get the names of all artifacts that have at least one downloaded entry (source or binary). + * + * @return array Artifact names + */ + public function getCachedArtifactNames(): array + { + return array_keys($this->cache); + } + /** * Save cache to file. */ diff --git a/src/StaticPHP/Artifact/ArtifactDownloader.php b/src/StaticPHP/Artifact/ArtifactDownloader.php index 8b64b60c..a9a25915 100644 --- a/src/StaticPHP/Artifact/ArtifactDownloader.php +++ b/src/StaticPHP/Artifact/ArtifactDownloader.php @@ -362,7 +362,8 @@ class ArtifactDownloader if ($result !== null) { return $result; } - throw new WrongUsageException("Artifact '{$artifact_name}' downloader does not support update checking."); + // logger()->warning("Artifact '{$artifact_name}' downloader does not support update checking, skipping."); + return new CheckUpdateResult(old: null, new: null, needUpdate: false, unsupported: true); } $cache = ApplicationContext::get(ArtifactCache::class); if ($prefer_source) { @@ -393,7 +394,33 @@ class ArtifactDownloader 'old_version' => $info['version'], ]); } - throw new WrongUsageException("Artifact '{$artifact_name}' downloader does not support update checking, exit."); + // logger()->warning("Artifact '{$artifact_name}' downloader does not support update checking, skipping."); + return new CheckUpdateResult(old: null, new: null, needUpdate: false, unsupported: true); + } + + /** + * Check updates for multiple artifacts, with optional parallel processing. + * + * @param array $artifact_names Artifact names to check + * @param bool $prefer_source Whether to prefer source over binary + * @param bool $bare Check without requiring artifact to be downloaded first + * @param null|callable $onResult Called immediately with (string $name, CheckUpdateResult) as each result arrives + * @return array Results keyed by artifact name + */ + public function checkUpdates(array $artifact_names, bool $prefer_source = false, bool $bare = false, ?callable $onResult = null): array + { + if ($this->parallel > 1 && count($artifact_names) > 1) { + return $this->checkUpdatesWithConcurrency($artifact_names, $prefer_source, $bare, $onResult); + } + $results = []; + foreach ($artifact_names as $name) { + $result = $this->checkUpdate($name, $prefer_source, $bare); + $results[$name] = $result; + if ($onResult !== null) { + ($onResult)($name, $result); + } + } + return $results; } public function getRetry(): int @@ -411,6 +438,61 @@ class ArtifactDownloader return $this->options[$name] ?? $default; } + private function checkUpdatesWithConcurrency(array $artifact_names, bool $prefer_source, bool $bare, ?callable $onResult): array + { + $results = []; + $fiber_pool = []; + $remaining = $artifact_names; + + Shell::passthruCallback(function () { + \Fiber::suspend(); + }); + + try { + while (!empty($remaining) || !empty($fiber_pool)) { + // fill pool + while (count($fiber_pool) < $this->parallel && !empty($remaining)) { + $name = array_shift($remaining); + $fiber = new \Fiber(function () use ($name, $prefer_source, $bare) { + return [$name, $this->checkUpdate($name, $prefer_source, $bare)]; + }); + $fiber->start(); + $fiber_pool[$name] = $fiber; + } + // check pool + foreach ($fiber_pool as $fiber_name => $fiber) { + if ($fiber->isTerminated()) { + // getReturn() re-throws if the fiber threw — propagates immediately + [$artifact_name, $result] = $fiber->getReturn(); + $results[$artifact_name] = $result; + if ($onResult !== null) { + ($onResult)($artifact_name, $result); + } + unset($fiber_pool[$fiber_name]); + } else { + $fiber->resume(); + } + } + } + } catch (\Throwable $e) { + // terminate all still-suspended fibers so their curl processes don't hang + foreach ($fiber_pool as $fiber) { + if (!$fiber->isTerminated()) { + try { + $fiber->throw($e); + } catch (\Throwable) { + // ignore — we only care about stopping them + } + } + } + throw $e; + } finally { + Shell::passthruCallback(null); + } + + return $results; + } + private function probeSourceCheckUpdate(Artifact $artifact, string $artifact_name): ?CheckUpdateResult { if (($callback = $artifact->getCustomSourceCheckUpdateCallback()) !== null) { diff --git a/src/StaticPHP/Artifact/Downloader/Type/CheckUpdateResult.php b/src/StaticPHP/Artifact/Downloader/Type/CheckUpdateResult.php index 468b643b..7e46e4ad 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/CheckUpdateResult.php +++ b/src/StaticPHP/Artifact/Downloader/Type/CheckUpdateResult.php @@ -8,7 +8,8 @@ readonly class CheckUpdateResult { public function __construct( public ?string $old, - public string $new, + public ?string $new, public bool $needUpdate, + public bool $unsupported = false, ) {} } diff --git a/src/StaticPHP/Command/CheckUpdateCommand.php b/src/StaticPHP/Command/CheckUpdateCommand.php index 4fac0f63..1663337c 100644 --- a/src/StaticPHP/Command/CheckUpdateCommand.php +++ b/src/StaticPHP/Command/CheckUpdateCommand.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace StaticPHP\Command; +use StaticPHP\Artifact\ArtifactCache; use StaticPHP\Artifact\ArtifactDownloader; +use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult; +use StaticPHP\DI\ApplicationContext; use StaticPHP\Exception\SPCException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; @@ -17,9 +20,10 @@ class CheckUpdateCommand extends BaseCommand public function configure(): void { - $this->addArgument('artifact', InputArgument::REQUIRED, 'The name of the artifact(s) to check for updates, comma-separated'); + $this->addArgument('artifact', InputArgument::OPTIONAL, 'The name of the artifact(s) to check for updates, comma-separated (default: all downloaded artifacts)'); $this->addOption('json', null, null, 'Output result in JSON format'); $this->addOption('bare', null, null, 'Check update without requiring the artifact to be downloaded first (old version will be null)'); + $this->addOption('parallel', 'p', InputOption::VALUE_REQUIRED, 'Number of parallel update checks (default: 10)', 10); // --with-php option for checking updates with a specific PHP version context $this->addOption('with-php', null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.4)', '8.4'); @@ -27,17 +31,27 @@ class CheckUpdateCommand extends BaseCommand public function handle(): int { - $artifacts = parse_comma_list($this->input->getArgument('artifact')); + $artifact_arg = $this->input->getArgument('artifact'); + if ($artifact_arg === null) { + $artifacts = ApplicationContext::get(ArtifactCache::class)->getCachedArtifactNames(); + if (empty($artifacts)) { + $this->output->writeln('No downloaded artifacts found.'); + return static::OK; + } + } else { + $artifacts = parse_comma_list($artifact_arg); + } try { $downloader = new ArtifactDownloader($this->input->getOptions()); $bare = (bool) $this->getOption('bare'); if ($this->getOption('json')) { + $results = $downloader->checkUpdates($artifacts, bare: $bare); $outputs = []; - foreach ($artifacts as $artifact) { - $result = $downloader->checkUpdate($artifact, bare: $bare); + foreach ($results as $artifact => $result) { $outputs[$artifact] = [ 'need-update' => $result->needUpdate, + 'unsupported' => $result->unsupported, 'old' => $result->old, 'new' => $result->new, ]; @@ -45,15 +59,17 @@ class CheckUpdateCommand extends BaseCommand $this->output->writeln(json_encode($outputs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); return static::OK; } - foreach ($artifacts as $artifact) { - $result = $downloader->checkUpdate($artifact, bare: $bare); - if (!$result->needUpdate) { - $this->output->writeln("Artifact {$artifact} is already up to date ({$result->new})"); + $downloader->checkUpdates($artifacts, bare: $bare, onResult: function (string $artifact, CheckUpdateResult $result) { + if ($result->unsupported) { + $this->output->writeln("Artifact {$artifact} does not support update checking, skipped"); + } elseif (!$result->needUpdate) { + $ver = $result->new ? "({$result->new})" : ''; + $this->output->writeln("Artifact {$artifact} is already up to date {$ver}"); } else { [$old, $new] = [$result->old ?? 'unavailable', $result->new ?? 'unknown']; $this->output->writeln("Update available for {$artifact}: {$old} -> {$new}"); } - } + }); return static::OK; } catch (SPCException $e) { $e->setSimpleOutput(); diff --git a/src/globals/defines.php b/src/globals/defines.php index dbcb63f2..38490046 100644 --- a/src/globals/defines.php +++ b/src/globals/defines.php @@ -104,7 +104,7 @@ const SPC_DOWNLOAD_TYPE_DISPLAY_NAME = [ 'local' => 'local dir', 'pie' => 'PHP Installer for Extensions (PIE)', 'url' => 'url', - 'php-release' => 'php.net', + 'php-release' => 'PHP website release', 'custom' => 'custom downloader', ];