diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index b6aa8861b..d43c1cd9b 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -1059,6 +1059,8 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | | `process-02` | `process` | `concurrency` (revalidation complete) | `Make Process callbacks and pools failure-safe`; finding `process-02` | | `server-process-10` | `server-process` | `foundation` (revalidation complete) | `Make custom server processes failure-safe`; finding `server-process-10` | +| `signal-05` | `contracts`, `signal` | `server-process` (revalidation complete) | `Complete Signal handler reliability, public APIs, and deployment guidance`; finding `signal-05` | +| `server-11` | `foundation`, `server` | `server-process` and `reverb` (revalidation complete) | `Complete Signal handler reliability, public APIs, and deployment guidance`; finding `server-11` | | `bus-03` | `bus`, `contracts`, `foundation` | `foundation` and `queue` (revalidation complete) | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-03` | | `bus-10` | `bus`, `queue` | `queue` (revalidation complete) | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-10` | | `bus-17` | `bus`, `foundation`, `queue`, `testing` | `log`, `foundation`, and `queue` (revalidation complete); later full `testing` audit | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-17` | diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 5f64b6991..0a2d702d9 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -682,7 +682,7 @@ Append package entries in checklist order. Keep each entry compact but complete | ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | |---|---|---|---|---|---| | `signal-01` | Defect | Major | High | A configured watcher can remain blocked for the default five-second signal timeout after worker/process exit begins, exceeding the default three-second Swoole shutdown allowance and causing forced termination | Replace reversible polling state with terminal manager-owned cleanup: wait indefinitely, track only watchers parked in the native wait, and exception-cancel those exact waiters at exit without interrupting active handlers | -| `signal-02` | Userland footgun | Minor | High | The package has no provenance or lifecycle guidance, so applications can unknowingly replace Swoole's native `SIGTERM`/`SIGINT` shutdown behavior or share mutable handler state across concurrent signal coroutines | Document provenance, native shutdown ownership, coroutine-enabled lifecycle, worker-lifetime handler instances, and the requirement that handlers be coroutine-safe | +| `signal-02` | Userland footgun | Minor | High | The package has no provenance or lifecycle guidance, so applications can unknowingly replace Swoole's native worker `SIGTERM` shutdown behavior, consume `SIGINT` before its default worker termination, or share mutable handler state across concurrent signal coroutines | Document provenance, native worker signal behavior, coroutine-enabled lifecycle, worker-lifetime handler instances, and the requirement that handlers be coroutine-safe | | `signal-03` | Improvement | Improvement | High | Signal tests omit known return types and retain a teardown that writes `null` into the non-coroutine fallback after the test coroutine and is then redundantly cleared by global cleanup | With owner approval, add the known test/fixture/callback return types and remove the misleading dead teardown | - **Watcher ownership boundary:** `SignalManager::listen()` keeps created IDs local until each spawn succeeds so later creation failure can still cancel every partial watcher. Each child records its own ID only while blocked in `EngineSignal::wait()` and clears that marker in `finally` before invoking application handlers. Terminal `stop()` snapshots and exception-cancels only the currently parked IDs; active handlers finish normally and terminal state prevents another wait. A native `false` result is terminal because indefinite waits return it only for an error or non-exception cancellation, and retrying would busy-spin. Real Swoole probes confirmed that `WorkerExit` can cancel a parked waiter from non-coroutine context and coroutine-enabled custom-process teardown can do the same from its process coroutine. @@ -695,6 +695,32 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** `composer fix` completed with zero formatter changes, both PHPStan configurations green, 23,186 component tests and 65,992 assertions passing with 1,600 expected skips, 346 Testbench contract tests and 1,029 assertions passing with 3 expected skips, and 4 dogfood tests and 7 assertions passing. `composer validate --strict src/signal/composer.json`, `git diff --check`, package-checklist parity, and repository-wide stale-reference scans were clean. A Swoole 6.2.2 process probe verified that a custom `SIGTERM` waiter consumes the signal and leaves the worker alive, grounding the README warning. Fresh self-review traced every watcher lifecycle, caller, failure path, public/config surface, retained state, and hot-path effect; independent post-implementation review signed off after the multiple-waiter regression and native-signal documentation evidence were added. - **Assessment:** All three accepted findings are closed. The implementation removes polling and obsolete reversible state while adding only the bounded ownership map required for exact cancellation. It adds no request-hot-path work, no registry abstraction, lock, channel, retry, timeout, destructor, compatibility layer, or handler-interruption path, and leaves no known stale or workaround code. +### Complete Signal handler reliability, public APIs, and deployment guidance + +- **Architecture and inspected risk surfaces:** This re-audit covers Signal as an application-facing extension package rather than only lifecycle infrastructure. The trace includes the public handler contract, manager, lifecycle listeners, provider, configuration, every production/test implementation and repository consumer, custom server-process startup and teardown, Swoole 6.2.2 worker/user-process signal and forced-termination paths, the application health route, reload commands, package documentation, and the existing Foundation server configuration tests. + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `signal-04` | Defect | Major | High | A throwing application handler escapes the watcher loop, terminates the sole coroutine for that signal, skips later handlers, and leaves the next delivery to the operating system's default action | Resolve one `SafeCaller` with the manager and isolate every handler invocation so failures are reported, later handlers run, and the watcher re-arms | +| `signal-05` | Improvement | Improvement | High | The Hyperf-shaped integer process constants and positional tuple API make application definitions easy to swap and hard for PHPStan to validate, while public `init()`/`getHandlers()` expose manager internals with no application use | Replace the contract with Laravel-shaped `SignalHandler`, string `WORKER`/`SERVER_PROCESS` constants, grouped `signals(): array`, and `handle()`; merge initialization into `listen(string $process)` and remove retained/public handler state | +| `signal-06` | Defect | Minor | High | A nonnumeric keyed handler priority is silently treated as an unprioritized class entry, and unvalidated handler definitions fail later through incidental PHP/native errors | Distinguish list entries by integer key, accept numeric integer/float/string priorities, normalize numeric strings once, and validate resolved handlers, process groups, and integer signals at the public configuration boundary | +| `server-11` | Improvement | Improvement | High | The shipped Swoole graceful-shutdown allowance is hardcoded, so applications cannot give legitimate long requests, WebSocket drains, or custom-process cleanup more time without editing published configuration | Expose integer `SERVER_MAX_WAIT_TIME` with default `3`, document its server-wide drain semantics and zero behavior, and normalize adjacent `SERVER_WORKERS` to an integer at the same config boundary | + +- **Final Signal contract:** `SignalHandler` declares string process keys `worker` and `server-process`; `signals()` returns a grouped map from those keys to lists of integer signals; `handle(int $signal): void` receives one delivered signal. A unit enum is rejected because PHP enum objects cannot be array keys. The manager validates its public process argument before resolving configured handlers, returns early after terminal stop or outside coroutine context, resolves and validates the complete handler map once, filters the requested process, and creates one watcher per signal. An empty signal list is valid. Native signal ranges remain Swoole-owned; list keys and duplicate entries are accepted as ordinary application configuration because stricter validation adds no supported guarantee. +- **Handler execution and ownership:** Each watcher remains manager-owned while parked in `EngineSignal::wait()`. Every application handler runs through the manager's one `SafeCaller`, so a reported failure cannot skip later handlers or destroy the watcher. Active handlers remain outside the cancellable waiter set and finish during terminal `stop()`. Partial creation cancels every earlier watcher through the real manager flow. Calling `listen()` twice is unsupported because competing native waits for the same signal strand the earlier waiter; do not add an idempotency registry or hidden deduplication state. +- **Configuration and lifecycle guidance:** Handler config is resolved when each coroutine-enabled worker or custom server process starts. The worker group applies to event workers and to task workers only when `task_enable_coroutine` is enabled. Higher numeric priorities run first. Workers retain Swoole's native `SIGTERM` shutdown unless an application intentionally registers that signal; the application handler then owns the replacement shutdown path. Swoole installs no worker `SIGINT` handler, so an application handler consumes an interrupt that would otherwise terminate the worker. Graceful custom-process shutdown requires both `ProcessStopHandler` and a process loop that observes `ProcessManager::isRunning()` and returns. A delivered signal targets one operating-system process, not every worker/process. `Swoole\Process::signal` and coroutine signal waits are mutually exclusive process-wide, regardless of signal number. `server:reload` reloads event and task workers only, not custom server processes. +- **Shutdown allowance and health boundary:** `SERVER_MAX_WAIT_TIME` is the server-wide graceful drain allowance, not a per-process exact deadline. Swoole may force-terminate work that exceeds it; applications with legitimate longer requests, WebSocket draining, or server-process cleanup increase the setting. Zero is not unlimited: workers receive no drain period and Swoole's final manager timeout for custom server processes is disabled. Custom server processes have no built-in startup timeout, readiness handshake, heartbeat, or health state, and the normal `/up` route does not inspect them automatically. Applications that depend on workload-specific process health publish suitable shared state and check it from the existing `DiagnosingHealth` event. Do not add a generic timeout, readiness, heartbeat, health, or watchdog subsystem without a concrete contract. +- **Regression strategy:** Use a process-isolated real two-delivery signal test with a higher-priority throwing handler followed by a recorder, proving both handlers run in order for both deliveries, both failures are reported, and the process survives. Cover grouped worker/server definitions, every validation branch, invalid public process values, non-coroutine and stopped no-resolution paths, priority normalization, terminal stop ownership, active-handler completion, and real partial-spawn rollback without reflection. Update both lifecycle-listener suites, the production `ProcessStopHandler` suite, and fixtures to the new contract. The new delivery-order test makes the second identical handler fixture redundant, while the surviving registration fixture no longer retains an unobserved coroutine-context recorder. Foundation config tests cover both numeric environment values through exception-safe application/container and environment restoration; the same file removes its duplicated environment cleanup boundary. +- **Documentation:** Add a Laravel-style Signal guide, index it, cross-link the complete graceful server-process recipe, add a concise Artisan signal cross-reference, and thin both package READMEs to the repository format. Deployment is the canonical home for `SERVER_MAX_WAIT_TIME` and its zero warning; Reverb names the setting where it already discusses shutdown draining. Keep native watcher/reactor mechanics out of user documentation. +- **Performance and complexity:** There is no request-path work. Listening adds one worker-start container resolution for `SafeCaller`, handler delivery adds one closure/method call per configured handler, and configuration adds environment reads only during config load. Safe invocation prevents worker/process loss rather than adding hot-path machinery. The grouped contract and direct validation replace retained manager state and public methods. No facade, runtime closure registry, attributes, enum, lock, channel, retry, process-name targeting, handler cloning/scoping, active-handler cancellation, started registry, or health subsystem is added. +- **Laravel-facing result:** Signal has no Laravel counterpart. The new application-facing surface follows Laravel naming and configuration ergonomics; Hyperf parity is not a constraint. The existing Hypervel-specific contract, public initialization/inspection methods, and positional definitions are removed completely rather than retained as compatibility wrappers. +- **Owner approval:** The owner approved the Signal public-API improvement, documentation surface, and implementation workflow, then separately approved exposing `SERVER_MAX_WAIT_TIME` and normalizing `SERVER_WORKERS` after reviewing their server-wide scope, zero behavior, tests, performance, and overengineering assessment. +- **Implementation:** Replaced the tuple-based `SignalHandlerInterface` with the grouped `SignalHandler` contract and migrated the manager, lifecycle listener, framework stop handler, fixtures, and tests without a compatibility layer. `SignalManager::listen()` now owns configuration resolution, validates the complete definition before spawning, keeps handlers local to that process group, and routes each invocation through one manager-resolved `SafeCaller` so a failure cannot skip later handlers or destroy the watcher. Removed the retained handler map and public initialization/inspection methods. The server configuration now reads integer `SERVER_WORKERS` and `SERVER_MAX_WAIT_TIME` values from the environment. Added the canonical Signal guide, indexed and cross-linked it from the related Artisan, Deployment, Reverb, and Server Process guides, and reduced both package READMEs to the repository format. +- **Cross-package revalidation:** Contracts, Foundation server configuration, Server Process signal handling, Signal lifecycle listeners, Reverb shutdown guidance, and the existing deterministic watcher-stop boundary were re-traced and tested together. `ProcessStopHandler` now declares only the server-process `SIGTERM` group, worker and server-process lifecycle events select the correct string group, active handlers still finish after terminal stop, and the server-wide shutdown setting does not add request or process-loop work. +- **Regression tests:** A real process-isolated two-delivery test proves priority order, per-handler failure reporting, continuation, watcher re-arming, and process survival. Focused coverage also proves grouped process selection, every accepted validation failure, numeric priority normalization, stopped and non-coroutine early returns, exact release of parked watchers, active-handler completion, real partial-spawn rollback, lifecycle listener routing, the production stop handler, and integer server environment values. Scheduler handoffs use explicit coroutine sleeps where a parked channel consumer resumes inside the producer's push. +- **Validation and review:** Every changed test file and the complete Signal, Server Process, and affected Foundation configuration groups are green. Final `composer fix` changed no formatted file, both PHPStan configurations are green, the complete parallel component suite passes with its expected service skips, and both Testbench suites pass. The Signal split manifest validates strictly, `git diff --check`, package-checklist parity, stale-symbol searches, caller/callee tracing, and the final lifecycle, public-API, documentation, performance, dead-code, and overengineering review are clean. Independent code review reproduced the scheduler behavior, verified all corrections, reran the focused package groups repeatedly, and signed off with no finding left open. +- **Assessment:** The final design removes retained manager state and four misleading Hyperf-shaped public concepts while adding only direct startup validation and failure isolation. It adds no request-path work, allocation, lookup, lock, yield, retry, registry, facade, health subsystem, compatibility shim, or process-name abstraction. Signal delivery adds one required safe-call boundary per configured handler, and all other new work occurs during process startup or configuration loading. The result is a smaller Laravel-shaped application API, deterministic lifecycle ownership, complete failure containment, and one canonical user documentation surface. + ### Bound pool resources and connection progress deterministically - **Architecture and inspected risk surfaces:** Pool is Hyperf-derived connection infrastructure with a worker-lifetime abstract pool, explicit managed/borrowed ownership, one canonical idle queue, optional frequency maintenance, a heartbeat-capable connection extension surface, and a callback-driven SimplePool extension surface. Database and Redis are its two production consumers and own their native connection construction, heartbeat, idle, lifetime, and event-duration boundaries. The audit covered every Pool source and test file; Pool contracts and split-package metadata; Coordinator timer and Engine channel/spawn behavior; every Database and Redis pool, connection, connector, config, test, and documentation consumer; current Hyperf Pool and consumer patterns; prior Pool lifecycle hardening; and focused spawn, heartbeat, frequency, configuration, and clock probes. diff --git a/src/boost/docs-ported.md b/src/boost/docs-ported.md index 94708b55d..519434034 100644 --- a/src/boost/docs-ported.md +++ b/src/boost/docs-ported.md @@ -79,8 +79,9 @@ scheduling.md scout.md search.md seeding.md -server-process.md +server-processes.md session.md +signals.md socialite.md starter-kits.md strings.md diff --git a/src/boost/docs/artisan.md b/src/boost/docs/artisan.md index b2deaf971..0e52a4541 100644 --- a/src/boost/docs/artisan.md +++ b/src/boost/docs/artisan.md @@ -996,6 +996,8 @@ $this->trap([SIGTERM, SIGQUIT], function (int $signal) { }); ``` +Artisan signal traps apply only to the current command. To handle signals in server workers or custom server processes, see the [Signal documentation](/docs/{{version}}/signals). + ## Stub Customization diff --git a/src/boost/docs/deployment.md b/src/boost/docs/deployment.md index 0bb6d26d9..a5ef51142 100644 --- a/src/boost/docs/deployment.md +++ b/src/boost/docs/deployment.md @@ -6,6 +6,7 @@ - [Nginx](#nginx) - [Nginx and WebSockets](#nginx-and-websockets) - [Running the Hypervel Server](#running-the-hypervel-server) + - [Graceful Shutdown](#graceful-shutdown) - [Directory Permissions](#directory-permissions) - [Optimization](#optimization) - [Caching Configuration](#optimizing-configuration-loading) @@ -126,12 +127,19 @@ In production, your Hypervel server should be kept running by a process monitor, php artisan serve ``` -By default, the HTTP server binds to `0.0.0.0:8000` with HTTP/2 enabled. You may configure the server host, port, worker count, max requests per worker, HTTP/2 support, and other Swoole settings using the `SERVER_HOST`, `SERVER_PORT`, `SERVER_WORKERS`, `SERVER_MAX_REQUESTS`, and `SERVER_HTTP2` environment variables read by `config/server.php`. +By default, the HTTP server binds to `0.0.0.0:8000` with HTTP/2 enabled. You may configure the server host, port, worker count, max requests per worker, graceful shutdown allowance, HTTP/2 support, and other Swoole settings using the `SERVER_HOST`, `SERVER_PORT`, `SERVER_WORKERS`, `SERVER_MAX_REQUESTS`, `SERVER_MAX_WAIT_TIME`, and `SERVER_HTTP2` environment variables read by `config/server.php`. Swoole's `event_object` setting is not supported because Hypervel dispatches its own lifecycle event objects from the native server callbacks. Leave this setting disabled and use Hypervel's lifecycle events when integrating with server activity. The `serve` command also accepts `--host` and `--port` options for overriding the HTTP server address for the current process. In production, prefer durable configuration in `config/server.php` and your environment. + +### Graceful Shutdown + +The `SERVER_MAX_WAIT_TIME` environment variable controls Swoole's server-wide graceful shutdown allowance in seconds. It defaults to `3`. Increase this value when long-running requests, WebSocket connections, or server-process cleanup need more time to finish. Swoole may forcefully terminate work that exceeds the configured allowance. + +A value of `0` does not provide unlimited shutdown time. Workers receive no graceful drain period, while Swoole's final timeout for custom server processes is disabled. + ### Directory Permissions @@ -221,6 +229,8 @@ php artisan server:reload The command will fail if the configured PID file cannot be read, does not contain a valid process ID, or the reload signal cannot be delivered. +Neither `reload` nor `server:reload` restarts custom server processes. Restart the server when server-process code or configuration changes. + ## Debug Mode diff --git a/src/boost/docs/documentation.md b/src/boost/docs/documentation.md index fd2a4706f..270b89ac1 100644 --- a/src/boost/docs/documentation.md +++ b/src/boost/docs/documentation.md @@ -54,7 +54,8 @@ - [Notifications](/docs/{{version}}/notifications) - [Package Development](/docs/{{version}}/packages) - [Processes](/docs/{{version}}/processes) - - [Server Processes](/docs/{{version}}/server-process) + - [Server Processes](/docs/{{version}}/server-processes) + - [Signals](/docs/{{version}}/signals) - [WebSockets](/docs/{{version}}/websockets) - [Queues](/docs/{{version}}/queues) - [Rate Limiting](/docs/{{version}}/rate-limiting) diff --git a/src/boost/docs/reverb.md b/src/boost/docs/reverb.md index 9d8fad176..fe6642bfb 100644 --- a/src/boost/docs/reverb.md +++ b/src/boost/docs/reverb.md @@ -231,7 +231,7 @@ If you would like to scale Reverb independently from the rest of your applicatio Swoole counts incoming WebSocket messages toward the same `SERVER_MAX_REQUESTS` limit as HTTP requests. When a worker reaches this limit, its connected WebSocket clients are disconnected while the worker restarts. Swoole adds a random grace of up to half the configured limit so workers do not all restart together. -For a dedicated Reverb deployment, you should set `SERVER_MAX_REQUESTS=0` to keep long-lived connections open. A mixed HTTP and Reverb deployment may retain a nonzero limit when periodic recycling is intentional, but its shutdown timeout should allow enough time to drain its configured Redis, connection-limit, and webhook workload. +For a dedicated Reverb deployment, you should set `SERVER_MAX_REQUESTS=0` to keep long-lived connections open. A mixed HTTP and Reverb deployment may retain a nonzero limit when periodic recycling is intentional, but its [`SERVER_MAX_WAIT_TIME` setting](/docs/{{version}}/deployment#graceful-shutdown) should allow enough time to drain its configured Redis, connection-limit, and webhook workload. ### Logging diff --git a/src/boost/docs/server-process.md b/src/boost/docs/server-processes.md similarity index 86% rename from src/boost/docs/server-process.md rename to src/boost/docs/server-processes.md index b21607a41..4168a1a09 100644 --- a/src/boost/docs/server-process.md +++ b/src/boost/docs/server-processes.md @@ -9,6 +9,8 @@ - [Registering Process Instances](#registering-process-instances) - [Process Lifecycle](#process-lifecycle) - [Lifecycle Events](#lifecycle-events) + - [Reloading Server Processes](#reloading-server-processes) + - [Process Health](#process-health) - [Signals](#signals) - [Inter-Process Communication](#inter-process-communication) - [Sending Messages](#sending-messages) @@ -158,10 +160,24 @@ Hypervel dispatches a `Hypervel\ServerProcess\Events\BeforeProcessHandle` event You may register listeners for these events in the same way as other Hypervel [event listeners](/docs/{{version}}/events#registering-events-and-listeners). + +### Reloading Server Processes + +The `server:reload` command reloads the server's event and task workers, but it does not reload custom server processes. Restart the server when server-process code or configuration changes. + + +### Process Health + +Server processes do not have a built-in startup timeout, readiness check, heartbeat, or health status. The application's normal `/up` route does not inspect them automatically. + +If your application depends on a server process, the process may publish suitable shared state for its workload. You may then check that state from a listener for the `Hypervel\Foundation\Events\DiagnosingHealth` event. For more information, see the [health route documentation](/docs/{{version}}/deployment#the-health-route). + ### Signals -If the Signal package is installed, server processes automatically use the process signal handlers listed in the `signal.handlers` configuration value. You do not need to register these handlers again in your process class. +If the Signal package is installed, coroutine-enabled server processes use the server-process signal handlers listed in the `signal.handlers` configuration value. You do not need to register these handlers again in your process class. + +Graceful shutdown is opt-in. Your application must register the framework's stop handler and ensure the process returns from `handle` when the server is stopping. See the [Signal documentation](/docs/{{version}}/signals#server-process-signals) for the complete setup. ## Inter-Process Communication diff --git a/src/boost/docs/signals.md b/src/boost/docs/signals.md new file mode 100644 index 000000000..f12604cdc --- /dev/null +++ b/src/boost/docs/signals.md @@ -0,0 +1,153 @@ +# Signals + +- [Introduction](#introduction) +- [Defining Signal Handlers](#defining-signal-handlers) + - [Process Groups](#process-groups) +- [Registering Signal Handlers](#registering-signal-handlers) + - [Handler Priority](#handler-priority) +- [Signal Lifecycle](#signal-lifecycle) + - [Worker Signals](#worker-signals) + - [Server Process Signals](#server-process-signals) +- [Native Signal Limitations](#native-signal-limitations) + + +## Introduction + +Operating systems use signals to notify running processes about events such as termination requests or application-defined commands. Hypervel's Signal package allows your application to handle these signals within server workers and custom [server processes](/docs/{{version}}/server-processes). + +If you only need to handle a signal within an Artisan command, you should use the command's [signal handling methods](/docs/{{version}}/artisan#signal-handling) instead. + + +## Defining Signal Handlers + +To define a signal handler, implement the `Hypervel\Contracts\Signal\SignalHandler` contract. The `signals` method declares the signals handled by the class, while the `handle` method receives the signal that was delivered: + +```php + [SIGUSR1], + self::SERVER_PROCESS => [SIGUSR1], + ]; + } + + /** + * Handle the received signal. + */ + public function handle(int $signal): void + { + // Write a diagnostic snapshot... + } +} +``` + +Each configured handler is resolved through the service container when a process starts. The same handler instance is used for every signal declared by that handler within the process. Because the same handler instance may handle different signals at the same time, you should not store data for an individual signal delivery on the handler instance. + + +### Process Groups + +The `SignalHandler::WORKER` group applies to the server's event workers. It also applies to task workers when the `task_enable_coroutine` server setting is enabled. The `SignalHandler::SERVER_PROCESS` group applies to coroutine-enabled custom server processes. You may declare either group or both groups, and an empty signal list is allowed: + +```php +return [ + self::WORKER => [SIGUSR1, SIGUSR2], + self::SERVER_PROCESS => [], +]; +``` + +Signal handlers are not started in processes where coroutine support is disabled. + + +## Registering Signal Handlers + +Signal handlers are registered in the `handlers` array of your application's `config/signal.php` configuration file: + +```php +use App\Signals\WriteDiagnostics; + +'handlers' => [ + WriteDiagnostics::class, +], +``` + +Handlers are resolved when each worker or server process starts. Register handlers in configuration before starting the server rather than changing the list while the application is running. + + +### Handler Priority + +When several handlers listen for the same signal, you may assign each handler a numeric priority. Handlers with a higher priority run first: + +```php +use App\Signals\FlushMetrics; +use App\Signals\WriteDiagnostics; + +'handlers' => [ + FlushMetrics::class => 20, + WriteDiagnostics::class => 10, +], +``` + +If a handler throws an exception, Hypervel reports the exception and continues running the remaining handlers. Once every handler has finished, Hypervel listens for the next delivery of the signal. + + +## Signal Lifecycle + +A signal is delivered to one operating system process. It is not automatically broadcast to every worker or server process. Use the server's normal lifecycle controls instead of assuming that one application signal reaches every process. + +Keep signal handlers short. While a handler is running, another delivery of the same signal may use the operating system's default behavior before Hypervel begins listening again. + + +### Worker Signals + +Swoole manages normal worker shutdown through `SIGTERM`. Registering an application handler for this signal replaces that native behavior within the worker, so your handler becomes responsible for completing the required shutdown. + +Swoole does not handle `SIGINT` in workers. If your application registers a handler for this signal, Hypervel handles an interrupt that would otherwise terminate the worker. + + +### Server Process Signals + +Graceful shutdown for a custom server process is opt-in. First, register Hypervel's `ProcessStopHandler` in your `config/signal.php` file: + +```php +use Hypervel\ServerProcess\Handlers\ProcessStopHandler; + +'handlers' => [ + ProcessStopHandler::class, +], +``` + +Then, ensure the process checks `ProcessManager::isRunning()` and returns from its `handle` method when the server is stopping: + +```php +use Hypervel\ServerProcess\ProcessManager; + +/** + * Run the server process. + */ +public function handle(): void +{ + while (ProcessManager::isRunning()) { + $this->processNextReport(); + } +} +``` + +Any blocking work within the loop must return periodically so the running state can be checked. If your process needs more time to finish its current work, increase the server's [graceful shutdown allowance](/docs/{{version}}/deployment#graceful-shutdown). + +The `server:reload` command reloads event and task workers, but it does not reload custom server processes. Restart the server when server-process code or configuration changes. + + +## Native Signal Limitations + +Swoole does not support waiting for `SIGCHLD` through the coroutine signal API used by Hypervel. In addition, you should not use `Swoole\Process::signal` in a process that uses Hypervel signal handlers. The two native signal mechanisms are mutually exclusive within a process. diff --git a/src/contracts/src/Signal/SignalHandlerInterface.php b/src/contracts/src/Signal/SignalHandler.php similarity index 51% rename from src/contracts/src/Signal/SignalHandlerInterface.php rename to src/contracts/src/Signal/SignalHandler.php index c5857c5bc..b1d33c7e7 100644 --- a/src/contracts/src/Signal/SignalHandlerInterface.php +++ b/src/contracts/src/Signal/SignalHandler.php @@ -4,18 +4,18 @@ namespace Hypervel\Contracts\Signal; -interface SignalHandlerInterface +interface SignalHandler { - public const WORKER = 1; + public const string WORKER = 'worker'; - public const PROCESS = 2; + public const string SERVER_PROCESS = 'server-process'; /** * Get the signals this handler listens for. * - * @return array Array of [process type, signal] pairs + * @return array> */ - public function listen(): array; + public function signals(): array; /** * Handle the received signal. diff --git a/src/foundation/config/server.php b/src/foundation/config/server.php index c25b58d66..30a5c847c 100644 --- a/src/foundation/config/server.php +++ b/src/foundation/config/server.php @@ -64,14 +64,14 @@ Constant::OPTION_ENABLE_COROUTINE => true, Constant::OPTION_TASK_ENABLE_COROUTINE => false, Constant::OPTION_TASK_WORKER_NUM => 0, - Constant::OPTION_WORKER_NUM => env('SERVER_WORKERS', swoole_cpu_num()), + Constant::OPTION_WORKER_NUM => (int) env('SERVER_WORKERS', swoole_cpu_num()), Constant::OPTION_PID_FILE => storage_path('framework/hypervel.pid'), Constant::OPTION_DAEMONIZE => false, Constant::OPTION_OPEN_TCP_NODELAY => true, Constant::OPTION_MAX_COROUTINE => 100000, Constant::OPTION_OPEN_HTTP2_PROTOCOL => (bool) env('SERVER_HTTP2', true), Constant::OPTION_MAX_REQUEST => (int) env('SERVER_MAX_REQUESTS', 100000), - Constant::OPTION_MAX_WAIT_TIME => 3, + Constant::OPTION_MAX_WAIT_TIME => (int) env('SERVER_MAX_WAIT_TIME', 3), Constant::OPTION_SOCKET_BUFFER_SIZE => 2 * 1024 * 1024, Constant::OPTION_BUFFER_OUTPUT_SIZE => 2 * 1024 * 1024, diff --git a/src/foundation/config/signal.php b/src/foundation/config/signal.php index 5360fa393..e3b48d986 100644 --- a/src/foundation/config/signal.php +++ b/src/foundation/config/signal.php @@ -8,20 +8,20 @@ | Signal Handlers |-------------------------------------------------------------------------- | - | Register signal handler classes that will be resolved when the server - | starts. Each handler implements SignalHandlerInterface and defines - | which signals it listens for and how to handle them. + | Register signal handler classes that are resolved when a worker or + | server process starts. Each handler implements SignalHandler and + | declares the signals it handles in workers, in server processes, or + | in both. | - | Handlers can be registered with a priority (numeric value). Higher - | priority handlers are initialized first. Use class name as the key - | and priority as the value, or just list the class name for default - | priority (0). + | You may register handlers with a numeric priority. Higher-priority + | handlers run first for the same signal. Use the class name as the key + | and the priority as the value, or list the class name to use the + | default priority of zero. | - | By default, no worker signal handlers are registered. Swoole's native - | shutdown path handles worker exit via the onWorkerExit callback, which - | resumes the WORKER_EXIT coordinator to unwind long-running coroutines. - | Custom handlers should only be added when application-specific shutdown - | logic is needed beyond the framework's built-in lifecycle. + | No handlers are registered by default. Swoole manages normal worker + | shutdown automatically, so add worker handlers only when your + | application needs custom signal behavior. Graceful server-process + | shutdown must be configured explicitly. | */ diff --git a/src/server-process/README.md b/src/server-process/README.md index daf9f6c6f..3f35f7c05 100644 --- a/src/server-process/README.md +++ b/src/server-process/README.md @@ -3,58 +3,6 @@ Server Process for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/server-process) -Ported from: https://github.com/hyperf/hyperf/tree/master/src/process - -## Defining Server Processes - -Server processes are custom Swoole child processes attached to the application -server. Define one by extending `AbstractProcess` and implementing `handle()`: - -```php -use Hypervel\ServerProcess\AbstractProcess; - -class ReportProcess extends AbstractProcess -{ - public string $name = 'reports'; - - public int $processCount = 2; - - public function handle(): void - { - // Run the process workload... - } -} -``` - -`isEnabled()` may be overridden when a process should only run for a particular -server configuration. +Documentation: https://hypervel.org/docs/server-processes -## Registering Server Processes - -Register process classes in `config/server.php`: - -```php -'processes' => [ - ReportProcess::class, -], -``` - -Classes are resolved through the service container and attached before the -server starts. When multiple distinctly configured instances of the same class -are needed, register those instances with `ProcessManager::register()` from a -service provider during boot. - -## Lifecycle and IPC - -Swoole owns each process after it is attached to the server. Hypervel dispatches -`BeforeProcessHandle` and `AfterProcessHandle` around `handle()`, sends uncaught -exceptions to the framework exception handler, completes child-local timer and -coordinator teardown, and applies `restartInterval` before the process callback -returns. When the Signal package is installed, process-scoped handlers -configured in `signal.handlers` are active for the same lifecycle. - -Coroutine-enabled processes listen for serialized values written through the -native handles exposed by `ProcessCollector`. Each valid value dispatches a -`PipeMessage`, including `false`, `null`, zero, empty strings, and empty arrays. -IPC is an internal application boundary: only write trusted serialized data, -and do not close collected handles owned by the server. +Ported from: https://github.com/hyperf/hyperf/tree/master/src/process diff --git a/src/server-process/src/Handlers/ProcessStopHandler.php b/src/server-process/src/Handlers/ProcessStopHandler.php index fe873eb93..fdb81d44c 100644 --- a/src/server-process/src/Handlers/ProcessStopHandler.php +++ b/src/server-process/src/Handlers/ProcessStopHandler.php @@ -4,18 +4,18 @@ namespace Hypervel\ServerProcess\Handlers; -use Hypervel\Contracts\Signal\SignalHandlerInterface; +use Hypervel\Contracts\Signal\SignalHandler; use Hypervel\ServerProcess\ProcessManager; -class ProcessStopHandler implements SignalHandlerInterface +class ProcessStopHandler implements SignalHandler { /** * Get the signals this handler listens for. */ - public function listen(): array + public function signals(): array { return [ - [self::PROCESS, SIGTERM], + self::SERVER_PROCESS => [SIGTERM], ]; } diff --git a/src/signal/README.md b/src/signal/README.md index edb82749c..ef9e9863c 100644 --- a/src/signal/README.md +++ b/src/signal/README.md @@ -3,18 +3,6 @@ Signal for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/signal) -Ported from: https://github.com/hyperf/hyperf/tree/master/src/signal - -## Signal Lifecycle +Documentation: https://hypervel.org/docs/signals -Configured signal handlers are resolved when a coroutine-enabled worker or -custom process starts and remain shared for that process incarnation. The same -handler instance can be invoked concurrently when it listens for different -signals, so handlers must not retain coroutine-specific mutable state on the -instance. - -Swoole owns normal worker shutdown. Registering an application handler for -`SIGTERM` or `SIGINT` consumes that signal through the custom handler instead of -Swoole's native shutdown path, so the handler must explicitly provide the -required shutdown behavior. Processes without coroutine support do not start -Signal watchers. +Ported from: https://github.com/hyperf/hyperf/tree/master/src/signal diff --git a/src/signal/src/SignalManager.php b/src/signal/src/SignalManager.php index 45ee98755..99b0dfeb6 100644 --- a/src/signal/src/SignalManager.php +++ b/src/signal/src/SignalManager.php @@ -6,23 +6,22 @@ use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Signal\SignalHandlerInterface as SignalHandler; +use Hypervel\Contracts\Signal\SignalHandler; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Engine\Signal as EngineSignal; +use Hypervel\Support\SafeCaller; use Hypervel\Support\SplPriorityQueue; +use InvalidArgumentException; use Swoole\Coroutine\CanceledException; use Throwable; class SignalManager { - /** - * @var SignalHandler[][][] - */ - protected array $handlers = []; - protected ConfigContract $config; + protected SafeCaller $safeCaller; + protected bool $stopped = false; /** @@ -38,58 +37,35 @@ class SignalManager public function __construct(protected Container $container) { $this->config = $container->make(ConfigContract::class); + $this->safeCaller = $container->make(SafeCaller::class); } /** - * Initialize the signal handlers from config. + * Start listening for signals for the given process type. * - * Boot-only. Reinitializing after listening starts leaves existing - * watchers using the prior handler set until the process exits. + * Boot-only. Call once for each process incarnation. Another call creates + * competing native waits and strands the earlier watcher for each signal. */ - public function init(): void + public function listen(string $process): void { - $this->handlers = []; - - foreach ($this->getQueue() as $class) { - /** @var SignalHandler $handler */ - $handler = $this->container->make($class); - foreach ($handler->listen() as [$process, $signal]) { - if ($process === SignalHandler::WORKER) { - $this->handlers[SignalHandler::WORKER][$signal][] = $handler; - } elseif ($process === SignalHandler::PROCESS) { - $this->handlers[SignalHandler::PROCESS][$signal][] = $handler; - } - } + if (! in_array($process, [SignalHandler::WORKER, SignalHandler::SERVER_PROCESS], true)) { + throw new InvalidArgumentException(sprintf( + 'Unsupported signal process [%s]. Supported processes are [%s] and [%s].', + $process, + SignalHandler::WORKER, + SignalHandler::SERVER_PROCESS, + )); } - } - - /** - * Get all registered signal handlers. - */ - public function getHandlers(): array - { - return $this->handlers; - } - /** - * Start listening for signals for the given process type. - * - * Boot-only. Each call creates another set of process-lifetime watchers - * that would invoke the configured handlers again for the same signal. - */ - public function listen(?int $process): void - { - if ($this->stopped - || ! in_array($process, [SignalHandler::PROCESS, SignalHandler::WORKER], true) - || ! Coroutine::inCoroutine() - ) { + if ($this->stopped || ! Coroutine::inCoroutine()) { return; } + $signalHandlers = $this->resolveHandlers($process); $coroutineIds = []; try { - foreach ($this->handlers[$process] ?? [] as $signal => $handlers) { + foreach ($signalHandlers as $signal => $handlers) { $coroutineIds[] = Coroutine::create(function () use ($signal, $handlers): void { $coroutineId = Coroutine::id(); @@ -110,7 +86,9 @@ public function listen(?int $process): void } foreach ($handlers as $handler) { - $handler->handle($signal); + $this->safeCaller->call( + fn () => $handler->handle($signal), + ); } } } catch (CanceledException) { @@ -133,8 +111,12 @@ public function listen(?int $process): void /** * Stop listening for signals in this process. * - * The deregister listener invokes this at worker/process exit. Stopping is - * terminal and permanently halts signal handling for this process incarnation. + * Parked native signal waits keep the Swoole reactor active. The deregister + * listener calls this at worker or server-process exit so the process can + * exit normally instead of waiting for forced termination. + * + * Stopping is terminal for the current process incarnation and prevents + * subsequent signal listeners from starting. */ public function stop(): void { @@ -148,8 +130,62 @@ public function stop(): void } } + /** + * Resolve the signal handlers for the given process type. + * + * @return array> + */ + protected function resolveHandlers(string $process): array + { + $signalHandlers = []; + + foreach ($this->getQueue() as $class) { + $handler = $this->container->make($class); + + if (! $handler instanceof SignalHandler) { + throw new InvalidArgumentException(sprintf( + 'Signal handler [%s] must implement [%s].', + $class, + SignalHandler::class, + )); + } + + foreach ($handler->signals() as $handlerProcess => $signals) { + if (! in_array($handlerProcess, [SignalHandler::WORKER, SignalHandler::SERVER_PROCESS], true)) { + throw new InvalidArgumentException(sprintf( + 'Signal handler [%s] declares unsupported process [%s]. Supported processes are [%s] and [%s].', + $class, + $handlerProcess, + SignalHandler::WORKER, + SignalHandler::SERVER_PROCESS, + )); + } + + if (! is_array($signals) || ! array_all($signals, static fn (mixed $signal): bool => is_int($signal))) { + throw new InvalidArgumentException(sprintf( + 'Signal handler [%s] must declare an array of signal numbers for the [%s] process.', + $class, + $handlerProcess, + )); + } + + if ($handlerProcess !== $process) { + continue; + } + + foreach ($signals as $signal) { + $signalHandlers[$signal][] = $handler; + } + } + } + + return $signalHandlers; + } + /** * Build the priority queue of signal handler classes from config. + * + * @return SplPriorityQueue, float|int> */ protected function getQueue(): SplPriorityQueue { @@ -157,10 +193,24 @@ protected function getQueue(): SplPriorityQueue $queue = new SplPriorityQueue; foreach ($handlers as $handler => $priority) { - if (! is_numeric($priority)) { + if (is_int($handler)) { + if (! is_string($priority)) { + throw new InvalidArgumentException(sprintf( + 'Signal handler at index [%d] must be a class name.', + $handler, + )); + } + $handler = $priority; $priority = 0; + } elseif (! is_numeric($priority)) { + throw new InvalidArgumentException(sprintf( + 'The priority for signal handler [%s] must be numeric.', + $handler, + )); } + + $priority = is_string($priority) ? $priority + 0 : $priority; $queue->insert($handler, $priority); } diff --git a/src/signal/src/SignalRegisterListener.php b/src/signal/src/SignalRegisterListener.php index 705ea82ec..fcd4fe40f 100644 --- a/src/signal/src/SignalRegisterListener.php +++ b/src/signal/src/SignalRegisterListener.php @@ -5,7 +5,7 @@ namespace Hypervel\Signal; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Signal\SignalHandlerInterface as SignalHandler; +use Hypervel\Contracts\Signal\SignalHandler; use Hypervel\Core\Events\BeforeWorkerStart; use Hypervel\ServerProcess\Events\BeforeProcessHandle; @@ -25,12 +25,10 @@ public function handle(BeforeWorkerStart|BeforeProcessHandle $event): void { $manager = $this->container->make(SignalManager::class); - $manager->init(); - if ($event instanceof BeforeWorkerStart) { $manager->listen(SignalHandler::WORKER); } elseif ($event instanceof BeforeProcessHandle) { - $manager->listen(SignalHandler::PROCESS); + $manager->listen(SignalHandler::SERVER_PROCESS); } } } diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index 038f79e1b..68bc32075 100644 --- a/tests/Foundation/FoundationConfigTest.php +++ b/tests/Foundation/FoundationConfigTest.php @@ -36,21 +36,35 @@ public function testAppConfigTreatsNullPreviousKeysAsAnEmptyList(): void public function testServerConfigUsesSafeTaskDefaults(): void { - $originalContainer = Container::getInstance(); - - try { - new Application(dirname(__DIR__, 2)); - - $config = require dirname(__DIR__, 2) . '/src/foundation/config/server.php'; - } finally { - Container::setInstance($originalContainer); - } + $config = $this->serverConfig(); $this->assertFalse($config['settings'][Constant::OPTION_TASK_ENABLE_COROUTINE]); $this->assertSame(0, $config['settings'][Constant::OPTION_TASK_WORKER_NUM]); $this->assertFalse($config['settings'][Constant::OPTION_DAEMONIZE]); } + public function testServerConfigReadsWorkerCountAsAnInteger(): void + { + $config = $this->withEnvironmentValue( + 'SERVER_WORKERS', + '12', + fn (): array => $this->serverConfig(), + ); + + $this->assertSame(12, $config['settings'][Constant::OPTION_WORKER_NUM]); + } + + public function testServerConfigReadsMaxWaitTimeAsAnInteger(): void + { + $config = $this->withEnvironmentValue( + 'SERVER_MAX_WAIT_TIME', + '15', + fn (): array => $this->serverConfig(), + ); + + $this->assertSame(15, $config['settings'][Constant::OPTION_MAX_WAIT_TIME]); + } + public function testReverbBroadcastingConfigUsesTheServerPath(): void { $config = $this->withEnvironmentValue('REVERB_SERVER_PATH', '/socket', function (): array { @@ -74,48 +88,23 @@ public function testViewCompiledPathFallsBackToStoragePathWhenDirectoryDoesNotEx { $key = 'VIEW_COMPILED_PATH'; $originalContainer = Container::getInstance(); - $originalPutenv = getenv($key); - $originalServerExists = array_key_exists($key, $_SERVER); - $originalServer = $_SERVER[$key] ?? null; - $originalEnvExists = array_key_exists($key, $_ENV); - $originalEnv = $_ENV[$key] ?? null; try { - unset($_SERVER[$key], $_ENV[$key]); - putenv($key); - Env::flushRepository(); - - $app = new Application(dirname(__DIR__, 2)); - $app->useStoragePath(sys_get_temp_dir() . '/hypervel-view-config-' . bin2hex(random_bytes(8))); - Container::setInstance($app); + $this->withEnvironmentValue($key, null, function (): void { + $app = new Application(dirname(__DIR__, 2)); + $app->useStoragePath(sys_get_temp_dir() . '/hypervel-view-config-' . bin2hex(random_bytes(8))); + Container::setInstance($app); - $compiledPath = $app->storagePath('framework/views'); + $compiledPath = $app->storagePath('framework/views'); - $this->assertDirectoryDoesNotExist($compiledPath); + $this->assertDirectoryDoesNotExist($compiledPath); - $config = require dirname(__DIR__, 2) . '/src/foundation/config/view.php'; + $config = require dirname(__DIR__, 2) . '/src/foundation/config/view.php'; - $this->assertSame($compiledPath, $config['compiled']); + $this->assertSame($compiledPath, $config['compiled']); + }); } finally { Container::setInstance($originalContainer); - - $originalPutenv === false - ? putenv($key) - : putenv("{$key}={$originalPutenv}"); - - if ($originalServerExists) { - $_SERVER[$key] = $originalServer; - } else { - unset($_SERVER[$key]); - } - - if ($originalEnvExists) { - $_ENV[$key] = $originalEnv; - } else { - unset($_ENV[$key]); - } - - Env::flushRepository(); } } @@ -129,10 +118,26 @@ protected function appConfigWithEnvironment(string $key, string $value): array }); } + /** + * Load the server configuration with an application instance. + */ + protected function serverConfig(): array + { + $originalContainer = Container::getInstance(); + + try { + Container::setInstance(new Application(dirname(__DIR__, 2))); + + return require dirname(__DIR__, 2) . '/src/foundation/config/server.php'; + } finally { + Container::setInstance($originalContainer); + } + } + /** * Run a callback with a temporary environment variable value. */ - protected function withEnvironmentValue(string $key, string $value, Closure $callback): mixed + protected function withEnvironmentValue(string $key, ?string $value, Closure $callback): mixed { $originalPutenv = getenv($key); $originalServerExists = array_key_exists($key, $_SERVER); @@ -141,7 +146,9 @@ protected function withEnvironmentValue(string $key, string $value, Closure $cal $originalEnv = $_ENV[$key] ?? null; try { - $this->setEnvironmentValue($key, $value); + $value === null + ? $this->unsetEnvironmentValue($key) + : $this->setEnvironmentValue($key, $value); return $callback(); } finally { diff --git a/tests/ServerProcess/ProcessStopHandlerTest.php b/tests/ServerProcess/ProcessStopHandlerTest.php index 908a3e650..8fc046afe 100644 --- a/tests/ServerProcess/ProcessStopHandlerTest.php +++ b/tests/ServerProcess/ProcessStopHandlerTest.php @@ -4,29 +4,29 @@ namespace Hypervel\Tests\ServerProcess; -use Hypervel\Contracts\Signal\SignalHandlerInterface; +use Hypervel\Contracts\Signal\SignalHandler; use Hypervel\ServerProcess\Handlers\ProcessStopHandler; use Hypervel\ServerProcess\ProcessManager; use Hypervel\Tests\TestCase; class ProcessStopHandlerTest extends TestCase { - public function testImplementsSignalHandlerInterface() + public function testImplementsSignalHandler(): void { $handler = new ProcessStopHandler; - $this->assertInstanceOf(SignalHandlerInterface::class, $handler); + $this->assertInstanceOf(SignalHandler::class, $handler); } - public function testListensForSigtermOnProcess() + public function testListensForSigtermOnServerProcess(): void { $handler = new ProcessStopHandler; - $signals = $handler->listen(); + $signals = $handler->signals(); $this->assertCount(1, $signals); - $this->assertSame([SignalHandlerInterface::PROCESS, SIGTERM], $signals[0]); + $this->assertSame([SIGTERM], $signals[SignalHandler::SERVER_PROCESS]); } - public function testHandleSetsProcessManagerToNotRunning() + public function testHandleSetsProcessManagerToNotRunning(): void { ProcessManager::setRunning(true); $this->assertTrue(ProcessManager::isRunning()); diff --git a/tests/Signal/Fixtures/SignalHandler2Stub.php b/tests/Signal/Fixtures/SignalHandler2Stub.php deleted file mode 100644 index d7f7f6e4d..000000000 --- a/tests/Signal/Fixtures/SignalHandler2Stub.php +++ /dev/null @@ -1,23 +0,0 @@ - [SIGTERM], ]; } public function handle(int $signal): void { - CoroutineContext::set('test.signal', $signal); } } diff --git a/tests/Signal/SignalManagerCreateFailureTest.php b/tests/Signal/SignalManagerCreateFailureTest.php index 8f9dd2265..28324b153 100644 --- a/tests/Signal/SignalManagerCreateFailureTest.php +++ b/tests/Signal/SignalManagerCreateFailureTest.php @@ -4,17 +4,18 @@ namespace Hypervel\Tests\Signal; +use Hypervel\Config\Repository; use Hypervel\Container\Container; +use Hypervel\Contracts\Config\Repository as ConfigContract; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; -use Hypervel\Contracts\Signal\SignalHandlerInterface as SignalHandler; +use Hypervel\Contracts\Signal\SignalHandler; use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\Signal\SignalManager; -use Hypervel\Tests\Signal\Fixtures\SignalHandlerStub; +use Hypervel\Support\SafeCaller; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\RunInSeparateProcess; -use ReflectionClass; -use ReflectionProperty; use Swoole\Coroutine as SwooleCoroutine; class SignalManagerCreateFailureTest extends TestCase @@ -34,22 +35,31 @@ public function testPartialListenerCreationCancelsEarlierWatchers(): void $exceptionHandler, ); - $manager = (new ReflectionClass(SignalManager::class)) - ->newInstanceWithoutConstructor(); - $handler = new SignalHandlerStub; + $handler = new class implements SignalHandler { + public function signals(): array + { + return [self::WORKER => [SIGUSR1, SIGUSR2]]; + } - (new ReflectionProperty($manager, 'handlers'))->setValue($manager, [ - SignalHandler::WORKER => [ - SIGUSR1 => [$handler], - SIGUSR2 => [$handler], - ], - ]); + public function handle(int $signal): void + { + } + }; + $container = m::mock(ContainerContract::class); + $container->shouldReceive('make')->with(ConfigContract::class)->andReturn(new Repository([ + 'signal' => ['handlers' => [$handler::class]], + ])); + $container->shouldReceive('make')->with(SafeCaller::class)->andReturn(new SafeCaller($container)); + $container->shouldReceive('make')->with($handler::class)->andReturn($handler); + $manager = new SignalManager($container); try { $manager->listen(SignalHandler::WORKER); $this->fail('Expected the second signal watcher creation to fail.'); } catch (CoroutineCreateException) { $this->assertSame(1, SwooleCoroutine::stats()['coroutine_num']); + } finally { + $manager->stop(); } }); } diff --git a/tests/Signal/SignalManagerNonCoroutineTest.php b/tests/Signal/SignalManagerNonCoroutineTest.php new file mode 100644 index 000000000..09a12b17d --- /dev/null +++ b/tests/Signal/SignalManagerNonCoroutineTest.php @@ -0,0 +1,36 @@ +shouldReceive('make')->with(ConfigContract::class)->andReturn(new Repository([ + 'signal' => ['handlers' => [SignalHandlerStub::class]], + ])); + $container->shouldReceive('make')->with(SafeCaller::class)->andReturn(new SafeCaller($container)); + $container->shouldNotReceive('make')->with(SignalHandlerStub::class); + $manager = new SignalManager($container); + + $this->assertFalse(Coroutine::inCoroutine()); + + $manager->listen(SignalHandler::WORKER); + } +} diff --git a/tests/Signal/SignalManagerTest.php b/tests/Signal/SignalManagerTest.php index da1b638e5..9b1229ec4 100644 --- a/tests/Signal/SignalManagerTest.php +++ b/tests/Signal/SignalManagerTest.php @@ -4,63 +4,284 @@ namespace Hypervel\Tests\Signal; +use ArrayObject; use Hypervel\Config\Repository; +use Hypervel\Container\Container; use Hypervel\Contracts\Config\Repository as ConfigContract; use Hypervel\Contracts\Container\Container as ContainerContract; -use Hypervel\Contracts\Signal\SignalHandlerInterface as SignalHandler; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Signal\SignalHandler; use Hypervel\Engine\Channel; use Hypervel\Signal\SignalManager; -use Hypervel\Tests\Signal\Fixtures\SignalHandler2Stub; +use Hypervel\Support\SafeCaller; use Hypervel\Tests\Signal\Fixtures\SignalHandlerStub; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use RuntimeException; use Swoole\Coroutine as SwooleCoroutine; class SignalManagerTest extends TestCase { - public function testGetHandlers(): void - { - $container = $this->getContainer(); - $container->shouldReceive('make')->with(ConfigContract::class)->andReturnUsing(function (): Repository { - return new Repository([ - 'signal' => [ - 'handlers' => [ - SignalHandlerStub::class, - SignalHandler2Stub::class => 1, - ], + #[RunInSeparateProcess] + public function testHigherPriorityHandlersContinueAfterFailureAndWatchAgain(): void + { + $trace = new ArrayObject; + $handled = new Channel(2); + $recordingHandler = new class($trace, $handled) implements SignalHandler { + public function __construct( + protected ArrayObject $trace, + protected Channel $handled, + ) { + } + + public function signals(): array + { + return [self::WORKER => [SIGUSR1]]; + } + + public function handle(int $signal): void + { + $this->trace[] = 'recorded'; + $this->handled->push(true); + } + }; + $throwingHandler = new class($trace) implements SignalHandler { + public function __construct(protected ArrayObject $trace) + { + } + + public function signals(): array + { + return [self::WORKER => [SIGUSR1]]; + } + + public function handle(int $signal): void + { + $this->trace[] = 'threw'; + + throw new RuntimeException('Signal handler failed.'); + } + }; + $exceptionHandler = m::mock(ExceptionHandlerContract::class); + $exceptionHandler->shouldReceive('report') + ->twice() + ->with(m::type(RuntimeException::class)); + $container = new Container; + $container->instance(ContainerContract::class, $container); + $container->instance(ConfigContract::class, new Repository([ + 'signal' => [ + 'handlers' => [ + $recordingHandler::class, + $throwingHandler::class => '10', ], - ]); - }); + ], + ])); + $container->instance(ExceptionHandlerContract::class, $exceptionHandler); + $container->instance($recordingHandler::class, $recordingHandler); + $container->instance($throwingHandler::class, $throwingHandler); $manager = new SignalManager($container); - $manager->init(); - $this->assertArrayHasKey(SignalHandler::WORKER, $manager->getHandlers()); - $this->assertArrayHasKey(SIGTERM, $manager->getHandlers()[SignalHandler::WORKER]); - $this->assertIsArray($manager->getHandlers()[SignalHandler::WORKER]); - $this->assertInstanceOf(SignalHandler2Stub::class, $manager->getHandlers()[SignalHandler::WORKER][SIGTERM][0]); - $this->assertInstanceOf(SignalHandlerStub::class, $manager->getHandlers()[SignalHandler::WORKER][SIGTERM][1]); + try { + $manager->listen(SignalHandler::WORKER); + + $this->assertTrue(posix_kill(getmypid(), SIGUSR1)); + $this->assertTrue($handled->pop(0.5)); + $this->assertSame(['threw', 'recorded'], $trace->getArrayCopy()); + + // The channel wakes this test before the watcher returns from handle; + // yield so it can re-arm before another process signal is delivered. + SwooleCoroutine::sleep(0.005); + + $this->assertTrue(posix_kill(getmypid(), SIGUSR1)); + $this->assertTrue($handled->pop(0.5)); + $this->assertSame(['threw', 'recorded', 'threw', 'recorded'], $trace->getArrayCopy()); + } finally { + $manager->stop(); + $handled->close(); + } } - public function testInitReplacesExistingHandlers(): void + public function testGroupedDefinitionsCreateOnlyRequestedSignalWatchers(): void { - $container = $this->getContainer(); - $container->shouldReceive('make')->with(ConfigContract::class)->andReturnUsing(function (): Repository { - return new Repository([ - 'signal' => [ - 'handlers' => [ - SignalHandlerStub::class, - SignalHandler2Stub::class, - ], - ], - ]); - }); + $handler = new class implements SignalHandler { + public function signals(): array + { + return [ + self::WORKER => [SIGUSR1], + self::SERVER_PROCESS => [SIGUSR2], + ]; + } - $manager = new SignalManager($container); - $manager->init(); - $manager->init(); + public function handle(int $signal): void + { + } + }; + $coroutinesBeforeListen = SwooleCoroutine::stats()['coroutine_num']; + $workerManager = $this->createManager($handler); + + try { + $workerManager->listen(SignalHandler::WORKER); + + $this->assertSame($coroutinesBeforeListen + 1, SwooleCoroutine::stats()['coroutine_num']); + } finally { + $workerManager->stop(); + } + + $serverProcessManager = $this->createManager($handler); + + try { + $serverProcessManager->listen(SignalHandler::SERVER_PROCESS); + + $this->assertSame($coroutinesBeforeListen + 1, SwooleCoroutine::stats()['coroutine_num']); + } finally { + $serverProcessManager->stop(); + } + } + + public function testRejectsUnsupportedProcess(): void + { + $manager = $this->createManagerFromConfig([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Unsupported signal process [workers]. Supported processes are [worker] and [server-process].', + ); + + $manager->listen('workers'); + } + + public function testRejectsHandlerThatDoesNotImplementContract(): void + { + $handler = new class { + public function signals(): array + { + return [SignalHandler::WORKER => [SIGUSR1]]; + } + + public function handle(int $signal): void + { + } + }; + $manager = $this->createManagerFromConfig([$handler::class], [$handler::class => $handler]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must implement [Hypervel\Contracts\Signal\SignalHandler]'); + + $manager->listen(SignalHandler::WORKER); + } + + public function testRejectsUnsupportedHandlerProcessEvenWhenListeningForAnotherProcess(): void + { + $handler = new class implements SignalHandler { + public function signals(): array + { + return [ + self::WORKER => [SIGUSR1], + 'process' => [SIGUSR2], + ]; + } + + public function handle(int $signal): void + { + } + }; + $manager = $this->createManager($handler); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'declares unsupported process [process]. Supported processes are [worker] and [server-process].', + ); + + $manager->listen(SignalHandler::WORKER); + } + + public function testRejectsNonArraySignalGroupEvenWhenListeningForAnotherProcess(): void + { + $handler = new class implements SignalHandler { + public function signals(): array + { + return [ + self::WORKER => [SIGUSR1], + self::SERVER_PROCESS => SIGUSR2, + ]; + } + + public function handle(int $signal): void + { + } + }; + $manager = $this->createManager($handler); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'must declare an array of signal numbers for the [server-process] process.', + ); + + $manager->listen(SignalHandler::WORKER); + } + + public function testRejectsNonIntegerSignal(): void + { + $handler = new class implements SignalHandler { + public function signals(): array + { + return [self::WORKER => [SIGUSR1, 'SIGUSR2']]; + } + + public function handle(int $signal): void + { + } + }; + $manager = $this->createManager($handler); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must declare an array of signal numbers for the [worker] process.'); + + $manager->listen(SignalHandler::WORKER); + } + + public function testAllowsEmptySignalGroup(): void + { + $handler = new class implements SignalHandler { + public function signals(): array + { + return [self::WORKER => []]; + } + + public function handle(int $signal): void + { + } + }; + $manager = $this->createManager($handler); + $coroutinesBeforeListen = SwooleCoroutine::stats()['coroutine_num']; + + $manager->listen(SignalHandler::WORKER); - $this->assertCount(2, $manager->getHandlers()[SignalHandler::WORKER][SIGTERM]); + $this->assertSame($coroutinesBeforeListen, SwooleCoroutine::stats()['coroutine_num']); + } + + public function testRejectsNonStringListEntry(): void + { + $manager = $this->createManagerFromConfig([[]]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Signal handler at index [0] must be a class name.'); + + $manager->listen(SignalHandler::WORKER); + } + + public function testRejectsNonnumericPriority(): void + { + $manager = $this->createManagerFromConfig([SignalHandlerStub::class => 'high']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The priority for signal handler [Hypervel\Tests\Signal\Fixtures\SignalHandlerStub] must be numeric.', + ); + + $manager->listen(SignalHandler::WORKER); } public function testStopReleasesWaitingSignalWatchers(): void @@ -84,12 +305,9 @@ public function testStopReleasesWaitingSignalWatchers(): void public function testStopReleasesEveryWaitingSignalWatcher(): void { $handler = new class implements SignalHandler { - public function listen(): array + public function signals(): array { - return [ - [self::WORKER, SIGUSR1], - [self::WORKER, SIGUSR2], - ]; + return [self::WORKER => [SIGUSR1, SIGUSR2]]; } public function handle(int $signal): void @@ -126,11 +344,9 @@ public function __construct( ) { } - public function listen(): array + public function signals(): array { - return [ - [self::WORKER, SIGUSR1], - ]; + return [self::WORKER => [SIGUSR1]]; } public function handle(int $signal): void @@ -153,21 +369,27 @@ public function handle(int $signal): void $continueHandler->push(true); $this->assertTrue($handlerFinished->pop(0.5)); - usleep(1_000); + SwooleCoroutine::sleep(0.001); $this->assertSame($coroutinesBeforeListen, SwooleCoroutine::stats()['coroutine_num']); } finally { $manager->stop(); $continueHandler->push(true, 0.01); - usleep(1_000); + SwooleCoroutine::sleep(0.001); $handlerStarted->close(); $continueHandler->close(); $handlerFinished->close(); } } - public function testListenAfterStopDoesNotSpawnSignalWatchers(): void + public function testListenAfterStopDoesNotResolveHandlersOrSpawnWatchers(): void { - $manager = $this->createManager(new SignalHandlerStub); + $container = m::mock(ContainerContract::class); + $container->shouldReceive('make')->with(ConfigContract::class)->andReturn(new Repository([ + 'signal' => ['handlers' => [SignalHandlerStub::class]], + ])); + $container->shouldReceive('make')->with(SafeCaller::class)->andReturn(new SafeCaller($container)); + $container->shouldNotReceive('make')->with(SignalHandlerStub::class); + $manager = new SignalManager($container); $manager->stop(); $coroutinesBeforeListen = SwooleCoroutine::stats()['coroutine_num']; @@ -176,50 +398,42 @@ public function testListenAfterStopDoesNotSpawnSignalWatchers(): void $this->assertSame($coroutinesBeforeListen, SwooleCoroutine::stats()['coroutine_num']); } - public function testSignalHandlerInterfaceConstantsHaveExpectedValues(): void - { - $this->assertSame(1, SignalHandler::WORKER); - $this->assertSame(2, SignalHandler::PROCESS); - } - - public function testInitWithNoHandlersConfigured(): void + public function testNoConfiguredHandlersSpawnNoWatchers(): void { - $container = $this->getContainer(); - $container->shouldReceive('make')->with(ConfigContract::class)->andReturn(new Repository([])); + $manager = $this->createManagerFromConfig([]); + $coroutinesBeforeListen = SwooleCoroutine::stats()['coroutine_num']; - $manager = new SignalManager($container); - $manager->init(); + $manager->listen(SignalHandler::WORKER); - $this->assertEmpty($manager->getHandlers()); + $this->assertSame($coroutinesBeforeListen, SwooleCoroutine::stats()['coroutine_num']); } protected function createManager(SignalHandler $handler): SignalManager { - $container = m::mock(ContainerContract::class); - $container->shouldReceive('make')->with(ConfigContract::class)->andReturn(new Repository([ - 'signal' => [ - 'handlers' => [$handler::class], - ], - ])); - $container->shouldReceive('make')->with($handler::class)->andReturn($handler); - - $manager = new SignalManager($container); - $manager->init(); - - return $manager; + return $this->createManagerFromConfig( + [$handler::class], + [$handler::class => $handler], + ); } - protected function getContainer(): ContainerContract + /** + * Create a signal manager from the given handler configuration. + * + * @param array $handlers + * @param array $instances + */ + protected function createManagerFromConfig(array $handlers, array $instances = []): SignalManager { $container = m::mock(ContainerContract::class); + $container->shouldReceive('make')->with(ConfigContract::class)->andReturn(new Repository([ + 'signal' => ['handlers' => $handlers], + ])); + $container->shouldReceive('make')->with(SafeCaller::class)->andReturn(new SafeCaller($container)); - $container->shouldReceive('make')->with(SignalHandlerStub::class)->andReturnUsing(function (): SignalHandlerStub { - return new SignalHandlerStub; - }); - $container->shouldReceive('make')->with(SignalHandler2Stub::class)->andReturnUsing(function (): SignalHandler2Stub { - return new SignalHandler2Stub; - }); + foreach ($instances as $class => $instance) { + $container->shouldReceive('make')->with($class)->andReturn($instance); + } - return $container; + return new SignalManager($container); } } diff --git a/tests/Signal/SignalRegisterListenerTest.php b/tests/Signal/SignalRegisterListenerTest.php index e6751f3c7..175c9f5df 100644 --- a/tests/Signal/SignalRegisterListenerTest.php +++ b/tests/Signal/SignalRegisterListenerTest.php @@ -5,7 +5,7 @@ namespace Hypervel\Tests\Signal; use Hypervel\Contracts\Container\Container as ContainerContract; -use Hypervel\Contracts\Signal\SignalHandlerInterface; +use Hypervel\Contracts\Signal\SignalHandler; use Hypervel\Core\Events\BeforeWorkerStart; use Hypervel\ServerProcess\Events\BeforeProcessHandle; use Hypervel\Signal\SignalManager; @@ -15,7 +15,7 @@ class SignalRegisterListenerTest extends TestCase { - public function testHandleBeforeWorkerStartInitializesAndListensForWorker(): void + public function testHandleBeforeWorkerStartListensForWorker(): void { $container = m::mock(ContainerContract::class); $manager = m::mock(SignalManager::class); @@ -26,16 +26,15 @@ public function testHandleBeforeWorkerStartInitializesAndListensForWorker(): voi ->once() ->andReturn($manager); - $manager->shouldReceive('init')->once(); $manager->shouldReceive('listen') - ->with(SignalHandlerInterface::WORKER) + ->with(SignalHandler::WORKER) ->once(); $listener = new SignalRegisterListener($container); $listener->handle($event); } - public function testHandleBeforeProcessHandleInitializesAndListensForProcess(): void + public function testHandleBeforeProcessHandleListensForServerProcess(): void { $container = m::mock(ContainerContract::class); $manager = m::mock(SignalManager::class); @@ -46,9 +45,8 @@ public function testHandleBeforeProcessHandleInitializesAndListensForProcess(): ->once() ->andReturn($manager); - $manager->shouldReceive('init')->once(); $manager->shouldReceive('listen') - ->with(SignalHandlerInterface::PROCESS) + ->with(SignalHandler::SERVER_PROCESS) ->once(); $listener = new SignalRegisterListener($container);