From 98c21f91189f38be853650722971477cfc2c8b4f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:41:39 +0000 Subject: [PATCH 1/9] Redesign the Signal handler API The inherited integer process constants and positional signal tuples were easy to swap and difficult to validate. SignalManager also exposed initialization and handler inspection methods that applications did not need, while a throwing handler could terminate the only watcher for a signal. Replace the old interface with a Laravel-shaped SignalHandler contract that groups signal numbers under clear worker and server-process keys. Resolve and validate the complete handler definition when listening starts, keep the resolved map local, and remove the retained handler registry and split initialization API. Run each handler through the framework safe-call boundary so one failure is reported without skipping lower-priority handlers or preventing the watcher from listening again. Preserve exact waiter ownership, active-handler completion, and partial-creation rollback. Migrate the server-process stop handler and lifecycle listeners to the new contract. Expand coverage for priorities, invalid definitions, process groups, stopped and non-coroutine paths, repeated delivery after failure, cleanup, and real coroutine creation failure. Remove the unused duplicate fixture and its stale coroutine state. --- ...HandlerInterface.php => SignalHandler.php} | 10 +- src/foundation/config/signal.php | 24 +- .../src/Handlers/ProcessStopHandler.php | 8 +- src/signal/src/SignalManager.php | 146 ++++--- src/signal/src/SignalRegisterListener.php | 6 +- .../ServerProcess/ProcessStopHandlerTest.php | 14 +- tests/Signal/Fixtures/SignalHandler2Stub.php | 23 -- tests/Signal/Fixtures/SignalHandlerStub.php | 10 +- .../Signal/SignalManagerCreateFailureTest.php | 36 +- .../Signal/SignalManagerNonCoroutineTest.php | 36 ++ tests/Signal/SignalManagerTest.php | 376 ++++++++++++++---- tests/Signal/SignalRegisterListenerTest.php | 12 +- 12 files changed, 491 insertions(+), 210 deletions(-) rename src/contracts/src/Signal/{SignalHandlerInterface.php => SignalHandler.php} (51%) delete mode 100644 tests/Signal/Fixtures/SignalHandler2Stub.php create mode 100644 tests/Signal/SignalManagerNonCoroutineTest.php 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/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/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/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/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); From cfc767dd079494a14011284f2194bb535d24cd62 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:41:52 +0000 Subject: [PATCH 2/9] Make the graceful shutdown allowance configurable The default Swoole shutdown allowance was hardcoded to three seconds. Applications with long requests, WebSocket drains, or custom server-process cleanup had no environment-level way to give legitimate work more time before forced termination. Read SERVER_MAX_WAIT_TIME from the environment while preserving the existing three-second default. Cast it to an integer at the configuration boundary, and apply the same normalization to SERVER_WORKERS so numeric environment values reach Swoole with their declared types. Add focused configuration coverage for both values. Reuse the existing environment helper for absent values so every environment source and the cached repository are restored through one exception-safe path instead of duplicating cleanup in the view configuration test. --- src/foundation/config/server.php | 4 +- tests/Foundation/FoundationConfigTest.php | 97 ++++++++++++----------- 2 files changed, 54 insertions(+), 47 deletions(-) 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/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index 038f79e1b..ef3145ec5 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 { + 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 { From ba65235ebf0574c4275a89a5697ddeb7dc528e4b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:42:08 +0000 Subject: [PATCH 3/9] Document application signal handling Signal was becoming an application-facing extension point, but its package README was the only user guide and mixed public behavior with worker-lifecycle details. That left the new handler contract, process groups, and native signal ownership without a canonical documentation surface. Add a Laravel-style Signal guide covering handler definitions, worker and server-process groups, configuration, priorities, failure behavior, process-local delivery, and the complete graceful server-process recipe. Explain the important native boundaries: worker SIGTERM ownership, worker SIGINT behavior, SIGCHLD support, and the process-wide conflict with Swoole Process signal callbacks. Add the guide to the documentation index and link Artisan command users to it when they need server-level handling. Reduce the package README to its documentation and upstream links so the guide remains the single source of truth. --- src/boost/docs/artisan.md | 2 + src/boost/docs/documentation.md | 1 + src/boost/docs/signal.md | 153 ++++++++++++++++++++++++++++++++ src/signal/README.md | 16 +--- 4 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 src/boost/docs/signal.md diff --git a/src/boost/docs/artisan.md b/src/boost/docs/artisan.md index b2deaf971..571121838 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}}/signal). + ## Stub Customization diff --git a/src/boost/docs/documentation.md b/src/boost/docs/documentation.md index fd2a4706f..85d168d09 100644 --- a/src/boost/docs/documentation.md +++ b/src/boost/docs/documentation.md @@ -55,6 +55,7 @@ - [Package Development](/docs/{{version}}/packages) - [Processes](/docs/{{version}}/processes) - [Server Processes](/docs/{{version}}/server-process) + - [Signals](/docs/{{version}}/signal) - [WebSockets](/docs/{{version}}/websockets) - [Queues](/docs/{{version}}/queues) - [Rate Limiting](/docs/{{version}}/rate-limiting) diff --git a/src/boost/docs/signal.md b/src/boost/docs/signal.md new file mode 100644 index 000000000..46d249ee0 --- /dev/null +++ b/src/boost/docs/signal.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-process). + +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/signal/README.md b/src/signal/README.md index edb82749c..5e094193e 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/signal -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 From da36d03ceec4dd9447fd04714962e1f1adb7e55e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:42:19 +0000 Subject: [PATCH 4/9] Document the server process signal lifecycle The Server Process guide said configured signal handlers were automatic but did not explain the coroutine requirement or the second half of graceful shutdown. It also omitted reload and health behavior that application developers need when treating a custom process as part of the running service. Clarify that only coroutine-enabled server processes use the server-process signal group. Point readers to the complete stop-handler and running-loop recipe, and document that server reloads do not restart custom processes. Describe the current health boundary without inventing a generic subsystem: custom processes have no built-in readiness, heartbeat, or health state, while applications may publish workload-specific state and inspect it through the existing health event. Replace the duplicate README guide with the canonical documentation link and retained upstream reference. --- src/boost/docs/server-process.md | 18 +++++++++- src/server-process/README.md | 56 ++------------------------------ 2 files changed, 19 insertions(+), 55 deletions(-) diff --git a/src/boost/docs/server-process.md b/src/boost/docs/server-process.md index b21607a41..1a3a37805 100644 --- a/src/boost/docs/server-process.md +++ b/src/boost/docs/server-process.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}}/signal#server-process-signals) for the complete setup. ## Inter-Process Communication diff --git a/src/server-process/README.md b/src/server-process/README.md index daf9f6c6f..6cd6adade 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-process -## 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 From 743537a4213baa8fbf72f1cf00d6157be211ded5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:42:33 +0000 Subject: [PATCH 5/9] Document graceful server shutdown The server-wide shutdown allowance now has an environment setting, but its scope and edge cases need to be clear before applications tune it. The setting governs more than custom processes and a zero value does not mean unlimited time. Document SERVER_MAX_WAIT_TIME beside the other server environment values. Explain the three-second default, when long requests, WebSocket drains, or process cleanup warrant an increase, and how Swoole treats zero for workers and custom server processes. Clarify that reload commands do not restart custom server processes. Link Reverb worker-recycling guidance to the canonical shutdown section so mixed HTTP and WebSocket deployments size the same server-wide allowance instead of relying on an unnamed timeout. --- src/boost/docs/deployment.md | 12 +++++++++++- src/boost/docs/reverb.md | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) 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/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 From d00574ad9fc68f935bd8c12d89a3980ba61fb171 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:42:47 +0000 Subject: [PATCH 6/9] Record the completed Signal audit Record the application-facing Signal re-audit after implementation, full validation, self-review, and independent review. Capture the verified handler failure, inherited API design, malformed configuration, and server shutdown configuration findings together with their final ownership boundaries. Preserve the settled design constraints: grouped string process keys, one safe-call boundary per handler, startup-only validation, exact watcher cleanup, ordinary duplicate configuration behavior, Swoole-owned signal ranges, and no registry, facade, retry, health subsystem, or compatibility layer. Document the completed Contracts, Foundation, Server Process, Reverb, and Signal revalidation, regression coverage, performance result, canonical documentation work, and green repository gates. Add the shared contract and server-setting findings to the cross-package index and keep the active routing entry precise for this worktree until the audit branch is integrated. --- ...amework-coroutine-state-lifecycle-audit.md | 6 ++-- ...-coroutine-state-lifecycle-audit-ledger.md | 28 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) 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 30da5a91f..8e248a423 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 @@ -990,8 +990,8 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** None. -- **Ledger entries required for the active work:** None. +- **Active package or work unit:** `signal` re-audit. +- **Ledger entries required for the active work:** `Release signal watchers deterministically at process exit`; `Complete Signal handler reliability, public APIs, and deployment guidance`. - **Pending revalidation carried into the active work:** None. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -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 ced6515e1..198897a05 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 @@ -674,7 +674,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. @@ -687,6 +687,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. From 183d206f7b55e53266f0fa41163482f2b38234dc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:56:19 +0000 Subject: [PATCH 7/9] Pluralize signal documentation slugs Rename the Signal and Server Process guides to match their plural titles and the convention used by other countable framework topics. Update navigation, cross-references, and package README documentation URLs so every link uses the new routes. Add the Signals guide to the published documentation registry, which previously omitted the page despite linking it from the documentation index. Keep the registry sorted and aligned with every indexed guide. --- src/boost/docs-ported.md | 3 ++- src/boost/docs/artisan.md | 2 +- src/boost/docs/documentation.md | 4 ++-- src/boost/docs/{server-process.md => server-processes.md} | 2 +- src/boost/docs/{signal.md => signals.md} | 2 +- src/server-process/README.md | 2 +- src/signal/README.md | 2 +- 7 files changed, 9 insertions(+), 8 deletions(-) rename src/boost/docs/{server-process.md => server-processes.md} (99%) rename src/boost/docs/{signal.md => signals.md} (99%) 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 571121838..0e52a4541 100644 --- a/src/boost/docs/artisan.md +++ b/src/boost/docs/artisan.md @@ -996,7 +996,7 @@ $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}}/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/documentation.md b/src/boost/docs/documentation.md index 85d168d09..270b89ac1 100644 --- a/src/boost/docs/documentation.md +++ b/src/boost/docs/documentation.md @@ -54,8 +54,8 @@ - [Notifications](/docs/{{version}}/notifications) - [Package Development](/docs/{{version}}/packages) - [Processes](/docs/{{version}}/processes) - - [Server Processes](/docs/{{version}}/server-process) - - [Signals](/docs/{{version}}/signal) + - [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/server-process.md b/src/boost/docs/server-processes.md similarity index 99% rename from src/boost/docs/server-process.md rename to src/boost/docs/server-processes.md index 1a3a37805..4168a1a09 100644 --- a/src/boost/docs/server-process.md +++ b/src/boost/docs/server-processes.md @@ -177,7 +177,7 @@ If your application depends on a server process, the process may publish suitabl 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}}/signal#server-process-signals) for the complete setup. +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/signal.md b/src/boost/docs/signals.md similarity index 99% rename from src/boost/docs/signal.md rename to src/boost/docs/signals.md index 46d249ee0..f12604cdc 100644 --- a/src/boost/docs/signal.md +++ b/src/boost/docs/signals.md @@ -13,7 +13,7 @@ ## 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-process). +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. diff --git a/src/server-process/README.md b/src/server-process/README.md index 6cd6adade..3f35f7c05 100644 --- a/src/server-process/README.md +++ b/src/server-process/README.md @@ -3,6 +3,6 @@ Server Process for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/server-process) -Documentation: https://hypervel.org/docs/server-process +Documentation: https://hypervel.org/docs/server-processes Ported from: https://github.com/hyperf/hyperf/tree/master/src/process diff --git a/src/signal/README.md b/src/signal/README.md index 5e094193e..ef9e9863c 100644 --- a/src/signal/README.md +++ b/src/signal/README.md @@ -3,6 +3,6 @@ Signal for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/signal) -Documentation: https://hypervel.org/docs/signal +Documentation: https://hypervel.org/docs/signals Ported from: https://github.com/hyperf/hyperf/tree/master/src/signal From 06a6feeacbfab619d200b9c49d54c76d56e1f6ab Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:37:47 +0000 Subject: [PATCH 8/9] Clear the completed Signal audit routing Reset the audit routing index after merging the completed Mail records into the Signal branch. Both work units are complete, so future context restoration should not treat the Signal re-audit as active work or require its ledger entries by default. --- ...26-07-12-0900-framework-coroutine-state-lifecycle-audit.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 896b71ba9..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 @@ -990,8 +990,8 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `signal` re-audit. -- **Ledger entries required for the active work:** `Release signal watchers deterministically at process exit`; `Complete Signal handler reliability, public APIs, and deployment guidance`. +- **Active package or work unit:** None. +- **Ledger entries required for the active work:** None. - **Pending revalidation carried into the active work:** None. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. From 80b2cb73756169d3fdd7a162c25afcc4ae77144b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:08:44 +0000 Subject: [PATCH 9/9] Make server config test setup explicit Set the temporary Application through the global container before loading the server configuration. Application construction already performs the same registration, but spelling out the dependency keeps this load-bearing setup from looking like an unused constructor call. Keep the existing exception-safe restoration of the previous container and leave runtime configuration behavior unchanged. --- tests/Foundation/FoundationConfigTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index ef3145ec5..68bc32075 100644 --- a/tests/Foundation/FoundationConfigTest.php +++ b/tests/Foundation/FoundationConfigTest.php @@ -126,7 +126,7 @@ protected function serverConfig(): array $originalContainer = Container::getInstance(); try { - new Application(dirname(__DIR__, 2)); + Container::setInstance(new Application(dirname(__DIR__, 2))); return require dirname(__DIR__, 2) . '/src/foundation/config/server.php'; } finally {