diff --git a/src/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php index a9bdbbeff..b6679d777 100644 --- a/src/Console/Commands/ServeCommand.php +++ b/src/Console/Commands/ServeCommand.php @@ -14,8 +14,9 @@ namespace Quantum\Console\Commands; -use Quantum\Di\Exceptions\DiException; use Quantum\Console\QtCommand; +use RuntimeException; +use Throwable; /** * Class ServeCommand @@ -26,7 +27,7 @@ class ServeCommand extends QtCommand /** * Platform Windows */ - public const PLATFORM_WINDOWS = 'WINNT'; + public const PLATFORM_WINDOWS = 'Windows'; /** * Platform Linux @@ -63,10 +64,10 @@ class ServeCommand extends QtCommand protected $defaultPort = 8000; /** - * The current port offset. + * Max ports to scan * @var int */ - protected $portOffset = 0; + protected $maxPortScan = 50; /** * Command arguments @@ -75,100 +76,245 @@ class ServeCommand extends QtCommand protected $options = [ ['host', null, 'optional', 'Host', '127.0.0.1'], ['port', null, 'optional', 'Port', '8000'], + ['open', 'o', 'none', 'Open browser'], ]; /** - * Executes the command - * @throws DiException + * Execute the command. */ public function exec() { - if (!$this->portAvailable()) { - $this->portOffset += 1; - $this->exec(); - } elseif (!$this->openBrowserCommand()) { - $this->info('Starting development server at: ' . $this->host() . ':' . $this->port()); - exec($this->runServerCommand(), $out); - } else { - $this->info('Starting development server at: ' . $this->host() . ':' . $this->port()); - exec($this->openBrowserCommand() . ' && ' . $this->runServerCommand(), $out); + $host = $this->host(); + $startPort = $this->port(); + + $serverProcess = $this->startServerOnAvailablePort($host, $startPort); + + $this->handleServerExecution($serverProcess); + } + + /** + * Start server on first available port. + * @param string $host + * @param int $startPort + * @return array + * @throws RuntimeException + */ + protected function startServerOnAvailablePort(string $host, int $startPort): array + { + for ($i = 0; $i < $this->maxPortScan && $startPort + $i <= 65535; $i++) { + $port = $startPort + $i; + + if ($this->isPortInUse($host, $port)) { + continue; + } + + $url = "http://{$host}:{$port}"; + $this->info("Starting development server at: {$url}"); + + $process = $this->startPhpServer($host, $port); + + try { + $this->waitUntilServerIsReady($host, $port, $process); + + return [ + 'process' => $process, + 'port' => $port, + 'url' => $url, + ]; + } catch (Throwable $e) { + $this->cleanupProcess($process); + } } + + throw new RuntimeException('Unable to start PHP server on any available port.'); } /** - * Starts the php development server - * @return string + * Handle server execution (browser opening and process monitoring). + * @param array $serverData + */ + protected function handleServerExecution(array $serverData): void + { + if ($this->shouldOpenBrowser()) { + $this->openBrowser($serverData['url']); + } + + $this->waitForProcess($serverData['process']); + } + + /** + * Clean up process resource. + * @param resource $process + */ + protected function cleanupProcess($process): void + { + if (is_resource($process)) { + proc_close($process); + } + } + + /** + * Check if port is already in use by another process. + * @param string $host + * @param int $port + * @return bool */ - protected function runServerCommand(): string + protected function isPortInUse(string $host, int $port): bool { - return 'php -S ' . $this->host() . ':' . $this->port() . ' -t public'; + $fp = @fsockopen($host, $port, $errno, $errstr, 0.1); + if ($fp) { + fclose($fp); + return true; + } + return false; } /** - * Tries to open the default browser - * @return string|null + * Start PHP built-in server. + * @param string $host + * @param int $port + * @return resource */ - protected function openBrowserCommand(): ?string + protected function startPhpServer(string $host, int $port) { - $platformCommand = $this->detectPlatformCommand(); + $cmd = [ + PHP_BINARY, + '-S', + "{$host}:{$port}", + '-t', + 'public', + ]; + + $descriptors = [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + + $process = proc_open($cmd, $descriptors, $pipes); - if (!$platformCommand) { - return null; + if (!is_resource($process)) { + throw new RuntimeException('Unable to start PHP development server.'); } - return $platformCommand . ' http://' . $this->host() . ':' . $this->port(); + return $process; } /** - * Checks the available port + * Wait until the PHP server is ready to accept connections. + * @param string $host + * @param int $port + * @param resource $process + */ + protected function waitUntilServerIsReady(string $host, int $port, $process): void + { + $start = time(); + + while (true) { + $status = proc_get_status($process); + if ($status === false || !$status['running']) { + throw new RuntimeException('PHP server process died unexpectedly.'); + } + + $fp = @fsockopen($host, $port, $e, $s, 0.5); + if ($fp) { + fclose($fp); + return; + } + + if (time() - $start > 10) { + throw new RuntimeException('Server failed to start within 10 seconds.'); + } + + usleep(200_000); + } + } + + /** + * Block until the PHP server process exits. + * @param resource $process + */ + protected function waitForProcess($process): void + { + while (proc_get_status($process)['running']) { + usleep(200_000); + } + + proc_close($process); + } + + /** + * Determine whether the browser should be opened. * @return bool */ - protected function portAvailable(): bool + protected function shouldOpenBrowser(): bool { - $connection = @fsockopen($this->host(), $this->port(), $errno, $err, 30); + return (bool) $this->getOption('open'); + } - if (!is_resource($connection)) { - return true; - } else { - fclose($connection); - return false; + /** + * Open the default browser. + * @param string $url + */ + protected function openBrowser(string $url): void + { + $cmd = $this->browserCommand($url); + if (!$cmd) { + return; + } + + $descriptors = [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + + $proc = proc_open($cmd, $descriptors, $pipes); + if (is_resource($proc)) { + proc_close($proc); } } /** - * Detects the platform - * @return string|null + * Resolve platform-specific browser command. + * @param string $url + * @return array|null */ - protected function detectPlatformCommand(): ?string + protected function browserCommand(string $url): ?array { - switch (PHP_OS) { - case self::PLATFORM_LINUX: - return 'xdg-open'; + switch (PHP_OS_FAMILY) { case self::PLATFORM_WINDOWS: - return 'start'; + return ['explorer.exe', $url]; + case self::PLATFORM_LINUX: + return ['xdg-open', $url]; case self::PLATFORM_MAC: - return 'open'; + return ['open', $url]; default: return null; } } /** - * Gets the host - * @return mixed|string + * Get host option. + * @return string */ - protected function host() + protected function host(): string { - return $this->getOption('host') ?: $this->defaultHost; + return (string) ($this->getOption('host') ?: $this->defaultHost); } /** - * Gets the port - * @return int|mixed|string + * Get and validate port option. + * @return int */ - protected function port() + protected function port(): int { - $port = $this->getOption('port') ?: $this->defaultPort; - return $port + $this->portOffset; + $port = (int) ($this->getOption('port') ?: $this->defaultPort); + + if ($port < 1 || $port > 65535) { + throw new RuntimeException("Port must be between 1 and 65535, got: {$port}"); + } + + return $port; } }