From 7ba4df89784d3ed4f408c83141beeae0ca219356 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:39:58 +0400 Subject: [PATCH 1/2] Improve serve Command Reliability --- src/Console/Commands/ServeCommand.php | 233 +++++++++++++++++++++----- 1 file changed, 187 insertions(+), 46 deletions(-) diff --git a/src/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php index a9bdbbeff..004ab492c 100644 --- a/src/Console/Commands/ServeCommand.php +++ b/src/Console/Commands/ServeCommand.php @@ -14,8 +14,8 @@ namespace Quantum\Console\Commands; -use Quantum\Di\Exceptions\DiException; use Quantum\Console\QtCommand; +use RuntimeException; /** * Class ServeCommand @@ -63,10 +63,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 +75,241 @@ 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 */ 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); + $endpoint = $this->resolveEndpoint(); + + $this->info("Starting development server at: {$endpoint['url']}"); + + $process = $this->startPhpServer($endpoint['host'], $endpoint['port']); + $this->waitUntilServerIsReady($endpoint['host'], $endpoint['port'], $process); + + if ($this->shouldOpenBrowser()) { + $this->openBrowser($endpoint['url']); } + + $this->waitForProcess($process); + } + + /** + * Resolves the endpoint + * @return array + */ + protected function resolveEndpoint(): array + { + $host = $this->host(); + $port = $this->findAvailablePort($host, $this->port()); + + return [ + 'host' => $host, + 'port' => $port, + 'url' => "http://{$host}:{$port}", + ]; } /** * Starts the php development server - * @return string + * @param string $host + * @param int $port + * @return resource */ - protected function runServerCommand(): string + protected function startPhpServer(string $host, int $port) { - return 'php -S ' . $this->host() . ':' . $this->port() . ' -t public'; + $cmd = [ + PHP_BINARY, + '-S', + "{$host}:{$port}", + '-t', + 'public', + ]; + + $descriptors = [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + + $process = proc_open($cmd, $descriptors, $pipes); + + if (!is_resource($process)) { + throw new RuntimeException('Unable to start PHP development server.'); + } + + return $process; } /** - * Tries to open the default browser - * @return string|null + * Wait until the PHP server is ready to accept connections. + * @param string $host + * @param int $port + * @param $process + * @return void */ - protected function openBrowserCommand(): ?string + protected function waitUntilServerIsReady(string $host, int $port, $process): void { - $platformCommand = $this->detectPlatformCommand(); + $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); + } + } + + /** + * Wait until the PHP server is ready to accept connections. + * @param $process + * @return void + */ + protected function waitForProcess($process): void + { + try { + while (true) { + $status = proc_get_status($process); + if ($status === false || !$status['running']) { + break; + } + + usleep(200_000); + } + } finally { + proc_close($process); + } + } - if (!$platformCommand) { - return null; + /** + * Block until the PHP server process exits. + * @param string $host + * @param int $startPort + * @return int + */ + protected function findAvailablePort(string $host, int $startPort): int + { + for ($i = 0; $i < $this->maxPortScan; $i++) { + $port = $startPort + $i; + if ($this->canBind($host, $port)) { + return $port; + } } - return $platformCommand . ' http://' . $this->host() . ':' . $this->port(); + throw new RuntimeException("No available ports found starting from {$startPort}"); } /** - * Checks the available port + * Check whether the given host and port can be bound. + * @param string $host + * @param int $port * @return bool */ - protected function portAvailable(): bool + protected function canBind(string $host, int $port): bool { - $connection = @fsockopen($this->host(), $this->port(), $errno, $err, 30); + $socket = @stream_socket_server( + "tcp://{$host}:{$port}", + $errno, + $errstr, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN + ); - if (!is_resource($connection)) { - return true; - } else { - fclose($connection); + if ($socket === false) { return false; } + + fclose($socket); + return true; + } + + /** + * Determine whether the browser should be opened. + * @return bool + */ + protected function shouldOpenBrowser(): bool + { + return $this->getOption('open'); } /** - * Detects the platform - * @return string|null + * Open the default system browser for the given URL. + * @param string $url + * @return void */ - protected function detectPlatformCommand(): ?string + 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); + } + } + + /** + * Resolve the platform-specific command used to open a URL. + * @param string $url + * @return string[]|null + */ + protected function browserCommand(string $url): ?array { switch (PHP_OS) { - case self::PLATFORM_LINUX: - return 'xdg-open'; 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 + * Resolve the platform-specific command used to open a URL. + * @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 the 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; } } From 0dccf499ea1e53e869f20d55067b00fbeceb5fd2 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:17:24 +0400 Subject: [PATCH 2/2] Fix serve command port binding when multiple servers are running --- src/Console/Commands/ServeCommand.php | 171 +++++++++++++------------- 1 file changed, 88 insertions(+), 83 deletions(-) diff --git a/src/Console/Commands/ServeCommand.php b/src/Console/Commands/ServeCommand.php index 004ab492c..b6679d777 100644 --- a/src/Console/Commands/ServeCommand.php +++ b/src/Console/Commands/ServeCommand.php @@ -16,6 +16,7 @@ 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 @@ -79,42 +80,97 @@ class ServeCommand extends QtCommand ]; /** - * Executes the command + * Execute the command. */ public function exec() { - $endpoint = $this->resolveEndpoint(); + $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); - $this->info("Starting development server at: {$endpoint['url']}"); + try { + $this->waitUntilServerIsReady($host, $port, $process); - $process = $this->startPhpServer($endpoint['host'], $endpoint['port']); - $this->waitUntilServerIsReady($endpoint['host'], $endpoint['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.'); + } + /** + * Handle server execution (browser opening and process monitoring). + * @param array $serverData + */ + protected function handleServerExecution(array $serverData): void + { if ($this->shouldOpenBrowser()) { - $this->openBrowser($endpoint['url']); + $this->openBrowser($serverData['url']); } - $this->waitForProcess($process); + $this->waitForProcess($serverData['process']); } /** - * Resolves the endpoint - * @return array + * Clean up process resource. + * @param resource $process */ - protected function resolveEndpoint(): array + protected function cleanupProcess($process): void { - $host = $this->host(); - $port = $this->findAvailablePort($host, $this->port()); + if (is_resource($process)) { + proc_close($process); + } + } - return [ - 'host' => $host, - 'port' => $port, - 'url' => "http://{$host}:{$port}", - ]; + /** + * Check if port is already in use by another process. + * @param string $host + * @param int $port + * @return bool + */ + protected function isPortInUse(string $host, int $port): bool + { + $fp = @fsockopen($host, $port, $errno, $errstr, 0.1); + if ($fp) { + fclose($fp); + return true; + } + return false; } /** - * Starts the php development server + * Start PHP built-in server. * @param string $host * @param int $port * @return resource @@ -148,8 +204,7 @@ protected function startPhpServer(string $host, int $port) * Wait until the PHP server is ready to accept connections. * @param string $host * @param int $port - * @param $process - * @return void + * @param resource $process */ protected function waitUntilServerIsReady(string $host, int $port, $process): void { @@ -175,66 +230,17 @@ protected function waitUntilServerIsReady(string $host, int $port, $process): vo } } - /** - * Wait until the PHP server is ready to accept connections. - * @param $process - * @return void - */ - protected function waitForProcess($process): void - { - try { - while (true) { - $status = proc_get_status($process); - if ($status === false || !$status['running']) { - break; - } - - usleep(200_000); - } - } finally { - proc_close($process); - } - } - /** * Block until the PHP server process exits. - * @param string $host - * @param int $startPort - * @return int + * @param resource $process */ - protected function findAvailablePort(string $host, int $startPort): int - { - for ($i = 0; $i < $this->maxPortScan; $i++) { - $port = $startPort + $i; - if ($this->canBind($host, $port)) { - return $port; - } - } - - throw new RuntimeException("No available ports found starting from {$startPort}"); - } - - /** - * Check whether the given host and port can be bound. - * @param string $host - * @param int $port - * @return bool - */ - protected function canBind(string $host, int $port): bool + protected function waitForProcess($process): void { - $socket = @stream_socket_server( - "tcp://{$host}:{$port}", - $errno, - $errstr, - STREAM_SERVER_BIND | STREAM_SERVER_LISTEN - ); - - if ($socket === false) { - return false; + while (proc_get_status($process)['running']) { + usleep(200_000); } - fclose($socket); - return true; + proc_close($process); } /** @@ -243,13 +249,12 @@ protected function canBind(string $host, int $port): bool */ protected function shouldOpenBrowser(): bool { - return $this->getOption('open'); + return (bool) $this->getOption('open'); } /** - * Open the default system browser for the given URL. + * Open the default browser. * @param string $url - * @return void */ protected function openBrowser(string $url): void { @@ -271,13 +276,13 @@ protected function openBrowser(string $url): void } /** - * Resolve the platform-specific command used to open a URL. + * Resolve platform-specific browser command. * @param string $url - * @return string[]|null + * @return array|null */ protected function browserCommand(string $url): ?array { - switch (PHP_OS) { + switch (PHP_OS_FAMILY) { case self::PLATFORM_WINDOWS: return ['explorer.exe', $url]; case self::PLATFORM_LINUX: @@ -290,7 +295,7 @@ protected function browserCommand(string $url): ?array } /** - * Resolve the platform-specific command used to open a URL. + * Get host option. * @return string */ protected function host(): string @@ -299,7 +304,7 @@ protected function host(): string } /** - * Get and validate the port option. + * Get and validate port option. * @return int */ protected function port(): int