From e264d4aade73da0b8d97d09fb9cb607f0992406e Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sun, 5 Jul 2026 11:14:37 +0300 Subject: [PATCH] Fix serve on Windows without signal support --- docs/binaries-phar-docker.md | 2 +- docs/commands.md | 2 +- docs/preview.md | 2 + roadmap.md | 1 + src/Console/ServeCommand.php | 14 ++- src/Console/ServeRuntimeCapabilities.php | 72 +++++++++++ .../Console/ServeRuntimeCapabilitiesTest.php | 112 ++++++++++++++++++ 7 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 src/Console/ServeRuntimeCapabilities.php create mode 100644 tests/Unit/Console/ServeRuntimeCapabilitiesTest.php diff --git a/docs/binaries-phar-docker.md b/docs/binaries-phar-docker.md index ce6b587..1cf52e9 100644 --- a/docs/binaries-phar-docker.md +++ b/docs/binaries-phar-docker.md @@ -38,7 +38,7 @@ The PHAR builder copies only runtime inputs into the build stage: `config/`, `pu PHPDoc comments are stripped from packaged PHP files to keep the standalone PHAR and embedded static-binary PHAR smaller while preserving runtime comments, code, and dependency PHPDoc that is read through reflection at runtime. Benchmark fixture helpers, Composer's `installed.json`, VCS placeholders, and non-runtime type stubs are omitted because packaged commands do not need them. Packaged PHAR entries are gzip-compressed before the archive is finalized. The static binary appends that same PHAR to the micro SAPI executable, so PHAR compression reduces both the standalone PHAR and the Linux, macOS, and Windows static executables. -The static executable includes `ext-highlighter`, so syntax highlighting does not need FFI or an external shared library. `serve` uses ReactPHP stream sockets with preforked worker processes, serves built files and live reload SSE in the server loop, keeps one shared live reload watcher per worker, and does not require PHP's native `sockets` extension. +The static executable includes `ext-highlighter`, so syntax highlighting does not need FFI or an external shared library. `serve` uses ReactPHP stream sockets, serves built files and live reload SSE in the server loop, keeps one shared live reload watcher per server process, and does not require PHP's native `sockets` extension. On POSIX platforms with PCNTL and signal support, `serve` can prefork worker processes; Windows static binaries run a single server process. Relative `content-dir`, `output-dir`, `new`, `clean`, `serve`, and `import` paths are resolved from the directory where you run `yiipress`, not from the packaged executable location. PHAR and static binary runs keep build cache and incremental manifests under the OS temp directory, keyed by the current project directory, instead of writing to `runtime/` in the site checkout. `yiipress clean` removes that packaged cache as well as the configured output directory. diff --git a/docs/commands.md b/docs/commands.md index 6b23b3f..552fb6c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -86,7 +86,7 @@ Starts the preview server for local development. - `--content-dir`, `-c` — path to the content directory (default: `content`). Absolute or relative to project root. - `--output-dir`, `-o` — path to the output directory served by the preview server (default: `output`). Absolute or relative to project root. - `--port`, `-p` — port to serve at when the address argument does not include a port (default: `19777`). -- `--workers`, `-w` — number of preforked server workers (default: `2`). +- `--workers`, `-w` — number of preforked server workers on POSIX platforms with PCNTL support (default: `2`). Windows and other runtimes without signal support run a single server process. On startup, `serve` prints the URL it is listening on. Build progress is printed by rebuilds triggered after file changes. Content and output paths resolve from the current working directory, so run the binary from the site directory or pass explicit `--content-dir` and `--output-dir` paths. diff --git a/docs/preview.md b/docs/preview.md index 4f87e3b..74d3903 100644 --- a/docs/preview.md +++ b/docs/preview.md @@ -8,6 +8,8 @@ The preview server serves the built site during local development: The static binary is the recommended way to run preview. It resolves `content/` and `output/` from the current directory by default, and custom paths can be passed with `--content-dir` and `--output-dir`. +On Windows, the preview server runs as a single ReactPHP server process. Preforked `--workers` and POSIX signal handlers are used only on platforms where PCNTL and signal constants are available. + The command validates paths before opening the socket. The content directory must exist, and the output directory must exist or be creatable and writable. If either check fails, run it with explicit paths such as `./yiipress serve --content-dir=content --output-dir=output`. ## Static file serving diff --git a/roadmap.md b/roadmap.md index dd3fe7b..48e960e 100644 --- a/roadmap.md +++ b/roadmap.md @@ -48,6 +48,7 @@ ## Priority 3: Developer experience - [x] Live reload / file watching during `yiipress serve` +- [x] Windows-compatible `yiipress serve` without PCNTL or POSIX signal handlers - [x] PHAR and static PHP binary packaging - [x] Worker-mode serving for PHAR and static PHP binary packages - [x] Linux, macOS, Windows, and distroless Docker release packaging diff --git a/src/Console/ServeCommand.php b/src/Console/ServeCommand.php index 2a09306..228058c 100644 --- a/src/Console/ServeCommand.php +++ b/src/Console/ServeCommand.php @@ -108,6 +108,12 @@ final class ServeCommand extends Command private int $nextLiveReloadClientId = 1; private ?TimerInterface $liveReloadPingTimer = null; + public function __construct( + private readonly ServeRuntimeCapabilities $runtimeCapabilities = new ServeRuntimeCapabilities(), + ) { + parent::__construct(); + } + public function configure(): void { $this @@ -195,7 +201,7 @@ private function executeReactServer(InputInterface $input, OutputInterface $outp return ExitCode::UNSPECIFIED_ERROR; } - if ($workers > 1 && function_exists('pcntl_fork')) { + if ($workers > 1 && $this->runtimeCapabilities->supportsWorkerPool()) { return $this->runWorkerPool($server, $address, $workers); } @@ -284,8 +290,10 @@ private function runServer(SocketServer $server, string $address): int $server->close(); $loop->stop(); }; - $loop->addSignal(\SIGINT, $stop); - $loop->addSignal(\SIGTERM, $stop); + if ($this->runtimeCapabilities->supportsEventLoopSignals()) { + $loop->addSignal(\SIGINT, $stop); + $loop->addSignal(\SIGTERM, $stop); + } $loop->run(); return ExitCode::OK; diff --git a/src/Console/ServeRuntimeCapabilities.php b/src/Console/ServeRuntimeCapabilities.php new file mode 100644 index 0000000..81db5e6 --- /dev/null +++ b/src/Console/ServeRuntimeCapabilities.php @@ -0,0 +1,72 @@ +directorySeparator = $directorySeparator ?? \DIRECTORY_SEPARATOR; + $this->sigintDefined = $sigintDefined ?? defined('SIGINT'); + $this->sigtermDefined = $sigtermDefined ?? defined('SIGTERM'); + $this->pcntlAsyncSignalsAvailable = $pcntlAsyncSignalsAvailable ?? function_exists('pcntl_async_signals'); + $this->pcntlForkAvailable = $pcntlForkAvailable ?? function_exists('pcntl_fork'); + $this->pcntlSignalDispatchAvailable = $pcntlSignalDispatchAvailable + ?? function_exists('pcntl_signal_dispatch'); + $this->pcntlSignalAvailable = $pcntlSignalAvailable ?? function_exists('pcntl_signal'); + $this->pcntlWaitAvailable = $pcntlWaitAvailable ?? function_exists('pcntl_wait'); + $this->pcntlWaitStatusAvailable = $pcntlWaitStatusAvailable + ?? ( + function_exists('pcntl_wexitstatus') + && function_exists('pcntl_wifexited') + && function_exists('pcntl_wifsignaled') + ); + $this->posixKillAvailable = $posixKillAvailable ?? function_exists('posix_kill'); + } + + public function supportsEventLoopSignals(): bool + { + return $this->sigintDefined + && $this->sigtermDefined + && $this->pcntlSignalAvailable + && $this->pcntlSignalDispatchAvailable; + } + + public function supportsWorkerPool(): bool + { + return $this->directorySeparator !== '\\' + && $this->supportsEventLoopSignals() + && $this->pcntlAsyncSignalsAvailable + && $this->pcntlForkAvailable + && $this->pcntlSignalAvailable + && $this->pcntlWaitAvailable + && $this->pcntlWaitStatusAvailable + && $this->posixKillAvailable; + } +} diff --git a/tests/Unit/Console/ServeRuntimeCapabilitiesTest.php b/tests/Unit/Console/ServeRuntimeCapabilitiesTest.php new file mode 100644 index 0000000..0e0c2d6 --- /dev/null +++ b/tests/Unit/Console/ServeRuntimeCapabilitiesTest.php @@ -0,0 +1,112 @@ +supportsWorkerPool()); + self::assertFalse($capabilities->supportsEventLoopSignals()); + } + + #[Test] + public function missingSignalConstantsDisableServeSignalHooks(): void + { + $capabilities = new ServeRuntimeCapabilities( + directorySeparator: '/', + sigintDefined: false, + sigtermDefined: true, + pcntlAsyncSignalsAvailable: true, + pcntlForkAvailable: true, + pcntlSignalDispatchAvailable: true, + pcntlSignalAvailable: true, + pcntlWaitAvailable: true, + pcntlWaitStatusAvailable: true, + posixKillAvailable: true, + ); + + self::assertFalse($capabilities->supportsWorkerPool()); + self::assertFalse($capabilities->supportsEventLoopSignals()); + } + + #[Test] + public function unixRuntimeWithPcntlAndSignalsSupportsWorkerPool(): void + { + $capabilities = new ServeRuntimeCapabilities( + directorySeparator: '/', + sigintDefined: true, + sigtermDefined: true, + pcntlAsyncSignalsAvailable: true, + pcntlForkAvailable: true, + pcntlSignalDispatchAvailable: true, + pcntlSignalAvailable: true, + pcntlWaitAvailable: true, + pcntlWaitStatusAvailable: true, + posixKillAvailable: true, + ); + + self::assertTrue($capabilities->supportsWorkerPool()); + self::assertTrue($capabilities->supportsEventLoopSignals()); + } + + #[Test] + public function missingPcntlFunctionsDisableWorkerPoolOnly(): void + { + $capabilities = new ServeRuntimeCapabilities( + directorySeparator: '/', + sigintDefined: true, + sigtermDefined: true, + pcntlAsyncSignalsAvailable: false, + pcntlForkAvailable: true, + pcntlSignalDispatchAvailable: true, + pcntlSignalAvailable: true, + pcntlWaitAvailable: true, + pcntlWaitStatusAvailable: true, + posixKillAvailable: true, + ); + + self::assertFalse($capabilities->supportsWorkerPool()); + self::assertTrue($capabilities->supportsEventLoopSignals()); + } + + #[Test] + public function missingPcntlSignalDispatchDisablesEventLoopSignals(): void + { + $capabilities = new ServeRuntimeCapabilities( + directorySeparator: '/', + sigintDefined: true, + sigtermDefined: true, + pcntlAsyncSignalsAvailable: true, + pcntlForkAvailable: true, + pcntlSignalDispatchAvailable: false, + pcntlSignalAvailable: true, + pcntlWaitAvailable: true, + pcntlWaitStatusAvailable: true, + posixKillAvailable: true, + ); + + self::assertFalse($capabilities->supportsWorkerPool()); + self::assertFalse($capabilities->supportsEventLoopSignals()); + } +}