From c1db6bcf9e9a8d60f79c6f811e75b65a9edbe0c9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:10:56 +0000 Subject: [PATCH 01/40] Add test suite lifecycle robustness plan Document the verified causes of the intermittent parallel-suite hang and the related lifecycle, ownership, and concurrency defects uncovered during the audit. Define the final low-level designs for coroutine creation failures, exhaustive cleanup, pool ownership, queue and Horizon process control, watcher lifecycles, Testbench isolation, and deterministic programmatic console execution. Include the complete implementation order, regression matrix, performance constraints, and overengineering guardrails used to validate the work. Normalize older plan headers by removing obsolete author, date, and status metadata while retaining their scope sections. --- .../plans/2026-07-01-fortify-passkeys-port.md | 6 +- ...07-03-fortify-otphp-chillerlan-refactor.md | 6 +- ...4-testing-after-each-cleanup-registrars.md | 6 +- ...te-lifecycle-and-concurrency-robustness.md | 1695 +++++++++++++++++ 4 files changed, 1701 insertions(+), 12 deletions(-) create mode 100644 docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md diff --git a/docs/plans/2026-07-01-fortify-passkeys-port.md b/docs/plans/2026-07-01-fortify-passkeys-port.md index 3d1a76a1c..2ebecc240 100644 --- a/docs/plans/2026-07-01-fortify-passkeys-port.md +++ b/docs/plans/2026-07-01-fortify-passkeys-port.md @@ -2,11 +2,9 @@ > Superseded note: the Fortify password broker inference described in this historical plan was later removed by `2026-07-06-auth-guard-declared-password-brokers.md`. Guards now declare password brokers with `auth.guards.{guard}.passwords`; `Fortify::passwordBrokerName()` no longer exists. -Date: 2026-07-01 +## Scope -Author: Codex - -Scope: port Laravel Fortify and Laravel Passkeys Server into the Hypervel components monorepo, with Swoole coroutine safety, worker-lifetime performance, clean Hypervel-native APIs, full tests, and updated Boost documentation. +Port Laravel Fortify and Laravel Passkeys Server into the Hypervel components monorepo, with Swoole coroutine safety, worker-lifetime performance, clean Hypervel-native APIs, full tests, and updated Boost documentation. ## Source Material Reviewed diff --git a/docs/plans/2026-07-03-fortify-otphp-chillerlan-refactor.md b/docs/plans/2026-07-03-fortify-otphp-chillerlan-refactor.md index a3afa6136..fe11e057c 100644 --- a/docs/plans/2026-07-03-fortify-otphp-chillerlan-refactor.md +++ b/docs/plans/2026-07-03-fortify-otphp-chillerlan-refactor.md @@ -1,10 +1,8 @@ # Fortify OTPHP And Chillerlan Refactor Plan -Date: 2026-07-03 +## Scope -Author: Codex - -Scope: refactor Hypervel Fortify two-factor authentication to use `spomky-labs/otphp` for TOTP and `chillerlan/php-qrcode` for QR SVG generation, while preserving Fortify's public API shape and making the final codebase read as if it was designed this way from the start. +Refactor Hypervel Fortify two-factor authentication to use `spomky-labs/otphp` for TOTP and `chillerlan/php-qrcode` for QR SVG generation, while preserving Fortify's public API shape and making the final codebase read as if it was designed this way from the start. ## Goal diff --git a/docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md b/docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md index fff545710..518582a79 100644 --- a/docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md +++ b/docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md @@ -1,10 +1,8 @@ # Testing After Each Cleanup Registrars Plan -Date: 2026-07-04 +## Scope -Author: Codex - -Scope: make Hypervel's after-each test cleanup extensible for apps and packages while keeping framework cleanup centralized, deterministic, and independent of application boot. +Make Hypervel's after-each test cleanup extensible for apps and packages while keeping framework cleanup centralized, deterministic, and independent of application boot. ## Goal diff --git a/docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md b/docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md new file mode 100644 index 000000000..04a3f985a --- /dev/null +++ b/docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md @@ -0,0 +1,1695 @@ +# Test-suite lifecycle and concurrency robustness + +## Scope + +Harden coroutine creation, test cleanup, queue and Horizon process lifecycle, test-owned resources, lock liveness, test database pooling, and deterministic programmatic console execution. + +## Goal + +Eliminate the intermittent parallel-suite hang and the related flakes uncovered while tracing it, then make the affected production and testing lifecycle boundaries correct by construction. + +The finished design must have these properties: + +- a failed coroutine spawn cannot strand a channel token, wait-group count, timer registration, or caller; +- every test-owned coroutine, process, socket, timer, coordinator, database lease, and Mockery container has a bounded and exception-safe owner; +- production queue and Horizon shutdown cannot wait forever on state that may never make progress; +- worker-global state is used only where the state is genuinely worker-global; +- synchronization failures fail loudly within a bounded interval rather than consuming a CPU forever; +- programmatic console calls are deterministic regardless of the parent test runner's verbosity; +- fixes live at the lowest correct production boundary, with focused regression tests demonstrating the old failure; +- no compatibility shims, obsolete reset calls, workaround comments, duplicated test seams, or dead helpers remain. + +This is not a request to make every asynchronous operation synchronous or to add defensive timeouts indiscriminately. Each timeout, ownership boundary, and API change below corresponds to a concrete unbounded wait, leaked resource, or ambiguous failure contract found in the current code. + +## Source material and verified evidence + +### Repository sources + +- `src/engine/src/{Coroutine,SafeSocket}.php` +- `src/engine/src/Http/Server.php` +- `src/coroutine/src/{Coroutine,Concurrent,WaitConcurrent,Parallel,Waiter}.php` +- `src/concurrency/src/CoroutineDriver.php` +- `src/redis/src/Subscriber/CommandInvoker.php` +- `src/prompts/src/{Task,Spinner}.php` +- `src/console/src/SignalRegistry.php` +- `src/signal/src/SignalManager.php` +- `src/core/src/Bootstrap/WorkerExitCallback.php` +- `src/coordinator/src/Timer.php` +- `src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php` +- `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` +- `src/testing/src/Concerns/InteractsWithMockery.php` +- `src/foundation/src/Testing/{TestCase,DatabaseConnectionResolver}.php` +- `src/queue/src/Worker.php` +- `src/horizon/src/{Supervisor,MasterSupervisor,ProcessPool,WorkerProcess,SupervisorProcess,ListensForSignals}.php` +- `src/horizon/src/Console/HorizonRestartStrategy.php` +- `src/cache/src/SwooleTableState.php` +- `src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php` +- `src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php` +- `src/routing/src/CompiledRouteCollection.php` +- `src/console/src/Application.php` +- `src/foundation/src/Console/{ConfigCacheCommand,RouteCacheCommand}.php` +- `src/testbench/src/{Bootstrapper,Foundation/Process/RemoteCommand}.php` +- `src/pool/src/{Pool,Channel}.php` +- `src/object-pool/src/{ObjectPool,Channel}.php` +- `src/watcher/src/{Watcher,RestartStrategy,ServerRestartStrategy,WatchPath}.php` +- `src/watcher/src/Driver/{AbstractDriver,FindDriver,FindNewerDriver,ScanFileDriver,FswatchDriver}.php` +- the corresponding unit and integration tests under `tests/` + +### Local upstream references + +- Symfony Console's `vendor/symfony/console/Tester/ApplicationTester.php` saves, unsets, and restores `SHELL_VERBOSITY` around a single-threaded test run. That is useful evidence for the inherited-verbosity cause, but its process-global mutation is not safe for Hypervel's coroutine-concurrent framework API. +- Symfony Console's `Application::run()` also installs a process-global exception handler and saves/restores process-global shell verbosity around `configureIO()` and `doRun()`. Hypervel's programmatic API already disables Symfony exception catching, so the safe boundary is a dedicated global-free IO configuration followed by `doRun()`. +- Laravel's local `CompiledRouteCollection` keeps its name cache on the collection instance, not in a static shared across collections. +- Laravel Horizon scales worker pools down into a terminating collection and applies the configured worker timeout while pruning them. +- Laravel's queue worker uses a real sleeping primitive. Hypervel's production `Worker::sleep(0)` reaches hooked `usleep(0)`, which yields; the test double that replaced the method with recording-only behavior did not. + +### Captured parallel-suite hang + +The incomplete ParaTest run was not a generic ParaTest deadlock: + +- the only missing class was `Hypervel\Tests\Queue\QueueWorkerTest`; +- exactly 34 tests from that class were absent from the final result; +- the assigned worker emitted 27 completed tests and stopped while entering test 28, `testWorkerStoppingIsDispatched`; +- the stuck PHP worker was runnable, consumed approximately one core, had no child process, and reported kernel wait channel `0`; +- the test's `InsomniacWorker::sleep()` override only recorded the duration and never yielded; +- `Worker::daemon()` can repeatedly revisit its concurrency-full branch and invoke that override while a child job coroutine is waiting to resume; +- an isolated probe reproduced the CPU-bound loop, while delegating to `parent::sleep(0)` allowed the child to run and the daemon to finish. + +The exact scheduler interleaving that made an otherwise inline fake job park in the rare captured run cannot be reconstructed after the process is gone. The owning class, hot-loop mechanism, and deterministic regression are nevertheless established. Repeatedly running the full suite is not an appropriate reproduction strategy for a flake observed only a few times over many months. + +### Additional verified failures found during the audit + +1. Clearing `CoordinatorManager` after resuming a timer's coordinator can cause the timer coroutine to resolve a fresh open coordinator and wait forever. +2. A throwing coroutine teardown hook skips later hooks and the `WORKER_EXIT` resume, stranding child coroutines. +3. An unmet Mockery expectation throws before the global subscriber's framework reset list, leaving statics dirty; PHPUnit reports subscriber exceptions as extension warnings rather than failures belonging to the test. +4. `Swoole\Coroutine::create()` returns `false` at the coroutine limit. Assigning that to Engine Coroutine's `?int` ID produces a `TypeError`, while higher layers document `-1`/`false` and leak their pre-acquired bookkeeping. +5. The cache multiprocess test has bounded barrier setup but unbounded pipe reads and waits, without failure cleanup. +6. Engine socket tests reuse a fixed port and only shut servers down on the success path. +7. A renderer test manually joins child coroutines with unbounded channel pops that hide child exceptions. +8. ParaTest's `-v` exports `SHELL_VERBOSITY`; programmatic `Application::call()` inherits it, changing otherwise normal route and schedule output. +9. Compiled routes cache `Route` objects statically by route name. A later collection using the same name can receive the first collection's route object. +10. Supervisor persistence is detached solely to accommodate a test timing workaround, allowing writes to overlap, reorder, and report errors outside the loop that initiated them. +11. Horizon worker termination and queue-worker kill paths contain unbounded waits. +12. Cache and Reverb striped locks can spin forever after a holder dies; Reverb also logs from inside the critical section if its lock table is full. +13. The test database resolver permanently abandons each checked-out pooled wrapper after extracting its bare connection, so pool capacity is never returned. +14. `Frequency::frequency()` divides by an empty sample immediately after construction, before `flush()` has a prior second to zero-fill. +15. Testbench may kill a live PID based only on parent PID and a stale PID file, which is unsafe after PID reuse. +16. Several Redis tests own long-lived subscribers without guaranteed teardown. +17. Watcher drivers have inconsistent lifetimes: one blocks, three detach timers and return, and their failures cross different ownership boundaries. Watcher cannot observe completion or failure consistently and never invokes the driver's stop contract. +18. Programmatic console calls inherit Symfony's process-global shell verbosity even though Hypervel already clones each Command per execution and safely supports concurrent and nested command calls. Adding a second serialization layer would guard no observable state and deadlock commands that delegate work to child coroutines. +19. Config and route cache subprocesses do not inherit cache-path overrides stored only in PHP's `$_SERVER`, so a child can load a different cache from the one its parent cleared. +20. Tests that mutate Testbench bootstrap, provider, route, and published files use unchecked sequential restoration; one failed cleanup can silently contaminate a later subprocess boot. +21. Engine HTTP request dispatch does not catch a coroutine creation failure at the native Swoole callback boundary, so overload can escape without completing the response. +22. Pool and object-pool channels can enqueue or destroy an item and then throw while creating an outside-coroutine wake helper, reporting a committed ownership change as failed. +23. `SafeSocket` treats the valid string payload `"0"` as a closed receive, and Fswatch treats arbitrary read chunks as complete newline-delimited records. +24. Child-reaping tests treat every `waitpid()` error as success or fall back to an unbounded blocking wait, so interruption can hide or create an owned-process leak. +25. Reverb commits a live Redis subscriber before spawning its consumer. A failed spawn leaks that subscriber, and resetting the retry counter before the subscription handshake makes its retry limit unreachable. +26. Horizon can signal a newly forked worker or supervisor before that child installs its handlers. An early SIGUSR2 keeps its default terminating disposition, producing the intermittent paused-supervisor failure that strict child reaping exposes. + +## Design principles + +1. **Ownership is explicit.** The component that registers or borrows a resource records the exact handle and releases that exact handle in `finally`. +2. **Creation is transactional.** Code that reserves capacity before spawning either completes the spawn or rolls the reservation back. +3. **Cleanup is exhaustive.** One cleanup failure must not skip unrelated cleanup. The first failure remains primary. +4. **Timeouts guard external progress, not normal work.** Lock acquisition, child-process IPC, and process termination need bounds because the owner can die. Ordinary coroutine joins retain semantic completion waits once spawn is guaranteed. +5. **Production boundaries own production correctness.** Environment isolation belongs in `Application::call()`, route cache identity belongs in the route collection, and coroutine-spawn failure belongs in Engine. +6. **Tests use deterministic seams.** Tests inject a timer or process boundary rather than installing real signal handlers, leaving detached timers behind, or relying on fixed ports. +7. **Fix the lowest inconsistent contract.** Do not stack a local mutex, mode, retry, or identity mechanism over a lower-level contract that can be made correct for every caller. +8. **No unnecessary abstractions.** Do not add a general cancellation framework, configurable striped-lock policy, test-only coroutine factory, broad process supervisor, or defensive machinery for a state with no observable failure. The APIs below are the minimum reusable capabilities the verified ownership model lacks. + +## Final design + +### 1. Make coroutine creation a throwing, typed contract + +#### Problem + +Engine currently assigns the `false` returned by `Swoole\Coroutine::create()` to `?int`. High-level helpers then attempt to convert failure into `-1` or `false`, but the `TypeError` occurs first. Callers such as `Concurrent`, `WaitConcurrent`, `Parallel`, and `CoroutineDriver` mutate bookkeeping before spawn and cannot roll it back reliably under the current ambiguous contract. + +#### Decision + +Add `Hypervel\Engine\Exceptions\CoroutineCreateException`. Engine throws it immediately when native creation returns `false`. Every successful public spawn API returns an `int`; failure always throws. + +```php +class CoroutineCreateException extends RuntimeException +{ + public static function fromLastError(): static + { + $code = swoole_last_error(); + + return new static( + sprintf('Unable to create coroutine: %s', swoole_strerror($code)), + $code, + ); + } +} +``` + +```php +public function execute(...$data): static +{ + $id = @SwooleCo::create($this->callable, ...$data); + + if ($id === false) { + throw CoroutineCreateException::fromLastError(); + } + + $this->id = $id; + + return $this; +} +``` + +`Hypervel\Coroutine\Coroutine::create()` no longer catches `getId()` failures or documents `-1`: + +```php +public static function create(callable $callable): int +{ + return Co::create(static function () use ($callable): void { + try { + foreach (static::$afterCreatedCallbacks as $callback) { + try { + $callback(); + } catch (Throwable $throwable) { + static::printLog($throwable); + } + } + + $callable(); + } catch (Throwable $throwable) { + static::printLog($throwable); + } + })->getId(); +} +``` + +`co()` and `go()` become exact aliases returning `int`: + +```php +function go(callable $callable, bool|array $copyContext = false): int +{ + return $copyContext === false + ? Coroutine::create($callable) + : Coroutine::fork($callable, is_array($copyContext) ? $copyContext : []); +} +``` + +#### Transactional rollback in callers + +`Concurrent` releases its capacity token when spawn itself throws: + +```php +$this->channel->push(true); + +try { + Coroutine::create(function () use ($callable): void { + try { + $callable(); + } catch (Throwable $exception) { + $this->reportException($exception); + } finally { + $this->channel->pop(); + } + }); +} catch (Throwable $exception) { + $this->channel->pop(); + throw $exception; +} +``` + +`WaitConcurrent` balances the wait group if its parent spawn fails: + +```php +$this->wg->add(); + +try { + parent::create(function () use ($callable): void { + try { + $callable(); + } finally { + $this->wg->done(); + } + }); +} catch (Throwable $exception) { + $this->wg->done(); + throw $exception; +} +``` + +`Parallel` and `CoroutineDriver` treat spawn failures as failures of the corresponding input key and balance both concurrency and wait bookkeeping immediately: + +```php +try { + $this->copyContext === false + ? Coroutine::create($childCallable) + : Coroutine::fork( + $childCallable, + is_array($this->copyContext) ? $this->copyContext : [], + ); +} catch (Throwable $exception) { + $this->throwables[$key] = $exception; + unset($this->results[$key]); + $this->concurrentChannel?->pop(); + $wg->done(); +} +``` + +`CoroutineDriver` has no concurrency channel, so its matching rollback is smaller: + +```php +try { + Coroutine::fork($childCallable); +} catch (Throwable $exception) { + $exceptions[$key] = $exception; + $waitGroup->done(); +} +``` + +`Waiter` needs no compensating bookkeeping: a creation failure propagates immediately instead of being misreported ten seconds later as a channel timeout. + +Database and Redis health checks are the two intentional boolean boundaries. Inability to create the probe coroutine means the pooled connection is not currently healthy: + +```php +try { + go($probe); +} catch (CoroutineCreateException) { + return false; +} +``` + +Catch only `CoroutineCreateException`; do not swallow application or connection errors. + +Audit every `Coroutine::create()`, `Coroutine::fork()`, `go()`, and `co()` caller. Callers without pre-spawn bookkeeping naturally propagate the exception. Delete all `-1`, `false`, and `bool|int` creation-failure branches and stale docblocks. + +The complete caller audit requires these additional dispositions: + +- `SafeSocket::loop()` resets `$loop` if spawning fails. Its child also clears that flag when the send loop exits, treats native `sendAll() === false` as terminal, and closes the channel/socket so later sends cannot enqueue behind a dead consumer. Expected closed/timeout conditions are not reported as critical errors; unexpected throwables still are. Closed receive errors retain the native message and code, and an already-closing channel never spawns a phantom consumer. `recvAll()` and `recvPacket()` use strict terminal checks so the valid payload string `"0"` is not mistaken for a closed socket. +- `Redis\Subscriber\CommandInvoker` wraps receive-loop creation and shutdown-watcher registration transactionally; if either fails, it interrupts the connection, closes all four channels (including `pingChannel`), and clears any timer it registered. Store the shutdown timer ID so normal `interrupt()` also removes the watcher. Constructor rollback preserves the creation failure if `interrupt()` also throws; cleanup cannot replace the primary failure. +- `Prompts\Task` and `Prompts\Spinner` move animation spawn inside their existing `try/finally`, so cursor/render cleanup also runs when spawn fails. +- `Console\SignalRegistry` rolls back the handler appended for a failed waiter spawn. Its array registration path tracks additions and removes only those additions if a later signal cannot be registered; it terminally cancels a newly created waiter only when no handlers remain. Both normal unregister and rollback use `throwException: true`, while the waiter catches `CanceledException` as expected control flow. The canceller alone owns the registry slot. +- `Signal\SignalManager::listen()` keeps a local array of IDs created by that invocation and terminally cancels those IDs if a later signal watcher cannot be created, preventing a partially installed listener set without adding persistent registry state. Its waiters likewise catch intentional `CanceledException` without reporting it. Swoole 6.2 was probed directly: default cancellation interrupts one `System::waitSignal()` call but lets an unconditional waiter loop continue spinning; exception-injecting cancellation is required to terminate the coroutine. +- `Core\Bootstrap\WorkerExitCallback` removes its unnecessary coroutine and resumes the worker-exit coordinator synchronously in `finally`, so a throwing worker-exit listener cannot strand shutdown waiters. `Coordinator::resume()` only closes its channel and is valid at that lifecycle point; eliminating the spawn also eliminates a shutdown-time creation failure. +- background queue dispatch, Sentry request dispatch, and server-process listeners hold no pre-spawn mutable bookkeeping. They propagate creation failure as the honest failure of the requested operation. +- Reverb Redis pub/sub startup is transactional. `connect()` creates and subscribes a locally owned `Subscriber` before committing it to the provider, checks `shouldRetry` again after the yielding handshake, then drains queued publishes and spawns a consumer that captures that exact subscriber and channel name. Any handshake, queued-publish, or spawn failure closes the local subscriber, clears committed state only when it still refers to that object, preserves the primary failure for logging, and enters the existing bounded reconnect path. The retry counter resets only after the complete startup transaction succeeds, so repeated failures reach the existing limit instead of resetting it on every attempt. + + Remove the unused public `subscribe()` method from `PubSubProvider`; `connect()` owns the complete lifecycle and the sole implementation has no external `subscribe()` caller. The internal consumer closes its captured subscriber and clears provider state by object identity before reconnecting, so an older consumer cannot clear a newer connection. + + Drain queued publishes in order and remove each payload only after a successful publish. A `JsonException` is a permanent payload defect: log and drop that one payload so it cannot poison every reconnect. A Redis or connection failure is transient: retain that payload and the remaining tail, abort the startup transaction, and retry through the existing bounded path. Do not replace the now-bounded `connect()`/`reconnect()` control flow with a general reconnect state machine. +- Engine HTTP request dispatch is a native callback boundary, not an ordinary propagation boundary. Catch `CoroutineCreateException` around the spawn itself, log the overload failure, and complete the native response with HTTP 503. The existing child `try/catch` cannot catch a failure that occurs before the child exists, and an exception must not escape through Swoole's native callback. +- pool and object-pool channels enqueue or destroy an item before `Channel::signal()` may create a helper coroutine for an outside-coroutine caller. A spawn failure must not report release/discard as failed after ownership already changed. Handle this once in both synchronized channel implementations: treat an unavailable helper spawn as a missed wake, and make pool checkout recheck idle/capacity state once at its deadline before reporting exhaustion. This also closes the ordinary timeout-versus-release race. Keep both channel implementations structurally aligned; do not catch the exception at every release caller or add a permanent dispatcher coroutine per pool. + +The channel boundary is narrow: + +```php +try { + Coroutine::create($this->pushSignal(...)); +} catch (CoroutineCreateException) { + // State is already committed. The checkout loop performs one final state check. +} +``` + +The pool checkout loop records a timed-out wait, loops once more through its existing closed/idle/capacity checks, and throws exhaustion only if that final immediate pass still finds no progress. Pin that shape with a flag checked before any second wait: + +```php +$timedOut = false; + +while (true) { + // Existing closed, idle-item, and capacity/create checks. + + if ($timedOut) { + throw new RuntimeException( + 'Connection pool exhausted. Cannot establish new connection before wait_timeout.' + ); + } + + $timedOut = ! $this->waitForStateChange($deadline); +} +``` + +The final pass may consume an item or create against newly freed capacity, but it cannot call `waitForStateChange()` again. Normal operation does not poll. Apply the same rule to connection pool and object pool with their existing concrete exhaustion messages. + +- `FswatchDriver` already owns process teardown in `finally`. Remove its unnecessary raw per-batch coroutine: path matching runs inline in the owned driver coroutine, so matching failures flow through Watcher's failure slot, channel capacity provides bounded backpressure, delivery remains ordered, and no detached batch child survives teardown. `WatchPath` compiles its immutable glob regex once during construction instead of once per file match. +- `SignalRegistry`'s `$handling[$signo] = Coroutine::create(...)` assignment is naturally atomic because PHP does not assign the right-hand result when it throws; only its separately appended callback needs rollback. + +Add focused creation-failure coverage for every stateful item in this list, not merely the four concurrency helpers. + +### 2. Give coordinator timers stable identity and transactional registration + +#### Problem + +`Timer` resolves its coordinator inside the child coroutine. If teardown resumes and clears the manager before that child first runs, the child creates a new open coordinator and waits forever. `tick()` also waits for one more interval after a callback clears itself. A failed spawn leaves `$closures[$id]` registered. + +#### Decision + +Resolve the coordinator before spawning and capture the object. Check registration before every wait. Roll registration back if `go()` throws. + +```php +public function tick( + float $timeout, + callable $closure, + string $identifier = Constants::WORKER_EXIT, +): int { + $id = ++$this->id; + $coordinator = CoordinatorManager::until($identifier); + $this->closures[$id] = true; + + try { + go(function () use ($timeout, $closure, $coordinator, $id): void { + $round = 0; + + try { + ++Timer::$count; + + while (isset($this->closures[$id])) { + $isClosing = $coordinator->yield(max($timeout, 0.000001)); + + if (! isset($this->closures[$id])) { + break; + } + + $result = null; + + try { + $result = $closure($isClosing); + } catch (Throwable $exception) { + if ($this->logger !== null) { + $this->logger->error((string) $exception); + } else { + error_log((string) $exception); + } + } + + if ($result === self::STOP || $isClosing) { + break; + } + + ++$round; + ++Timer::$round; + } + } finally { + unset($this->closures[$id]); + Timer::$round -= $round; + --Timer::$count; + } + }); + } catch (Throwable $exception) { + unset($this->closures[$id]); + throw $exception; + } + + return $id; +} +``` + +Apply the same synchronous coordinator capture and registration rollback to `after()`, preserving its zero-timeout behavior exactly: + +```php +public function after( + float $timeout, + callable $closure, + string $identifier = Constants::WORKER_EXIT, +): int { + $id = ++$this->id; + $coordinator = CoordinatorManager::until($identifier); + $this->closures[$id] = true; + + try { + go(function () use ($timeout, $closure, $coordinator, $id): void { + try { + ++Timer::$count; + + $isClosing = match (true) { + $timeout > 0 => $coordinator->yield($timeout), + $timeout === 0.0 => $coordinator->isClosing(), + default => $coordinator->yield(), + }; + + if (isset($this->closures[$id])) { + $closure($isClosing); + } + } finally { + unset($this->closures[$id]); + --Timer::$count; + } + }); + } catch (Throwable $exception) { + unset($this->closures[$id]); + throw $exception; + } + + return $id; +} +``` + +Keep the tick callback error-reporting behavior inline; a one-use helper would add indirection without creating a useful abstraction. + +### 3. Make coroutine test teardown exhaustive + +#### Problem + +`RunTestsInCoroutine` invokes teardown hooks and cleanup sequentially. Any exception prevents context cleanup, native timer cleanup, `WORKER_EXIT` resume, and manager clear. That is a direct hang path when a child is waiting for worker exit. + +#### Decision + +Capture the first throwable and run every independent cleanup action. The test body exception remains primary; otherwise the first teardown failure is thrown. Later cleanup failures must not replace it. There is no suppressed-exception facility in PHP, so do not invent an exception aggregate solely for teardown. + +```php +$capture = static function (callable $callback) use (&$exception): void { + try { + $callback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } +}; + +try { + // setup and test body +} catch (Throwable $throwable) { + $exception = $throwable; +} finally { + if ($shouldBootFramework) { + $this->invokeTearDownInCoroutine($capture); + } + + $capture(fn () => $this->cleanupTestContext()); + $capture(fn () => Timer::clearAll()); + + $capture(fn () => CoordinatorManager::until(Constants::WORKER_EXIT)->resume()); + $capture(fn () => CoordinatorManager::clear(Constants::WORKER_EXIT)); +} +``` + +Change the existing teardown helper to run each hook independently through that callback while preserving its current ordering: + +```php +protected function invokeTearDownInCoroutine(callable $capture): void +{ + if (method_exists($this, 'tearDownInCoroutine')) { + $capture(fn () => $this->tearDownInCoroutine()); + } + + foreach (class_uses_recursive(static::class) as $trait) { + $method = 'tearDown' . class_basename($trait) . 'InCoroutine'; + + if (method_exists($this, $method)) { + $capture(fn () => $this->{$method}()); + } + } +} +``` + +### 4. Move Mockery verification into test-case ownership and harden the subscriber fallback + +#### Problem + +The global finished subscriber calls `Mockery::close()` before framework state resets. An unmet expectation skips every reset. PHPUnit also treats subscriber exceptions as extension warnings, not as failures belonging to the test that created the expectation. Foundation tests do not currently close Mockery from their own lifecycle. + +#### Decision + +Move the shared trait to `Hypervel\Testing\Concerns\InteractsWithMockery` and use it from: + +- `tests/TestCase.php`; +- `Hypervel\Testbench\PHPUnit\TestCase`; +- `Hypervel\Foundation\Testing\TestCase`. + +Delete the old Testbench trait. Update imports and package autoload references; do not retain a forwarding alias. + +The trait keeps assertion counting and `Mockery::close()`. Each base test case invokes it from `tearDown()` even if its framework teardown throws, preserving the first exception: + +```php +protected function tearDown(): void +{ + $exception = null; + + try { + if (! $this->withoutBootingFramework()) { + $this->tearDownTheTestEnvironment(); + } + } catch (Throwable $throwable) { + $exception = $throwable; + } + + try { + $this->tearDownTheTestEnvironmentUsingMockery(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } +} +``` + +The subscriber remains a fallback for arbitrary PHPUnit base classes. It preserves the existing callback-before-Mockery order, captures either failure, performs every framework reset, then rethrows the first failure: + +```php +public function flushStateAfterTest(): void +{ + $exception = null; + + try { + AfterEachTestCleanup::runCallbacks(); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + try { + Mockery::close(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + $this->flushFrameworkState(); + + if ($exception !== null) { + throw $exception; + } +} +``` + +Treat `DatabaseConnectionResolver::flushCachedConnections()` as throwable resource cleanup rather than a pure static reset. Run it through the same first-failure capture before `flushFrameworkState()`, so an invalid or already-closed pooled wrapper cannot skip the remaining authoritative framework-static reset list. + +Remove `Mockery::close()` from `flushFrameworkState()`. Framework `flushState()` methods are designed as no-throw reset boundaries; do not allocate an array of hundreds of closures on every test merely to guard hypothetical contract violations. + +`tests/TestCase.php` must also guard its own pre-Mockery cleanup. Run `HandleExceptions::flushState($this)` and `tearDownTheTestEnvironmentUsingMockery()` as independent captured actions so an exception from the first cannot skip expectation verification. + +Update both Mockery guidance and the general teardown guidance in `AGENTS.md`: framework base cases own Mockery verification, while the extension provides fallback verification and authoritative framework-static cleanup. Tests must not add ad hoc `Mockery::close()` calls, but the shared base-case trait is the intentional exception to the general rule against duplicating subscriber-owned cleanup. Update `docs/ai/differences-vs-laravel.md`, which also says Mockery is handled only globally. Search all current component guidance for the old convention and leave no instruction that tells test authors or porting agents to rely exclusively on the subscriber for expectation verification. + +### 5. Correct the queue worker's timer, signal, and kill lifecycle + +#### Problem + +Queue worker tests install real signal handlers and detached timers through duplicated callable seams. The worker does not own or clear its monitor ID on every daemon exit. `monitorLocked` remains true if timeout processing throws. `kill()` waits for active job coroutines even though the timeout path is specifically declaring the worker unrecoverable. The test worker's recording-only `sleep()` creates the captured CPU loop. + +#### Decision + +Inject one optional `Coordinator\Timer` dependency into `Worker`; default it to a real timer. Remove both monitor callables: the constructor `$monitorTimeoutJobs` and `daemon()`'s optional callback. Store the timer and its returned ID. + +```php +public function __construct( + /* existing dependencies */, + ?Timer $timer = null, +) { + $this->timer = $timer ?? new Timer; +} + +protected function monitorTimeoutJobs(WorkerOptions $options): void +{ + if ($this->monitorId !== null) { + return; + } + + $this->monitorId = $this->timer->tick( + $this->monitorInterval, + function () use ($options): void { + $this->withCoroutineContext($options, function () use ($options): void { + if ($this->monitorLocked) { + return; + } + + $this->monitorLocked = true; + + try { + $this->terminateTimeoutJobs($options); + + if ($this->hasTimeoutJobs()) { + $this->shouldQuit = true; + $this->kill(static::EXIT_SUCCESS, $options); + } + } finally { + $this->monitorLocked = false; + } + }); + }, + ); +} +``` + +Wrap the daemon body in `try/finally` and clear only the monitor this worker owns: + +```php +$this->monitorTimeoutJobs($options); + +try { + while (true) { + // The existing daemon loop, including all of its current return paths. + } +} finally { + if ($this->monitorId !== null) { + $this->timer->clear($this->monitorId); + $this->monitorId = null; + } +} +``` + +Do not extract a one-use `runDaemonLoop()` solely to create the `try/finally`; wrap the existing monitor registration and daemon loop directly so ownership remains visible where the timer is acquired. + +`kill()` dispatches its stopping event and immediately invokes the process-kill boundary. It must not wait for unrelated active coroutines after deciding a timed-out job requires process death. Under concurrency greater than one this intentionally terminates healthy sibling jobs too: once any job has exceeded its hard timeout, the worker process is poisoned and cannot safely remain alive merely to drain unrelated work. Pin that behavior with a regression test. Preserve `$timeoutJobIds`, because `process()` uses them after `fire()` to suppress `JobProcessed` for timed-out work. + +```php +public function kill(int $status = 0, ?WorkerOptions $options = null): never +{ + $this->events->dispatch(new WorkerStopping($status, $options)); + $this->terminateProcess($status); +} + +/** + * Terminate the current worker process immediately. + */ +protected function terminateProcess(int $status): never +{ + if (extension_loaded('posix')) { + posix_kill(getmypid(), SIGKILL); + } + + exit($status); +} +``` + +The protected process boundary is the test seam. Production still has an immediate terminal path; tests override it by throwing a sentinel exception instead of killing PHPUnit. Do not inject a general process controller into `Worker` for this one terminal operation. + +In `InsomniacWorker`: + +```php +public function sleep(int|float $seconds): void +{ + $this->sleptFor = $seconds; + parent::sleep(0); +} + +protected function supportsAsyncSignals(): bool +{ + return false; +} +``` + +Keep `InsomniacWorker::$sleptFor` scalar and type it as `int|float|null`, preserving the two existing single-sleep assertions. The regression needs scheduler progress, not a new sleep-history API. + +No extra production yield is needed. Production's hooked `usleep(0)` already yields. `Coroutine::yield()` is not a substitute: it parks until an explicit resume and would introduce a new hang. + +### 6. Bound Horizon persistence and termination + +#### Problem + +`Supervisor::loop()` and `MasterSupervisor::loop()` detach persistence into `go()` to accommodate a test retry. This allows overlapping and reordered writes and moves failures outside the initiating loop's exception boundary. `Supervisor::terminate()` waits without a deadline. ProcessPool's timeout fallback calls Symfony's default `stop()`, which can itself wait for another long timeout. + +#### Decision + +Persist synchronously before dispatching the looped event: + +```php +$this->persist(); +event(new SupervisorLooped($this)); +``` + +Coroutine-hooked Redis yields the current coroutine while I/O is pending; it does not block the worker. Remove detached-persistence imports, nested exception reporting, retry comments, and tests that only compensate for eventual persistence. + +Terminate a supervisor through the existing pool state machine: + +```php +$this->working = false; +app(SupervisorRepository::class)->forget($this->name); + +$this->processPools->each->scale(0); + +if ($this->shouldWait()) { + while ($this->processPools->map->runningProcesses()->collapse()->isNotEmpty()) { + sleep(1); + } +} + +$this->shouldExitLoop = true; +``` + +`scale(0)` moves active workers into `terminatingProcesses`, records `terminatedAt`, and sends graceful termination. `runningProcesses()` prunes that set, applying each pool's configured timeout. + +Add an immediate hard-stop primitive: + +```php +public function kill(): void +{ + if ($this->process->isRunning()) { + $this->process->stop(0); + } +} +``` + +`ProcessPool::stopTerminatingProcessesThatAreHanging()` calls `kill()` after the configured grace period. `MasterSupervisor::terminate()` retains its longest-active-timeout deadline but hard-stops any supervisor process still running after the deadline instead of merely abandoning it. `SupervisorProcess` extends `WorkerProcess`, so the same public immediate kill primitive is the required mechanism: + +```php +$runningSupervisors = $this->supervisors->filter( + fn (SupervisorProcess $supervisor): bool => $supervisor->isRunning(), +); + +if ($deadlineExpired) { + $runningSupervisors->each->kill(); + break; +} +``` + +Retain the existing master/supervisor repository cleanup, fast-termination cache cleanup, working flags, and loop-exit flags around these replacements. + +Protect child control signals during process bootstrap. Before `WorkerProcess::start()` invokes Symfony Process, block the exact signal set handled by that child and capture the parent's prior mask. The fork/exec child inherits the blocked mask, so an early signal remains pending rather than taking its default action. Restore the parent's exact prior mask in exhaustive cleanup, preserving the first start or restoration failure. Queue workers block SIGQUIT, SIGTERM, SIGINT, SIGUSR2, and SIGCONT; `SupervisorProcess` overrides the set with SIGTERM, SIGUSR1, SIGUSR2, and SIGCONT. + +After queue `Worker::listenForSignals()` and Horizon `ListensForSignals::listenForSignals()` install their handlers, each unblocks exactly the set it registered. Pending control signals are then delivered to the installed asynchronous handlers. Keep the sets as protected constants on their owning classes/trait and assert parent-block and child-handler equality in tests so cross-package drift cannot silently leave a signal unprotected or permanently blocked. + +Do not replace the manual block/restore boundary with Symfony Process `setIgnoredSignals()`. Symfony suppresses sends for ignored signals when PHP uses SIGCHLD handling, but Horizon must continue sending these signals through `Process::signal()`. The manual mask changes only fork-time inheritance and does not classify the signals as ignored. + +Horizon test teardown must own both active and terminating processes. In `finally`, request graceful termination, then unconditionally hard-stop remaining real child processes and reap them. Delete the stale teardown TODO and any polling whose sole purpose was waiting for detached persistence. + +### 7. Add bounded striped-lock acquisition without burdening the API + +#### Problem + +Cache and Reverb locks can spin forever if a process dies while holding a stripe. Reverb has no backoff at all, so contention consumes a full core. Reverb also logs from inside the lock when its lock table is full, extending and potentially yielding in the critical section. + +#### Decision + +Use the same bounded internal policy in both state objects: + +```php +protected const LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 1_000_000_000; +protected const SPINS_BEFORE_BACKOFF = 64; + +protected function acquire(Atomic $lock): void +{ + $deadline = null; + $spins = 0; + + while (! $lock->cmpset(0, 1)) { + $deadline ??= hrtime(true) + static::LOCK_ACQUIRE_TIMEOUT_NANOSECONDS; + + if (++$spins < static::SPINS_BEFORE_BACKOFF) { + continue; + } + + if (hrtime(true) >= $deadline) { + throw new RuntimeException('Timed out acquiring a Swoole table state lock.'); + } + + $spins = 0; + usleep(1); + } +} +``` + +Use `static::` for the protected constants so tests can shorten only the timeout with a subclass. The deadline is initialized lazily after the first failed compare-and-set, so the normal uncontended path remains exactly one compare-and-set. The fixed one-second failure bound is an internal invariant, not new user configuration. + +Because `acquire()` can now throw, Cache's all-stripe acquisition must include the acquisition loop inside the protected region and release only successfully acquired locks: + +```php +$acquired = []; + +try { + foreach ($this->rowLocks as $lock) { + $this->acquire($lock); + $acquired[] = $lock; + } + + return $callback(); +} finally { + while ($lock = array_pop($acquired)) { + $this->release($lock); + } +} +``` + +This prevents a timeout on stripe N from permanently leaking stripes 0 through N-1. + +Reverb's `setLockRow()` returns status only. Its callers release the stripe first, then call a shared reporting method if insertion failed: + +```php +$stored = false; + +try { + $stored = $this->setLockRow($key, $timestamp); +} finally { + $this->release($lock); +} + +if (! $stored) { + $this->reportFullLockTable($key); +} +``` + +Keep all critical sections short, non-yielding, and free of logging or arbitrary callbacks. Replace comments that say holder death leaves a permanent lock with the new bounded-failure contract. + +For `tryLock()`, preserve its live-marker early return and defer reporting until after release: + +```php +$stored = false; + +try { + $row = $this->lockTable->get($key, 'locked_at'); + $now = microtime(true); + + if ($row !== false && ($now - (float) $row) < ($ttlMs / 1000.0)) { + return false; + } + + $stored = $this->setLockRow($key, $now); +} finally { + $this->release($lock); +} + +if (! $stored) { + $this->reportFullLockTable($key); +} + +return $stored; +``` + +The early `return false` still runs `finally`, while only a failed write reaches the post-release reporter. + +### 8. Make compiled route object caching collection-owned + +#### Problem + +The static name cache is keyed only by route name, but separate compiled collections can use the same name for different routes. Replacing the collection therefore does not establish object identity unless every replacement site remembers a global flush. Tests can receive stale routes when cleanup was skipped. + +#### Decision + +Make the cache an instance property: + +```php +/** @var array */ +protected array $cachedRoutesByName = []; + +public function getByName(string $name): ?Route +{ + if (isset($this->attributes[$name])) { + return $this->cachedRoutesByName[$name] + ??= $this->newRoute($this->attributes[$name]); + } + + return $this->routes->getByName($name); +} +``` + +Delete `CompiledRouteCollection::flushCache()`, remove its subscriber reset, and remove it from `Router::flushRoutingCaches()`. Update warmup and cache comments to say collection-owned rather than worker-global. Keep the other genuinely static routing-cache resets. + +### 9. Isolate programmatic console verbosity + +#### Problem + +`Application::call()` is a programmatic API, but Symfony's root `run()` wrapper reads and writes process-global terminal and `SHELL_VERBOSITY` state. A verbose parent runner therefore silently makes route and schedule commands verbose, changing tested output. Saving, clearing, and restoring those globals would race between coroutines. + +Hypervel already solves the real shared command-state problem at the lower boundary: `freshCommandForRun()` clones every command before execution. Symfony's remaining Application state does not justify serialization for programmatic calls: + +- `runningCommand` is only observed by `run()`'s exception renderer, which the programmatic path bypasses; +- `wantHelps` is set, consumed, and reset synchronously without a coroutine yield; +- existing concurrent and nested command tests prove that per-execution command clones isolate input and output state. + +A per-Application mutex would therefore guard no observable failure and would deadlock a command such as `schedule:run` when it waits for a child coroutine that calls the same Application. + +#### Decision + +Keep the existing coroutine-local output buffer. Do not copy Symfony Tester's save/unset/restore workaround and do not add a programmatic-mode context flag: `call()` already owns the programmatic path and can invoke its dedicated IO configuration directly. + +```php +public function call( + string|SymfonyCommand $command, + array $parameters = [], + ?OutputInterface $outputBuffer = null, +): int { + [$command, $input] = $this->parseCommand($command, $parameters); + + if (! $this->has($command)) { + throw new CommandNotFoundException(sprintf( + 'The command "%s" does not exist.', + $command, + )); + } + + return $this->runProgrammatically( + $input, + CoroutineContext::set( + self::LAST_OUTPUT_CONTEXT_KEY, + $outputBuffer ?: new BufferedOutput, + ), + ); +} + +protected function runProgrammatically( + InputInterface $input, + OutputInterface $output, +): int { + $this->configureProgrammaticIO($input, $output); + + return $this->doRun($input, $output); +} +``` + +Place this synchronization comment on the real method so future Symfony upgrades surface the deliberate boundary during source review: + +```php +/** + * Run a command without Symfony's process-global CLI wrapper. + * + * Symfony's run() owns root CLI concerns: terminal environment export, + * process-global exception handling, shell-verbosity restoration, and + * auto-exit. Programmatic calls need explicit IO configuration and doRun() only. + * Keep this boundary aligned with Symfony through the parity tests. + * + * @see SymfonyApplication::run() + */ +``` + +Add `configureProgrammaticIO()` with Symfony's current explicit input-option handling, a normal default, and no process-global writes. Root CLI execution keeps Symfony's inherited `configureIO()` unchanged: + +```php +protected function configureProgrammaticIO(InputInterface $input, OutputInterface $output): void +{ + if ($input->hasParameterOption(['--ansi'], true)) { + $output->setDecorated(true); + } elseif ($input->hasParameterOption(['--no-ansi'], true)) { + $output->setDecorated(false); + } + + $shellVerbosity = match (true) { + $input->hasParameterOption(['--silent'], true) => -2, + $input->hasParameterOption(['--quiet', '-q'], true) => -1, + $input->hasParameterOption('-vvv', true) + || $input->hasParameterOption('--verbose=3', true) + || 3 === $input->getParameterOption('--verbose', false, true) => 3, + $input->hasParameterOption('-vv', true) + || $input->hasParameterOption('--verbose=2', true) + || 2 === $input->getParameterOption('--verbose', false, true) => 2, + $input->hasParameterOption('-v', true) + || $input->hasParameterOption('--verbose=1', true) + || $input->hasParameterOption('--verbose', true) + || $input->getParameterOption('--verbose', false, true) => 1, + default => 0, + }; + + $output->setVerbosity(match ($shellVerbosity) { + -2 => OutputInterface::VERBOSITY_SILENT, + -1 => OutputInterface::VERBOSITY_QUIET, + 1 => OutputInterface::VERBOSITY_VERBOSE, + 2 => OutputInterface::VERBOSITY_VERY_VERBOSE, + 3 => OutputInterface::VERBOSITY_DEBUG, + default => $output->getVerbosity(), + }); + + if ($shellVerbosity < 0 + || $input->hasParameterOption(['--no-interaction', '-n'], true) + ) { + $input->setInteractive(false); + } +} +``` + +`runProgrammatically()` deliberately calls `configureProgrammaticIO()` and `doRun()` directly instead of Symfony's root `run()` wrapper. Hypervel has exception catching disabled for this API, and bypassing that wrapper avoids its process-global exception-handler, terminal export, and `SHELL_VERBOSITY` save/restore machinery. The normal root CLI path still delegates to Symfony's `run()` and keeps shell-verbosity semantics. Programmatic calls ignore inherited `SHELL_VERBOSITY`, honor explicit `--silent`, `-q`, `-v`, `-vv`, and `-vvv`, never mutate process-global environment, and preserve Hypervel's coroutine-local `output()` behavior. Do not modify `PendingCommand`: every programmatic caller deserves the same boundary. + +Keep the source comment on `runProgrammatically()` and focused behavioral parity tests as the Symfony-upgrade tripwire. Cover command execution, arguments and options, output, exit code, exception propagation, ANSI decoration, interaction flags, quiet/explicit verbosity, help handling, and console event ordering. Run `run()`-versus-`call()` verbosity-mapping parity with `SHELL_VERBOSITY` absent from `getenv()`, `$_ENV`, and `$_SERVER`, so the intended inherited-default divergence does not mask a future change to Symfony's explicit option mapping. Retain the existing concurrent and nested command-cloning regressions rather than adding serialization assertions. Nested calls continue through `doRun()` and retain their normal Application event lifecycle. Do not assert Symfony source text or a source hash; harmless upstream refactors must not fail the suite. + +Delete the mutex, depth constant, context restoration, `runNestedProgrammatically()`, LazyCommand unwrapping, and tests that assert serialized execution or suppressed nested events. They solve no observed state problem and conflict with legitimate child-coroutine command execution. + +### 10. Add a real discard operation to connection pools + +#### Problem + +The testing database resolver checks out a pooled wrapper, extracts its bare `Connection`, and abandons the wrapper. The pool continues to count it as borrowed forever. Disconnecting the bare PDO does not clear pool ownership or restore capacity. The current docblock explicitly rationalizes this leak. + +#### Decision + +Add explicit discard to the existing full connection-pool contracts. The abbreviated snippet shows only the new neighboring methods; retain every current method: + +```php +interface PoolInterface +{ + public function release(ConnectionInterface $connection): void; + + public function discard(ConnectionInterface $connection): void; +} + +interface ConnectionInterface +{ + public function release(): void; + + public function discard(): void; +} +``` + +`Pool::discard()` accepts only a connection currently borrowed from that pool, then destroys it and releases capacity: + +```php +public function discard(ConnectionInterface $connection): void +{ + $this->assertBorrowed($connection, 'discard'); + $this->destroyConnection($connection); +} +``` + +`Connection` and `KeepaliveConnection` delegate to their owning pool: + +```php +public function discard(): void +{ + $this->pool->discard($this); +} +``` + +Audit every implementation and test double of both contracts. Do not make `discard()` an alias of `close()` on the wrapper: pool bookkeeping must be updated atomically by the owner. + +The contract boundary is intentionally narrow and already connection-specific: + +- `Hypervel\Contracts\Pool\PoolInterface` is implemented by abstract `Hypervel\Pool\Pool`; `SimplePool\Pool`, `Database\Pool\DbPool`, and `Redis\Pool\RedisPool` inherit the implementation; +- `Hypervel\Contracts\Pool\ConnectionInterface` is directly implemented by `Pool\Connection`, `Pool\KeepaliveConnection`, and `Database\Pool\PooledConnection`; their SimplePool and Redis descendants inherit delegation; +- update `NonCoroutinePoolConnection`, `PoolConnectionStub`, and every anonymous/mock implementer in tests. + +Do not touch `Hypervel\ObjectPool\Contracts\ObjectPool`: it is a separate object-pool hierarchy used by Sentry and `SimpleObjectPool`, and it already has correct `discard(object)` semantics. + +The testing resolver stores both objects: + +```php +use Hypervel\Contracts\Pool\ConnectionInterface as PoolConnectionInterface; +use Hypervel\Database\ConnectionInterface; +``` + +Keep the explicit alias: the bare database connection and its owning pooled wrapper implement different interfaces with the same short name. + +```php +/** @var array */ +protected static array $connections = []; + +/** @var array */ +protected static array $pooledConnections = []; +``` + +On first resolution: + +```php +$pooled = $this->factory->getPool($connectionName->requested)->get(); + +try { + $connection = $pooled->getConnection(); + + if (! $connection instanceof ConnectionInterface) { + throw new LogicException('The database pool returned an invalid connection.'); + } +} catch (Throwable $exception) { + $pooled->discard(); + throw $exception; +} + +static::$pooledConnections[$connectionName->requested] = $pooled; +static::$connections[$connectionName->requested] = $connection; +``` + +The pool owns cleanup failure handling: `destroyConnection()` already reports connection-close failures, and the channel change in section 1 makes a missed wake non-throwing after committed ownership changes. A valid borrowed wrapper's `discard()` is therefore terminal and no-throw. Do not add a second catch/report layer in the resolver merely to guard an internal ownership assertion that would indicate a framework invariant bug. + +Split the misleading reset API: + +- `resetCachedConnections()` resets reusable bare connections for another setup phase, detects a new container, discards old-container wrappers, and registers the dispatcher rebinding hook; +- `flushCachedConnections()` is terminal: discard every cached wrapper and clear both arrays, the container ID, and rebinding state; +- `flush($name)` discards that name's wrapper and removes both entries. + +`InteractsWithTestCaseLifecycle` calls `resetCachedConnections()` after creating the application. During teardown it calls terminal `flushCachedConnections()` before `PoolFactory::flushAll()`, so borrowed wrappers are returned to pool ownership before the pool closes. The subscriber retains terminal flush as a fallback. + +Delete the stale “hybrid lifecycle” and “discard is acceptable” comments. Document the actual reason the bare connection remains stable during a test: its wrapper is deliberately retained and explicitly discarded at teardown. + +### 11. Bound and clean up the cache multiprocess test harness + +#### Problem + +The start barrier has a deadline, but parent pipe reads and child reaping do not. Any child that exits early or never writes can hang the suite. Failure paths do not release the start barrier, close pipes, kill live children, or reap owned PIDs. + +#### Decision + +Make every child operation owned by a single `try/finally`. Record PIDs returned from `start()`. Put pipes in nonblocking mode and poll them against a monotonic deadline. Prefix each serialized payload with a fixed-width length, enforce a small maximum frame size, and accumulate reads until the complete frame arrives; do not assume one pipe read returns one write. + +```php +try { + foreach ($processes as $process) { + $pid = $process->start(); + + if ($pid === false) { + throw new RuntimeException('Unable to start cache concurrency child.'); + } + + $pids[$pid] = $process; + $process->setBlocking(false); + } + + $this->waitForReadyProcesses($ready, $count); + $start->set(1); + + foreach ($pids as $pid => $process) { + $payload = $this->readChildPayload($process, $pid, $deadline); + // validate ok/result/error protocol + } +} finally { + $start->set(1); + + foreach ($pids as $pid => $process) { + if (Process::kill($pid, 0)) { + Process::kill($pid, SIGKILL); + } + + $process->close(); + } + + $this->reapOwnedChildren($pids); +} +``` + +`readChildPayload()` loops on nonblocking `read()`, checks whether the child is still alive, uses `hrtime(true)` for its deadline, and sleeps briefly between empty reads. `reapOwnedChildren()` uses `pcntl_waitpid($pid, $status, WNOHANG)` for each recorded PID against a short deadline. Treat the PID result as reaped, and treat `PCNTL_ECHILD` as already collected; retry `PCNTL_EINTR` and throw a descriptive error for any other wait failure. It never calls global `Process::wait()`, so it cannot consume an unrelated child's status. + +The child catches `Throwable` and writes an explicit `{ok,error}` payload through a write-all helper that handles partial writes. The forced `SIGKILL` remains necessary to avoid inherited PHPUnit/Testbench shutdown handlers deleting the parent's runtime app. Apply the same bounded `WNOHANG` reap rule to the Reverb lock regression; it must not finish cleanup with an unbounded blocking `waitpid()`. + +### 12. Give Engine socket tests ephemeral ports and unconditional teardown + +Use `new Server('127.0.0.1', 0)` and read the assigned `$server->port`. The native constructor binds and listens immediately; signal readiness through a capacity-one channel after handler registration and immediately before `start()`. Do not probe readiness with a synthetic client connection: that connection executes the real handler and can fill completion channels or make assertions pass for the wrong client. + +```php +$ready = new Channel(1); +$finished = new WaitGroup(1); +$serverErrors = []; + +go(function () use ($server, $ready, $finished, &$serverErrors): void { + try { + $server->handle(/* parent-visible error capture */); + $ready->push(true); + $server->start(); + } catch (Throwable $exception) { + $serverErrors[] = $exception; + } finally { + $finished->done(); + } +}); + +try { + if ($ready->pop(0.5) !== true) { + throw $serverErrors[0] ?? new RuntimeException('The Engine test server did not become ready.'); + } + + // Connect the one real client and close it in its own finally block. +} finally { + $server->shutdown(); + $finished->wait(1.0); + $ready->close(); +} +``` + +Child server coroutines capture errors into parent-owned state and are joined through a bounded primitive; they do not invoke PHPUnit assertions asynchronously. Replace every fixed `9506`, every success-only shutdown, every active readiness probe, and any server created without being started or closed. + +### 13. Make the Watcher own and observe its asynchronous work + +#### Problem + +`DriverInterface::watch()` has no consistent lifetime contract. `FswatchDriver` blocks for the lifetime of its subprocess, while `FindDriver`, `FindNewerDriver`, and `ScanFileDriver` register detached coordinator timers and return immediately. `Watcher` therefore cannot interpret a return as either successful startup or terminal completion. + +The old Watcher starts the driver through the raw Engine coroutine. An uncaught Fswatch failure can escape fatally from that coroutine, while polling-driver callback failures are caught and logged by `Timer` outside Watcher's ownership. The current WaitGroup implementation is also incorrect: it treats the three polling drivers' immediate return as completion and stops them as soon as they start. + +The lower-level driver contract must be made consistent before Watcher observes it. + +`ServerRestartStrategy` has the same ownership gap: its server coroutine pops the single launch token but only pushes it back on the success path. An exception from `proc_open()` or `proc_close()` permanently prevents later restart attempts. It also uses the raw Engine coroutine without an internal catch, so either exception is process-fatal. Its PID file is cast without validation; empty or corrupted contents can become PID 0 (the entire process group) or an unrelated numeric-prefix PID. + +#### Decision + +Define `DriverInterface::watch()` as a blocking, single-lifecycle operation: it returns only after `stop()` unblocks it or the driver reaches terminal completion, and it throws terminal failures to its caller. `stop()` remains public and idempotent. + +Polling drivers do not need a detached `Timer`. Move the shared `$stopping` flag to `AbstractDriver`, give it one nullable owned stop channel, and add a small interval-loop helper. Remove the abstract driver's Timer property, timer ID, import, destructor, and `FindNewerDriver`'s duplicate stopping property. Waiting on the stop channel with the scan interval as its timeout both preserves timer cadence and lets `stop()` wake the driver immediately. The scan callback runs in the driver coroutine, so failures propagate naturally instead of being caught by an unrelated Timer coroutine. + +```php +protected function watchAtInterval(float $seconds, callable $scan): void +{ + if ($this->stopping) { + return; + } + + $stopSignal = $this->stopSignal = new Channel(1); + + try { + while (! $this->stopping) { + $signal = $stopSignal->pop($seconds); + + if ($signal !== false || ! $stopSignal->isTimeout()) { + return; + } + + $scan(); + } + } finally { + if (! $stopSignal->isClosing()) { + $stopSignal->close(); + } + + $this->stopSignal = null; + } +} + +public function stop(): void +{ + if ($this->stopping) { + return; + } + + $this->stopping = true; + $this->stopSignal?->close(); +} +``` + +Create the stop channel lazily inside the owned driver coroutine and remove `AbstractDriver::__destruct()`: native channel teardown must be explicit and must not run after Swoole has destroyed the channel handle. A driver instance owns one watch lifecycle and is not restarted after `stop()`. + +`FindDriver`, `FindNewerDriver`, and `ScanFileDriver` call `watchAtInterval()` with their existing scan bodies. Preserve `FindNewerDriver`'s scanning reference-file ownership while replacing its timer registration: its override calls `parent::stop()` first, then removes reference files immediately only when no scan is active; an active scan retains the existing deferred-finally cleanup. `FswatchDriver` remains a blocking process loop and its `stop()` calls the parent stop before terminating the subprocess and closing its pipes. Buffer incomplete `fread()` data between reads, emit only newline-terminated paths, and process any final buffered path before treating EOF as terminal. A read can split a path at any byte; exploding each chunk independently loses changes under a large batch. + +Add public idempotent `stop(): void` to `RestartStrategy`. `ServerRestartStrategy` implements it by stopping the managed server. `Horizon\Console\HorizonRestartStrategy`, the other implementer, changes its existing protected `stop()` to public; its nullable-process/running check is already idempotent and matches the contract. + +With every driver now blocking, `Watcher::run()` can own one `WaitGroup` for the driver coroutine and a shared child failure slot. The child always completes the wait group; the parent checks the non-blocking `count()` before each existing millisecond channel poll, then rethrows the captured failure or returns after a clean driver exit. + +```php +$driverFinished = new WaitGroup(1); +$driverFailure = null; +$driverStarted = false; +$exception = null; +$capture = static function (callable $callback) use (&$exception): void { + try { + $callback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } +}; + +try { + $this->strategy?->start(); + + Coroutine::create(function () use ( + $channel, + $driverFinished, + &$driverFailure, + ): void { + try { + $this->driver->watch($channel); + } catch (Throwable $throwable) { + $driverFailure = $throwable; + } finally { + $driverFinished->done(); + } + }); + $driverStarted = true; + + while ($driverFinished->count() > 0) { + // Existing debounce and restart handling. + } + + if ($driverFailure !== null) { + throw $driverFailure; + } + + while ($channel->getLength() > 0) { + $file = $channel->pop(); + $this->output->writeln('File changed: ' . $file); + $result[] = $file; + } + + if ($result !== []) { + $this->strategy?->restart(); + } +} catch (Throwable $throwable) { + $exception = $throwable; +} finally { + $capture(fn () => $this->driver->stop()); + $capture(fn () => $this->strategy?->stop()); + $capture(fn () => $channel->close()); + + $capture(function () use ($driverStarted, $driverFinished): void { + if ($driverStarted && ! $driverFinished->wait(1.0)) { + throw new RuntimeException('The file watcher did not stop within one second.'); + } + }); +} + +if ($exception !== null) { + throw $exception; +} +``` + +`WaitGroup::wait(0.0)` must not be used as a poll: native `Channel::pop(0.0)` blocks on an empty channel. Preserve a primary driver/restart exception if bounded cleanup also fails, using the same first-exception rule as other teardown in this plan. Cleanup stops the driver and strategy, closes the owned change channel, then performs the positive one-second join. `stop()` interrupts the polling interval and Fswatch process waits, while closing the change channel interrupts a driver blocked while pushing a large batch. The join is therefore a contract assertion, not a configurable watch timeout. + +The post-completion drain is required because a synchronous driver can fill the buffered channel and exit before the parent enters the loop. The final restart is independently required because the last item can be popped immediately before the wait-group count reaches zero, without allowing the debounce timeout branch to run. + +`ServerRestartStrategy::launchServer()` uses the high-level reporting coroutine and restores its launch token in `finally` after a successful pop: + +```php +Coroutine::create(function (): void { + $this->channel->pop(); + + try { + $process = $this->openProcess($descriptorSpec, $pipes); + + if (! is_resource($process)) { + throw new RuntimeException('Unable to launch the watched server process.'); + } + + $this->closeProcess($process); + } finally { + $this->channel->push(1); + } +}); +``` + +Use protected `openProcess()` and `closeProcess()` wrappers only as deterministic native test boundaries; retain precise resource docblocks. A launch or close failure is a genuine asynchronous error and is reported by the high-level coroutine wrapper rather than killing the watcher. + +Before stopping a server, trim the PID file, require every character to be a digit, convert to `int`, and reject non-positive values before output or event dispatch. Model `BeforeServerRestart::$pid` as `int`. Route both the liveness probe and SIGTERM through one protected `signalProcess()` native boundary. This prevents PID 0 process-group signals and numeric-prefix corruption without adding a process abstraction. + +Keep the argv-array command boundary already established by the watcher package. Do not reintroduce shell tokenization. Do not add a general driver task, cancellation, or event abstraction: one owned coroutine, one stop channel for polling drivers, and the existing change channel express the complete lifecycle. + +### 14. Replace hand-written coroutine joins where the channel is not under test + +`ListenerContextIsolationTest` should use `parallel()`: + +```php +$results = parallel([ + 'a' => fn (): array => $this->recordQueries('conn-a', ['SELECT a1', 'SELECT a2']), + 'b' => fn (): array => $this->recordQueries('conn-b', ['SELECT b1']), +]); +``` + +This propagates child exceptions and owns the wait group. Do not mechanically replace tests whose subject is channel behavior. + +For Redis integration tests that explicitly instantiate a channel-based `Subscriber`, wrap the subscriber lifetime in `try/finally` and close it there. The callback subscription API already owns cleanup through `RedisProxy::handleSubscribe()` and `CommandInvoker`'s worker-exit watcher; do not add a second public cancellation API. + +### 15. Fix empty-window frequency calculation + +```php +public function frequency(): float +{ + $this->flush(); + + $sampleCount = count($this->hits); + + if ($sampleCount === 0) { + return 0.0; + } + + return array_sum($this->hits) / $sampleCount; +} +``` + +This preserves the existing average-per-populated-second calculation, including zero-filled seconds created by `flush()`. It only defines the previously undefined empty sample immediately after construction. Do not seed a fake hit or change low-frequency thresholds. + +### 16. Verify Testbench process identity before killing a PID + +#### Problem + +`Bootstrapper::isOrphanedServeProcess()` can act on a reused PID because PPID 1 and a PID-file match do not prove the process is the Testbench server that created the runtime directory. The current owner-token implementation closes that gap only for `remote('serve')`; a direct `vendor/bin/testbench serve` receives no injected token, writes no marker, and can never be positively identified for later cleanup. + +#### Decision + +Identify the process itself rather than one launch path. When a runtime copy is created, write a private marker containing the current PID and its OS process-start identity. On Linux use `/proc//stat` field 22; parse after the command's closing parenthesis so spaces or parentheses in the command name cannot shift fields. On macOS use the trimmed `ps -p -o lstart=` value. The marker needs no secret: it distinguishes process incarnations after PID reuse and is not an authentication boundary against the same local user. + +macOS `lstart` has one-second resolution, unlike Linux's clock-tick start identity. Record that bound in the reader's source comment. Do not add another token, elapsed-time reconstruction, or other hardening: matching an orphaned PID, same-second PID reuse, and the same validated serve command is not a realistic remaining collision. + +Require all of: + +1. the PID file parses to the queried positive PID; +2. the process is alive and its parent is PID 1; +3. its command line identifies a Hypervel/Testbench `serve` command; +4. the PID and process-start identity exactly match the marker written when the runtime directory was created. + +```php +$startIdentity = static::processStartIdentity(getmypid()); + +if ($startIdentity !== null) { + $filesystem->replace( + join_paths($runtimePath, '.testbench-process'), + json_encode([ + 'pid' => getmypid(), + 'started_at' => $startIdentity, + ], JSON_THROW_ON_ERROR), + 0600, + ); +} +``` + +Read and validate the marker as a typed two-field record. Compare the stored start identity to a fresh reading for the candidate PID. On Linux, read `/proc//cmdline`; on macOS, use `ps -p -o command=`. If identity cannot be positively proven, do not signal the live process or delete its runtime directory. Dead-PID cleanup remains unchanged because there is no live process to misidentify. + +Extract small protected readers only where necessary to test OS data independently: + +```php +protected static function processCommand(int $pid): ?string; + +protected static function processStartIdentity(int $pid): ?string; +``` + +Delete `TESTBENCH_RUNTIME_OWNER`, its `RemoteCommand` injection, environment parser, token tests, and token commentary. Keep `TESTBENCH_BASE_PATH` behavior unchanged: `serve` still creates its own runtime copy rather than reusing its parent's. Do not add a general process-inspection service for one bootstrap cleanup boundary. + +### 17. Remove stale implementation guidance and document test ownership + +Update or remove all commentary made false by this design: + +- detached Horizon persistence and retry guidance; +- the database resolver's abandoned-wrapper rationale; +- permanent-lock comments in Cache and Reverb; +- worker-global compiled-route cache and reset guidance; +- queue monitor callback seams and real-signal test setup; +- `AGENTS.md`'s inaccurate Mockery rule; +- `docs/ai/differences-vs-laravel.md` and any other current guidance that says Mockery closes only through global cleanup; +- coroutine documentation that says creation returns `false` or `-1`; +- console mutex/depth/nested-execution comments and tests; +- Testbench owner-token environment and process-environment parsing guidance; +- Watcher comments that describe timer-registration return as terminal driver completion. + +Add a concise section to `src/boost/docs/testing.md` explaining that tests which create child coroutines, subscribers, processes, servers, or other asynchronous resources must join or close them in `finally`, and should use framework ownership primitives such as `parallel()` rather than unbounded hand-built channel joins when the channel is not the subject. + +Update `src/boost/docs/coroutines.md` to document that `go()`, `co()`, `Coroutine::create()`, and `Coroutine::fork()` return a positive coroutine ID on success and throw `CoroutineCreateException` when the native runtime cannot create one. + +Do not turn public documentation into an incident report. Document the resulting contracts and recommended usage only. + +Do not add a Horizon README divergence note for the internal persistence and termination fixes. The repository's three-place divergence rule applies when a Laravel feature or method is intentionally omitted, not when Hypervel preserves the public behavior with safer Swoole-aware internals. + +### 18. Keep cache subprocess paths and test-owned route sources deterministic + +`RouteCacheCommand` and `ConfigCacheCommand` spawn fresh-application subprocesses after clearing a resolved cache path in the parent. PHP-array-only environment overrides are not inherited by Symfony Process, so each command must explicitly pass its own resolved cache path: + +```php +'APP_ROUTES_CACHE' => $this->hypervel->getCachedRoutesPath(), +'APP_CONFIG_CACHE' => $this->hypervel->getCachedConfigPath(), +``` + +Do not switch Testbench's parallel configuration to `putenv()`: that would reintroduce process-global mutation. Event and view cache commands run in-process and need no subprocess propagation. + +Add a test-only `CleanupActions` helper that executes every supplied cleanup callback, preserves the first throwable, and rethrows it after all actions run. It contains no file, application, or PHPUnit knowledge. Use it in the six tests that own route-producing Testbench state: + +- API and Broadcasting install command tests; +- Route cache command tests; +- Horizon and Telescope install command tests; +- provider generator tests. + +Each owner keeps its ordered resource list locally, restores bootstrap/provider files through checked atomic `Filesystem::replace()`, deletes every owned file through checked `Filesystem::delete()`, and includes `parent::tearDown()` as the final independent action. A focused helper test proves later cleanup continues and the first failure remains primary. + +`RouteCacheCommandTest` also retains an assertion-only worker-state guard: runtime `bootstrap/app.php` and `bootstrap/providers.php` must match the pristine Testbench skeleton, and `routes/` must contain no PHP source files before each test. The guard reports contamination but never repairs it. + +## Implementation sequence + +The order is chosen so lower-level failure contracts exist before their consumers are hardened. + +1. Add the Engine coroutine-creation exception and exact `int` return contract; define the native HTTP overload response. +2. Update high-level coroutine helpers, transactionally balance all pre-spawn bookkeeping including Reverb pub/sub startup, and make connection/object-pool wake failures non-throwing after committed state with one final checkout-state pass. +3. Stabilize `Coordinator\Timer` identity and creation rollback. +4. Harden `RunTestsInCoroutine` cleanup and Mockery ownership/fallback reset. +5. Add pool/connection `discard()` and repair test database resolver lifecycle. +6. Make compiled route caching instance-owned and isolate programmatic console verbosity through the dedicated global-free IO path, retaining the existing command-cloning boundary for concurrent and nested calls. +7. Refactor queue-worker timer ownership, test fake yielding, signal seams, and kill behavior. +8. Make Horizon persistence synchronous, termination bounded, and child control signals safe during process bootstrap. +9. Bound Cache/Reverb striped locks and move Reverb logging outside locks. +10. Standardize every Watcher driver as a blocking, explicitly stoppable lifecycle; then make Watcher/RestartStrategy ownership terminal, observable, and exception-safe. +11. Propagate resolved route/config cache paths into subprocesses and make all six test owners restore their Testbench files exhaustively through the shared cleanup helper. +12. Harden the cache/Reverb fork harnesses, Engine socket tests, renderer join, Redis subscriber tests, frequency calculation, Fswatch stream framing, and Testbench process-incarnation identity. +13. Remove stale code/comments/imports, update documentation and `AGENTS.md`, and run the full verification matrix. + +Each step must include its focused tests before moving to the next. Do not leave temporary adapters or compatibility wrappers between steps. + +## Testing plan + +### Coroutine creation and bookkeeping + +Use a process-isolated test or a purpose-built subprocess so changing Swoole's process-global `max_coroutine` cannot pollute the test worker. + +- exhaust the native coroutine limit and assert Engine throws `CoroutineCreateException` with the native error; +- assert `Coroutine::create()`, `fork()`, `go()`, and `co()` return positive `int` IDs on success; +- assert `Concurrent` restores its channel length after spawn failure; +- assert `WaitConcurrent` balances its wait group and does not hang; +- assert `Parallel` records the failure under the correct key and produces `ParallelExecutionException` normally; +- assert `CoroutineDriver` rethrows the first failure in input order without waiting forever; +- assert `Timer` removes a just-registered closure when spawn fails; +- assert database and Redis health checks return false specifically for creation failure; +- assert `Waiter` surfaces creation failure immediately, not as a timeout; +- assert SafeSocket resets its loop flag when creation fails, treats a real native `sendAll() === false` as terminal in both caller modes, closes its channel/socket, and rejects later sends without a phantom consumer spawn; +- assert SafeSocket returns the valid payload `"0"` from both receive methods; +- assert CommandInvoker interrupts a partially constructed subscription and removes its shutdown timer; +- make CommandInvoker construction and connection cleanup both fail; assert the construction failure remains primary and every channel still closes; +- assert Task and Spinner restore cursor/render state when animation spawn fails; +- assert SignalRegistry removes only registrations from the failed call, normal unregister terminally removes the native waiter, SignalManager cancels a partially created watcher set, and intentional cancellation is never reported to the exception handler; +- assert worker-exit coordination no longer consumes a coroutine slot; +- make an `OnWorkerExit` listener throw and assert the worker-exit coordinator is still resumed while the listener failure propagates; +- exhaust creation from the Engine HTTP native request callback and assert it logs the failure, returns HTTP 503, and does not invoke the application handler; +- for both connection pool and object pool, release and discard from outside a coroutine while a waiter exists and helper creation is unavailable; assert ownership remains committed, the releasing caller does not receive a false failure, the waiter performs exactly one final immediate state pass, and neither implementation enters a second wait or reports false exhaustion; +- exhaust creation after Reverb completes the Redis subscription handshake; assert the exact subscriber is closed, provider state is not left connected, and reconnect begins without leaking its receive coroutine or shutdown timer; +- make Reverb subscriber creation or subscription fail repeatedly with faked sleeps; assert the retry counter reaches its existing limit instead of resetting on every attempt; +- disconnect while the Reverb subscription handshake yields; assert the locally owned subscriber is closed without being committed or spawning a consumer; +- queue a permanently unencodable Reverb payload followed by publishable payloads, then make the first Redis publish fail transiently; assert the poison payload is logged and dropped while the failed payload and ordered tail remain for the next successful connection; + +Keep one isolated test per distinct bookkeeping or boundary invariant. Consolidate cases that only repeat the same exact public alias, but do not remove separate rollback coverage merely because every case is triggered through the same native limit. Measure the isolated group before and after consolidation. Avoid a mutable global coroutine-factory seam in production solely for these tests. + +### Timer and coroutine test lifecycle + +- capture a coordinator, register a timer, resume and clear the manager before the child first waits, and assert the timer observes the captured closed coordinator; +- assert `after(0.0, ...)` still executes immediately and reports the captured coordinator's current closing state without yielding; +- clear a tick from inside its callback and assert no extra interval is awaited; +- make `tearDownInCoroutine` throw while a child waits for `WORKER_EXIT`; assert the test returns, the original exception is thrown, and the child is resumed; +- use multiple trait teardown hooks where the first throws; assert later hooks, context cleanup, native timer cleanup, coordinator resume, and coordinator clear all occur; +- make the test body and cleanup both throw; assert the test body exception remains primary. + +### Mockery lifecycle + +- create an unmet expectation in each supported base test case and assert PHPUnit attributes the failure to that test rather than emitting only an extension warning; +- verify Mockery expectation counts still contribute to assertion counts; +- invoke subscriber fallback with an unmet expectation and a sentinel framework static; assert the sentinel is reset before the Mockery failure is rethrown; +- make fallback database-wrapper cleanup throw; assert the failure remains primary and the framework-static reset sequence still runs; +- verify cleanup callbacks still run and their first failure is preserved; +- make `HandleExceptions::flushState()` throw in the components base case and assert Mockery verification still runs. + +### Queue worker + +- run a daemon with a deliberately yielding fake job and concurrency one using `InsomniacWorker`; assert it completes and the fake records sleeps; +- assert the fake does not install process signal handlers; +- inject a fake Timer and verify its exact monitor ID is cleared on every daemon return and throw path; +- make timeout scanning throw once and assert `monitorLocked` is reset so the next tick can run; +- assert `kill()` dispatches the stopping event and reaches the kill seam without waiting for unrelated active jobs; +- with concurrency greater than one, assert a hard timeout terminates immediately rather than waiting for a healthy sibling job; +- retain the scalar `$sleptFor` assertions while proving `parent::sleep(0)` yields; +- retain tests proving timeout IDs suppress `JobProcessed`. + +### Horizon + +- assert supervisor and master state is persisted before the corresponding looped event is observed; +- assert persistence exceptions are reported by the same loop invocation; +- remove retry loops needed only for detached persistence; +- use controlled time to assert a worker receives graceful termination, remains in the terminating set, then is hard-stopped after `SupervisorOptions::$timeout`; +- assert supervisor termination scales every pool to zero and completes after pruning; +- assert master termination hard-stops any supervisor process still alive at its deadline; +- exercise teardown failure paths and verify all active and terminating child processes are stopped and reaped. +- delay child handler installation, signal it during that window, then assert the inherited blocked signal is delivered after handler installation/unblock and the child remains alive; +- assert `WorkerProcess::start()` restores the parent's exact prior signal mask after both successful and throwing starts; +- assert queue-worker parent block and child handler sets are equal, and supervisor-process parent block and Horizon child handler sets are equal; +- pause a newly started worker without an arbitrary readiness sleep and assert SIGUSR2 does not terminate it before handler installation. + +### Pool discard and test database resolver + +- assert releasing a borrowed connection returns it idle, while discarding destroys it, decrements current connections, and restores capacity; +- reject foreign, idle, already released, and already discarded connections with the existing ownership error style; +- cover `Connection` and `KeepaliveConnection` delegation; +- repeatedly resolve and flush a testing database connection against a max-one pool and prove capacity is never exhausted; +- assert `resetCachedConnections()` retains the wrapper but resets bare per-test state; +- assert terminal flush, named flush, construction failure, and container change discard the wrapper; +- retain write-routing and event-dispatcher rebinding behavior; +- assert resolver flush occurs before pool factory closure during test teardown. + +### Locks + +- verify the uncontended Cache and Reverb paths acquire and release normally; +- hold a stripe briefly in another process/coroutine and assert the contender backs off and later succeeds; +- pre-lock a stripe in a test subclass with a millisecond timeout and assert a descriptive `RuntimeException` rather than a hang; +- make all-stripe acquisition fail on stripe N and assert every earlier acquired stripe is released; +- fill the Reverb lock table and assert reporting occurs only after the stripe is released; +- exercise all three `setLockRow()` callers so none reintroduces logging under lock. + +Use subprocess isolation for any regression whose old implementation would spin forever; a PHPUnit timeout alone is not sufficient if the worker cannot process its signal. + +### Process, socket, Redis, and routing tests + +- cache harness: child exits before ready, exits before payload, writes an error payload, and stalls; each case fails within its deadline and leaves no live or unreaped owned PID; +- cache and Reverb harnesses retry `EINTR`, accept only the owned PID or `ECHILD` as reaped, and never use an unbounded final wait; +- Engine sockets: parallel tests bind independent ephemeral ports; readiness signaling never opens a synthetic connection; a client assertion failure still closes the client and server; child server exceptions surface in the parent; +- renderer context test: a child exception propagates through `parallel()` and no channel pop can block forever; +- Redis channel subscriber: an assertion failure still closes the subscriber and raw publish connection; +- compiled routes: create two collections with the same route name and different domain, port, URI, and action; assert each returns its own stable object without calling a global reset; +- router collection replacement keeps the remaining static routing caches correct. + +### Watcher + +- a driver exception is rethrown by `Watcher::run()` rather than leaving its poll loop alive; +- a clean driver return ends the command cleanly; +- a driver spawn failure after strategy start still stops both driver and strategy; +- a restart exception still stops the driver and managed server; +- a driver whose `stop()` does not unblock within one second produces a bounded diagnostic; +- a driver blocked pushing beyond the full change-channel capacity is unblocked by cleanup, reaches completion before the bounded join returns, and preserves the parent's primary failure; +- if operation and cleanup both fail, the operation failure remains primary and later cleanup still runs; +- a synchronous final driver batch is drained and restarted exactly once; a previously debounced batch followed by a terminal tail restarts twice without duplication; +- `ServerRestartStrategy` reports rather than fatals after `proc_open()` failure or `proc_close()` failure, returns its launch token after each failure and normal server exit, and allows the next start/restart attempt; +- invalid PID-file contents (empty, whitespace, zero, negative, non-numeric, or numeric-prefix corruption) perform no POSIX call; a dead positive PID is probed but not terminated; a live positive PID dispatches an integer event and receives SIGTERM; +- Fswatch batches are delivered in order without detached children, and a path-matching failure propagates through the owned driver coroutine; +- split one Fswatch path across two reads and place multiple complete paths around the split; assert every path is emitted once and in order; +- immutable WatchPath glob patterns are compiled once and retain all existing matching behavior; +- both Server and Horizon restart strategies expose idempotent public `stop()` implementations; +- exercise `FindDriver`, `FindNewerDriver`, and `ScanFileDriver` through their real interval loop; assert `watch()` remains active until `stop()`, stop wakes it immediately, and a scan failure reaches Watcher rather than an unrelated Timer logger; + +### Console and Testbench + +- with `SHELL_VERBOSITY` absent from all three process-global stores, compare regular `run()` and programmatic `call()` behavior for command execution, arguments/options, output, exit codes, console-event ordering, exception propagation, ANSI decoration, interaction, quiet/explicit verbosity, and help handling; +- set different values in `getenv()`, `$_ENV`, and `$_SERVER`; assert a normal `Application::call()` is not made verbose and none of the three process-global stores changes; +- repeat when the called command throws and with two concurrent coroutine calls using different explicit verbosity; +- assert `output()` remains isolated per coroutine; +- retain the existing overlapping and nested call regressions proving distinct command clones, isolated output, and the normal console event lifecycle; add no mutex, execution-peak, depth-marker, or suppressed-event assertions; +- assert explicit `-v`, `-vv`, and `-vvv` still take effect; +- run the affected schedule and route PendingCommand tests under an inherited `SHELL_VERBOSITY`; +- set custom resolved route and config cache paths while placing stale data at the defaults; assert each subprocess loads and writes only the parent's resolved path; +- force an early `CleanupActions` callback to fail; assert every later callback runs and the original throwable remains primary; +- exercise the six Testbench file owners and assert their checked restoration/deletion leaves pristine bootstrap/provider files and no owned route or generated-provider source; +- assert the RouteCache worker-state guard diagnoses bootstrap, provider, and route-source contamination without repairing it; +- Testbench PID cleanup: matching PID/start identity/serve command is recognized; command mismatch, start-identity mismatch, malformed marker, and unreadable identity are refused; a dead PID file is cleaned without signaling a real process; +- exercise both direct `vendor/bin/testbench serve` and `remote('serve')` runtime creation and assert each writes the same process-incarnation marker format for its own process while `serve` still does not receive `TESTBENCH_BASE_PATH`. + +### Frequency + +- immediately after construction, including within the same second as `beginTime`, it returns `0.0`; +- non-empty window calculations and low-frequency behavior remain unchanged. + +### Full gates + +Run focused tests after modifying each source/test pair. At completion run: + +```bash +composer fix +``` + +This repository's `fix` gate includes formatting/linting, PHPStan, parallel tests, Testbench tests, and dogfood tests. Also run the focused Redis and Horizon integration groups with their required services so a skipped service-dependent path cannot masquerade as verification. + +Finally, run a full diff review and trace every new/changed contract through all implementers, callers, fakes, providers, docs, and reset registries. + +## Fresh-review checklist + +Before implementation begins, and again before declaring it complete, verify: + +- [ ] every native coroutine creation path has one failure contract; +- [ ] every pre-spawn counter/token/registration is rolled back exactly once; +- [ ] native HTTP request overload completes with a controlled 503 instead of escaping through Swoole; +- [ ] pool/object-pool wake failure cannot turn a committed release or discard into a reported failure, and checkout performs one final state pass; +- [ ] Timer captures coordinator identity before scheduling; +- [ ] cleanup preserves the first failure but never skips later independent cleanup; +- [ ] Mockery verification belongs to test lifecycle and subscriber fallback cannot skip resets; +- [ ] queue tests do not install real signals or detach unowned timers; +- [ ] production queue sleep retains its existing efficient hooked yield; no redundant yield was added; +- [ ] Horizon writes cannot overlap across loop iterations and all termination waits are bounded; +- [ ] Horizon child control signals remain blocked until the child installs its exact matching handler set, and every parent spawn restores its prior signal mask; +- [ ] lock acquisition adds no work to the uncontended path beyond the existing compare-and-set; +- [ ] partial all-stripe acquisition releases every stripe acquired before failure; +- [ ] Reverb performs no logging or arbitrary callback while holding a stripe; +- [ ] compiled route objects cannot cross collection identity; +- [ ] programmatic console calls never mutate `SHELL_VERBOSITY`, retain context-local output, honor explicit verbosity, and rely on per-execution command clones without mutex, mode, or nested-path machinery; +- [ ] route/config cache subprocesses receive the exact paths resolved by their parent Application; +- [ ] all six Testbench file owners use checked exhaustive cleanup, and the assertion-only route guard never repairs state; +- [ ] pool discard updates ownership and capacity before/while closing the connection; +- [ ] the test resolver never holds a bare connection without retaining the owning wrapper; +- [ ] process cleanup kills and reaps only PIDs it started; +- [ ] socket and subscriber cleanup is unconditional; +- [ ] every Watcher driver blocks for one owned lifecycle, is immediately unblocked by idempotent `stop()`, and propagates terminal failures to Watcher; +- [ ] Fswatch preserves newline framing across partial reads; +- [ ] Testbench process-incarnation markers cover direct and remote serve paths and prevent signaling a process whose identity cannot be proved; +- [ ] no obsolete `flushCache()`, monitor callable, detached-persistence retry, abandoned-wrapper comment, owner-token code, console mutex/depth path, or old Mockery trait remains; +- [ ] public docs describe final contracts without incident history; +- [ ] focused regressions fail or hang against the old implementation for the intended reason and pass with the fix; +- [ ] `composer fix` and service-backed focused integration tests pass. From 014e2ea8fa5b0220452ae4512f7e5c217fb5ece0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:10:45 +0000 Subject: [PATCH 02/40] Make coroutine creation failures explicit Replace the ambiguous false and -1 creation outcomes with a typed CoroutineCreateException at the Engine boundary. Make the high-level create, fork, go, and co APIs return positive integer IDs on success and propagate creation failure consistently. Document the new contract and add process-isolated regressions that exhaust Swoole's coroutine capacity without polluting the surrounding test worker. --- src/boost/docs/coroutines.md | 4 +- src/coroutine/src/Coroutine.php | 8 +- src/coroutine/src/functions.php | 12 +- src/engine/src/Coroutine.php | 12 +- .../Exceptions/CoroutineCreateException.php | 21 ++ .../Coroutine/CoroutineCreateFailureTest.php | 221 ++++++++++++++++++ tests/Coroutine/CoroutineTest.php | 6 + tests/Coroutine/FunctionTest.php | 9 +- tests/Engine/CoroutineCreateFailureTest.php | 35 +++ 9 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 src/engine/src/Exceptions/CoroutineCreateException.php create mode 100644 tests/Coroutine/CoroutineCreateFailureTest.php create mode 100644 tests/Engine/CoroutineCreateFailureTest.php diff --git a/src/boost/docs/coroutines.md b/src/boost/docs/coroutines.md index 51f56bab5..c5a634606 100644 --- a/src/boost/docs/coroutines.md +++ b/src/boost/docs/coroutines.md @@ -146,7 +146,7 @@ go(function () { echo 'Hello world!' . PHP_EOL; ``` -The `go` function returns the created coroutine ID, or `false` if the coroutine could not be created. The `co` function is an alias of `go`: +The `go` function returns the positive ID of the created coroutine. If the native runtime cannot create the coroutine, Hypervel throws a `CoroutineCreateException`. The `co` function is an alias of `go`: ```php use function Hypervel\Coroutine\co; @@ -156,7 +156,7 @@ $id = co(function () { }); ``` -You may also create a coroutine directly through the `Coroutine` class. This method returns the coroutine ID, or `-1` if creation failed: +You may also create a coroutine directly through the `Coroutine` class. The `create` and `fork` methods follow the same contract: they return a positive coroutine ID on success and throw `CoroutineCreateException` when creation fails: ```php use Hypervel\Coroutine\Coroutine; diff --git a/src/coroutine/src/Coroutine.php b/src/coroutine/src/Coroutine.php index 8ae5f941a..e2ae603e9 100644 --- a/src/coroutine/src/Coroutine.php +++ b/src/coroutine/src/Coroutine.php @@ -87,8 +87,6 @@ public static function pid(?int $coroutineId = null): int /** * Create a new coroutine. - * - * @return int The coroutine ID, or -1 if creation failed */ public static function create(callable $callable): int { @@ -108,11 +106,7 @@ public static function create(callable $callable): int } }); - try { - return $coroutine->getId(); - } catch (Throwable) { - return -1; - } + return $coroutine->getId(); } /** diff --git a/src/coroutine/src/functions.php b/src/coroutine/src/functions.php index 233d1a119..652a130a7 100644 --- a/src/coroutine/src/functions.php +++ b/src/coroutine/src/functions.php @@ -43,13 +43,11 @@ function wait(Closure $closure, ?float $timeout = null) * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. */ -function co(callable $callable, bool|array $copyContext = false): bool|int +function co(callable $callable, bool|array $copyContext = false): int { - $id = $copyContext === false + return $copyContext === false ? Coroutine::create($callable) : Coroutine::fork($callable, is_array($copyContext) ? $copyContext : []); - - return $id > 0 ? $id : false; } // defer() wrapper was removed intentionally. Use Coroutine::defer() directly for @@ -62,13 +60,11 @@ function co(callable $callable, bool|array $copyContext = false): bool|int * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. */ -function go(callable $callable, bool|array $copyContext = false): bool|int +function go(callable $callable, bool|array $copyContext = false): int { - $id = $copyContext === false + return $copyContext === false ? Coroutine::create($callable) : Coroutine::fork($callable, is_array($copyContext) ? $copyContext : []); - - return $id > 0 ? $id : false; } /** diff --git a/src/engine/src/Coroutine.php b/src/engine/src/Coroutine.php index 2f045933b..20cccef9b 100644 --- a/src/engine/src/Coroutine.php +++ b/src/engine/src/Coroutine.php @@ -6,6 +6,7 @@ use ArrayObject; use Hypervel\Contracts\Engine\CoroutineInterface; +use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\Engine\Exceptions\CoroutineDestroyedException; use Hypervel\Engine\Exceptions\RunningInNonCoroutineException; use Hypervel\Engine\Exceptions\RuntimeException; @@ -43,7 +44,16 @@ public static function create(callable $callable, ...$data): static */ public function execute(...$data): static { - $this->id = SwooleCo::create($this->callable, ...$data); + // Swoole warns when its coroutine limit is exceeded; expose that native + // failure through the typed framework exception instead of two signals. + $id = @SwooleCo::create($this->callable, ...$data); + + if ($id === false) { + throw CoroutineCreateException::fromLastError(); + } + + $this->id = $id; + return $this; } diff --git a/src/engine/src/Exceptions/CoroutineCreateException.php b/src/engine/src/Exceptions/CoroutineCreateException.php new file mode 100644 index 000000000..3823194ea --- /dev/null +++ b/src/engine/src/Exceptions/CoroutineCreateException.php @@ -0,0 +1,21 @@ +atCoroutineLimit(function (): void { + foreach ([ + static fn (): int => Coroutine::create(static fn (): null => null), + static fn (): int => Coroutine::fork(static fn (): null => null), + static fn (): int => go(static fn (): null => null), + static fn (): int => co(static fn (): null => null), + ] as $create) { + try { + $create(); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->addToAssertionCount(1); + } + } + }); + } + + #[RunInSeparateProcess] + public function testConcurrentRestoresCapacityWhenCreationFails(): void + { + $this->atCoroutineLimit(function (): void { + $concurrent = new Concurrent(1); + + try { + $concurrent->create(static fn (): null => null); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertSame(0, $concurrent->length()); + } + + try { + $concurrent->fork(static fn (): null => null); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertSame(0, $concurrent->length()); + } + }); + } + + #[RunInSeparateProcess] + public function testWaitConcurrentBalancesItsWaitGroupWhenCreationFails(): void + { + $this->atCoroutineLimit(function (): void { + $concurrent = new WaitConcurrent(1); + + try { + $concurrent->create(static fn (): null => null); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertSame(0, $concurrent->length()); + $this->assertTrue($concurrent->wait(0.001)); + } + }); + } + + #[RunInSeparateProcess] + public function testParallelRecordsCreationFailuresWithoutWaitingForever(): void + { + $this->atCoroutineLimit(function (): void { + $parallel = new Parallel(concurrent: 1); + $parallel->add(static fn (): string => 'never', 'first'); + $parallel->add(static fn (): string => 'never', 'second'); + + try { + $parallel->wait(); + $this->fail('Expected parallel execution to fail.'); + } catch (ParallelExecutionException $exception) { + $this->assertSame(['first', 'second'], array_keys($exception->getThrowables())); + $this->assertContainsOnlyInstancesOf( + CoroutineCreateException::class, + $exception->getThrowables(), + ); + } + }); + } + + #[RunInSeparateProcess] + public function testCoroutineDriverBalancesCreationFailuresInInputOrder(): void + { + $this->atCoroutineLimit(function (): void { + try { + (new CoroutineDriver)->run([ + 'first' => static fn (): string => 'never', + 'second' => static fn (): string => 'never', + ]); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->addToAssertionCount(1); + } + }); + } + + #[RunInSeparateProcess] + public function testWaiterSurfacesCreationFailureWithoutTimingOut(): void + { + $this->atCoroutineLimit(function (): void { + try { + (new Waiter(10))->wait(static fn (): string => 'never'); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->addToAssertionCount(1); + } + }); + } + + #[RunInSeparateProcess] + public function testTimerRollsBackRegistrationsWhenCreationFails(): void + { + $this->atCoroutineLimit(function (): void { + foreach (['after', 'tick'] as $method) { + $timer = new Timer; + + try { + $timer->{$method}(1.0, static fn (): null => null); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $closures = (new ReflectionProperty($timer, 'closures'))->getValue($timer); + $this->assertSame([], $closures); + $this->assertSame(['num' => 0, 'round' => 0], Timer::stats()); + } + } + }); + } + + #[RunInSeparateProcess] + public function testSafeSocketResetsItsConsumerStateWhenCreationFails(): void + { + $this->atCoroutineLimit(function (): void { + $socket = new SafeSocket( + new SwooleSocket(AF_INET, SOCK_STREAM, 0), + ); + + try { + $socket->sendAll('payload'); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertFalse( + (new ReflectionProperty($socket, 'loop'))->getValue($socket), + ); + $this->assertFalse( + (new ReflectionProperty($socket, 'channel')) + ->getValue($socket) + ->isClosing(), + ); + } finally { + $socket->close(); + } + }); + } + + #[RunInSeparateProcess] + public function testConnectionHealthChecksReturnFalseWhenProbeCreationFails(): void + { + $this->atCoroutineLimit(function (): void { + $database = (new ReflectionClass(PooledConnection::class)) + ->newInstanceWithoutConstructor(); + (new ReflectionProperty(PooledConnection::class, 'connection')) + ->setValue( + $database, + new DatabaseConnection(new PDO('sqlite::memory:')), + ); + + $redis = (new ReflectionClass(PhpRedisConnection::class)) + ->newInstanceWithoutConstructor(); + (new ReflectionProperty(RedisConnection::class, 'connection')) + ->setValue($redis, new NativeRedis); + + $this->assertFalse($database->ping(1.0)); + $this->assertFalse($redis->heartbeatCheck(1.0)); + }); + } + + /** + * Run a callback as the only coroutine allowed by the native runtime. + */ + private function atCoroutineLimit(Closure $callback): void + { + SwooleCoroutine::set(['max_coroutine' => 1]); + SwooleCoroutine\run($callback); + } +} diff --git a/tests/Coroutine/CoroutineTest.php b/tests/Coroutine/CoroutineTest.php index 7e020e220..ba2438519 100644 --- a/tests/Coroutine/CoroutineTest.php +++ b/tests/Coroutine/CoroutineTest.php @@ -18,6 +18,12 @@ class CoroutineTest extends TestCase { + public function testCreateAndForkReturnPositiveCoroutineIds(): void + { + $this->assertGreaterThan(0, Coroutine::create(static fn (): null => null)); + $this->assertGreaterThan(0, Coroutine::fork(static fn (): null => null)); + } + public function testCoroutineParentId() { $pid = Coroutine::id(); diff --git a/tests/Coroutine/FunctionTest.php b/tests/Coroutine/FunctionTest.php index 9e56c3ef7..4db608cf6 100644 --- a/tests/Coroutine/FunctionTest.php +++ b/tests/Coroutine/FunctionTest.php @@ -15,17 +15,22 @@ class FunctionTest extends TestCase { - public function testReturnOfGo() + public function testReturnOfGo(): void { $uniqid = uniqid(); $id = go(function () use (&$uniqid) { $uniqid = 'Hypervel'; }); - $this->assertTrue(is_int($id)); + $this->assertGreaterThan(0, $id); $this->assertSame('Hypervel', $uniqid); } + public function testCoReturnsAPositiveCoroutineId(): void + { + $this->assertGreaterThan(0, co(static fn (): null => null)); + } + public function testDefer() { $channel = new Channel(10); diff --git a/tests/Engine/CoroutineCreateFailureTest.php b/tests/Engine/CoroutineCreateFailureTest.php new file mode 100644 index 000000000..c8bd05e4d --- /dev/null +++ b/tests/Engine/CoroutineCreateFailureTest.php @@ -0,0 +1,35 @@ + 1]); + + SwooleCoroutine\run(function (): void { + try { + Coroutine::create(static fn (): null => null); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException $exception) { + $this->assertSame(SWOOLE_ERROR_PHP_FATAL_ERROR, $exception->getCode()); + $this->assertStringContainsString( + swoole_strerror(SWOOLE_ERROR_PHP_FATAL_ERROR), + $exception->getMessage(), + ); + } + }); + } +} From 602b9c2403ad70499daa35aab627d116b93ec4a1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:10:51 +0000 Subject: [PATCH 03/40] Balance concurrent work when coroutine creation fails Roll back capacity tokens and wait-group counts when a child coroutine cannot be created. Record keyed failures in Parallel and the concurrency driver so their normal aggregation and input-order exception behavior remains intact instead of hanging on bookkeeping for work that never started. --- src/concurrency/src/CoroutineDriver.php | 23 +++++++----- src/coroutine/src/Concurrent.php | 48 +++++++++++++++---------- src/coroutine/src/Parallel.php | 15 +++++--- src/coroutine/src/WaitConcurrent.php | 12 +++++-- 4 files changed, 65 insertions(+), 33 deletions(-) diff --git a/src/concurrency/src/CoroutineDriver.php b/src/concurrency/src/CoroutineDriver.php index ee702a9d2..76f327f16 100644 --- a/src/concurrency/src/CoroutineDriver.php +++ b/src/concurrency/src/CoroutineDriver.php @@ -37,15 +37,20 @@ public function run(Closure|array $tasks): array $waitGroup = new WaitGroup(count($tasks)); foreach ($tasks as $key => $task) { - Coroutine::fork(function () use ($task, $key, &$results, &$exceptions, $waitGroup) { - try { - $results[$key] = $task(); - } catch (Throwable $e) { - $exceptions[$key] = $e; - } finally { - $waitGroup->done(); - } - }); + try { + Coroutine::fork(function () use ($task, $key, &$results, &$exceptions, $waitGroup): void { + try { + $results[$key] = $task(); + } catch (Throwable $exception) { + $exceptions[$key] = $exception; + } finally { + $waitGroup->done(); + } + }); + } catch (Throwable $exception) { + $exceptions[$key] = $exception; + $waitGroup->done(); + } } $waitGroup->wait(); diff --git a/src/coroutine/src/Concurrent.php b/src/coroutine/src/Concurrent.php index e986baf97..cfa59896e 100644 --- a/src/coroutine/src/Concurrent.php +++ b/src/coroutine/src/Concurrent.php @@ -86,15 +86,21 @@ public function create(callable $callable): void { $this->channel->push(true); - Coroutine::create(function () use ($callable) { - try { - $callable(); - } catch (Throwable $exception) { - $this->reportException($exception); - } finally { - $this->channel->pop(); - } - }); + try { + Coroutine::create(function () use ($callable): void { + try { + $callable(); + } catch (Throwable $exception) { + $this->reportException($exception); + } finally { + $this->channel->pop(); + } + }); + } catch (Throwable $exception) { + $this->channel->pop(); + + throw $exception; + } } /** @@ -106,15 +112,21 @@ public function fork(callable $callable, array $keys = []): void { $this->channel->push(true); - Coroutine::fork(function () use ($callable) { - try { - $callable(); - } catch (Throwable $exception) { - $this->reportException($exception); - } finally { - $this->channel->pop(); - } - }, $keys); + try { + Coroutine::fork(function () use ($callable): void { + try { + $callable(); + } catch (Throwable $exception) { + $this->reportException($exception); + } finally { + $this->channel->pop(); + } + }, $keys); + } catch (Throwable $exception) { + $this->channel->pop(); + + throw $exception; + } } /** diff --git a/src/coroutine/src/Parallel.php b/src/coroutine/src/Parallel.php index edd5cc549..d51de882a 100644 --- a/src/coroutine/src/Parallel.php +++ b/src/coroutine/src/Parallel.php @@ -88,10 +88,17 @@ public function wait(bool $throw = true): array } }; - if ($this->copyContext === false) { - Coroutine::create($childCallable); - } else { - Coroutine::fork($childCallable, is_array($this->copyContext) ? $this->copyContext : []); + try { + if ($this->copyContext === false) { + Coroutine::create($childCallable); + } else { + Coroutine::fork($childCallable, is_array($this->copyContext) ? $this->copyContext : []); + } + } catch (Throwable $throwable) { + $this->throwables[$key] = $throwable; + unset($this->results[$key]); + $this->concurrentChannel?->pop(); + $wg->done(); } } $wg->wait(); diff --git a/src/coroutine/src/WaitConcurrent.php b/src/coroutine/src/WaitConcurrent.php index 991754fca..220335361 100644 --- a/src/coroutine/src/WaitConcurrent.php +++ b/src/coroutine/src/WaitConcurrent.php @@ -4,6 +4,8 @@ namespace Hypervel\Coroutine; +use Throwable; + /** * @method bool isFull() * @method bool isEmpty() @@ -26,7 +28,7 @@ public function create(callable $callable): void { $this->wg->add(); - $callable = function () use ($callable) { + $callable = function () use ($callable): void { try { $callable(); } finally { @@ -34,7 +36,13 @@ public function create(callable $callable): void } }; - parent::create($callable); + try { + parent::create($callable); + } catch (Throwable $exception) { + $this->wg->done(); + + throw $exception; + } } /** From 3f79c255f740949138f6f8dbc4e71f16b7746849 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:11:40 +0000 Subject: [PATCH 04/40] Handle coroutine exhaustion in connection health checks Treat inability to create a bounded database or Redis probe coroutine as an unhealthy pooled connection. Catch only CoroutineCreateException so connection and application failures retain their existing behavior while pool maintenance avoids the obsolete boolean creation contract. --- src/database/src/Pool/PooledConnection.php | 17 +++++++++-------- src/redis/src/RedisConnection.php | 17 +++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index 36e380a5c..418060454 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -13,6 +13,7 @@ use Hypervel\Database\Events\ConnectionEstablished; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; +use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\Pool\Events\ReleaseConnection; use Hypervel\Pool\PoolOption; use PDO; @@ -212,14 +213,14 @@ public function ping(float $timeout): bool $result = new Channel(1); - $started = go(static function () use ($pdos, $result) { - try { - $result->push(self::pingPdos($pdos), 0.0); - } catch (CanceledException) { - } - }); - - if ($started === false) { + try { + $started = go(static function () use ($pdos, $result): void { + try { + $result->push(self::pingPdos($pdos), 0.0); + } catch (CanceledException) { + } + }); + } catch (CoroutineCreateException) { return false; } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index a2a9acae2..836fd2dfd 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; +use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\Pool\Connection as BaseConnection; use Hypervel\Pool\Exceptions\ConnectionException; use Hypervel\Pool\PoolOption; @@ -651,14 +652,14 @@ public function heartbeatCheck(float $timeout): bool $result = new Channel(1); - $started = go(function () use ($result) { - try { - $result->push($this->pingForHeartbeat(), 0.0); - } catch (CanceledException) { - } - }); - - if ($started === false) { + try { + $started = go(function () use ($result): void { + try { + $result->push($this->pingForHeartbeat(), 0.0); + } catch (CanceledException) { + } + }); + } catch (CoroutineCreateException) { return false; } From d43ef837e17bcdb8b2eb0ff6bf1fa597bdafc2f0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:11:53 +0000 Subject: [PATCH 05/40] Harden Engine socket lifecycle Make SafeSocket roll back its send-loop state when coroutine creation fails, treat native send failures as terminal, preserve native error details, and accept the valid payload string zero. Refactor socket regressions to use ephemeral ports, explicit readiness, parent-visible child failures, bounded joins, and unconditional client and server cleanup so parallel tests cannot collide or leak listeners. --- src/engine/src/SafeSocket.php | 79 ++++-- tests/Engine/SocketTest.php | 517 +++++++++++++++++++++++++--------- 2 files changed, 437 insertions(+), 159 deletions(-) diff --git a/src/engine/src/SafeSocket.php b/src/engine/src/SafeSocket.php index be1277aab..a1d4679b2 100644 --- a/src/engine/src/SafeSocket.php +++ b/src/engine/src/SafeSocket.php @@ -81,12 +81,18 @@ public function sendAll(string $data, float $timeout = 0): false|int public function recvAll(int $length = 65536, float $timeout = 0): false|string { $res = $this->socket->recvAll($length, $timeout); - if (! $res) { + if ($res === false || $res === '') { if ($this->socket->errCode === SOCKET_ETIMEDOUT) { - $this->throw && throw new SocketTimeoutException('Recv timeout'); + $this->throw && throw new SocketTimeoutException( + $this->socket->errMsg ?: 'Recv timeout', + $this->socket->errCode, + ); } - $this->throw && throw new SocketClosedException('The socket is closed.'); + $this->throw && throw new SocketClosedException( + $this->socket->errMsg ?: 'The socket is closed.', + $this->socket->errCode, + ); } return $res; @@ -101,12 +107,18 @@ public function recvAll(int $length = 65536, float $timeout = 0): false|string public function recvPacket(float $timeout = 0): false|string { $res = $this->socket->recvPacket($timeout); - if (! $res) { + if ($res === false || $res === '') { if ($this->socket->errCode === SOCKET_ETIMEDOUT) { - $this->throw && throw new SocketTimeoutException('Recv timeout'); + $this->throw && throw new SocketTimeoutException( + $this->socket->errMsg ?: 'Recv timeout', + $this->socket->errCode, + ); } - $this->throw && throw new SocketClosedException('The socket is closed.'); + $this->throw && throw new SocketClosedException( + $this->socket->errMsg ?: 'The socket is closed.', + $this->socket->errCode, + ); } return $res; @@ -136,27 +148,54 @@ public function setLogger(?LoggerInterface $logger): static */ protected function loop(): void { - if ($this->loop) { + if ($this->loop || $this->channel->isClosing()) { return; } $this->loop = true; - Coroutine::create(function () { - try { - while (true) { - $data = $this->channel->pop(-1); - if ($this->channel->isClosing()) { - return; + try { + Coroutine::create(function (): void { + try { + while (true) { + $data = $this->channel->pop(-1); + + if ($this->channel->isClosing()) { + return; + } + + [$data, $timeout] = $data; + + if ($this->socket->sendAll($data, $timeout) === false) { + if ($this->socket->errCode === SOCKET_ETIMEDOUT) { + throw new SocketTimeoutException( + $this->socket->errMsg ?: 'Send timeout', + $this->socket->errCode, + ); + } + + throw new SocketClosedException( + $this->socket->errMsg ?: 'The socket is closed.', + $this->socket->errCode, + ); + } + } + } catch (Throwable $exception) { + if (! $exception instanceof SocketClosedException + && ! $exception instanceof SocketTimeoutException + ) { + $this->logger?->critical((string) $exception); } - [$data, $timeout] = $data; - - $this->socket->sendAll($data, $timeout); + $this->close(); + } finally { + $this->loop = false; } - } catch (Throwable $exception) { - $this->logger?->critical((string) $exception); - } - }); + }); + } catch (Throwable $exception) { + $this->loop = false; + + throw $exception; + } } } diff --git a/tests/Engine/SocketTest.php b/tests/Engine/SocketTest.php index dee02216a..efb6599c2 100644 --- a/tests/Engine/SocketTest.php +++ b/tests/Engine/SocketTest.php @@ -4,20 +4,26 @@ namespace Hypervel\Tests\Engine; +use Hypervel\Contracts\Engine\SocketInterface; use Hypervel\Engine\Channel; use Hypervel\Engine\Exceptions\SocketClosedException; use Hypervel\Engine\Exceptions\SocketConnectException; use Hypervel\Engine\SafeSocket; use Hypervel\Engine\Socket; use Hypervel\Tests\TestCase; +use ReflectionProperty; +use RuntimeException; +use Swoole\Coroutine as SwooleCoroutine; use Swoole\Coroutine\Server; +use Swoole\Coroutine\Socket as SwooleSocket; +use Swoole\Coroutine\WaitGroup; use Throwable; use function Hypervel\Coroutine\go; class SocketTest extends TestCase { - public function testSocketConnectFailed() + public function testSocketConnectFailed(): void { try { (new Socket\SocketFactory)->make(new Socket\SocketOption('127.0.0.1', 33333)); @@ -34,231 +40,464 @@ public function testSocketConnectFailed() } } - public function testSafeSocketSendAndRecvPacket() + public function testSafeSocketSendAndRecvPacket(): void { - $server = new Server('0.0.0.0', 9506); + $server = new Server('127.0.0.1', 0); $p = function (string $data): string { return pack('N', strlen($data)) . $data; }; - go(function () use ($server, $p) { - $server->set([ - 'open_length_check' => true, - 'package_max_length' => 1024 * 1024 * 2, - 'package_length_type' => 'N', - 'package_length_offset' => 0, - 'package_body_offset' => 4, - ]); - $server->handle(function (Server\Connection $connection) use ($p) { + $server->set($this->packetProtocol()); + + $this->withServer( + $server, + function (Server\Connection $connection, array &$serverErrors) use ($p): void { $socket = new SafeSocket($connection->exportSocket(), 65535); + while (true) { try { $body = $socket->recvPacket(); + if (empty($body)) { $socket->close(); break; } - go(function () use ($socket, $body, $p) { - $body = substr($body, 4); - if ($body === 'ping') { - $socket->sendAll($p('pong')); - } else { - $socket->sendAll($p($body)); + + go(function () use ($socket, $body, $p, &$serverErrors): void { + try { + $body = substr($body, 4); + $socket->sendAll($p($body === 'ping' ? 'pong' : $body)); + } catch (Throwable $exception) { + $serverErrors[] = $exception; } }); } catch (Throwable $exception) { $socket->close(); - $this->assertInstanceOf(SocketClosedException::class, $exception); + + if (! $exception instanceof SocketClosedException) { + $serverErrors[] = $exception; + } + break; } } - }); - $server->start(); - }); - - $this->waitForServerToBeReady(9506); - - $socket = (new Socket\SocketFactory)->make(new Socket\SocketOption('127.0.0.1', 9506, protocol: [ - 'open_length_check' => true, - 'package_max_length' => 1024 * 1024 * 2, - 'package_length_type' => 'N', - 'package_length_offset' => 0, - 'package_body_offset' => 4, - ])); - - for ($i = 0; $i < 200; ++$i) { - $res = $socket->sendAll($p(str_repeat('s', 10240)), 1); - } + }, + function (int $port) use ($p): void { + $socket = $this->connect($port); - for ($i = 0; $i < 200; ++$i) { - $socket->recvPacket(1); - } + try { + for ($i = 0; $i < 200; ++$i) { + $socket->sendAll($p(str_repeat('s', 10240)), 1); + } - $server->shutdown(); + for ($i = 0; $i < 200; ++$i) { + $socket->recvPacket(1); + } + } finally { + $socket->close(); + } + }, + ); } - public function testSafeSocketBroken() + public function testSafeSocketBroken(): void { - $server = new Server('0.0.0.0', 9506); + $server = new Server('127.0.0.1', 0); $closed = new Channel(1); $p = function (string $data): string { return pack('N', strlen($data)) . $data; }; - go(function () use ($server, $p, $closed) { - $server->set([ - 'open_length_check' => true, - 'package_max_length' => 1024 * 1024 * 2, - 'package_length_type' => 'N', - 'package_length_offset' => 0, - 'package_body_offset' => 4, - ]); - $server->handle(function (Server\Connection $connection) use ($p, $closed) { + $server->set($this->packetProtocol()); + + $this->withServer( + $server, + function (Server\Connection $connection, array &$serverErrors) use ($p, $closed): void { $socket = new SafeSocket($connection->exportSocket(), 65535); + while (true) { try { $body = $socket->recvPacket(); + if (empty($body)) { $socket->close(); break; } - go(function () use ($socket, $body, $p) { - $body = substr($body, 4); - if ($body === 'ping') { - $socket->sendAll($p('pong')); - } else { - $socket->sendAll($p($body)); + + go(function () use ($socket, $body, $p, &$serverErrors): void { + try { + $body = substr($body, 4); + $socket->sendAll($p($body === 'ping' ? 'pong' : $body)); + } catch (Throwable $exception) { + $serverErrors[] = $exception; } }); } catch (Throwable $exception) { $socket->close(); - $this->assertInstanceOf(SocketClosedException::class, $exception); + + if (! $exception instanceof SocketClosedException) { + $serverErrors[] = $exception; + } + break; } } $closed->push(true); - }); - $server->start(); - }); - - $this->waitForServerToBeReady(9506); - - $socket = (new Socket\SocketFactory)->make(new Socket\SocketOption('127.0.0.1', 9506, protocol: [ - 'open_length_check' => true, - 'package_max_length' => 1024 * 1024 * 2, - 'package_length_type' => 'N', - 'package_length_offset' => 0, - 'package_body_offset' => 4, - ])); - - $socket->sendAll($p(str_repeat('s', 10240)), 1); - $socket->recvPacket(1); - $socket->sendAll($p(str_repeat('s', 10240)), 1); - $socket->recvPacket(1); - - $socket->close(); - - $this->assertTrue($closed->pop(0.5)); + }, + function (int $port) use ($p, $closed): void { + $socket = $this->connect($port); + + try { + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + } finally { + $socket->close(); + } - $server->shutdown(); + $this->assertTrue($closed->pop(0.5)); + }, + ); } - public function testSafeSocketBrokenDontThrow() + public function testSafeSocketBrokenDontThrow(): void { - $server = new Server('0.0.0.0', 9506); + $server = new Server('127.0.0.1', 0); $closed = new Channel(1); $p = function (string $data): string { return pack('N', strlen($data)) . $data; }; - go(function () use ($server, $p, $closed) { - $server->set([ - 'open_length_check' => true, - 'package_max_length' => 1024 * 1024 * 2, - 'package_length_type' => 'N', - 'package_length_offset' => 0, - 'package_body_offset' => 4, - ]); - $server->handle(function (Server\Connection $connection) use ($p, $closed) { + $server->set($this->packetProtocol()); + + $this->withServer( + $server, + function (Server\Connection $connection, array &$serverErrors) use ($p, $closed): void { $socket = new SafeSocket($connection->exportSocket(), 65535, false); + while (true) { $body = $socket->recvPacket(); + if (empty($body)) { $socket->close(); break; } - go(function () use ($socket, $body, $p) { - $body = substr($body, 4); - if ($body === 'ping') { - $socket->sendAll($p('pong')); - } else { - $socket->sendAll($p($body)); + + go(function () use ($socket, $body, $p, &$serverErrors): void { + try { + $body = substr($body, 4); + $socket->sendAll($p($body === 'ping' ? 'pong' : $body)); + } catch (Throwable $exception) { + $serverErrors[] = $exception; } }); } - $this->assertTrue(true); + $closed->push(true); - }); - $server->start(); - }); + }, + function (int $port) use ($p, $closed): void { + $socket = $this->connect($port); + + try { + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + $socket->sendAll($p(str_repeat('s', 10240)), 1); + $socket->recvPacket(1); + } finally { + $socket->close(); + } + + $this->assertTrue($closed->pop(0.5)); + }, + ); + } - $this->waitForServerToBeReady(9506); + public function testSocketGetOption(): void + { + $server = new Server('127.0.0.1', 0); + $server->set($this->packetProtocol()); + + $this->withServer( + $server, + fn (Server\Connection $connection) => $connection->close(), + function (int $port): void { + $option = new Socket\SocketOption( + '127.0.0.1', + $port, + protocol: $this->packetProtocol(), + ); + $socket = (new Socket\SocketFactory)->make($option); + + try { + $this->assertSame($option, $socket->getSocketOption()); + } finally { + $socket->close(); + } + }, + ); + } - $socket = (new Socket\SocketFactory)->make(new Socket\SocketOption('127.0.0.1', 9506, protocol: [ - 'open_length_check' => true, - 'package_max_length' => 1024 * 1024 * 2, - 'package_length_type' => 'N', - 'package_length_offset' => 0, - 'package_body_offset' => 4, - ])); + public function testServerHandlerExceptionSurfacesInTheParent(): void + { + $server = new Server('127.0.0.1', 0); + $handled = new Channel(1); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('expected server handler failure'); + + $this->withServer( + $server, + function () use ($handled): never { + $handled->push(true); + + throw new RuntimeException('expected server handler failure'); + }, + function (int $port) use ($handled): void { + $socket = (new Socket\SocketFactory)->make( + new Socket\SocketOption('127.0.0.1', $port), + ); + + try { + $this->assertTrue($handled->pop(0.5)); + } finally { + $socket->close(); + } + }, + ); + } + + public function testClientFailureStillShutsDownTheServer(): void + { + $server = new Server('127.0.0.1', 0); + $coroutinesBefore = SwooleCoroutine::stats()['coroutine_num']; + + try { + $this->withServer( + $server, + fn (Server\Connection $connection) => $connection->close(), + function (int $port): never { + $socket = (new Socket\SocketFactory)->make( + new Socket\SocketOption('127.0.0.1', $port), + ); + + try { + throw new RuntimeException('expected client failure'); + } finally { + $socket->close(); + } + }, + ); - $socket->sendAll($p(str_repeat('s', 10240)), 1); - $socket->recvPacket(1); - $socket->sendAll($p(str_repeat('s', 10240)), 1); - $socket->recvPacket(1); + $this->fail('The client failure should escape the server boundary.'); + } catch (RuntimeException $exception) { + $this->assertSame('expected client failure', $exception->getMessage()); + } - $socket->close(); + $this->assertSame( + $coroutinesBefore, + SwooleCoroutine::stats()['coroutine_num'], + ); + } - $this->assertTrue($closed->pop(0.5)); + public function testSafeSocketClosesAfterANativeSendFailure(): void + { + $server = new Server('127.0.0.1', 0); + $peerClosed = new Channel(2); + + $this->withServer( + $server, + function (Server\Connection $connection) use ($peerClosed): void { + $connection->close(); + $peerClosed->push(true); + }, + function (int $port) use ($peerClosed): void { + foreach ([true, false] as $throw) { + $nativeSocket = (new Socket\SocketFactory)->make( + new Socket\SocketOption('127.0.0.1', $port), + ); + $this->assertInstanceOf(SwooleSocket::class, $nativeSocket); + $socket = new SafeSocket($nativeSocket, throw: $throw); - $server->shutdown(); + try { + $this->assertTrue($peerClosed->pop(0.5)); + $this->driveSafeSocketToTerminalSendFailure($socket); + + if ($throw) { + try { + $socket->sendAll('after-close'); + $this->fail('A closed SafeSocket should reject later sends.'); + } catch (SocketClosedException) { + $this->addToAssertionCount(1); + } + } else { + $this->assertFalse($socket->sendAll('after-close')); + } + } finally { + $socket->close(); + } + } + }, + ); } - public function testSocketGetOption() + public function testSafeSocketPreservesAZeroStringPayload(): void { - $server = new Server('0.0.0.0', 9506); + $nativeSocket = new ZeroPayloadSocket; + $socket = new SafeSocket($nativeSocket); - $this->waitForServerToBeReady(9506); + try { + $this->assertSame('0', $socket->recvAll()); + $this->assertSame('0', $socket->recvPacket()); + } finally { + $socket->close(); + } + } + + /** + * Run assertions against a test server with bounded, unconditional cleanup. + */ + private function withServer(Server $server, callable $handler, callable $callback): void + { + $serverErrors = []; + $ready = new Channel(1); + $finished = new WaitGroup(1); - $socket = (new Socket\SocketFactory)->make($option = new Socket\SocketOption('127.0.0.1', 9506, protocol: [ + go(function () use ($server, $handler, &$serverErrors, $ready, $finished): void { + try { + $server->handle(function (Server\Connection $connection) use ( + $handler, + &$serverErrors, + ): void { + try { + $handler($connection, $serverErrors); + } catch (Throwable $exception) { + $serverErrors[] = $exception; + } + }); + $ready->push(true); + $server->start(); + } catch (Throwable $exception) { + $serverErrors[] = $exception; + } finally { + $finished->done(); + } + }); + + $exception = null; + + try { + if ($ready->pop(0.5) !== true) { + if ($serverErrors !== []) { + throw $serverErrors[0]; + } + + throw new RuntimeException('The Engine test server did not become ready.'); + } + + $callback($server->port); + } catch (Throwable $throwable) { + $exception = $throwable; + } finally { + try { + $server->shutdown(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + if (! $finished->wait(1.0)) { + throw new RuntimeException('The Engine test server did not stop within one second.'); + } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + $ready->close(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + + if ($serverErrors !== []) { + throw $serverErrors[0]; + } + } + + /** + * Connect a packet socket to the given test server. + */ + private function connect(int $port): SocketInterface + { + return (new Socket\SocketFactory)->make(new Socket\SocketOption( + '127.0.0.1', + $port, + protocol: $this->packetProtocol(), + )); + } + + /** + * Get the length-framed packet protocol used by these tests. + */ + private function packetProtocol(): array + { + return [ 'open_length_check' => true, 'package_max_length' => 1024 * 1024 * 2, 'package_length_type' => 'N', 'package_length_offset' => 0, 'package_body_offset' => 4, - ])); + ]; + } - $this->assertSame($option, $socket->getSocketOption()); + /** + * Send until the background consumer observes the closed peer. + */ + private function driveSafeSocketToTerminalSendFailure(SafeSocket $socket): void + { + /** @var Channel $channel */ + $channel = (new ReflectionProperty($socket, 'channel'))->getValue($socket); + $loop = new ReflectionProperty($socket, 'loop'); + $deadline = hrtime(true) + 500_000_000; + $payload = str_repeat('x', 65_536); - $socket->close(); + while (! $channel->isClosing() && hrtime(true) < $deadline) { + try { + $socket->sendAll($payload, 0.05); + } catch (SocketClosedException) { + break; + } + + usleep(100); + } - $server->shutdown(); + $this->assertTrue($channel->isClosing()); + + while ($loop->getValue($socket) === true && hrtime(true) < $deadline) { + usleep(100); + } + + $this->assertFalse($loop->getValue($socket)); } +} - private function waitForServerToBeReady(int $port): void +class ZeroPayloadSocket extends SwooleSocket +{ + public function __construct() { - $factory = new Socket\SocketFactory; - $deadline = microtime(true) + 0.5; - - do { - try { - $socket = $factory->make(new Socket\SocketOption('127.0.0.1', $port, 0.01)); - $socket->close(); + parent::__construct(AF_INET, SOCK_STREAM, 0); + } - return; - } catch (SocketConnectException) { - usleep(10000); - } - } while (microtime(true) < $deadline); + public function recvAll(int $length = 65536, float $timeout = 0): false|string + { + return '0'; + } - self::fail(sprintf('Timed out waiting for test server on port %d.', $port)); + public function recvPacket(float $timeout = 0): false|string + { + return '0'; } } From b7d41f26b8f5c0e30e441b454ca209a8e3bf20cf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:12:01 +0000 Subject: [PATCH 06/40] Return a controlled response on HTTP coroutine exhaustion Catch CoroutineCreateException at the native Swoole request callback boundary, where the child coroutine's internal exception handler cannot observe a failed spawn. Log the overload and complete the response with HTTP 503 without invoking the application handler or leaking an exception through native code. --- src/engine/src/Http/Server.php | 23 ++++++++--- tests/Engine/HttpServerTest.php | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 tests/Engine/HttpServerTest.php diff --git a/src/engine/src/Http/Server.php b/src/engine/src/Http/Server.php index 286e439ac..687847396 100644 --- a/src/engine/src/Http/Server.php +++ b/src/engine/src/Http/Server.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Engine\Http\ServerInterface; use Hypervel\Engine\Coroutine; +use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\HttpServer\RequestBridge; use Psr\Log\LoggerInterface; use Swoole\Coroutine\Http\Server as HttpServer; @@ -57,8 +58,18 @@ public function handle(callable $callable): static */ public function start(): void { - $this->server->handle('/', function ($request, $response) { - Coroutine::create(function () use ($request, $response) { + $this->server->handle('/', $this->dispatchRequest(...)); + + $this->server->start(); + } + + /** + * Dispatch a native request without letting overload escape its callback. + */ + protected function dispatchRequest(mixed $request, mixed $response): void + { + try { + Coroutine::create(function () use ($request, $response): void { try { $handler = $this->handler; @@ -67,9 +78,11 @@ public function start(): void $this->logger->critical((string) $exception); } }); - }); - - $this->server->start(); + } catch (CoroutineCreateException $exception) { + $this->logger->critical((string) $exception); + $response->status(503); + $response->end('Service Unavailable'); + } } /** diff --git a/tests/Engine/HttpServerTest.php b/tests/Engine/HttpServerTest.php new file mode 100644 index 000000000..f654c96a2 --- /dev/null +++ b/tests/Engine/HttpServerTest.php @@ -0,0 +1,68 @@ + 1]); + + SwooleCoroutine\run(function (): void { + $logger = m::mock(LoggerInterface::class); + $logger->shouldReceive('critical') + ->once() + ->with(m::on(fn (string $message): bool => str_contains($message, 'Unable to create coroutine'))); + $response = new OverloadResponse; + $handled = false; + $server = new InspectableHttpServer($logger); + $server->handle(function () use (&$handled): void { + $handled = true; + }); + + $server->dispatch(new stdClass, $response); + + $this->assertFalse($handled); + $this->assertSame(503, $response->status); + $this->assertSame('Service Unavailable', $response->body); + }); + } +} + +class InspectableHttpServer extends Server +{ + public function dispatch(mixed $request, mixed $response): void + { + $this->dispatchRequest($request, $response); + } +} + +class OverloadResponse +{ + public ?int $status = null; + + public ?string $body = null; + + public function status(int $status): void + { + $this->status = $status; + } + + public function end(string $body): void + { + $this->body = $body; + } +} From 2fe8e82d9e510694d2323d31bd271bd0f22bc5d0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:12:07 +0000 Subject: [PATCH 07/40] Make signal watcher registration transactional Roll back only the handlers and native waiters created by a failed registration call. Use exception-injecting cancellation so Swoole wait loops terminate, treat intentional cancellation as control flow, and prevent partially installed signal sets from surviving coroutine exhaustion. --- src/console/src/SignalRegistry.php | 57 ++++++++++++++-- src/signal/src/SignalManager.php | 45 ++++++++---- .../SignalRegistryCreateFailureTest.php | 68 +++++++++++++++++++ .../Signal/SignalManagerCreateFailureTest.php | 61 +++++++++++++++++ 4 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 tests/Console/SignalRegistryCreateFailureTest.php create mode 100644 tests/Signal/SignalManagerCreateFailureTest.php diff --git a/src/console/src/SignalRegistry.php b/src/console/src/SignalRegistry.php index 8ea8e8410..ba7b2c4cd 100644 --- a/src/console/src/SignalRegistry.php +++ b/src/console/src/SignalRegistry.php @@ -7,6 +7,8 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Engine\Signal; +use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -37,12 +39,43 @@ public function __construct( public function register(int|array $signo, callable $signalHandler): void { if (is_array($signo)) { - array_map(fn ($s) => $this->register((int) $s, $signalHandler), $signo); + $registered = []; + + try { + foreach ($signo as $signal) { + $signal = (int) $signal; + $this->register($signal, $signalHandler); + $registered[] = $signal; + } + } catch (Throwable $exception) { + foreach (array_reverse($registered) as $signal) { + array_pop($this->signalHandlers[$signal]); + + if ($this->signalHandlers[$signal] === []) { + unset($this->signalHandlers[$signal]); + $this->cancelSignal($signal); + } + } + + throw $exception; + } + return; } $this->pushSignalHandler($signo, $signalHandler); - $this->waitSignal($signo); + + try { + $this->waitSignal($signo); + } catch (Throwable $exception) { + array_pop($this->signalHandlers[$signo]); + + if ($this->signalHandlers[$signo] === []) { + unset($this->signalHandlers[$signo]); + } + + throw $exception; + } } /** @@ -94,19 +127,26 @@ protected function waitSignal(int $signo): void return; } - $this->handling[$signo] = Coroutine::create(function () use ($signo) { - while (true) { - if (Signal::wait($signo, $this->timeout)) { + $this->handling[$signo] = Coroutine::create(function () use ($signo): void { + try { + while (true) { + if (! Signal::wait($signo, $this->timeout)) { + continue; + } + unset($this->handling[$signo]); $callbacks = array_map(fn ($callback) => fn () => $callback($signo), $this->signalHandlers[$signo] ?? []); try { - return parallel($callbacks, $this->concurrentLimit); + parallel($callbacks, $this->concurrentLimit); + return; } finally { posix_kill(posix_getpid(), $signo); } } + } catch (CanceledException) { + // Intentional cancellation; the canceller owns the registry slot. } }); } @@ -120,7 +160,10 @@ protected function cancelSignal(int $signo): void return; } - EngineCoroutine::cancelById($this->handling[$signo]); + EngineCoroutine::cancelById( + $this->handling[$signo], + throwException: true, + ); unset($this->handling[$signo]); } diff --git a/src/signal/src/SignalManager.php b/src/signal/src/SignalManager.php index e7040d658..9a9f7ea22 100644 --- a/src/signal/src/SignalManager.php +++ b/src/signal/src/SignalManager.php @@ -8,8 +8,11 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Signal\SignalHandlerInterface as SignalHandler; use Hypervel\Coroutine\Coroutine; +use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Engine\Signal as EngineSignal; use Hypervel\Support\SplPriorityQueue; +use Swoole\Coroutine\CanceledException; +use Throwable; class SignalManager { @@ -67,21 +70,39 @@ public function listen(?int $process): void return; } - foreach ($this->handlers[$process] ?? [] as $signal => $handlers) { - Coroutine::create(function () use ($signal, $handlers) { - while (true) { - $ret = EngineSignal::wait($signal, $this->config->float('signal.timeout', 5.0)); - if ($ret) { - foreach ($handlers as $handler) { - $handler->handle($signal); + $coroutineIds = []; + + try { + foreach ($this->handlers[$process] ?? [] as $signal => $handlers) { + $coroutineIds[] = Coroutine::create(function () use ($signal, $handlers): void { + try { + while (true) { + $ret = EngineSignal::wait($signal, $this->config->float('signal.timeout', 5.0)); + + if ($ret) { + foreach ($handlers as $handler) { + $handler->handle($signal); + } + } + + if ($this->isStopped()) { + break; + } } + } catch (CanceledException) { + // Intentional cancellation; the manager owns watcher cleanup. } + }); + } + } catch (Throwable $exception) { + foreach ($coroutineIds as $coroutineId) { + EngineCoroutine::cancelById( + $coroutineId, + throwException: true, + ); + } - if ($this->isStopped()) { - break; - } - } - }); + throw $exception; } } diff --git a/tests/Console/SignalRegistryCreateFailureTest.php b/tests/Console/SignalRegistryCreateFailureTest.php new file mode 100644 index 000000000..b5d227c27 --- /dev/null +++ b/tests/Console/SignalRegistryCreateFailureTest.php @@ -0,0 +1,68 @@ + 2]); + + SwooleCoroutine\run(function (): void { + $registry = new SignalRegistry; + + try { + $registry->register( + [SIGUSR1, SIGUSR2], + static fn (int $signal): null => null, + ); + $this->fail('Expected the second waiter creation to fail.'); + } catch (CoroutineCreateException) { + $invoker = new ClassInvoker($registry); + + $this->assertSame([], $invoker->signalHandlers); + $this->assertSame([], $invoker->handling); + $this->assertSame(1, SwooleCoroutine::stats()['coroutine_num']); + } + }); + } + + #[RunInSeparateProcess] + public function testUnregisterTerminallyCancelsItsWaiterWithoutReporting(): void + { + SwooleCoroutine\run(function (): void { + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldNotReceive('report'); + Container::getInstance()->instance(ExceptionHandlerContract::class, $handler); + + $registry = new SignalRegistry; + $registry->register(SIGUSR1, static fn (int $signal): null => null); + + $invoker = new ClassInvoker($registry); + $coroutineId = $invoker->handling[SIGUSR1]; + + $this->assertTrue(Coroutine::exists($coroutineId)); + + $registry->unregister(SIGUSR1); + + $this->assertFalse(Coroutine::exists($coroutineId)); + $this->assertSame([], $invoker->handling); + }); + } +} diff --git a/tests/Signal/SignalManagerCreateFailureTest.php b/tests/Signal/SignalManagerCreateFailureTest.php new file mode 100644 index 000000000..b19074a9f --- /dev/null +++ b/tests/Signal/SignalManagerCreateFailureTest.php @@ -0,0 +1,61 @@ + 2]); + + SwooleCoroutine\run(function (): void { + $exceptionHandler = m::mock(ExceptionHandlerContract::class); + $exceptionHandler->shouldNotReceive('report'); + Container::getInstance()->instance( + ExceptionHandlerContract::class, + $exceptionHandler, + ); + + $manager = (new ReflectionClass(SignalManager::class)) + ->newInstanceWithoutConstructor(); + $handler = new SignalHandlerStub; + + (new ReflectionProperty($manager, 'config'))->setValue( + $manager, + new Repository(['signal' => ['timeout' => 5.0]]), + ); + (new ReflectionProperty($manager, 'handlers'))->setValue($manager, [ + SignalHandler::WORKER => [ + SIGUSR1 => [$handler], + SIGUSR2 => [$handler], + ], + ]); + + try { + $manager->listen(SignalHandler::WORKER); + $this->fail('Expected the second signal watcher creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertSame(1, SwooleCoroutine::stats()['coroutine_num']); + } + }); + } +} From cf5883e91c2dc903efc192cd33d9a0c04237fdfe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:12:44 +0000 Subject: [PATCH 08/40] Own Redis subscriber background work transactionally Make subscriber construction roll back its receive loop, shutdown timer, connection, and every channel when either background registration fails. Retain the shutdown timer ID for normal interruption and preserve the original construction failure if cleanup also encounters an error. --- src/redis/src/Subscriber/CommandInvoker.php | 36 +++++- .../CommandInvokerCreateFailureTest.php | 120 ++++++++++++++++++ 2 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 tests/Redis/Subscriber/CommandInvokerCreateFailureTest.php diff --git a/src/redis/src/Subscriber/CommandInvoker.php b/src/redis/src/Subscriber/CommandInvoker.php index 4e96df114..76b68382e 100644 --- a/src/redis/src/Subscriber/CommandInvoker.php +++ b/src/redis/src/Subscriber/CommandInvoker.php @@ -21,14 +21,27 @@ class CommandInvoker private Timer $timer; + private ?int $shutdownTimerId = null; + public function __construct(protected Connection $connection, protected ?StdoutLoggerInterface $logger = null) { $this->resultChannel = new Channel; $this->pingChannel = new Channel; $this->messageChannel = new Channel(100); $this->timer = new Timer; - $this->loop(); - $this->watchForShutdown(); + + try { + $this->loop(); + $this->watchForShutdown(); + } catch (Throwable $exception) { + try { + $this->interrupt(); + } catch (Throwable) { + // Construction failure remains primary after exhaustive cleanup. + } + + throw $exception; + } } public function invoke(int|string|array|null $command, int $number): array @@ -56,9 +69,18 @@ public function channel(): Channel public function interrupt(): bool { - $this->connection->close(); - $this->resultChannel->close(); - $this->messageChannel->close(); + if ($this->shutdownTimerId !== null) { + $this->timer->clear($this->shutdownTimerId); + $this->shutdownTimerId = null; + } + + try { + $this->connection->close(); + } finally { + $this->resultChannel->close(); + $this->pingChannel->close(); + $this->messageChannel->close(); + } return true; } @@ -170,14 +192,14 @@ protected function receive(Connection $connection): void */ protected function watchForShutdown(): void { - $this->timer->until(function () { + $this->shutdownTimerId = $this->timer->until(function (): void { $this->interrupt(); }); } protected function loop(): void { - Coroutine::create(function () { + Coroutine::create(function (): void { $this->receive($this->connection); }); } diff --git a/tests/Redis/Subscriber/CommandInvokerCreateFailureTest.php b/tests/Redis/Subscriber/CommandInvokerCreateFailureTest.php new file mode 100644 index 000000000..3f8e3d1ce --- /dev/null +++ b/tests/Redis/Subscriber/CommandInvokerCreateFailureTest.php @@ -0,0 +1,120 @@ + 1]); + + SwooleCoroutine\run(function (): void { + $connection = new ConstructionFailureConnection; + + try { + new InspectableCommandInvoker($connection); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $invoker = InspectableCommandInvoker::$constructing; + + $this->assertInstanceOf(InspectableCommandInvoker::class, $invoker); + $this->assertTrue($connection->wasClosed); + $this->assertNull( + (new ReflectionProperty(CommandInvoker::class, 'shutdownTimerId')) + ->getValue($invoker), + ); + + foreach (['resultChannel', 'pingChannel', 'messageChannel'] as $property) { + $channel = (new ReflectionProperty(CommandInvoker::class, $property)) + ->getValue($invoker); + + $this->assertInstanceOf(Channel::class, $channel); + $this->assertTrue($channel->isClosing()); + } + } finally { + InspectableCommandInvoker::$constructing = null; + } + }); + } + + #[RunInSeparateProcess] + public function testCleanupFailureDoesNotReplaceTheConstructionFailure(): void + { + SwooleCoroutine::set(['max_coroutine' => 1]); + + SwooleCoroutine\run(function (): void { + $connection = new ThrowingConstructionFailureConnection; + + try { + new InspectableCommandInvoker($connection); + $this->fail('Expected coroutine creation to fail.'); + } catch (CoroutineCreateException $exception) { + $this->assertStringContainsString('Unable to create coroutine', $exception->getMessage()); + $this->assertTrue($connection->wasClosed); + + $invoker = InspectableCommandInvoker::$constructing; + $this->assertInstanceOf(InspectableCommandInvoker::class, $invoker); + + foreach (['resultChannel', 'pingChannel', 'messageChannel'] as $property) { + $channel = (new ReflectionProperty(CommandInvoker::class, $property)) + ->getValue($invoker); + + $this->assertTrue($channel->isClosing()); + } + } finally { + InspectableCommandInvoker::$constructing = null; + } + }); + } +} + +class InspectableCommandInvoker extends CommandInvoker +{ + public static ?self $constructing = null; + + public function __construct(Connection $connection) + { + static::$constructing = $this; + + parent::__construct($connection); + } +} + +class ConstructionFailureConnection extends Connection +{ + public bool $wasClosed = false; + + public function __construct() + { + } + + public function close(): void + { + $this->wasClosed = true; + } +} + +class ThrowingConstructionFailureConnection extends ConstructionFailureConnection +{ + public function close(): void + { + parent::close(); + + throw new RuntimeException('cleanup failed'); + } +} From 1735480560e3594add7b68de4bba88843b8d8904 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:12:50 +0000 Subject: [PATCH 09/40] Restore prompt state when animation startup fails Move Task and Spinner animation creation inside their existing exception-safe lifecycle. Cursor visibility and final rendering now recover even when Swoole cannot create the animation coroutine, with focused regressions for both prompt implementations. --- src/prompts/src/Spinner.php | 20 ++--- src/prompts/src/Task.php | 22 +++--- tests/Prompts/CoroutineCreateFailureTest.php | 82 ++++++++++++++++++++ 3 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 tests/Prompts/CoroutineCreateFailureTest.php diff --git a/src/prompts/src/Spinner.php b/src/prompts/src/Spinner.php index 158ac862f..6d9d11775 100644 --- a/src/prompts/src/Spinner.php +++ b/src/prompts/src/Spinner.php @@ -49,20 +49,20 @@ public function spin(Closure $callback): mixed { $this->capturePreviousNewLines(); - $this->hideCursor(); - $this->render(); + try { + $this->hideCursor(); + $this->render(); - Coroutine::fork(function () { - while (! $this->hasFinished) { - $this->render(); + Coroutine::fork(function (): void { + while (! $this->hasFinished) { + $this->render(); - ++$this->count; + ++$this->count; - usleep($this->interval * 1000); - } - }); + usleep($this->interval * 1000); + } + }); - try { return $callback(); } finally { $this->hasFinished = true; diff --git a/src/prompts/src/Task.php b/src/prompts/src/Task.php index 8fb4d85de..aa0498b8f 100644 --- a/src/prompts/src/Task.php +++ b/src/prompts/src/Task.php @@ -456,18 +456,18 @@ protected function renderStatically(Closure $callback): mixed */ protected function renderInCoroutine(Closure $callback): mixed { - $this->hideCursor(); - $this->render(); - - Coroutine::fork(function () { - while (! $this->finished) { - $this->render(); - ++$this->count; - usleep($this->interval * 1000); - } - }); - try { + $this->hideCursor(); + $this->render(); + + Coroutine::fork(function (): void { + while (! $this->finished) { + $this->render(); + ++$this->count; + usleep($this->interval * 1000); + } + }); + $logger = new CoroutineLogger($this); return $callback($logger); diff --git a/tests/Prompts/CoroutineCreateFailureTest.php b/tests/Prompts/CoroutineCreateFailureTest.php new file mode 100644 index 000000000..4b7f833ee --- /dev/null +++ b/tests/Prompts/CoroutineCreateFailureTest.php @@ -0,0 +1,82 @@ + 1]); + + SwooleCoroutine\run(function (): void { + $callbackRan = false; + $spinner = new Spinner('Running'); + + try { + $spinner->spin(function () use (&$callbackRan): void { + $callbackRan = true; + }); + $this->fail('Expected animation coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertFalse($callbackRan); + $this->assertTrue( + (new ReflectionProperty($spinner, 'hasFinished')) + ->getValue($spinner), + ); + } + + unset($spinner); + + $this->assertFalse( + (new ReflectionProperty(Prompt::class, 'cursorHidden')) + ->getValue(), + ); + }); + } + + #[RunInSeparateProcess] + public function testTaskRestoresItsTerminalStateWhenAnimationCreationFails(): void + { + Prompt::fake(); + SwooleCoroutine::set(['max_coroutine' => 1]); + + SwooleCoroutine\run(function (): void { + $callbackRan = false; + $task = new Task('Running'); + + try { + $task->run(function () use (&$callbackRan): void { + $callbackRan = true; + }); + $this->fail('Expected animation coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertFalse($callbackRan); + $this->assertTrue( + (new ReflectionProperty($task, 'finished'))->getValue($task), + ); + } + + unset($task); + + $this->assertFalse( + (new ReflectionProperty(Prompt::class, 'cursorHidden')) + ->getValue(), + ); + }); + } +} From 56006c21c0cb65a3aca8e795939c7571d8a43e5c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:12:57 +0000 Subject: [PATCH 10/40] Resume worker exit coordination synchronously Remove an unnecessary shutdown-time coroutine and resume the worker-exit coordinator in finally after listener dispatch. This guarantees shutdown waiters are released even when a listener throws and avoids consuming a coroutine slot while the worker is already exiting. --- src/core/src/Bootstrap/WorkerExitCallback.php | 8 +-- .../Core/Bootstrap/WorkerExitCallbackTest.php | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/Core/Bootstrap/WorkerExitCallbackTest.php diff --git a/src/core/src/Bootstrap/WorkerExitCallback.php b/src/core/src/Bootstrap/WorkerExitCallback.php index 28f3a3161..0bc672911 100644 --- a/src/core/src/Bootstrap/WorkerExitCallback.php +++ b/src/core/src/Bootstrap/WorkerExitCallback.php @@ -8,7 +8,6 @@ use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Core\Events\OnWorkerExit; -use Hypervel\Coroutine\Coroutine; use Swoole\Server; class WorkerExitCallback @@ -22,9 +21,10 @@ public function __construct(protected Dispatcher $dispatcher) */ public function onWorkerExit(Server $server, int $workerId): void { - $this->dispatcher->dispatch(new OnWorkerExit($server, $workerId)); - Coroutine::create(function () { + try { + $this->dispatcher->dispatch(new OnWorkerExit($server, $workerId)); + } finally { CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); - }); + } } } diff --git a/tests/Core/Bootstrap/WorkerExitCallbackTest.php b/tests/Core/Bootstrap/WorkerExitCallbackTest.php new file mode 100644 index 000000000..d596117bc --- /dev/null +++ b/tests/Core/Bootstrap/WorkerExitCallbackTest.php @@ -0,0 +1,67 @@ + 1]); + + SwooleCoroutine\run(function (): void { + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(OnWorkerExit::class)); + $coordinator = CoordinatorManager::until(Constants::WORKER_EXIT); + + (new WorkerExitCallback($dispatcher))->onWorkerExit( + m::mock(Server::class), + 3, + ); + + $this->assertTrue($coordinator->isClosing()); + $this->assertSame(1, SwooleCoroutine::stats()['coroutine_num']); + }); + } + + #[RunInSeparateProcess] + public function testWorkerExitResumesCoordinationWhenAListenerThrows(): void + { + SwooleCoroutine\run(function (): void { + $failure = new RuntimeException('worker exit listener failed'); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->andThrow($failure); + $coordinator = CoordinatorManager::until(Constants::WORKER_EXIT); + + try { + (new WorkerExitCallback($dispatcher))->onWorkerExit( + m::mock(Server::class), + 3, + ); + $this->fail('The listener failure should propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($coordinator->isClosing()); + }); + } +} From e69e14e31a3da44fe5e85c844c5f82df51a84b11 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:13:03 +0000 Subject: [PATCH 11/40] Make coordinator timers lifecycle safe Capture coordinator identity before spawning timer work so teardown cannot make a delayed child resolve a fresh open coordinator. Roll back registrations when creation fails, stop cleared ticks before another interval, and preserve immediate zero-timeout behavior with focused lifecycle regressions. --- src/coordinator/src/Timer.php | 108 +++++++++++++++++++------------- tests/Coordinator/TimerTest.php | 46 ++++++++++++++ 2 files changed, 111 insertions(+), 43 deletions(-) diff --git a/src/coordinator/src/Timer.php b/src/coordinator/src/Timer.php index cc29134fb..50cb378a2 100644 --- a/src/coordinator/src/Timer.php +++ b/src/coordinator/src/Timer.php @@ -31,23 +31,33 @@ public function __construct(private ?LoggerInterface $logger = null) public function after(float $timeout, callable $closure, string $identifier = Constants::WORKER_EXIT): int { $id = ++$this->id; + $coordinator = CoordinatorManager::until($identifier); $this->closures[$id] = true; - go(function () use ($timeout, $closure, $identifier, $id) { - try { - ++Timer::$count; - $isClosing = match (true) { - $timeout > 0 => CoordinatorManager::until($identifier)->yield($timeout), // Run after $timeout seconds. - $timeout === 0.0 => CoordinatorManager::until($identifier)->isClosing(), // Run immediately. - default => CoordinatorManager::until($identifier)->yield(), // Run until $identifier resume. - }; - if (isset($this->closures[$id])) { - $closure($isClosing); + + try { + go(function () use ($timeout, $closure, $coordinator, $id): void { + try { + ++Timer::$count; + $isClosing = match (true) { + $timeout > 0 => $coordinator->yield($timeout), // Run after $timeout seconds. + $timeout === 0.0 => $coordinator->isClosing(), // Run immediately. + default => $coordinator->yield(), // Run until $identifier resume. + }; + + if (isset($this->closures[$id])) { + $closure($isClosing); + } + } finally { + unset($this->closures[$id]); + --Timer::$count; } - } finally { - unset($this->closures[$id]); - --Timer::$count; - } - }); + }); + } catch (Throwable $exception) { + unset($this->closures[$id]); + + throw $exception; + } + return $id; } @@ -57,42 +67,54 @@ public function after(float $timeout, callable $closure, string $identifier = Co public function tick(float $timeout, callable $closure, string $identifier = Constants::WORKER_EXIT): int { $id = ++$this->id; + $coordinator = CoordinatorManager::until($identifier); $this->closures[$id] = true; - go(function () use ($timeout, $closure, $identifier, $id) { - try { + + try { + go(function () use ($timeout, $closure, $coordinator, $id): void { $round = 0; - ++Timer::$count; - while (true) { - $isClosing = CoordinatorManager::until($identifier)->yield(max($timeout, 0.000001)); - if (! isset($this->closures[$id])) { - break; - } - $result = null; + try { + ++Timer::$count; - try { - $result = $closure($isClosing); - } catch (Throwable $exception) { - if ($this->logger !== null) { - $this->logger->error((string) $exception); - } else { - error_log((string) $exception); + while (isset($this->closures[$id])) { + $isClosing = $coordinator->yield(max($timeout, 0.000001)); + + if (! isset($this->closures[$id])) { + break; } - } - if ($result === self::STOP || $isClosing) { - break; - } + $result = null; + + try { + $result = $closure($isClosing); + } catch (Throwable $exception) { + if ($this->logger !== null) { + $this->logger->error((string) $exception); + } else { + error_log((string) $exception); + } + } + + if ($result === self::STOP || $isClosing) { + break; + } - ++$round; - ++Timer::$round; + ++$round; + ++Timer::$round; + } + } finally { + unset($this->closures[$id]); + Timer::$round -= $round; + --Timer::$count; } - } finally { - unset($this->closures[$id]); - Timer::$round -= $round; - --Timer::$count; - } - }); + }); + } catch (Throwable $exception) { + unset($this->closures[$id]); + + throw $exception; + } + return $id; } diff --git a/tests/Coordinator/TimerTest.php b/tests/Coordinator/TimerTest.php index 5406bd315..7160a7a98 100644 --- a/tests/Coordinator/TimerTest.php +++ b/tests/Coordinator/TimerTest.php @@ -7,7 +7,9 @@ use Closure; use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Waiter; +use Hypervel\Engine\Channel; use Hypervel\Filesystem\Filesystem; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; @@ -52,6 +54,34 @@ public function testAfterWhenClosing(): void }); } + public function testAfterUsesTheCoordinatorCapturedBeforeTheChildStarts(): void + { + $identifier = uniqid(); + $resumed = false; + $result = new Channel(1); + + Coroutine::afterCreated(function () use ($identifier, &$resumed): void { + if ($resumed) { + return; + } + + $resumed = true; + CoordinatorManager::until($identifier)->resume(); + CoordinatorManager::clear($identifier); + }); + + try { + (new Timer)->after(-1.0, static function (bool $isClosing) use ($result): void { + $result->push($isClosing); + }, $identifier); + + $this->assertTrue($result->pop(0.1)); + } finally { + Coroutine::flushState(); + CoordinatorManager::clear($identifier); + } + } + public function testAfterWhenClear(): void { $this->wait(function () { @@ -99,6 +129,22 @@ public function testTickWhenReturnStop(): void }); } + public function testTickClearedFromItsCallbackDoesNotWaitAnotherInterval(): void + { + $timer = new Timer; + $called = new Channel(1); + $id = null; + + $id = $timer->tick(0.1, function () use ($timer, &$id, $called): void { + $timer->clear($id); + $called->push(true); + }, uniqid()); + + $this->assertTrue($called->pop(0.2)); + usleep(10_000); + $this->assertSame(['num' => 0, 'round' => 0], Timer::stats()); + } + public function testTickReportsThroughTheConfiguredLoggerAndContinues(): void { $this->wait(function (): void { From f5a849cb7d78e11d53229c8b28b1091cb184bd4e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:13:21 +0000 Subject: [PATCH 12/40] Make coroutine test teardown exhaustive Run each coroutine teardown hook and every independent framework cleanup action even when an earlier action fails. Preserve the test-body exception or first teardown failure while still clearing context, native timers, and worker-exit coordination so a failed test cannot strand later work. --- .../Testing/Concerns/RunTestsInCoroutine.php | 28 +++--- tests/Foundation/Testing/UnitTestTest.php | 90 +++++++++++++++++++ 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index cf5447fa8..08912555e 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -40,8 +40,16 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m $testResult = null; $exception = null; + $capture = static function (callable $callback) use (&$exception): void { + try { + $callback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + }; + /* @phpstan-ignore-next-line */ - run(function () use (&$testResult, &$exception, $methodName, $testArguments) { + run(function () use (&$testResult, &$exception, $capture, $methodName, $testArguments): void { $this->clearNonCoroutineTransactionContext(); if ($this->copyNonCoroutineContext) { @@ -60,17 +68,17 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m $exception = $e; } finally { if ($shouldBootFramework) { - $this->invokeTearDownInCoroutine(); + $this->invokeTearDownInCoroutine($capture); } - $this->cleanupTestContext(); - Timer::clearAll(); - CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); - CoordinatorManager::clear(Constants::WORKER_EXIT); + $capture(fn () => $this->cleanupTestContext()); + $capture(fn () => Timer::clearAll()); + $capture(fn () => CoordinatorManager::until(Constants::WORKER_EXIT)->resume()); + $capture(fn () => CoordinatorManager::clear(Constants::WORKER_EXIT)); } }); - if ($exception) { + if ($exception !== null) { throw $exception; } @@ -100,17 +108,17 @@ protected function invokeSetupInCoroutine(): void } } - protected function invokeTearDownInCoroutine(): void + protected function invokeTearDownInCoroutine(callable $capture): void { if (method_exists($this, 'tearDownInCoroutine')) { - call_user_func([$this, 'tearDownInCoroutine']); + $capture(fn () => $this->tearDownInCoroutine()); } // Call trait-specific coroutine teardown methods (e.g., tearDownDatabaseTransactionsInCoroutine) foreach (class_uses_recursive(static::class) as $trait) { $method = 'tearDown' . class_basename($trait) . 'InCoroutine'; if (method_exists($this, $method)) { - $this->{$method}(); + $capture(fn () => $this->{$method}()); } } } diff --git a/tests/Foundation/Testing/UnitTestTest.php b/tests/Foundation/Testing/UnitTestTest.php index a2dc9ed18..765a18cf7 100644 --- a/tests/Foundation/Testing/UnitTestTest.php +++ b/tests/Foundation/Testing/UnitTestTest.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Foundation\Testing; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\CoordinatorManager; +use Hypervel\Coroutine\Coroutine as HypervelCoroutine; use Hypervel\Foundation\Testing\Attributes\UnitTest; use Hypervel\Foundation\Testing\DatabaseTransactions; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -12,6 +15,7 @@ use Hypervel\Tests\TestCase; use RuntimeException; use Swoole\Coroutine; +use Swoole\Timer as SwooleTimer; class UnitTestTest extends TestCase { @@ -97,6 +101,37 @@ public function testUnitTestWithDatabaseTransactionsSkipsDatabaseCoroutineHooks( $this->assertTrue($testCase->ranInsideCoroutine); $this->assertSame(0, $testCase->applicationCreationCount); } + + public function testCoroutineCleanupContinuesAfterATeardownHookFails(): void + { + $testCase = new FailingCoroutineCleanupTestCase('frameworkMethod'); + + try { + $testCase->runTestMethod('frameworkMethod'); + $this->fail('Expected coroutine teardown to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('first trait cleanup failed', $exception->getMessage()); + } + + $this->assertTrue($testCase->laterTraitCleanupRan); + $this->assertTrue($testCase->workerExitChildResumed); + $this->assertNotNull($testCase->nativeTimerId); + $this->assertFalse(SwooleTimer::exists($testCase->nativeTimerId)); + } + + public function testTestBodyFailureRemainsPrimaryWhenCleanupAlsoFails(): void + { + $testCase = new FailingBodyAndCleanupTestCase('failingMethod'); + + try { + $testCase->runTestMethod('failingMethod'); + $this->fail('Expected the test body to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('test body failed', $exception->getMessage()); + } + + $this->assertTrue($testCase->cleanupAttempted); + } } class UnitTestFoundationTestCase extends FoundationTestCase @@ -172,3 +207,58 @@ protected function tearDownUnitTestCoroutineLifecycleHookTraitInCoroutine(): voi $this->tearDownInCoroutineCalled = true; } } + +class FailingCoroutineCleanupTestCase extends UnitTestFoundationTestCase +{ + use ThrowingCoroutineCleanupTrait; + use RecordingCoroutineCleanupTrait; + + public bool $workerExitChildResumed = false; + + public ?int $nativeTimerId = null; + + public function frameworkMethod(): void + { + HypervelCoroutine::create(function (): void { + CoordinatorManager::until(Constants::WORKER_EXIT)->yield(); + $this->workerExitChildResumed = true; + }); + + $this->nativeTimerId = SwooleTimer::after(60_000, static fn (): null => null); + } +} + +class FailingBodyAndCleanupTestCase extends UnitTestFoundationTestCase +{ + public bool $cleanupAttempted = false; + + public function failingMethod(): never + { + throw new RuntimeException('test body failed'); + } + + protected function cleanupTestContext(): void + { + $this->cleanupAttempted = true; + + throw new RuntimeException('cleanup failed'); + } +} + +trait ThrowingCoroutineCleanupTrait +{ + protected function tearDownThrowingCoroutineCleanupTraitInCoroutine(): never + { + throw new RuntimeException('first trait cleanup failed'); + } +} + +trait RecordingCoroutineCleanupTrait +{ + public bool $laterTraitCleanupRan = false; + + protected function tearDownRecordingCoroutineCleanupTraitInCoroutine(): void + { + $this->laterTraitCleanupRan = true; + } +} From b1e34b25743031e0e8ad0d5a20399b6ee0a5b4b1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:13:37 +0000 Subject: [PATCH 13/40] Make test cleanup ownership explicit Move Mockery verification into the shared framework base-test lifecycle so unmet expectations are attributed to the test that created them. Keep the PHPUnit subscriber as an exhaustive fallback, capture throwable resource cleanup separately from the pure static-reset registry, and preserve first-failure ordering. Remove the duplicate Testbench trait, cover all supported base cases and failure combinations, and update repository guidance so individual tests neither close Mockery nor duplicate framework-owned resets. --- AGENTS.md | 8 +- docs/ai/differences-vs-laravel.md | 2 - src/foundation/src/Testing/TestCase.php | 22 ++- src/testbench/src/PHPUnit/TestCase.php | 2 +- .../src/Concerns/InteractsWithMockery.php | 2 +- .../src/PHPUnit/AfterEachTestSubscriber.php | 29 +++- tests/TestCase.php | 29 +++- .../Concerns/InteractsWithMockeryTest.php | 152 ++++++++++++++++++ .../PHPUnit/AfterEachTestSubscriberTest.php | 118 ++++++++++++++ 9 files changed, 346 insertions(+), 18 deletions(-) rename src/{testbench => testing}/src/Concerns/InteractsWithMockery.php (95%) create mode 100644 tests/Testing/Concerns/InteractsWithMockeryTest.php diff --git a/AGENTS.md b/AGENTS.md index 7fbca8d75..42dd67a1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -580,7 +580,7 @@ Examples: `tests/Inertia/CoroutineIsolationTest.php`, `tests/Container/Coroutine #### Static State and Test Cleanup -`AfterEachTestSubscriber` handles global cleanup between tests. It calls `flushState()` on framework classes that hold static state, and resets the container itself — `Container::flushState()` + `setInstance(null)` — plus `CoroutineContext::flush()`, with each test in a fresh coroutine. So container singleton/auto-singleton instance state and coroutine context don't leak between tests; only `static`/process-global state and live external resources need package cleanup, not mutable state on a container-cached instance. **Never add cleanup in `tearDown()`** — it's handled. `AfterEachTestSubscriber` is the one authoritative registry; don't register cleanup elsewhere. +`AfterEachTestSubscriber` handles framework-global cleanup between tests. It calls `flushState()` on framework classes that hold static state, and resets the container itself — `Container::flushState()` + `setInstance(null)` — plus `CoroutineContext::flush()`, with each test in a fresh coroutine. So container singleton/auto-singleton instance state and coroutine context don't leak between tests; only `static`/process-global state and live external resources need package cleanup, not mutable state on a container-cached instance. Do not duplicate framework-static resets in `tearDown()`; `AfterEachTestSubscriber` is their one authoritative registry. Tests still own resources they create, such as child coroutines, subscribers, processes, sockets, and temporary files, and must close or join them through exception-safe cleanup. When porting source classes that use static properties for caching (e.g., `$booted`, `$globalScopes`, resolved config values, compiled formats): 1. Add a `public static function flushState(): void` method that resets the static properties to their initial values @@ -627,7 +627,11 @@ A per-package base class is only justified when there is shared setUp logic — **Always import as `m`:** Use `use Mockery as m;` and call `m::mock()`, `m::spy()`, etc. Never use the full `Mockery::` prefix. -**Never add `Mockery::close()` to tearDown.** It's handled globally by `AfterEachTestSubscriber` for all tests. +Framework base test cases own Mockery verification in `tearDown()` so unmet +expectations are attributed to the test that created them. The global +`AfterEachTestSubscriber` remains a fallback for tests using another base case +and always resets framework state even when Mockery verification fails. Tests +must not add their own `Mockery::close()` calls. #### Docblocks and Types diff --git a/docs/ai/differences-vs-laravel.md b/docs/ai/differences-vs-laravel.md index ce354b37b..594d4b4f0 100644 --- a/docs/ai/differences-vs-laravel.md +++ b/docs/ai/differences-vs-laravel.md @@ -78,5 +78,3 @@ Hypervel caches more aggressively than Laravel: any class resolved via `make()` - `setUp()` / `tearDown()` run outside the test's coroutine. Use `setUpInCoroutine()` / `tearDownInCoroutine()` for code that must run inside it. - Request and Response are coroutine-local. The `'request'` and `Hypervel\Http\Response::class` container bindings are `bind()` closures that read from `RequestContext` / `ResponseContext`. The Laravel pattern `$this->app->instance('request', $r)` (or `instance(Response::class, $r)`) doesn't apply — it overrides the closure with a worker-global value and bypasses the production resolution path. Use `RequestContext::set($r)` / `ResponseContext::set($r)` instead. - After seeding via `RequestContext::set(...)`, `request()->merge([...])` works as in Laravel. Without seeding, each `request()` call returns a throwaway, so `merge()` is lost. -- Don't add `Mockery::close()` to `tearDown()` — handled globally. - diff --git a/src/foundation/src/Testing/TestCase.php b/src/foundation/src/Testing/TestCase.php index 3200fb17d..51868931a 100644 --- a/src/foundation/src/Testing/TestCase.php +++ b/src/foundation/src/Testing/TestCase.php @@ -23,6 +23,7 @@ use Hypervel\Foundation\Testing\Concerns\MakesHttpRequests; use Hypervel\Foundation\Testing\Concerns\MocksApplicationServices; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; +use Hypervel\Testing\Concerns\InteractsWithMockery; use ReflectionMethod; use Throwable; @@ -43,6 +44,7 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase use InteractsWithViews; use MocksApplicationServices; use RunTestsInCoroutine; + use InteractsWithMockery; use WithFaker; /** @@ -89,11 +91,25 @@ protected function createApplication(): ApplicationContract */ protected function tearDown(): void { - if ($this->withoutBootingFramework()) { - return; + $exception = null; + + try { + if (! $this->withoutBootingFramework()) { + $this->tearDownTheTestEnvironment(); + } + } catch (Throwable $throwable) { + $exception = $throwable; } - $this->tearDownTheTestEnvironment(); + try { + $this->tearDownTheTestEnvironmentUsingMockery(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } } /** diff --git a/src/testbench/src/PHPUnit/TestCase.php b/src/testbench/src/PHPUnit/TestCase.php index a27d6450f..32c77e0e6 100644 --- a/src/testbench/src/PHPUnit/TestCase.php +++ b/src/testbench/src/PHPUnit/TestCase.php @@ -5,7 +5,7 @@ namespace Hypervel\Testbench\PHPUnit; use Hypervel\Testbench\Concerns\HandlesAssertions; -use Hypervel\Testbench\Concerns\InteractsWithMockery; +use Hypervel\Testing\Concerns\InteractsWithMockery; use Override; use Throwable; diff --git a/src/testbench/src/Concerns/InteractsWithMockery.php b/src/testing/src/Concerns/InteractsWithMockery.php similarity index 95% rename from src/testbench/src/Concerns/InteractsWithMockery.php rename to src/testing/src/Concerns/InteractsWithMockery.php index 451308d5d..ea310c1f5 100644 --- a/src/testbench/src/Concerns/InteractsWithMockery.php +++ b/src/testing/src/Concerns/InteractsWithMockery.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Testbench\Concerns; +namespace Hypervel\Testing\Concerns; use Mockery; use PHPUnit\Framework\TestCase as PHPUnitTestCase; diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index aadb8df31..432f30362 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -7,6 +7,7 @@ use Mockery; use PHPUnit\Event\Test\Finished; use PHPUnit\Event\Test\FinishedSubscriber; +use Throwable; /** * Global cleanup after every test method. @@ -30,10 +31,30 @@ public function notify(Finished $event): void */ public function flushStateAfterTest(): void { + $exception = null; + try { AfterEachTestCleanup::runCallbacks(); - } finally { - $this->flushFrameworkState(); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + try { + Mockery::close(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + \Hypervel\Foundation\Testing\DatabaseConnectionResolver::flushCachedConnections(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + $this->flushFrameworkState(); + + if ($exception !== null) { + throw $exception; } } @@ -42,8 +63,6 @@ public function flushStateAfterTest(): void */ protected function flushFrameworkState(): void { - Mockery::close(); - \Carbon\Carbon::resetMacros(); \Carbon\Carbon::resetToStringFormat(); \Carbon\Carbon::serializeUsing(null); @@ -136,7 +155,6 @@ protected function flushFrameworkState(): void \Hypervel\Foundation\PackageManifest::flushState(); \Hypervel\Foundation\Support\Providers\EventServiceProvider::flushState(); \Hypervel\Foundation\Support\Providers\RouteServiceProvider::flushState(); - \Hypervel\Foundation\Testing\DatabaseConnectionResolver::flushCachedConnections(); \Hypervel\Foundation\Vite::flush(); \Hypervel\Foundation\WorkerCachedMaintenanceMode::flushCache(); \Hypervel\Http\Client\Request::flushState(); @@ -167,7 +185,6 @@ protected function flushFrameworkState(): void \Hypervel\Queue\Queue::flushState(); \Hypervel\Queue\Worker::flushState(); \Hypervel\Routing\CallableDispatcher::flushState(); - \Hypervel\Routing\CompiledRouteCollection::flushCache(); \Hypervel\Routing\ControllerDispatcher::flushState(); \Hypervel\Routing\ImplicitRouteBinding::flushCache(); \Hypervel\Routing\Middleware\ThrottleRequests::flushState(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 85152480c..7f0016583 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,8 +7,9 @@ use Hypervel\Foundation\Bootstrap\HandleExceptions; use Hypervel\Foundation\Testing\Concerns\InteractsWithEnvironment; use Hypervel\Foundation\Testing\Concerns\RunTestsInCoroutine; -use Hypervel\Testbench\Concerns\InteractsWithMockery; +use Hypervel\Testing\Concerns\InteractsWithMockery; use PHPUnit\Framework\TestCase as BaseTestCase; +use Throwable; class TestCase extends BaseTestCase { @@ -18,8 +19,30 @@ class TestCase extends BaseTestCase protected function tearDown(): void { - HandleExceptions::flushState($this); + $exception = null; + + try { + $this->flushExceptionHandlerState(); + } catch (Throwable $throwable) { + $exception = $throwable; + } - $this->tearDownTheTestEnvironmentUsingMockery(); + try { + $this->tearDownTheTestEnvironmentUsingMockery(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } + } + + /** + * Flush the global exception-handler state. + */ + protected function flushExceptionHandlerState(): void + { + HandleExceptions::flushState($this); } } diff --git a/tests/Testing/Concerns/InteractsWithMockeryTest.php b/tests/Testing/Concerns/InteractsWithMockeryTest.php new file mode 100644 index 000000000..625407d65 --- /dev/null +++ b/tests/Testing/Concerns/InteractsWithMockeryTest.php @@ -0,0 +1,152 @@ + $testCaseClass + */ + #[DataProvider('caseProvider')] + public function testBaseCaseVerifiesUnmetExpectations(string $testCaseClass): void + { + $testCase = new $testCaseClass('placeholder'); + m::mock()->shouldReceive('expected')->once(); + + try { + $testCase->finish(); + $this->fail('Expected Mockery verification to fail.'); + } catch (InvalidCountException) { + $this->addToAssertionCount(1); + } + + $this->assertNull((new ReflectionProperty(m::class, '_container'))->getValue()); + } + + /** + * @param class-string $testCaseClass + */ + #[DataProvider('caseProvider')] + public function testBaseCaseAddsMockeryExpectationsToItsAssertionCount(string $testCaseClass): void + { + $testCase = new $testCaseClass('placeholder'); + $mock = m::mock(); + $mock->shouldReceive('expected')->once(); + $mock->expected(); + + $testCase->finish(); + + $this->assertSame(1, $testCase->assertionCount()); + } + + /** + * @return iterable}> + */ + public static function caseProvider(): iterable + { + yield 'components' => [ComponentsMockeryTestCase::class]; + yield 'testbench' => [TestbenchMockeryTestCase::class]; + yield 'foundation' => [FoundationMockeryTestCase::class]; + } + + public function testComponentsBaseCaseStillVerifiesMockeryWhenExceptionCleanupFails(): void + { + $testCase = new ComponentsMockeryTestCase('placeholder'); + $testCase->failExceptionCleanup = true; + m::mock()->shouldReceive('expected')->once(); + + try { + $testCase->finish(); + $this->fail('Expected exception cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('exception cleanup failed', $exception->getMessage()); + } + + $this->assertNull((new ReflectionProperty(m::class, '_container'))->getValue()); + } +} + +interface MockeryLifecycleTestCase +{ + public function finish(): void; + + public function assertionCount(): int; +} + +class ComponentsMockeryTestCase extends TestCase implements MockeryLifecycleTestCase +{ + public bool $failExceptionCleanup = false; + + public function placeholder(): void + { + } + + public function finish(): void + { + $this->tearDown(); + } + + public function assertionCount(): int + { + return $this->numberOfAssertionsPerformed(); + } + + protected function flushExceptionHandlerState(): void + { + if ($this->failExceptionCleanup) { + throw new RuntimeException('exception cleanup failed'); + } + + parent::flushExceptionHandlerState(); + } +} + +class TestbenchMockeryTestCase extends TestbenchTestCase implements MockeryLifecycleTestCase +{ + public function placeholder(): void + { + } + + public function finish(): void + { + $this->tearDown(); + } + + public function assertionCount(): int + { + return $this->numberOfAssertionsPerformed(); + } +} + +class FoundationMockeryTestCase extends FoundationTestCase implements MockeryLifecycleTestCase +{ + public function placeholder(): void + { + } + + public function finish(): void + { + $this->tearDown(); + } + + public function assertionCount(): int + { + return $this->numberOfAssertionsPerformed(); + } + + protected function withoutBootingFramework(): bool + { + return true; + } +} diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index 7e47ec790..d46948cad 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -4,10 +4,15 @@ namespace Hypervel\Tests\Testing\PHPUnit; +use Hypervel\Contracts\Pool\ConnectionInterface; +use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Testing\PHPUnit\AfterEachTestCleanup; use Hypervel\Testing\PHPUnit\AfterEachTestSubscriber; use Hypervel\Tests\TestCase; +use Mockery as m; +use Mockery\Exception\InvalidCountException; use Override; +use ReflectionClass; use RuntimeException; class AfterEachTestSubscriberTest extends TestCase @@ -84,4 +89,117 @@ protected function flushFrameworkState(): void $this->assertSame(['custom', 'framework'], $subscriber->order); } + + public function testFlushStateAfterTestRunsFrameworkCleanupWhenMockeryVerificationFails(): void + { + $subscriber = new class extends AfterEachTestSubscriber { + public bool $frameworkStateFlushed = false; + + /** + * Flush framework-owned static state. + */ + protected function flushFrameworkState(): void + { + $this->frameworkStateFlushed = true; + } + }; + m::mock()->shouldReceive('expected')->once(); + + try { + $subscriber->flushStateAfterTest(); + $this->fail('Expected Mockery verification to fail.'); + } catch (InvalidCountException) { + $this->addToAssertionCount(1); + } + + $this->assertTrue($subscriber->frameworkStateFlushed); + } + + public function testCustomCleanupFailureRemainsPrimaryWhenMockeryAlsoFails(): void + { + $subscriber = new class extends AfterEachTestSubscriber { + public bool $frameworkStateFlushed = false; + + /** + * Flush framework-owned static state. + */ + protected function flushFrameworkState(): void + { + $this->frameworkStateFlushed = true; + } + }; + $expectedException = new RuntimeException('custom cleanup failed'); + AfterEachTestCleanup::flushUsing('vendor/package', static function () use ($expectedException): never { + throw $expectedException; + }); + m::mock()->shouldReceive('expected')->once(); + + try { + $subscriber->flushStateAfterTest(); + $this->fail('Expected cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($expectedException, $exception); + } + + $this->assertTrue($subscriber->frameworkStateFlushed); + } + + public function testDatabaseCleanupFailureDoesNotSkipFrameworkStateReset(): void + { + $expectedException = new RuntimeException('database cleanup failed'); + $connection = new class($expectedException) implements ConnectionInterface { + public function __construct(private RuntimeException $exception) + { + } + + public function getConnection(): mixed + { + return null; + } + + public function reconnect(): bool + { + return true; + } + + public function check(): bool + { + return true; + } + + public function close(): bool + { + return true; + } + + public function release(): void + { + } + + public function discard(): void + { + throw $this->exception; + } + }; + $property = (new ReflectionClass(DatabaseConnectionResolver::class)) + ->getProperty('pooledConnections'); + $property->setValue(null, ['testing' => $connection]); + $subscriber = new class extends AfterEachTestSubscriber { + public bool $frameworkStateFlushed = false; + + protected function flushFrameworkState(): void + { + $this->frameworkStateFlushed = true; + } + }; + + try { + $subscriber->flushStateAfterTest(); + $this->fail('Expected database cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($expectedException, $exception); + } + + $this->assertTrue($subscriber->frameworkStateFlushed); + } } From 481c5fdc9a83d14af3b92ea02601f1b321b09a6c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:13:56 +0000 Subject: [PATCH 14/40] Harden pool ownership transitions Add an explicit discard operation to connection pools so borrowed resources can be destroyed while restoring capacity. Enforce managed and borrowed ownership across connection wrappers and preserve structurally aligned connection-pool and object-pool behavior. Make outside-coroutine wake failure non-throwing after state is committed, and give checkout exactly one final idle and capacity pass at its deadline. Cover release, discard, missed wake, foreign ownership, and no-second-wait behavior in both pool implementations. --- .../src/Pool/ConnectionInterface.php | 5 + src/contracts/src/Pool/PoolInterface.php | 5 + src/object-pool/src/Channel.php | 7 +- src/object-pool/src/ObjectPool.php | 6 +- src/pool/src/Channel.php | 7 +- src/pool/src/Connection.php | 8 ++ src/pool/src/KeepaliveConnection.php | 8 ++ src/pool/src/Pool.php | 29 ++++- tests/ObjectPool/ChannelTest.php | 24 ++++ .../ObjectPool/ObjectPoolNonCoroutineTest.php | 78 ++++++++++++ tests/ObjectPool/ObjectPoolTest.php | 56 +++++++++ tests/Pool/ChannelTest.php | 24 ++++ tests/Pool/ConnectionTest.php | 14 +++ tests/Pool/HeartbeatConnectionTest.php | 12 ++ tests/Pool/PoolNonCoroutineTest.php | 55 +++++++++ tests/Pool/PoolTest.php | 111 ++++++++++++++++++ 16 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 tests/ObjectPool/ObjectPoolNonCoroutineTest.php diff --git a/src/contracts/src/Pool/ConnectionInterface.php b/src/contracts/src/Pool/ConnectionInterface.php index abb4cf1ba..7cd8e495b 100644 --- a/src/contracts/src/Pool/ConnectionInterface.php +++ b/src/contracts/src/Pool/ConnectionInterface.php @@ -30,4 +30,9 @@ public function close(): bool; * Release the connection to pool. */ public function release(): void; + + /** + * Discard the connection from its pool. + */ + public function discard(): void; } diff --git a/src/contracts/src/Pool/PoolInterface.php b/src/contracts/src/Pool/PoolInterface.php index 87d4c34c9..8db02bc42 100644 --- a/src/contracts/src/Pool/PoolInterface.php +++ b/src/contracts/src/Pool/PoolInterface.php @@ -21,6 +21,11 @@ public function get(): ConnectionInterface; */ public function release(ConnectionInterface $connection): void; + /** + * Discard a borrowed connection from the connection pool. + */ + public function discard(ConnectionInterface $connection): void; + /** * Close idle connections in excess of the minimum pool size. */ diff --git a/src/object-pool/src/Channel.php b/src/object-pool/src/Channel.php index a4b036255..a260f5b31 100644 --- a/src/object-pool/src/Channel.php +++ b/src/object-pool/src/Channel.php @@ -6,6 +6,7 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel as EngineChannel; +use Hypervel\Engine\Exceptions\CoroutineCreateException; use SplQueue; /** @@ -104,7 +105,11 @@ public function signal(): void return; } - Coroutine::create($this->pushSignal(...)); + try { + Coroutine::create($this->pushSignal(...)); + } catch (CoroutineCreateException) { + // The state change is already committed; checkout performs a final state pass. + } } /** diff --git a/src/object-pool/src/ObjectPool.php b/src/object-pool/src/ObjectPool.php index 81ec86b7c..ae10075c8 100644 --- a/src/object-pool/src/ObjectPool.php +++ b/src/object-pool/src/ObjectPool.php @@ -326,6 +326,8 @@ protected function exceedsMaxLifetime(object $object): bool */ private function getObject(int $deadline): object { + $timedOut = false; + while (true) { if ($this->closed) { throw new RuntimeException('Cannot borrow from a closed pool.'); @@ -377,9 +379,11 @@ private function getObject(int $deadline): object return $object; } - if (! $this->waitForStateChange($deadline)) { + if ($timedOut) { throw new RuntimeException('Object pool exhausted. Cannot create new object before wait_timeout.'); } + + $timedOut = ! $this->waitForStateChange($deadline); } } diff --git a/src/pool/src/Channel.php b/src/pool/src/Channel.php index f38815998..bee6254d4 100644 --- a/src/pool/src/Channel.php +++ b/src/pool/src/Channel.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Pool\ConnectionInterface; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel as EngineChannel; +use Hypervel\Engine\Exceptions\CoroutineCreateException; use SplQueue; /** @@ -105,7 +106,11 @@ public function signal(): void return; } - Coroutine::create($this->pushSignal(...)); + try { + Coroutine::create($this->pushSignal(...)); + } catch (CoroutineCreateException) { + // The state change is already committed; checkout performs a final state pass. + } } /** diff --git a/src/pool/src/Connection.php b/src/pool/src/Connection.php index 1292b4958..b07200b96 100644 --- a/src/pool/src/Connection.php +++ b/src/pool/src/Connection.php @@ -62,6 +62,14 @@ public function release(): void } } + /** + * Discard the connection from its pool. + */ + public function discard(): void + { + $this->pool->discard($this); + } + /** * Get the underlying connection, with retry on failure. */ diff --git a/src/pool/src/KeepaliveConnection.php b/src/pool/src/KeepaliveConnection.php index 381d3b668..b6a0875a1 100644 --- a/src/pool/src/KeepaliveConnection.php +++ b/src/pool/src/KeepaliveConnection.php @@ -55,6 +55,14 @@ public function release(): void $this->pool->release($this); } + /** + * Discard the connection from its pool. + */ + public function discard(): void + { + $this->pool->discard($this); + } + /** * @throws InvalidArgumentException */ diff --git a/src/pool/src/Pool.php b/src/pool/src/Pool.php index 0586ac6c8..11f978c60 100644 --- a/src/pool/src/Pool.php +++ b/src/pool/src/Pool.php @@ -91,7 +91,7 @@ public function get(): ConnectionInterface */ public function release(ConnectionInterface $connection): void { - $connectionId = $this->assertBorrowed($connection); + $connectionId = $this->assertBorrowed($connection, 'release'); unset($this->borrowedConnections[$connectionId]); if ($this->closed) { @@ -103,6 +103,15 @@ public function release(ConnectionInterface $connection): void $this->requeueConnection($connection); } + /** + * Discard a borrowed connection from the pool. + */ + public function discard(ConnectionInterface $connection): void + { + $this->assertBorrowed($connection, 'discard'); + $this->destroyConnection($connection); + } + /** * Close idle connections in excess of the minimum pool size. */ @@ -337,6 +346,8 @@ protected function getLogger(): ?StdoutLoggerInterface */ private function getConnection(int $deadline): ConnectionInterface { + $timedOut = false; + while (true) { if ($this->closed) { throw new RuntimeException('Cannot borrow from a closed connection pool.'); @@ -381,11 +392,13 @@ private function getConnection(int $deadline): ConnectionInterface return $connection; } - if (! $this->waitForStateChange($deadline)) { + if ($timedOut) { throw new RuntimeException( 'Connection pool exhausted. Cannot establish new connection before wait_timeout.' ); } + + $timedOut = ! $this->waitForStateChange($deadline); } } @@ -429,16 +442,22 @@ protected function deadline(float $seconds): int /** * Assert that a connection is currently borrowed from this pool. */ - private function assertBorrowed(ConnectionInterface $connection): int + private function assertBorrowed(ConnectionInterface $connection, string $operation): int { $connectionId = spl_object_id($connection); if (! isset($this->managedConnections[$connectionId])) { - throw new RuntimeException('Cannot release a connection this pool does not manage.'); + throw new RuntimeException(sprintf( + 'Cannot %s a connection this pool does not manage.', + $operation, + )); } if (! isset($this->borrowedConnections[$connectionId])) { - throw new RuntimeException('Cannot release a connection that is not checked out (double release?).'); + throw new RuntimeException(sprintf( + 'Cannot %s a connection that is not checked out.', + $operation, + )); } return $connectionId; diff --git a/tests/ObjectPool/ChannelTest.php b/tests/ObjectPool/ChannelTest.php index d21c3cb48..11e1a3802 100644 --- a/tests/ObjectPool/ChannelTest.php +++ b/tests/ObjectPool/ChannelTest.php @@ -7,7 +7,10 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\ObjectPool\Channel; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use stdClass; +use Swoole\Coroutine as SwooleCoroutine; +use Swoole\Event; use function Hypervel\Coroutine\run; @@ -128,6 +131,27 @@ public function testPushAfterCloseIsRejectedWithoutRetainingTheObject(): void $this->assertSame(0, $channel->length()); $this->assertFalse($channel->pop()); } + + #[RunInSeparateProcess] + public function testOutsideCoroutinePushCommitsWhenAWakeCoroutineCannotBeCreated(): void + { + SwooleCoroutine::set(['max_coroutine' => 1]); + $channel = new Channel(1); + $waitResult = null; + $object = new stdClass; + + SwooleCoroutine::create(function () use ($channel, &$waitResult): void { + $waitResult = $channel->wait(1.0); + }); + + $this->assertTrue($channel->push($object)); + $this->assertSame($object, $channel->pop()); + + $channel->close(); + Event::wait(); + + $this->assertTrue($waitResult); + } } class FullSignalObjectPoolChannel extends Channel diff --git a/tests/ObjectPool/ObjectPoolNonCoroutineTest.php b/tests/ObjectPool/ObjectPoolNonCoroutineTest.php new file mode 100644 index 000000000..3002378e6 --- /dev/null +++ b/tests/ObjectPool/ObjectPoolNonCoroutineTest.php @@ -0,0 +1,78 @@ +createPool(); + $borrowed = $pool->get(); + $replacement = null; + SwooleCoroutine::set(['max_coroutine' => 1]); + SwooleCoroutine::create(function () use ($pool, &$replacement): void { + $replacement = $pool->get(); + }); + + $pool->release($borrowed); + Event::wait(); + + $this->assertSame($borrowed, $replacement); + $pool->release($replacement); + } + + #[RunInSeparateProcess] + public function testDeadlineDiscardRemainsCommittedWhenItsWakeCannotBeCreated(): void + { + $pool = $this->createPool(); + $borrowed = $pool->get(); + $replacement = null; + SwooleCoroutine::set(['max_coroutine' => 1]); + SwooleCoroutine::create(function () use ($pool, &$replacement): void { + $replacement = $pool->get(); + }); + + $pool->discard($borrowed); + Event::wait(); + + $this->assertInstanceOf(stdClass::class, $replacement); + $this->assertNotSame($borrowed, $replacement); + $pool->release($replacement); + } + + private function createPool(): NonCoroutineObjectPool + { + $container = new Container; + Container::setInstance($container); + + return new NonCoroutineObjectPool( + $container, + PoolOptions::fromArray([ + 'max_objects' => 1, + 'wait_timeout' => 0.001, + ]), + ); + } +} + +class NonCoroutineObjectPool extends ObjectPool +{ + protected function createObject(): object + { + return new stdClass; + } +} diff --git a/tests/ObjectPool/ObjectPoolTest.php b/tests/ObjectPool/ObjectPoolTest.php index 803072774..f09d9ffc4 100644 --- a/tests/ObjectPool/ObjectPoolTest.php +++ b/tests/ObjectPool/ObjectPoolTest.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Coroutine\Coroutine; +use Hypervel\ObjectPool\Channel as ObjectPoolChannel; use Hypervel\ObjectPool\ObjectPool; use Hypervel\ObjectPool\PoolOptions; use Hypervel\Tests\TestCase; @@ -359,6 +360,38 @@ public function testExhaustedPoolUsesOneWaitTimeoutFailurePath(): void $pool->get(); } + public function testCheckoutPerformsOneFinalPassAfterADeadlineRelease(): void + { + $pool = $this->pool(['max_objects' => 1, 'wait_timeout' => 0.001]); + $borrowed = $pool->get(); + $channel = new DeadlineObjectPoolChannel(function () use ($borrowed, $pool): void { + $pool->release($borrowed); + }); + $pool->replaceChannel($channel); + + $this->assertSame($borrowed, $returned = $pool->get()); + $this->assertSame(1, $channel->waitCount); + + $pool->release($returned); + $pool->close(); + } + + public function testCheckoutPerformsOneFinalPassAfterADeadlineDiscard(): void + { + $pool = $this->pool(['max_objects' => 1, 'wait_timeout' => 0.001]); + $borrowed = $pool->get(); + $channel = new DeadlineObjectPoolChannel(function () use ($borrowed, $pool): void { + $pool->discard($borrowed); + }); + $pool->replaceChannel($channel); + + $this->assertNotSame($borrowed, $replacement = $pool->get()); + $this->assertSame(1, $channel->waitCount); + + $pool->release($replacement); + $pool->close(); + } + public function testSweepExpiredDestroysBelowTheRetentionFloor(): void { $pool = $this->pool([ @@ -679,8 +712,31 @@ public function agePool(float $seconds): void $this->lastUsedAt = hrtime(true) - (int) ($seconds * 1e9); } + public function replaceChannel(ObjectPoolChannel $channel): void + { + $this->channel = $channel; + } + protected function createObject(): object { return ($this->factory)(); } } + +class DeadlineObjectPoolChannel extends ObjectPoolChannel +{ + public int $waitCount = 0; + + public function __construct(protected Closure $onWait) + { + parent::__construct(1); + } + + public function wait(float $timeout): bool + { + ++$this->waitCount; + ($this->onWait)(); + + return false; + } +} diff --git a/tests/Pool/ChannelTest.php b/tests/Pool/ChannelTest.php index cf5b86e4c..245ca2102 100644 --- a/tests/Pool/ChannelTest.php +++ b/tests/Pool/ChannelTest.php @@ -9,6 +9,9 @@ use Hypervel\Pool\Channel; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use Swoole\Coroutine as SwooleCoroutine; +use Swoole\Event; use function Hypervel\Coroutine\run; @@ -129,6 +132,27 @@ public function testPushAfterCloseIsRejectedWithoutRetainingTheConnection(): voi $this->assertSame(0, $channel->length()); $this->assertFalse($channel->pop()); } + + #[RunInSeparateProcess] + public function testOutsideCoroutinePushCommitsWhenAWakeCoroutineCannotBeCreated(): void + { + SwooleCoroutine::set(['max_coroutine' => 1]); + $channel = new Channel(1); + $waitResult = null; + $connection = m::mock(ConnectionInterface::class); + + SwooleCoroutine::create(function () use ($channel, &$waitResult): void { + $waitResult = $channel->wait(1.0); + }); + + $this->assertTrue($channel->push($connection)); + $this->assertSame($connection, $channel->pop()); + + $channel->close(); + Event::wait(); + + $this->assertTrue($waitResult); + } } class FullSignalConnectionPoolChannel extends Channel diff --git a/tests/Pool/ConnectionTest.php b/tests/Pool/ConnectionTest.php index f09508e04..ce5d463b0 100644 --- a/tests/Pool/ConnectionTest.php +++ b/tests/Pool/ConnectionTest.php @@ -65,6 +65,20 @@ public function testDontHaveEvents(): void $this->assertTrue(true); } + public function testDiscardDelegatesToOwningPool(): void + { + $container = m::mock(ContainerContract::class); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->once()->andReturnFalse(); + $container->shouldReceive('bound')->with('events')->andReturnFalse(); + $pool = m::mock(Pool::class); + $connection = new ActiveConnectionStub($container, $pool); + $pool->shouldReceive('discard')->once()->with($connection); + + $connection->discard(); + + $this->addToAssertionCount(1); + } + public function testCheckDoesNotResetActivityTimestamp(): void { $container = m::mock(ContainerContract::class); diff --git a/tests/Pool/HeartbeatConnectionTest.php b/tests/Pool/HeartbeatConnectionTest.php index 87b647862..3a19e552b 100644 --- a/tests/Pool/HeartbeatConnectionTest.php +++ b/tests/Pool/HeartbeatConnectionTest.php @@ -68,6 +68,18 @@ public function send(string $data): string $this->assertSame($result, str_repeat($str, 2)); } + public function testDiscardDelegatesToOwningPool(): void + { + $container = $this->getContainer(); + $pool = $container->make(HeartbeatPoolStub::class); + $connection = $pool->get(); + + $connection->discard(); + + $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getConnectionsInChannel()); + } + public function testConnectionHeartbeat(): void { $container = $this->getContainer(['heartbeat' => 0.001]); diff --git a/tests/Pool/PoolNonCoroutineTest.php b/tests/Pool/PoolNonCoroutineTest.php index 6cb0df053..c5dc7f57d 100644 --- a/tests/Pool/PoolNonCoroutineTest.php +++ b/tests/Pool/PoolNonCoroutineTest.php @@ -8,7 +8,10 @@ use Hypervel\Contracts\Pool\ConnectionInterface; use Hypervel\Pool\Pool; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use RuntimeException; +use Swoole\Coroutine as SwooleCoroutine; +use Swoole\Event; class PoolNonCoroutineTest extends TestCase { @@ -28,6 +31,54 @@ public function testExhaustedPoolFailsWithoutTryingToBlock(): void $pool->get(); } + + #[RunInSeparateProcess] + public function testDeadlineReleaseRemainsCommittedWhenItsWakeCannotBeCreated(): void + { + $pool = $this->createPool(); + $borrowed = $pool->get(); + $replacement = null; + SwooleCoroutine::set(['max_coroutine' => 1]); + SwooleCoroutine::create(function () use ($pool, &$replacement): void { + $replacement = $pool->get(); + }); + + $pool->release($borrowed); + Event::wait(); + + $this->assertSame($borrowed, $replacement); + $pool->release($replacement); + } + + #[RunInSeparateProcess] + public function testDeadlineDiscardRemainsCommittedWhenItsWakeCannotBeCreated(): void + { + $pool = $this->createPool(); + $borrowed = $pool->get(); + $replacement = null; + SwooleCoroutine::set(['max_coroutine' => 1]); + SwooleCoroutine::create(function () use ($pool, &$replacement): void { + $replacement = $pool->get(); + }); + + $pool->discard($borrowed); + Event::wait(); + + $this->assertInstanceOf(ConnectionInterface::class, $replacement); + $this->assertNotSame($borrowed, $replacement); + $pool->release($replacement); + } + + private function createPool(): NonCoroutinePool + { + $container = new Container; + Container::setInstance($container); + + return new NonCoroutinePool($container, 'test', [ + 'max_connections' => 1, + 'wait_timeout' => 0.001, + ]); + } } class NonCoroutinePool extends Pool @@ -63,4 +114,8 @@ public function close(): bool public function release(): void { } + + public function discard(): void + { + } } diff --git a/tests/Pool/PoolTest.php b/tests/Pool/PoolTest.php index c1848c2ee..3e9c938f5 100644 --- a/tests/Pool/PoolTest.php +++ b/tests/Pool/PoolTest.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Pool\ConnectionInterface; use Hypervel\Contracts\Pool\FrequencyInterface; use Hypervel\Coroutine\Coroutine; +use Hypervel\Pool\Channel as PoolChannel; use Hypervel\Pool\LowFrequencyInterface; use Hypervel\Pool\Pool; use Hypervel\Tests\TestCase; @@ -207,6 +208,59 @@ public function testForeignAndDoubleReleasesAreRejected(): void $pool->release(new PoolConnectionStub); } + public function testDiscardDestroysBorrowedConnectionAndRestoresCapacity(): void + { + $connections = []; + $pool = $this->createPool( + ['max_connections' => 1], + function () use (&$connections): ConnectionInterface { + return $connections[] = new PoolConnectionStub; + }, + ); + $connection = $pool->get(); + + $pool->discard($connection); + + $this->assertSame(1, $connection->closeCount); + $this->assertSame(0, $pool->getCurrentConnections()); + $this->assertSame(0, $pool->getConnectionsInChannel()); + $this->assertNotSame($connection, $replacement = $pool->get()); + $this->assertSame(1, $pool->getCurrentConnections()); + $pool->release($replacement); + } + + public function testForeignIdleAndAlreadyDiscardedConnectionsAreRejected(): void + { + $pool = $this->createPool(); + + try { + $pool->discard(new PoolConnectionStub); + $this->fail('Discarding a foreign connection must throw.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('does not manage', $exception->getMessage()); + } + + $idle = $pool->get(); + $pool->release($idle); + + try { + $pool->discard($idle); + $this->fail('Discarding an idle connection must throw.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('not checked out', $exception->getMessage()); + } + + $discarded = $pool->get(); + $pool->discard($discarded); + + try { + $pool->discard($discarded); + $this->fail('Discarding a destroyed connection must throw.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('does not manage', $exception->getMessage()); + } + } + public function testDuplicateFactoryConnectionIsRejected(): void { $connection = new PoolConnectionStub; @@ -302,6 +356,36 @@ public function testExhaustedPoolTimesOut(): void $pool->get(); } + public function testCheckoutPerformsOneFinalPassAfterADeadlineRelease(): void + { + $pool = $this->createPool(['max_connections' => 1, 'wait_timeout' => 0.001]); + $borrowed = $pool->get(); + $channel = new DeadlinePoolChannel(function () use ($borrowed, $pool): void { + $pool->release($borrowed); + }); + $pool->replaceChannel($channel); + + $this->assertSame($borrowed, $returned = $pool->get()); + $this->assertSame(1, $channel->waitCount); + + $pool->release($returned); + } + + public function testCheckoutPerformsOneFinalPassAfterADeadlineDiscard(): void + { + $pool = $this->createPool(['max_connections' => 1, 'wait_timeout' => 0.001]); + $borrowed = $pool->get(); + $channel = new DeadlinePoolChannel(function () use ($borrowed, $pool): void { + $pool->discard($borrowed); + }); + $pool->replaceChannel($channel); + + $this->assertNotSame($borrowed, $replacement = $pool->get()); + $this->assertSame(1, $channel->waitCount); + + $pool->release($replacement); + } + public function testUnhealthyIdleConnectionIsDestroyed(): void { $first = new PoolConnectionStub(checkCallback: static fn (): bool => false); @@ -433,12 +517,35 @@ public function deadlineForTest(float $seconds): int return $this->deadline($seconds); } + public function replaceChannel(PoolChannel $channel): void + { + $this->channel = $channel; + } + protected function createConnection(): ConnectionInterface { return ($this->connectionFactory)(); } } +class DeadlinePoolChannel extends PoolChannel +{ + public int $waitCount = 0; + + public function __construct(protected Closure $onWait) + { + parent::__construct(1); + } + + public function wait(float $timeout): bool + { + ++$this->waitCount; + ($this->onWait)(); + + return false; + } +} + class PoolConnectionStub implements ConnectionInterface { public int $checkCount = 0; @@ -478,4 +585,8 @@ public function close(): bool public function release(): void { } + + public function discard(): void + { + } } From 3c06dccb1f383038131e3c3848fec316d0b1265c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:14:09 +0000 Subject: [PATCH 15/40] Retain pooled wrappers for testing database connections Keep each testing resolver's owning pooled wrapper alongside its stable bare database connection, then discard the wrapper explicitly during named, terminal, and container-change cleanup. Split reusable reset behavior from terminal flush behavior and order resolver teardown before pool shutdown. Always detach a discarded wrapper from its connection while preserving the pool-retained shared in-memory SQLite PDO, including rollback of transactions owned by that wrapper. Add max-one-capacity, write-routing, shared-database, transaction, and lifecycle regressions. --- src/database/src/Pool/PooledConnection.php | 16 ++- .../InteractsWithTestCaseLifecycle.php | 6 +- .../Testing/DatabaseConnectionResolver.php | 122 +++++++++++------- .../DatabaseConnectionResolverTest.php | 81 +++++++++++- 4 files changed, 165 insertions(+), 60 deletions(-) diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index 418060454..679a3a16f 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -241,11 +241,9 @@ public function ping(float $timeout): bool public function close(): bool { if ($this->connection instanceof Connection) { - // Only disconnect if NOT using shared in-memory SQLite PDO. - // Shared PDO is owned by the pool, not individual connections. - if ($this->pool->getSharedInMemorySqlitePdo() === null) { - $this->connection->disconnect(); - } + // This drops only the wrapper's reference. The pool retains a shared + // in-memory SQLite PDO, while wrapper-owned transactions still roll back. + $this->connection->disconnect(); } $this->connection = null; @@ -295,6 +293,14 @@ public function release(): void } } + /** + * Discard the connection from its pool. + */ + public function discard(): void + { + $this->pool->discard($this); + } + /** * Get the last use time. */ diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 7f1f8ce3d..b68cc2b26 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -73,7 +73,7 @@ protected function setUpTheTestEnvironment(): void // Reset after Application exists so container-change detection works correctly // and rebinding hooks are registered on the current container. - DatabaseConnectionResolver::flushCachedConnections(); + DatabaseConnectionResolver::resetCachedConnections(); $this->runInCoroutine(function () { $this->setUpTraits(); @@ -106,6 +106,10 @@ protected function tearDownTheTestEnvironment(): void fn () => $this->callBeforeApplicationDestroyedCallbacks() ); + $this->runInCoroutine( + fn () => DatabaseConnectionResolver::flushCachedConnections() + ); + // Flush the DB connection pool in a separate coroutine so the // pooled connections checked out during the destroyed callbacks // (e.g. migrate:rollback) are first released by their Coroutine::defer diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 52a719d27..2f865b4d4 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -8,11 +8,14 @@ use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Contracts\Pool\ConnectionInterface as PoolConnectionInterface; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionName; use Hypervel\Database\ConnectionResolver; use Hypervel\Database\FlushableConnectionResolver; +use LogicException; +use Throwable; use UnitEnum; use function Hypervel\Support\enum_value; @@ -20,21 +23,9 @@ /** * Database connection resolver for the testing environment. * - * Uses a hybrid lifecycle model: connections are created through the real - * pool infrastructure (same factory, config, and connection path as production) - * but cached statically instead of using the pool's checkout/release cycle. - * - * This is intentional — Testbench needs stable bare connections across its - * setup, transaction, and assertion helpers, while production's defer-based - * pool lifecycle releases borrowed wrappers at coroutine end. Instead, - * connections are cached per-name and reset between tests. - * - * The PooledConnection wrapper is discarded after extracting the bare - * Connection. This means pool release() never runs on the wrapper, but - * that's acceptable because: - * - flushCachedConnections() handles per-test state reset (resetForPool) - * - flush() / flushCachedConnections() disconnect PDOs to prevent leaks - * - DB::purge() flushes the entire pool when connection config changes + * Testbench needs stable bare connections across its setup, transaction, and + * assertion helpers. The resolver therefore retains each borrowed wrapper + * alongside its bare connection and explicitly discards both at teardown. */ class DatabaseConnectionResolver extends ConnectionResolver implements FlushableConnectionResolver { @@ -45,6 +36,13 @@ class DatabaseConnectionResolver extends ConnectionResolver implements Flushable */ protected static array $connections = []; + /** + * Borrowed pooled wrappers that own the cached bare connections. + * + * @var array + */ + protected static array $pooledConnections = []; + /** * The object ID of the container when connections were cached. * Used to detect when a new test's container differs from previous. @@ -57,7 +55,7 @@ class DatabaseConnectionResolver extends ConnectionResolver implements Flushable protected static bool $rebindingRegistered = false; /** - * Flush all cached connections to clean state. + * Reset all cached connections to clean state for another setup phase. * * Called after Application is created to prevent test pollution (query logs, * event listeners, transaction state, etc.) from leaking between tests. @@ -67,37 +65,36 @@ class DatabaseConnectionResolver extends ConnectionResolver implements Flushable * services. A rebinding hook is registered so Event::fake() automatically * updates cached connections with the new dispatcher. */ - public static function flushCachedConnections(): void + public static function resetCachedConnections(): void { $container = Container::getInstance(); $currentContainerId = spl_object_id($container); - // If container changed, disconnect and flush all cached connections since - // they hold stale references to the old container's dispatcher and other services if (static::$containerId !== $currentContainerId) { + static::discardCachedConnections(); static::$containerId = $currentContainerId; - - foreach (static::$connections as $connection) { - if ($connection instanceof Connection) { - $connection->disconnect(); - } - } - - static::$connections = []; static::$rebindingRegistered = false; } - // Reset per-request state on remaining connections foreach (static::$connections as $connection) { if ($connection instanceof Connection) { $connection->resetForPool(); } } - // Register rebinding hook so Event::fake() updates cached connections static::registerDispatcherRebinding($container); } + /** + * Discard all cached connections and clear resolver lifecycle state. + */ + public static function flushCachedConnections(): void + { + static::discardCachedConnections(); + static::$containerId = null; + static::$rebindingRegistered = false; + } + /** * Register a rebinding hook for the event dispatcher. * @@ -128,25 +125,24 @@ protected static function registerDispatcherRebinding(ContainerContract $contain /** * Flush a cached connection. * - * Disconnects the underlying PDO before clearing the cache, ensuring - * the database connection is properly closed rather than orphaned. + * Discard the owning pooled wrapper before clearing the bare connection. */ public function flush(string $name): void { - if (isset(static::$connections[$name]) && static::$connections[$name] instanceof Connection) { - static::$connections[$name]->disconnect(); + try { + if (isset(static::$pooledConnections[$name])) { + static::$pooledConnections[$name]->discard(); + } + } finally { + unset(static::$pooledConnections[$name], static::$connections[$name]); } - - unset(static::$connections[$name]); } /** * Get a database connection instance. * - * Creates connections through the pool factory so they use the same - * configuration and creation path as production, then caches the bare - * Connection statically. The PooledConnection wrapper is intentionally - * discarded — see the class docblock for the reasoning. + * Creates connections through the pool factory and retains their owning + * wrappers until terminal test teardown. */ public function connection(UnitEnum|string|null $name = null): ConnectionInterface { @@ -162,24 +158,56 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa if ($connection = static::$connections[$connectionName->requested] ?? null) { if ($connectionName->isWrite() && $connection instanceof Connection) { - // flushCachedConnections() runs resetForPool() between tests and clears this flag. + // resetCachedConnections() runs resetForPool() between tests and clears this flag. $connection->useWriteConnectionWhenReading(); } return $connection; } - // Check out from pool, extract bare Connection, discard the wrapper. - // The wrapper's release() is not needed — see class docblock. - $connection = $this->factory - ->getPool($connectionName->requested) - ->get() - ->getConnection(); + $pooled = $this->factory->getPool($connectionName->requested)->get(); + + try { + $connection = $pooled->getConnection(); - if ($connectionName->isWrite()) { + if (! $connection instanceof ConnectionInterface) { + throw new LogicException('The database pool returned an invalid connection.'); + } + } catch (Throwable $exception) { + $pooled->discard(); + + throw $exception; + } + + if ($connectionName->isWrite() && $connection instanceof Connection) { $connection->useWriteConnectionWhenReading(); } + static::$pooledConnections[$connectionName->requested] = $pooled; + return static::$connections[$connectionName->requested] = $connection; } + + /** + * Discard every cached pooled wrapper. + */ + protected static function discardCachedConnections(): void + { + $exception = null; + + foreach (static::$pooledConnections as $pooled) { + try { + $pooled->discard(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + static::$pooledConnections = []; + static::$connections = []; + + if ($exception !== null) { + throw $exception; + } + } } diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index 5c73084f3..cdeece7e9 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -4,7 +4,10 @@ namespace Hypervel\Tests\Foundation\Testing; +use Hypervel\Container\Container; use Hypervel\Database\Connection; +use Hypervel\Database\Pool\PooledConnection; +use Hypervel\Database\Pool\PoolFactory; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Testbench\TestCase; @@ -27,7 +30,7 @@ public function testFlushDisconnectsCachedConnection() $this->assertNull($connection->getRawPdo()); } - public function testFlushCachedConnectionsDisconnectsOnContainerChange() + public function testResetCachedConnectionsDiscardsConnectionsFromAnOldContainer(): void { $resolver = $this->app->make(DatabaseConnectionResolver::class); @@ -36,18 +39,18 @@ public function testFlushCachedConnectionsDisconnectsOnContainerChange() $this->assertNotNull($connection->getPdo()); // Simulate a container change by creating a new container with a different object ID. - // flushCachedConnections() detects this via spl_object_id and should disconnect + // resetCachedConnections() detects this via spl_object_id and should disconnect // all cached connections before clearing them. - $newContainer = new \Hypervel\Container\Container; - \Hypervel\Container\Container::setInstance($newContainer); + $newContainer = new Container; + Container::setInstance($newContainer); - DatabaseConnectionResolver::flushCachedConnections(); + DatabaseConnectionResolver::resetCachedConnections(); // The old connection should have been disconnected $this->assertNull($connection->getRawPdo()); // Restore original container - \Hypervel\Container\Container::setInstance($this->app); + Container::setInstance($this->app); } public function testCachedWriteConnectionReappliesWriteReadRoutingAfterReset(): void @@ -70,11 +73,75 @@ public function testCachedWriteConnectionReappliesWriteReadRoutingAfterReset(): $connection->insert('insert into users (name) values (?)', ['Taylor']); $this->assertSame('Taylor', $connection->selectOne('select name from users')->name); - DatabaseConnectionResolver::flushCachedConnections(); + DatabaseConnectionResolver::resetCachedConnections(); $cachedConnection = $resolver->connection('testing_readwrite_suffix::write'); $this->assertSame($connection, $cachedConnection); $this->assertSame('Taylor', $cachedConnection->selectOne('select name from users')->name); } + + public function testNamedFlushDiscardsTheOwningWrapperAndRestoresPoolCapacity(): void + { + $this->app->make('config')->set('database.connections.testing.pool.max_connections', 1); + $resolver = $this->app->make(DatabaseConnectionResolver::class); + $first = $resolver->connection(); + + $resolver->flush($first->getName()); + + $this->assertNull($first->getRawPdo()); + $this->assertNotSame($first, $second = $resolver->connection()); + $this->assertNotNull($second->getPdo()); + } + + public function testTerminalFlushDiscardsEveryCachedWrapper(): void + { + $resolver = $this->app->make(DatabaseConnectionResolver::class); + $connection = $resolver->connection(); + + DatabaseConnectionResolver::flushCachedConnections(); + + $this->assertNull($connection->getRawPdo()); + $this->assertNotSame($connection, $resolver->connection()); + } + + public function testDiscardInvalidatesOnlyItsBareSharedSqliteConnection(): void + { + $pool = $this->app->make(PoolFactory::class)->getPool('testing'); + $pooled = $pool->get(); + $this->assertInstanceOf(PooledConnection::class, $pooled); + $connection = $pooled->getConnection(); + $connection->statement('create table ownership_test (value varchar)'); + $connection->insert('insert into ownership_test (value) values (?)', ['preserved']); + + $pooled->discard(); + + $this->assertNull($connection->getRawPdo()); + $replacement = $pool->get(); + $this->assertSame( + 'preserved', + $replacement->getConnection()->selectOne('select value from ownership_test')->value, + ); + $replacement->release(); + } + + public function testDiscardAndReconnectRollBackSharedSqliteTransactions(): void + { + $pool = $this->app->make(PoolFactory::class)->getPool('testing'); + $sharedPdo = $pool->getSharedInMemorySqlitePdo(); + $this->assertNotNull($sharedPdo); + + $discarded = $pool->get(); + $discarded->getConnection()->beginTransaction(); + $this->assertTrue($sharedPdo->inTransaction()); + $discarded->discard(); + $this->assertFalse($sharedPdo->inTransaction()); + + $reconnected = $pool->get(); + $reconnected->getConnection()->beginTransaction(); + $this->assertTrue($sharedPdo->inTransaction()); + $this->assertTrue($reconnected->reconnect()); + $this->assertFalse($sharedPdo->inTransaction()); + $reconnected->discard(); + } } From fc19cacbff1f76ccbec81f9cb4c084af4f6ef6c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:14:16 +0000 Subject: [PATCH 16/40] Make queue worker lifecycle explicitly owned Replace ad hoc timeout-monitor callables with one injected Timer whose exact registration is cleared on every daemon exit. Reset the monitor lock through finally, keep timeout-job suppression intact, and make the recording test worker yield without installing real signal handlers. Treat a hard job timeout as terminal for the poisoned worker process instead of waiting indefinitely for unrelated job coroutines. Unblock the worker's exact control-signal set only after its handlers are installed so a supervising parent can protect the bootstrap window. Cover monitor failures, every daemon return path, scheduler progress, stopping events, signal ownership, and immediate process termination under concurrency. --- src/queue/src/Worker.php | 211 +++++++++++++++++--------------- tests/Queue/QueueWorkerTest.php | 203 ++++++++++++++++++++++++++++-- 2 files changed, 299 insertions(+), 115 deletions(-) diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 9d9770f3d..70a1af941 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -32,6 +32,7 @@ use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Support\Carbon; use Hypervel\Support\Str; +use RuntimeException; use Throwable; class Worker @@ -51,6 +52,17 @@ class Worker */ public const RESTART_SIGNAL_CACHE_KEY = 'illuminate:queue:restart'; + /** + * Signals installed when the worker daemon starts. + */ + protected const HANDLED_SIGNALS = [ + SIGQUIT, + SIGTERM, + SIGINT, + SIGUSR2, + SIGCONT, + ]; + /** * The name of the worker. */ @@ -68,13 +80,6 @@ class Worker */ protected $isDownForMaintenance; - /** - * The callback used to monitor timeout jobs. - * - * @var null|callable - */ - protected $monitorTimeoutJobs; - /** * The running jobs. */ @@ -88,7 +93,12 @@ class Worker /** * The job monitor's ID. */ - protected int $monitorId = 0; + protected ?int $monitorId = null; + + /** + * The timer that owns the timeout monitor. + */ + protected Timer $timer; /** * Indicates if the job monitor is checking for timeout jobs. @@ -150,17 +160,17 @@ public function __construct( protected Dispatcher $events, protected ExceptionHandlerContract $exceptions, callable $isDownForMaintenance, - ?callable $monitorTimeoutJobs = null, protected int $monitorInterval = 1, + ?Timer $timer = null, ) { $this->isDownForMaintenance = $isDownForMaintenance; - $this->monitorTimeoutJobs = $monitorTimeoutJobs; + $this->timer = $timer ?? new Timer; } /** * Listen to the given queue in a loop. */ - public function daemon(string $connectionName, string $queue, WorkerOptions $options, ?callable $monitorTimeoutJobs = null): int + public function daemon(string $connectionName, string $queue, WorkerOptions $options): int { if ($this->supportsAsyncSignals()) { $this->listenForSignals($connectionName, $queue, $options); @@ -175,90 +185,94 @@ public function daemon(string $connectionName, string $queue, WorkerOptions $opt $waiter = new Waiter; $concurrent = new Concurrent($options->concurrency); - // Before we begin processing, we will setup the monitor to check for timeout jobs - $monitorTimeoutJobs - ? $monitorTimeoutJobs($options) - : $this->monitorTimeoutJobs($options); + $this->monitorTimeoutJobs($options); - while (true) { - // Before reserving any jobs, we will make sure this queue is not paused and - // if it is we will just pause this worker for a given amount of time and - // make sure we do not need to kill this worker process off completely. - if (! $this->daemonShouldRun($options, $connectionName, $queue)) { - [$status, $reason] = $this->pauseWorker($options, $lastRestart) ?? [null, null]; + try { + while (true) { + // Before reserving any jobs, we will make sure this queue is not paused and + // if it is we will just pause this worker for a given amount of time and + // make sure we do not need to kill this worker process off completely. + if (! $this->daemonShouldRun($options, $connectionName, $queue)) { + [$status, $reason] = $this->pauseWorker($options, $lastRestart) ?? [null, null]; + + if (! is_null($status)) { + return $this->stop($status, $options, $reason); + } - if (! is_null($status)) { - return $this->stop($status, $options, $reason); + continue; } - continue; - } + // If there are timeout jobs or the concurrency limit is hit, + // we should not accept new jobs + if ($this->hasTimeoutJobs() || $concurrent->isFull()) { + $this->sleep($options->sleep); + + $status = $this->stopIfNecessary( + $options, + $lastRestart, + $startTime, + $jobsProcessed, + checkQueueEmpty: false, + ); + + if (! is_null($status)) { + [$status, $reason] = $status; + + while (! $concurrent->isEmpty()) { + usleep(1000); + } + + return $this->stop($status, $options, $reason); + } + + continue; + } + + // First, we will attempt to get the next job off of the queue. + // Then, we can fire off this job in coroutine. If there are no jobs, + // we will need to sleep the worker so no more jobs are processed + // until they should be processed. + $job = $waiter->wait(fn () => $this->getNextJob( + $this->manager->connection($connectionName), + $queue + )); + if ($job) { + ++$jobsProcessed; + $concurrent->create(fn () => $this->runJob($job, $connectionName, $options)); - // If there are timeout jobs or the concurrency limit is hit, - // we should not accept new jobs - if ($this->hasTimeoutJobs() || $concurrent->isFull()) { - $this->sleep($options->sleep); + if ($options->rest > 0) { + $this->sleep($options->rest); + } + } else { + $this->sleep($options->sleep); + } + // Finally, we will check to see if we have exceeded our memory limits or if + // the queue should restart based on other indications. If so, we'll stop + // this worker and let whatever is "monitoring" it restart the process. $status = $this->stopIfNecessary( $options, $lastRestart, $startTime, $jobsProcessed, - checkQueueEmpty: false, + $job ); if (! is_null($status)) { [$status, $reason] = $status; + // Ensure in-flight job coroutines finish before daemon() reports completion. while (! $concurrent->isEmpty()) { usleep(1000); } return $this->stop($status, $options, $reason); } - - continue; - } - - // First, we will attempt to get the next job off of the queue. - // Then, we can fire off this job in coroutine. If there are no jobs, - // we will need to sleep the worker so no more jobs are processed - // until they should be processed. - $job = $waiter->wait(fn () => $this->getNextJob( - $this->manager->connection($connectionName), - $queue - )); - if ($job) { - ++$jobsProcessed; - $concurrent->create(fn () => $this->runJob($job, $connectionName, $options)); - - if ($options->rest > 0) { - $this->sleep($options->rest); - } - } else { - $this->sleep($options->sleep); } - - // Finally, we will check to see if we have exceeded our memory limits or if - // the queue should restart based on other indications. If so, we'll stop - // this worker and let whatever is "monitoring" it restart the process. - $status = $this->stopIfNecessary( - $options, - $lastRestart, - $startTime, - $jobsProcessed, - $job - ); - - if (! is_null($status)) { - [$status, $reason] = $status; - - // Ensure in-flight job coroutines finish before daemon() reports completion. - while (! $concurrent->isEmpty()) { - usleep(1000); - } - - return $this->stop($status, $options, $reason); + } finally { + if ($this->monitorId !== null) { + $this->timer->clear($this->monitorId); + $this->monitorId = null; } } } @@ -268,31 +282,28 @@ public function daemon(string $connectionName, string $queue, WorkerOptions $opt */ protected function monitorTimeoutJobs(WorkerOptions $options): void { - if ($this->monitorId) { - return; - } - - if ($this->monitorTimeoutJobs) { - ($this->monitorTimeoutJobs)($options); + if ($this->monitorId !== null) { return; } - $this->monitorId = (new Timer)->tick($this->monitorInterval, function () use ($options) { - $this->withCoroutineContext($options, function () use ($options) { + $this->monitorId = $this->timer->tick($this->monitorInterval, function () use ($options): void { + $this->withCoroutineContext($options, function () use ($options): void { if ($this->monitorLocked) { return; } $this->monitorLocked = true; - $this->terminateTimeoutJobs($options); + try { + $this->terminateTimeoutJobs($options); - if ($this->hasTimeoutJobs()) { - $this->shouldQuit = true; - $this->kill(static::EXIT_SUCCESS, $options); + if ($this->hasTimeoutJobs()) { + $this->shouldQuit = true; + $this->kill(static::EXIT_SUCCESS, $options); + } + } finally { + $this->monitorLocked = false; } - - $this->monitorLocked = false; }); }); } @@ -312,14 +323,6 @@ protected function terminateTimeoutJobs(WorkerOptions $options): void } } - /** - * Determine if there are any active coroutines. - */ - protected function hasActiveCoroutines(): bool - { - return (bool) count($this->runningJobs); - } - /** * Determine if there are any timeout jobs. */ @@ -874,6 +877,10 @@ protected function listenForSignals(string $connectionName, string $queue, Worke pcntl_signal(SIGUSR2, fn () => $this->handlePauseSignal($connectionName, $queue, $options)); pcntl_signal(SIGCONT, fn () => $this->handleResumeSignal($connectionName, $queue, $options)); + + if (! pcntl_sigprocmask(SIG_UNBLOCK, self::HANDLED_SIGNALS)) { + throw new RuntimeException('Unable to unblock queue worker signals.'); + } } /** @@ -965,19 +972,19 @@ public function stop(int $status = 0, ?WorkerOptions $options = null, ?WorkerSto /** * Kill the process. - * - * @return never */ - public function kill(int $status = 0, ?WorkerOptions $options = null): void + public function kill(int $status = 0, ?WorkerOptions $options = null): never { $this->events->dispatch(new WorkerStopping($status, $options)); - // If there are any active coroutines, we will need to wait for - // them to finish before we can exit. - while ($this->hasActiveCoroutines()) { - $this->sleep(1); - } + $this->terminateProcess($status); + } + /** + * Terminate the current worker process immediately. + */ + protected function terminateProcess(int $status): never + { if (extension_loaded('posix')) { posix_kill(getmypid(), SIGKILL); } diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 21967d03f..2cb290a4f 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -16,6 +16,9 @@ use Hypervel\Contracts\Queue\Job; use Hypervel\Contracts\Queue\Job as QueueJobContract; use Hypervel\Contracts\Queue\Queue; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\Coroutine; use Hypervel\Queue\CallQueuedHandler; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobPopped; @@ -133,16 +136,15 @@ public function testWorkerCanMonitorTimeoutJobs() $workerOptions = new WorkerOptions; $workerOptions->stopWhenEmpty = true; - $monitored = false; + $timer = new QueueWorkerTimer; $worker = $this->getWorker('default', ['queue' => [ $firstJob = new WorkerFakeJob, - ]]); + ]], timer: $timer); - $status = $worker->daemon('default', 'queue', $workerOptions, function () use (&$monitored) { - $monitored = true; - }); + $status = $worker->daemon('default', 'queue', $workerOptions); - $this->assertTrue($monitored); + $this->assertSame([1], $timer->registered); + $this->assertSame([1], $timer->cleared); $this->assertTrue($firstJob->fired); @@ -151,6 +153,64 @@ public function testWorkerCanMonitorTimeoutJobs() $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessing::class))->once(); } + public function testDaemonClearsItsMonitorWhenTheLoopThrows(): void + { + $timer = new QueueWorkerTimer; + $worker = $this->getWorker( + 'default', + ['queue' => []], + static fn (): never => throw new LoopBreakerException, + $timer, + ); + + try { + $worker->daemon('default', 'queue', new WorkerOptions); + $this->fail('Expected the daemon loop to fail.'); + } catch (LoopBreakerException) { + $this->addToAssertionCount(1); + } + + $this->assertSame([1], $timer->registered); + $this->assertSame([1], $timer->cleared); + } + + public function testTimeoutMonitorUnlocksWhenScanningThrows(): void + { + $timer = new QueueWorkerTimer; + $worker = new MonitorFailureWorker( + ...$this->workerDependencies(timer: $timer), + ); + $worker->startMonitorForTest(new WorkerOptions); + + try { + $timer->fire(1); + $this->fail('Expected timeout scanning to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('timeout scan failed', $exception->getMessage()); + } + + $this->assertFalse($worker->monitorIsLockedForTest()); + $timer->fire(1); + $this->assertSame(2, $worker->scanCount); + $this->assertFalse($worker->monitorIsLockedForTest()); + } + + public function testKillDoesNotWaitForUnrelatedActiveJobs(): void + { + $worker = new KillTestWorker(...$this->workerDependencies()); + $worker->registerCoroutineJobForTest(new WorkerFakeJob, new WorkerOptions); + + try { + $worker->kill(Worker::EXIT_SUCCESS, new WorkerOptions); + $this->fail('Expected the process termination seam to throw.'); + } catch (WorkerKilledException $exception) { + $this->assertSame(Worker::EXIT_SUCCESS, $exception->status); + } + + $this->assertNull($worker->sleptFor); + $this->events->shouldHaveReceived('dispatch')->with(m::type(WorkerStopping::class))->once(); + } + public function testWorkerCanWorkUntilQueueIsEmpty() { $workerOptions = new WorkerOptions; @@ -173,6 +233,34 @@ public function testWorkerCanWorkUntilQueueIsEmpty() $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessed::class))->twice(); } + public function testRecordingSleepStillYieldsToAConcurrencyLimitedJob(): void + { + $jobCompleted = false; + $reported = null; + $this->exceptionHandler->shouldReceive('report')->andReturnUsing( + function (Throwable $exception) use (&$reported): void { + $reported = $exception; + }, + ); + $job = new WorkerFakeJob(function () use (&$jobCompleted): void { + Coroutine::sleep(0.001); + $jobCompleted = true; + }); + $worker = $this->getWorker('default', ['queue' => [$job]]); + $options = new WorkerOptions; + $options->concurrency = 1; + $options->sleep = 0; + $options->stopWhenEmpty = true; + + $status = $worker->daemon('default', 'queue', $options); + + $this->assertSame(Worker::EXIT_SUCCESS, $status); + $this->assertTrue($job->fired); + $this->assertNull($reported, $reported?->getMessage() ?? 'Unexpected job failure.'); + $this->assertTrue($jobCompleted); + $this->assertSame(0, $worker->sleptFor); + } + public function testDaemonSleepsWhenQueueIsEmptyAtStartup() { // Regression: the idle branch must sleep even when $jobsProcessed is still 0 @@ -714,15 +802,23 @@ public function testJobReleasedEvent() * @param mixed $connectionName * @param mixed $jobs */ - private function getWorker($connectionName = 'default', $jobs = [], ?callable $isInMaintenanceMode = null): InsomniacWorker - { + private function getWorker( + $connectionName = 'default', + $jobs = [], + ?callable $isInMaintenanceMode = null, + ?Timer $timer = null, + ): InsomniacWorker { return new InsomniacWorker( - ...$this->workerDependencies($connectionName, $jobs, $isInMaintenanceMode) + ...$this->workerDependencies($connectionName, $jobs, $isInMaintenanceMode, $timer) ); } - private function workerDependencies($connectionName = 'default', $jobs = [], ?callable $isInMaintenanceMode = null): array - { + private function workerDependencies( + $connectionName = 'default', + $jobs = [], + ?callable $isInMaintenanceMode = null, + ?Timer $timer = null, + ): array { return [ new WorkerFakeManager($connectionName, new WorkerFakeConnection($connectionName, $jobs)), $this->events, @@ -730,6 +826,8 @@ private function workerDependencies($connectionName = 'default', $jobs = [], ?ca $isInMaintenanceMode ?? function () { return false; }, + 1, + $timer, ]; } @@ -761,13 +859,14 @@ private function workerJobWithRunningCommand(object $command): WorkerFakeJob */ class InsomniacWorker extends Worker { - public $sleptFor; + public int|float|null $sleptFor = null; - public $stopOnMemoryExceeded = false; + public bool $stopOnMemoryExceeded = false; public function sleep(float|int $seconds): void { $this->sleptFor = $seconds; + parent::sleep(0); } public function stop(int $status = 0, ?WorkerOptions $options = null, ?WorkerStopReason $reason = null): int @@ -809,6 +908,49 @@ public function registerCoroutineJobForTest(Job $job, WorkerOptions $options): s { return parent::registerCoroutineJob($job, $options); } + + protected function supportsAsyncSignals(): bool + { + return false; + } +} + +class MonitorFailureWorker extends InsomniacWorker +{ + public int $scanCount = 0; + + public function startMonitorForTest(WorkerOptions $options): void + { + $this->monitorTimeoutJobs($options); + } + + public function monitorIsLockedForTest(): bool + { + return $this->monitorLocked; + } + + protected function terminateTimeoutJobs(WorkerOptions $options): void + { + if (++$this->scanCount === 1) { + throw new RuntimeException('timeout scan failed'); + } + } +} + +class KillTestWorker extends InsomniacWorker +{ + protected function terminateProcess(int $status): never + { + throw new WorkerKilledException($status); + } +} + +class WorkerKilledException extends RuntimeException +{ + public function __construct(public readonly int $status) + { + parent::__construct('Worker process terminated.'); + } } class LoopAwareWorker extends Worker @@ -819,6 +961,41 @@ public function daemonShouldRunForTest(WorkerOptions $options, string $connectio } } +class QueueWorkerTimer extends Timer +{ + /** @var array */ + public array $callbacks = []; + + /** @var int[] */ + public array $registered = []; + + /** @var int[] */ + public array $cleared = []; + + public function tick( + float $timeout, + callable $closure, + string $identifier = Constants::WORKER_EXIT, + ): int { + $id = count($this->registered) + 1; + $this->registered[] = $id; + $this->callbacks[$id] = $closure; + + return $id; + } + + public function clear(int $id): void + { + $this->cleared[] = $id; + unset($this->callbacks[$id]); + } + + public function fire(int $id): void + { + ($this->callbacks[$id])(); + } +} + class WorkerFakeManager extends QueueManager { public array $connections = []; From 104635a794c4e6d7a0304f19e41e25527714c1c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:14:43 +0000 Subject: [PATCH 17/40] Protect Horizon child process control Block each child's exact handled signal set across Symfony Process startup and restore the parent's prior mask on success or failure. Unblock pending signals only after queue-worker or supervisor handlers are installed, without using Symfony's ignored-signal API that can suppress later control sends on SIGCHLD builds. Add an immediate hard-stop primitive and regressions for delayed handler installation, pending-signal delivery, mask restoration, parent-child signal-set parity, and zero-grace process termination. --- src/horizon/src/ListensForSignals.php | 13 ++ src/horizon/src/SupervisorProcess.php | 10 ++ src/horizon/src/WorkerProcess.php | 48 +++++- .../Horizon/Feature/WorkerProcessTest.php | 162 ++++++++++++++++++ 4 files changed, 232 insertions(+), 1 deletion(-) diff --git a/src/horizon/src/ListensForSignals.php b/src/horizon/src/ListensForSignals.php index fb541af1d..04e8c8bea 100644 --- a/src/horizon/src/ListensForSignals.php +++ b/src/horizon/src/ListensForSignals.php @@ -4,8 +4,17 @@ namespace Hypervel\Horizon; +use RuntimeException; + trait ListensForSignals { + protected const HANDLED_SIGNALS = [ + SIGTERM, + SIGUSR1, + SIGUSR2, + SIGCONT, + ]; + /** * The pending signals that need to be processed. */ @@ -33,6 +42,10 @@ protected function listenForSignals(): void pcntl_signal(SIGCONT, function () { $this->pendingSignals['continue'] = 'continue'; }); + + if (! pcntl_sigprocmask(SIG_UNBLOCK, self::HANDLED_SIGNALS)) { + throw new RuntimeException('Unable to unblock Horizon process signals.'); + } } /** diff --git a/src/horizon/src/SupervisorProcess.php b/src/horizon/src/SupervisorProcess.php index f7bca0a7e..d8cce64c2 100644 --- a/src/horizon/src/SupervisorProcess.php +++ b/src/horizon/src/SupervisorProcess.php @@ -14,6 +14,16 @@ class SupervisorProcess extends WorkerProcess { + /** + * Signals handled by a supervisor after its application boots. + */ + protected const STARTUP_SIGNALS = [ + SIGTERM, + SIGUSR1, + SIGUSR2, + SIGCONT, + ]; + /** * The name of the supervisor. */ diff --git a/src/horizon/src/WorkerProcess.php b/src/horizon/src/WorkerProcess.php index f43a146b0..42023352e 100644 --- a/src/horizon/src/WorkerProcess.php +++ b/src/horizon/src/WorkerProcess.php @@ -8,14 +8,27 @@ use Closure; use Hypervel\Horizon\Events\UnableToLaunchProcess; use Hypervel\Horizon\Events\WorkerProcessRestarting; +use RuntimeException; use Symfony\Component\Process\Exception\ExceptionInterface; use Symfony\Component\Process\Process; +use Throwable; /** * @mixin Process */ class WorkerProcess { + /** + * Signals handled by a queue worker after its application boots. + */ + protected const STARTUP_SIGNALS = [ + SIGQUIT, + SIGTERM, + SIGINT, + SIGUSR2, + SIGCONT, + ]; + /** * The output handler callback. */ @@ -45,7 +58,30 @@ public function start(Closure $callback): static $this->cooldown(); - $this->process->start($callback); + $previousMask = []; + + // Symfony's setIgnoredSignals() also suppresses Process::signal() on + // SIGCHLD builds. Horizon must still send these control signals, so + // only the fork-inherited process mask is changed here. + if (! pcntl_sigprocmask(SIG_BLOCK, static::STARTUP_SIGNALS, $previousMask)) { + throw new RuntimeException('Unable to block Horizon child startup signals.'); + } + + $exception = null; + + try { + $this->process->start($callback); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + if (! pcntl_sigprocmask(SIG_SETMASK, $previousMask)) { + $exception ??= new RuntimeException('Unable to restore the Horizon parent signal mask.'); + } + + if ($exception !== null) { + throw $exception; + } return $this; } @@ -108,6 +144,16 @@ public function stop(): void } } + /** + * Stop the underlying process immediately. + */ + public function kill(): void + { + if ($this->process->isRunning()) { + $this->process->stop(0); + } + } + /** * Send a POSIX signal to the process. */ diff --git a/tests/Integration/Horizon/Feature/WorkerProcessTest.php b/tests/Integration/Horizon/Feature/WorkerProcessTest.php index ddbceb0e9..1e2a69220 100644 --- a/tests/Integration/Horizon/Feature/WorkerProcessTest.php +++ b/tests/Integration/Horizon/Feature/WorkerProcessTest.php @@ -5,15 +5,121 @@ namespace Hypervel\Tests\Integration\Horizon\Feature; use Carbon\CarbonImmutable; +use Closure; use Hypervel\Horizon\Events\UnableToLaunchProcess; use Hypervel\Horizon\Events\WorkerProcessRestarting; +use Hypervel\Horizon\MasterSupervisor; +use Hypervel\Horizon\Supervisor; +use Hypervel\Horizon\SupervisorProcess; use Hypervel\Horizon\WorkerProcess; +use Hypervel\Queue\Worker as QueueWorker; use Hypervel\Support\Facades\Event; use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; +use Mockery as m; +use ReflectionClass; +use RuntimeException; use Symfony\Component\Process\Process; class WorkerProcessTest extends IntegrationTestCase { + public function testControlSignalSentDuringBootstrapIsDeliveredAfterHandlerInstallation(): void + { + $script = <<<'PHP' + pcntl_async_signals(true); + usleep(200_000); + $received = false; + pcntl_signal(SIGUSR2, static function () use (&$received): void { + $received = true; + }); + pcntl_sigprocmask(SIG_UNBLOCK, [SIGUSR2]); + $deadline = microtime(true) + 1.0; + while (! $received && microtime(true) < $deadline) { + usleep(1_000); + } + fwrite(STDOUT, $received ? 'received' : 'missing'); + PHP; + $output = ''; + $process = new WorkerProcess(new Process([PHP_BINARY, '-r', $script])); + $process->start(static function (string $type, string $buffer) use (&$output): void { + $output .= $buffer; + }); + + $process->pause(); + $exitCode = $process->wait(); + + $this->assertSame(0, $exitCode); + $this->assertSame('received', $output); + } + + public function testStartRestoresTheParentSignalMaskAfterSuccess(): void + { + $before = $this->signalMask(); + $during = []; + $process = m::mock(Process::class); + $process->shouldReceive('start')->once()->with(m::type(Closure::class))->andReturnUsing( + static function () use (&$during): void { + $during = self::signalMaskStatically(); + }, + ); + + (new WorkerProcess($process))->start(static function (): void { + }); + + $expectedDuring = array_values(array_unique([ + ...$before, + ...$this->queueWorkerSignals(), + ])); + sort($expectedDuring); + + $this->assertSame($expectedDuring, $during); + $this->assertSame($before, $this->signalMask()); + } + + public function testStartRestoresTheParentSignalMaskWhenStartThrows(): void + { + $before = $this->signalMask(); + $failure = new RuntimeException('start failed'); + $process = m::mock(Process::class); + $process->shouldReceive('start')->once()->andThrow($failure); + + try { + (new WorkerProcess($process))->start(static function (): void { + }); + $this->fail('Expected process startup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame($before, $this->signalMask()); + } + + public function testParentBlockSetsMatchTheChildHandlerSets(): void + { + $this->assertSame( + $this->constant(QueueWorker::class, 'HANDLED_SIGNALS'), + $this->constant(WorkerProcess::class, 'STARTUP_SIGNALS'), + ); + $this->assertSame( + $this->constant(Supervisor::class, 'HANDLED_SIGNALS'), + $this->constant(SupervisorProcess::class, 'STARTUP_SIGNALS'), + ); + $this->assertSame( + $this->constant(Supervisor::class, 'HANDLED_SIGNALS'), + $this->constant(MasterSupervisor::class, 'HANDLED_SIGNALS'), + ); + } + + public function testKillStopsARunningProcessImmediately(): void + { + $process = m::mock(Process::class); + $process->shouldReceive('isRunning')->once()->andReturnTrue(); + $process->shouldReceive('stop')->once()->with(0)->andReturn(0); + + (new WorkerProcess($process))->kill(); + + $this->addToAssertionCount(1); + } + public function testWorkerProcessFiresEventIfStoppedProcessCantBeRestarted() { Event::fake(); @@ -103,4 +209,60 @@ protected function waitForProcessToExit(Process $process): void $this->assertNotNull($process->getExitCode()); }); } + + /** + * Read the current process signal mask in stable numeric order. + * + * @return list + */ + private function signalMask(): array + { + return self::signalMaskStatically(); + } + + /** + * @return list + */ + private static function signalMaskStatically(): array + { + $mask = []; + + if (! pcntl_sigprocmask(SIG_BLOCK, [SIGUSR1], $mask)) { + throw new RuntimeException('Unable to read the process signal mask.'); + } + + if (! pcntl_sigprocmask(SIG_SETMASK, $mask)) { + throw new RuntimeException('Unable to restore the process signal mask.'); + } + + sort($mask); + + return $mask; + } + + /** + * @return list + */ + private function queueWorkerSignals(): array + { + $signals = $this->constant(QueueWorker::class, 'HANDLED_SIGNALS'); + sort($signals); + + return $signals; + } + + /** + * Read a protected signal-set constant. + * + * @param class-string $class + * @return list + */ + private function constant(string $class, string $name): array + { + $value = (new ReflectionClass($class))->getConstant($name); + + $this->assertIsArray($value); + + return $value; + } } From ffe06353a108382df26d652d70aadfac77a8f998 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:14:55 +0000 Subject: [PATCH 18/40] Bound Horizon persistence and termination Persist supervisor and master state synchronously before loop events so writes cannot overlap, reorder, or escape the initiating error boundary. Scale workers through the existing terminating state machine, enforce configured grace periods, and hard-stop children that remain alive at their deadline. Replace eventual-persistence retries and permissive teardown with deterministic ordering, exhaustive active and terminating process cleanup, and regressions for persisted state visibility and bounded master shutdown. --- src/horizon/src/MasterSupervisor.php | 30 +++++----------- src/horizon/src/ProcessPool.php | 2 +- src/horizon/src/Supervisor.php | 33 +++-------------- .../Feature/Fixtures/EternalSupervisor.php | 25 ++++++++++--- .../Horizon/Feature/MasterSupervisorTest.php | 19 +++++++++- .../Horizon/Feature/SupervisorTest.php | 36 +++++++++++++++---- 6 files changed, 82 insertions(+), 63 deletions(-) diff --git a/src/horizon/src/MasterSupervisor.php b/src/horizon/src/MasterSupervisor.php index 2a711e004..cb8753948 100644 --- a/src/horizon/src/MasterSupervisor.php +++ b/src/horizon/src/MasterSupervisor.php @@ -20,8 +20,6 @@ use Hypervel\Support\Str; use Throwable; -use function Hypervel\Coroutine\go; - class MasterSupervisor implements Pausable, Restartable, Terminable { use ListensForSignals; @@ -166,9 +164,12 @@ public function terminate(int $status = 0): void // Here we will wait until all of the child supervisors finish terminating and // then exit the process. We will keep track of a timeout value so that the // process does not get stuck in an infinite loop here waiting for these. - while (count($this->supervisors->filter->isRunning())) { // @phpstan-ignore argument.type (higher-order proxy) + while (($runningSupervisors = $this->supervisors->filter( + fn (SupervisorProcess $supervisor): bool => $supervisor->isRunning() + ))->isNotEmpty()) { if (CarbonImmutable::now()->subSeconds($longest) ->gte($startedTerminating)) { + $runningSupervisors->each->kill(); break; } @@ -231,23 +232,7 @@ public function loop(): void $this->monitorSupervisors(); } - // Persistence is dispatched in a detached coroutine. Under Swoole - // the coroutine-hooked Redis write yields to the event loop; without - // this go() wrapper every loop iteration would block on that yield, - // which shifts supervisor lifecycle pacing enough to cause tearDown - // to exceed its retry budget in parallel tests. Callers that read - // persisted state immediately after loop() must retry for it (see - // the $this->wait(...) pattern in MasterSupervisorTest) — a - // synchronous read can miss an uncommitted persist. - go(function (): void { - // Exceptions thrown inside a spawned coroutine are not caught by - // the parent loop() try/catch, so report them in this coroutine. - try { - $this->persist(); - } catch (Throwable $e) { - app(ExceptionHandler::class)->report($e); - } - }); + $this->persist(); event(new MasterSupervisorLooped($this)); } catch (Throwable $e) { @@ -272,8 +257,9 @@ protected function monitorSupervisors(): void { $this->supervisors->each->monitor(); - /* @phpstan-ignore-next-line */ - $this->supervisors = $this->supervisors->reject->dead; + $this->supervisors = $this->supervisors->reject( + fn (SupervisorProcess $supervisor): bool => $supervisor->dead + ); } /** diff --git a/src/horizon/src/ProcessPool.php b/src/horizon/src/ProcessPool.php index dd8bed92b..637ed3bb6 100644 --- a/src/horizon/src/ProcessPool.php +++ b/src/horizon/src/ProcessPool.php @@ -228,7 +228,7 @@ protected function stopTerminatingProcessesThatAreHanging(): void $timeout = $this->options->timeout; if ($process['terminatedAt']->addSeconds((int) $timeout)->lte(CarbonImmutable::now())) { - $process['process']->stop(); + $process['process']->kill(); } } } diff --git a/src/horizon/src/Supervisor.php b/src/horizon/src/Supervisor.php index 0723d6405..a1cf2072a 100644 --- a/src/horizon/src/Supervisor.php +++ b/src/horizon/src/Supervisor.php @@ -18,8 +18,6 @@ use Hypervel\Support\Collection; use Throwable; -use function Hypervel\Coroutine\go; - class Supervisor implements Pausable, Restartable, Terminable { use ListensForSignals; @@ -182,14 +180,10 @@ public function terminate(int $status = 0): void // pools down to zero workers to gracefully terminate them all out here. app(SupervisorRepository::class)->forget($this->name); - $this->processPools->each(function ($pool) { - $pool->processes()->each(function ($process) { - $process->terminate(); - }); - }); + $this->processPools->each->scale(0); if ($this->shouldWait()) { - while ($this->processPools->map->runningProcesses()->collapse()->count()) { + while ($this->processPools->map->runningProcesses()->collapse()->isNotEmpty()) { sleep(1); } } @@ -261,27 +255,8 @@ public function loop(): void $this->processPools->each->monitor(); } - // Next, we'll persist the supervisor state to storage so that it can be read by a - // user interface. This contains information on the specific options for it and - // the current number of worker processes per queue for easy load monitoring. - // - // Persistence is dispatched in a detached coroutine. Under Swoole - // the coroutine-hooked Redis write yields to the event loop; without - // this go() wrapper every loop iteration would block on that yield, - // which shifts worker lifecycle pacing enough to cause tearDown to - // exceed its retry budget in parallel tests. Callers that read - // persisted state immediately after loop() must retry for it (see - // the $this->wait(...) pattern in SupervisorTest) — a synchronous - // read can miss an uncommitted persist. - go(function (): void { - // Exceptions thrown inside a spawned coroutine are not caught by - // the parent loop() try/catch, so report them in this coroutine. - try { - $this->persist(); - } catch (Throwable $e) { - app(ExceptionHandler::class)->report($e); - } - }); + // Persist before the loop event so observers always see this iteration's state. + $this->persist(); event(new SupervisorLooped($this)); } catch (Throwable $e) { diff --git a/tests/Integration/Horizon/Feature/Fixtures/EternalSupervisor.php b/tests/Integration/Horizon/Feature/Fixtures/EternalSupervisor.php index af4961679..5821ccb3e 100644 --- a/tests/Integration/Horizon/Feature/Fixtures/EternalSupervisor.php +++ b/tests/Integration/Horizon/Feature/Fixtures/EternalSupervisor.php @@ -4,16 +4,33 @@ namespace Hypervel\Tests\Integration\Horizon\Feature\Fixtures; -class EternalSupervisor +use Hypervel\Horizon\SupervisorOptions; +use Hypervel\Horizon\SupervisorProcess; +use Symfony\Component\Process\Process; + +class EternalSupervisor extends SupervisorProcess { - public $name = 'eternal'; + public bool $killed = false; + + public function __construct() + { + parent::__construct( + new SupervisorOptions('eternal', 'redis'), + new Process(['true']), + ); + } - public function terminate() + public function terminate(): void { } - public function isRunning() + public function isRunning(): bool { return true; } + + public function kill(): void + { + $this->killed = true; + } } diff --git a/tests/Integration/Horizon/Feature/MasterSupervisorTest.php b/tests/Integration/Horizon/Feature/MasterSupervisorTest.php index b1a747799..9bc022ebc 100644 --- a/tests/Integration/Horizon/Feature/MasterSupervisorTest.php +++ b/tests/Integration/Horizon/Feature/MasterSupervisorTest.php @@ -7,12 +7,14 @@ use Exception; use Hypervel\Horizon\Contracts\HorizonCommandQueue; use Hypervel\Horizon\Contracts\MasterSupervisorRepository; +use Hypervel\Horizon\Events\MasterSupervisorLooped; use Hypervel\Horizon\MasterSupervisor; use Hypervel\Horizon\MasterSupervisorCommands\AddSupervisor; use Hypervel\Horizon\PhpBinary; use Hypervel\Horizon\SupervisorOptions; use Hypervel\Horizon\SupervisorProcess; use Hypervel\Horizon\WorkerCommandString; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Redis; use Hypervel\Tests\Integration\Horizon\Feature\Fixtures\EternalSupervisor; use Hypervel\Tests\Integration\Horizon\Feature\Fixtures\SupervisorProcessWithFakeRestart; @@ -192,6 +194,20 @@ public function testMasterProcessInformationIsPersisted() $this->assertSame('paused', $masterRecord->status); } + public function testMasterStateIsPersistedBeforeLoopedEvent(): void + { + $master = new MasterSupervisor; + $master->working = false; + $observedStatus = null; + Event::listen(MasterSupervisorLooped::class, function () use ($master, &$observedStatus): void { + $observedStatus = resolve(MasterSupervisorRepository::class)->find($master->name)?->status; + }); + + $master->loop(); + + $this->assertSame('paused', $observedStatus); + } + public function testMasterProcessShouldNotAllowDuplicateMasterProcessOnSameMachine() { $this->expectException(Exception::class); @@ -231,12 +247,13 @@ public function testSupervisorContinuesTerminationIfSupervisorsTakeTooLong() $master = new MasterSupervisor; $master->working = true; - $master->supervisors = collect([new EternalSupervisor]); + $master->supervisors = collect([$supervisor = new EternalSupervisor]); $master->persist(); $master->terminate(); $this->assertTrue($master->shouldExitLoop); + $this->assertTrue($supervisor->killed); } protected function supervisorOptions() diff --git a/tests/Integration/Horizon/Feature/SupervisorTest.php b/tests/Integration/Horizon/Feature/SupervisorTest.php index 3b610b889..00021c385 100644 --- a/tests/Integration/Horizon/Feature/SupervisorTest.php +++ b/tests/Integration/Horizon/Feature/SupervisorTest.php @@ -11,6 +11,7 @@ use Hypervel\Horizon\Contracts\HorizonCommandQueue; use Hypervel\Horizon\Contracts\JobRepository; use Hypervel\Horizon\Contracts\SupervisorRepository; +use Hypervel\Horizon\Events\SupervisorLooped; use Hypervel\Horizon\Events\WorkerProcessRestarting; use Hypervel\Horizon\MasterSupervisor; use Hypervel\Horizon\PhpBinary; @@ -70,17 +71,27 @@ public function testSupervisorCanStartWorkerProcessWithGivenOptions() ); } - protected function terminateProcesses() + protected function terminateProcesses(): void { if (! $this->supervisor) { return; } - $this->supervisor->processes()->each->terminate(); + $processes = $this->supervisor->processes() + ->concat($this->supervisor->terminatingProcesses()->pluck('process')) + ->unique(static fn (WorkerProcess $process): int => spl_object_id($process)); - retry(200, function () { - $this->assertCount(0, $this->supervisor->processes()->filter->isRunning()); - }, 50); + try { + $processes->each->terminate(); + } finally { + $processes->each->kill(); + + $processes->each(function (WorkerProcess $process): void { + if ($process->isStarted()) { + $process->wait(); + } + }); + } } public function testSupervisorStartsMultiplePoolsWhenBalancing() @@ -192,7 +203,6 @@ public function testSupervisorInformationIsPersisted() $options->queue = 'default,another'; $supervisor->scale(2); - usleep(100 * 1000); $supervisor->loop(); @@ -531,6 +541,20 @@ public function testSupervisorProcessesCanBeCountedExternally() }); } + public function testSupervisorStateIsPersistedBeforeLoopedEvent(): void + { + $this->supervisor = $supervisor = new Supervisor($this->supervisorOptions()); + $supervisor->working = false; + $observedStatus = null; + Event::listen(SupervisorLooped::class, function () use ($supervisor, &$observedStatus): void { + $observedStatus = app(SupervisorRepository::class)->find($supervisor->name)?->status; + }); + + $supervisor->loop(); + + $this->assertSame('paused', $observedStatus); + } + public function testSupervisorDoesNotStartWorkersUntilLoopedAndActive() { SystemProcessCounter::$command = 'worker.php'; From 80eb4979ad76c70033276d63882ae3d85c228ed1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:15:20 +0000 Subject: [PATCH 19/40] Bound Swoole table lock acquisition Give Cache and Reverb striped locks a fixed internal acquisition deadline with backoff after the existing hot-spin window. Preserve the uncontended path as one compare-and-set and release every stripe acquired before a later all-lock timeout. Move Reverb's full-table logging outside critical sections and cover contention recovery, holder death, partial acquisition, and all failed lock-row call sites without adding user-facing lock policy. --- src/cache/src/SwooleTableState.php | 31 ++- .../Scaling/SwooleTableSharedState.php | 72 ++++-- .../SwooleTableSharedStateLockTest.php | 223 ++++++++++++++++++ 3 files changed, 294 insertions(+), 32 deletions(-) create mode 100644 tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php diff --git a/src/cache/src/SwooleTableState.php b/src/cache/src/SwooleTableState.php index 334b135e6..3100ff1c0 100644 --- a/src/cache/src/SwooleTableState.php +++ b/src/cache/src/SwooleTableState.php @@ -4,6 +4,7 @@ namespace Hypervel\Cache; +use RuntimeException; use Swoole\Atomic; /** @@ -24,6 +25,8 @@ class SwooleTableState protected const SPINS_BEFORE_BACKOFF = 64; + protected const LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 1_000_000_000; + /** * Striped locks for row lifecycle operations. * @@ -92,12 +95,12 @@ public function withAllRowLocks(callable $callback): mixed { $acquired = []; - foreach ($this->rowLocks as $lock) { - $this->acquire($lock); - $acquired[] = $lock; - } - try { + foreach ($this->rowLocks as $lock) { + $this->acquire($lock); + $acquired[] = $lock; + } + return $callback(); } finally { while ($lock = array_pop($acquired)) { @@ -119,16 +122,22 @@ protected function lockFor(string $key): Atomic */ protected function acquire(Atomic $lock): void { + $deadline = null; $spins = 0; while (! $lock->cmpset(0, 1)) { - // Critical sections must stay short, non-yielding, and fatal-free so finally can release the stripe. - // A hard process death while holding a stripe leaves it locked until the Swoole table state is recreated. - if (++$spins >= self::SPINS_BEFORE_BACKOFF) { - // Stay hot for short waits, then yield with raw usleep; lock internals must not use fakeable sleep. - $spins = 0; - usleep(1); + $deadline ??= hrtime(true) + static::LOCK_ACQUIRE_TIMEOUT_NANOSECONDS; + + if (++$spins < static::SPINS_BEFORE_BACKOFF) { + continue; + } + + if (hrtime(true) >= $deadline) { + throw new RuntimeException('Timed out acquiring a Swoole table state lock.'); } + + $spins = 0; + usleep(1); } } diff --git a/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php b/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php index 7239a4cf0..6f92c2c00 100644 --- a/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php +++ b/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php @@ -18,6 +18,10 @@ class SwooleTableSharedState implements SharedState */ protected const STRIPE_COUNT = 64; + protected const SPINS_BEFORE_BACKOFF = 64; + + protected const LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 1_000_000_000; + /** * Striped Atomic locks for inter-worker row lifecycle operations. * @@ -232,12 +236,17 @@ public function setSmoothingPending(string $appId, string $channel, int $ttlMs): $key = "smoothing:{$appId}:{$channel}"; $lock = $this->lockFor($key); $this->acquire($lock); + $stored = false; try { - $this->setLockRow($key, microtime(true)); + $stored = $this->setLockRow($key, microtime(true)); } finally { $this->release($lock); } + + if (! $stored) { + $this->reportFullLockTable($key); + } } /** @@ -256,12 +265,17 @@ public function setMemberSmoothingPending(string $appId, string $channel, string $key = "smoothing:{$appId}:{$channel}:{$userId}"; $lock = $this->lockFor($key); $this->acquire($lock); + $stored = false; try { - $this->setLockRow($key, microtime(true)); + $stored = $this->setLockRow($key, microtime(true)); } finally { $this->release($lock); } + + if (! $stored) { + $this->reportFullLockTable($key); + } } /** @@ -283,6 +297,7 @@ protected function tryLock(string $key, int $ttlMs): bool { $lock = $this->lockFor($key); $this->acquire($lock); + $stored = false; try { $row = $this->lockTable->get($key, 'locked_at'); @@ -292,10 +307,16 @@ protected function tryLock(string $key, int $ttlMs): bool return false; } - return $this->setLockRow($key, $now); + $stored = $this->setLockRow($key, $now); } finally { $this->release($lock); } + + if (! $stored) { + $this->reportFullLockTable($key); + } + + return $stored; } /** @@ -307,26 +328,21 @@ protected function tryLock(string $key, int $ttlMs): bool protected function setLockRow(string $key, float $timestamp): bool { try { - $result = $this->lockTable->set($key, ['locked_at' => $timestamp]); - } catch (ErrorException $e) { - Log::error( - "Reverb webhook lock table is full — increase 'reverb.swoole_shared_state.lock_rows' in config. " - . "Webhook suppressed due to full lock table for key [{$key}]." - ); - - return false; - } - - if ($result === false) { - Log::error( - "Reverb webhook lock table is full — increase 'reverb.swoole_shared_state.lock_rows' in config. " - . "Webhook suppressed due to full lock table for key [{$key}]." - ); - + return $this->lockTable->set($key, ['locked_at' => $timestamp]); + } catch (ErrorException) { return false; } + } - return true; + /** + * Report a failed write after releasing its stripe lock. + */ + protected function reportFullLockTable(string $key): void + { + Log::error( + "Reverb webhook lock table is full — increase 'reverb.swoole_shared_state.lock_rows' in config. " + . "Webhook suppressed due to full lock table for key [{$key}]." + ); } /** @@ -422,8 +438,22 @@ protected function lockFor(string $key): Atomic */ protected function acquire(Atomic $lock): void { + $deadline = null; + $spins = 0; + while (! $lock->cmpset(0, 1)) { - // Spin — lock is held for nanoseconds (C-level table operations only) + $deadline ??= hrtime(true) + static::LOCK_ACQUIRE_TIMEOUT_NANOSECONDS; + + if (++$spins < static::SPINS_BEFORE_BACKOFF) { + continue; + } + + if (hrtime(true) >= $deadline) { + throw new RuntimeException('Timed out acquiring a Swoole table shared-state lock.'); + } + + $spins = 0; + usleep(1); } } diff --git a/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php new file mode 100644 index 000000000..20917bf9b --- /dev/null +++ b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php @@ -0,0 +1,223 @@ +createState(LockTestSharedState::class); + $state->holdLockFor('key'); + $called = false; + + run(function () use ($state, &$called): void { + go(function () use ($state): void { + usleep(5_000); + $state->releaseLockFor('key'); + }); + + $state->withLockFor('key', function () use (&$called): void { + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testAbandonedStripeFailsWithinTheAcquisitionDeadline(): void + { + $state = $this->createState(LockTestSharedState::class); + $process = new Process(function (Process $process) use ($state): void { + $state->holdLockFor('key'); + + try { + $state->withLockFor('key', fn (): bool => true); + $message = 'lock unexpectedly acquired'; + } catch (RuntimeException $exception) { + $message = $exception->getMessage(); + } + + $process->write($message); + posix_kill(getmypid(), SIGKILL); + }, false, SOCK_STREAM); + + $pid = $process->start(); + + if ($pid === false) { + $this->fail('Unable to start Reverb lock child.'); + } + + $process->setBlocking(false); + $message = ''; + $reaped = false; + $deadline = hrtime(true) + 250_000_000; + + try { + while ($message === '' && hrtime(true) < $deadline) { + $chunk = $process->read(); + + if (is_string($chunk) && $chunk !== '') { + $message .= $chunk; + break; + } + + if ($this->reapIfExited($pid)) { + $reaped = true; + break; + } + + usleep(1_000); + } + } finally { + if (! $reaped && Process::kill($pid, 0)) { + Process::kill($pid, SIGKILL); + } + + $process->close(); + + if (! $reaped) { + $reapDeadline = hrtime(true) + 1_000_000_000; + + while (! $this->reapIfExited($pid)) { + if (hrtime(true) >= $reapDeadline) { + throw new RuntimeException("Timed out reaping Reverb lock child [{$pid}]."); + } + + usleep(1_000); + } + } + } + + $this->assertSame( + 'Timed out acquiring a Swoole table shared-state lock.', + $message, + ); + } + + public function testFailedLockRowsAreReportedOnlyAfterTheirStripeIsReleased(): void + { + $state = $this->createState(ReportingProbeSharedState::class); + + $this->assertFalse($state->tryCacheMissLock('app', 'channel')); + $state->setSmoothingPending('app', 'channel', 1_000); + $state->setMemberSmoothingPending('app', 'channel', 'user', 1_000); + + $this->assertSame([ + 'cache-miss-lock:app:channel', + 'smoothing:app:channel', + 'smoothing:app:channel:user', + ], $state->reportedKeys); + $this->assertFalse($state->reportedWhileLocked); + } + + /** + * Reap the owned child if it has exited. + */ + private function reapIfExited(int $pid): bool + { + $status = 0; + $result = pcntl_waitpid($pid, $status, WNOHANG); + + if ($result === $pid) { + return true; + } + + if ($result !== -1) { + return false; + } + + $error = pcntl_get_last_error(); + + if ($error === PCNTL_ECHILD) { + return true; + } + + if ($error === PCNTL_EINTR) { + return false; + } + + throw new RuntimeException( + "Unable to reap Reverb lock child [{$pid}]: " . pcntl_strerror($error), + ); + } + + /** + * Create a shared-state test double with real Swoole tables. + * + * @template T of SwooleTableSharedState + * @param class-string $class + * @return T + */ + private function createState(string $class): SwooleTableSharedState + { + $table = new Table(128); + $table->column('count', Table::TYPE_INT); + $table->create(); + + $lockTable = new Table(128); + $lockTable->column('locked_at', Table::TYPE_FLOAT); + $lockTable->create(); + + return new $class($table, $lockTable); + } +} + +class LockTestSharedState extends SwooleTableSharedState +{ + protected const LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 50_000_000; + + public function holdLockFor(string $key): void + { + $this->lockFor($key)->set(1); + } + + public function releaseLockFor(string $key): void + { + $this->lockFor($key)->set(0); + } + + public function withLockFor(string $key, callable $callback): mixed + { + $lock = $this->lockFor($key); + $this->acquire($lock); + + try { + return $callback(); + } finally { + $this->release($lock); + } + } +} + +class ReportingProbeSharedState extends SwooleTableSharedState +{ + /** @var list */ + public array $reportedKeys = []; + + public bool $reportedWhileLocked = false; + + protected function setLockRow(string $key, float $timestamp): bool + { + return false; + } + + protected function reportFullLockTable(string $key): void + { + $this->reportedKeys[] = $key; + $this->reportedWhileLocked = $this->reportedWhileLocked + || $this->lockFor($key)->get() !== 0; + } +} From f6b727c8d40ccff502e17d1c0fa3556573468812 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:15:39 +0000 Subject: [PATCH 20/40] Make Reverb Redis startup transactional Keep a new subscriber locally owned through creation and subscription, recheck disconnect state after yielding operations, and commit only the exact subscriber consumed by the spawned receive loop. Clear and reconnect by object identity so an older consumer cannot tear down a replacement. Reset retry state only after complete startup, drain queued publishes in order, drop permanently invalid JSON payloads, and retain transient publish failures with their ordered tail. Remove the unused public subscribe method and cover spawn, handshake, disconnect, retry-limit, and queue-drain failures. --- .../Hypervel/Contracts/PubSubProvider.php | 5 - .../Hypervel/Scaling/RedisPubSubProvider.php | 130 ++++++++++--- .../Scaling/RedisPubSubProviderTest.php | 177 ++++++++++++++++++ 3 files changed, 284 insertions(+), 28 deletions(-) create mode 100644 tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php diff --git a/src/reverb/src/Servers/Hypervel/Contracts/PubSubProvider.php b/src/reverb/src/Servers/Hypervel/Contracts/PubSubProvider.php index 97411226c..1b8b28071 100644 --- a/src/reverb/src/Servers/Hypervel/Contracts/PubSubProvider.php +++ b/src/reverb/src/Servers/Hypervel/Contracts/PubSubProvider.php @@ -16,11 +16,6 @@ public function connect(): void; */ public function disconnect(): void; - /** - * Subscribe to messages. - */ - public function subscribe(): void; - /** * Listen for a given event type. */ diff --git a/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php b/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php index 0a17a75c1..2307b39ab 100644 --- a/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php +++ b/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php @@ -10,6 +10,7 @@ use Hypervel\Reverb\Servers\Hypervel\Contracts\PubSubIncomingMessageHandler; use Hypervel\Reverb\Servers\Hypervel\Contracts\PubSubProvider; use Hypervel\Support\Sleep; +use JsonException; use Throwable; use function Hypervel\Coroutine\go; @@ -66,18 +67,39 @@ public function __construct( public function connect(): void { $this->shouldRetry = true; + $subscriber = null; try { - $this->subscriber = $this->redis->subscriber(); - $this->subscribedChannel = $this->subscriber->prefix . $this->channel; + $subscriber = $this->redis->subscriber(); + $subscribedChannel = $subscriber->prefix . $this->channel; + $subscriber->subscribe($this->channel); - $this->retryTimer = 0; + if (! $this->shouldRetry()) { + $this->closeSubscriber($subscriber); + + return; + } + + $this->subscriber = $subscriber; + $this->subscribedChannel = $subscribedChannel; $this->processQueuedPublishes(); - Log::info('Redis connection established'); + if (! $this->shouldRetry() || $this->subscriber !== $subscriber) { + $this->clearSubscriber($subscriber); + $this->closeSubscriber($subscriber); + + return; + } + + go(fn () => $this->consumeMessages($subscriber, $subscribedChannel)); - go(fn () => $this->subscribe()); + if ($this->subscriber === $subscriber) { + $this->retryTimer = 0; + Log::info('Redis connection established'); + } } catch (Throwable $e) { + $this->clearSubscriber($subscriber); + $this->closeSubscriber($subscriber); Log::error('Redis connection failed: ' . $e->getMessage()); $this->reconnect(); } @@ -89,19 +111,20 @@ public function connect(): void public function disconnect(): void { $this->shouldRetry = false; - $this->subscriber?->close(); - $this->subscriber = null; + $subscriber = $this->subscriber; + $this->clearSubscriber($subscriber); + $this->closeSubscriber($subscriber); } /** - * Subscribe to the Redis channel and process messages. + * Process messages from one committed subscriber. */ - public function subscribe(): void + protected function consumeMessages(Subscriber $subscriber, string $subscribedChannel): void { - try { - $this->subscriber->subscribe($this->channel); + $shouldReconnect = false; - $channel = $this->subscriber->channel(); + try { + $channel = $subscriber->channel(); while (true) { $message = $channel->pop(); @@ -110,7 +133,7 @@ public function subscribe(): void break; } - if ($message->channel === $this->subscribedChannel) { + if ($message->channel === $subscribedChannel) { try { $this->messageHandler->handle($message->payload); } catch (Throwable $e) { @@ -119,13 +142,17 @@ public function subscribe(): void } } } catch (Throwable $e) { - // Connection-level errors (socket failure, subscribe failure) — - // these require reconnection. + // Connection-level errors require reconnection. Log::error('Redis subscriber error: ' . $e->getMessage()); + } finally { + $shouldReconnect = $this->subscriber === $subscriber; + $this->clearSubscriber($subscriber); + $this->closeSubscriber($subscriber); } - $this->subscriber = null; - $this->reconnect(); + if ($shouldReconnect) { + $this->reconnect(); + } } /** @@ -171,7 +198,7 @@ public function publish(array $payload): int */ protected function reconnect(): void { - if (! $this->shouldRetry) { + if (! $this->shouldRetry()) { return; } @@ -188,7 +215,7 @@ protected function reconnect(): void Sleep::sleep(1); - if (! $this->shouldRetry) { // @phpstan-ignore booleanNot.alwaysFalse (coroutine yield: disconnect() can set shouldRetry=false during sleep) + if (! $this->shouldRetry()) { return; } @@ -200,11 +227,68 @@ protected function reconnect(): void */ protected function processQueuedPublishes(): void { - $queued = $this->queuedPublishes; - $this->queuedPublishes = []; + while ($this->queuedPublishes !== [] + && $this->shouldRetry() + && $this->subscriber !== null + ) { + $payload = $this->queuedPublishes[0]; + + try { + $encoded = json_encode($payload, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + array_shift($this->queuedPublishes); + Log::error('Discarding invalid queued Reverb payload: ' . $exception->getMessage()); + + continue; + } + + $this->redis->publish($this->channel, $encoded); + array_shift($this->queuedPublishes); + } + } - foreach ($queued as $payload) { - $this->publish($payload); + /** + * Determine whether reconnect work remains enabled. + * + * Hooked Redis I/O and Sleep may yield while disconnect() changes this state. + * + * @phpstan-impure + */ + protected function shouldRetry(): bool + { + return $this->shouldRetry; + } + + /** + * Clear committed state only when it still belongs to the given subscriber. + */ + protected function clearSubscriber(?Subscriber $subscriber): void + { + if ($subscriber === null || $this->subscriber !== $subscriber) { + return; + } + + $this->subscriber = null; + $this->subscribedChannel = ''; + } + + /** + * Close an owned subscriber without replacing the primary lifecycle failure. + */ + protected function closeSubscriber(?Subscriber $subscriber): void + { + if ($subscriber === null || $subscriber->closed) { + return; + } + + try { + $subscriber->close(); + } catch (Throwable $exception) { + try { + Log::error('Unable to close Redis subscriber: ' . $exception->getMessage()); + } catch (Throwable) { + // Cleanup reporting must not replace the lifecycle failure. + } } } } diff --git a/tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php b/tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php new file mode 100644 index 000000000..6339ba552 --- /dev/null +++ b/tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php @@ -0,0 +1,177 @@ +logger = m::mock(Logger::class); + $this->logger->shouldReceive('info', 'error')->zeroOrMoreTimes(); + $this->app->instance(Logger::class, $this->logger); + Log::flushState(); + } + + #[RunInSeparateProcess] + public function testSpawnFailureRollsBackTheCommittedSubscriber(): void + { + SwooleCoroutine::set(['max_coroutine' => 1]); + + $subscriber = $this->subscriber(); + $subscriber->shouldReceive('subscribe')->once()->with('reverb'); + $subscriber->shouldReceive('close')->once()->andReturnUsing(function () use ($subscriber): void { + $subscriber->closed = true; + }); + $redis = m::mock(RedisProxy::class); + $redis->shouldReceive('subscriber')->once()->andReturn($subscriber); + $provider = $this->provider($redis); + + $provider->connect(); + + $this->assertNull($provider->subscriberForTest()); + $this->assertSame(1, $provider->reconnectCount); + $this->assertTrue($subscriber->closed); + } + + public function testRepeatedConnectionFailuresReachTheRetryLimit(): void + { + Sleep::fake(); + $redis = m::mock(RedisProxy::class); + $redis->shouldReceive('subscriber') + ->times(60) + ->andThrow(new RuntimeException('redis unavailable')); + $provider = new RedisPubSubProvider( + m::mock(PubSubIncomingMessageHandler::class), + $redis, + 'reverb', + ); + + $provider->connect(); + + Sleep::assertSleptTimes(59); + } + + public function testDisconnectDuringSubscriptionDoesNotCommitOrSpawn(): void + { + $subscriber = $this->subscriber(); + $redis = m::mock(RedisProxy::class); + $provider = $this->provider($redis); + $subscriber->shouldReceive('subscribe')->once()->with('reverb')->andReturnUsing( + function () use ($provider): void { + $provider->disconnect(); + }, + ); + $subscriber->shouldReceive('close')->once()->andReturnUsing(function () use ($subscriber): void { + $subscriber->closed = true; + }); + $redis->shouldReceive('subscriber')->once()->andReturn($subscriber); + + $provider->connect(); + + $this->assertNull($provider->subscriberForTest()); + $this->assertSame(0, $provider->reconnectCount); + $this->assertTrue($subscriber->closed); + } + + public function testQueuedPublishesDropInvalidJsonAndRetainTransientFailuresInOrder(): void + { + $firstSubscriber = $this->subscriber(); + $firstSubscriber->shouldReceive('subscribe')->once()->with('reverb'); + $firstSubscriber->shouldReceive('close')->once()->andReturnUsing(function () use ($firstSubscriber): void { + $firstSubscriber->closed = true; + }); + + $secondSubscriber = $this->subscriber(); + $secondSubscriber->shouldReceive('subscribe')->once()->with('reverb'); + $secondSubscriber->shouldReceive('channel')->once()->andReturnUsing(static function (): Channel { + $channel = new Channel(1); + $channel->close(); + + return $channel; + }); + $secondSubscriber->shouldReceive('close')->once()->andReturnUsing(function () use ($secondSubscriber): void { + $secondSubscriber->closed = true; + }); + + $redis = m::mock(RedisProxy::class); + $redis->shouldReceive('subscriber')->twice()->andReturn($firstSubscriber, $secondSubscriber); + $redis->shouldReceive('publish') + ->once() + ->with('reverb', '{"id":1}') + ->andThrow(new RuntimeException('transient publish failure')); + $redis->shouldReceive('publish')->once()->with('reverb', '{"id":1}')->andReturn(1); + $redis->shouldReceive('publish')->once()->with('reverb', '{"id":2}')->andReturn(1); + $provider = $this->provider($redis); + $provider->publish(['invalid' => NAN]); + $provider->publish(['id' => 1]); + $provider->publish(['id' => 2]); + + $provider->connect(); + + $this->assertSame([['id' => 1], ['id' => 2]], $provider->queuedPublishesForTest()); + + $provider->connect(); + + $this->assertSame([], $provider->queuedPublishesForTest()); + $this->assertTrue($firstSubscriber->closed); + $this->assertTrue($secondSubscriber->closed); + } + + protected function provider(RedisProxy $redis): RedisPubSubProviderProbe + { + return new RedisPubSubProviderProbe( + m::mock(PubSubIncomingMessageHandler::class), + $redis, + 'reverb', + ); + } + + protected function subscriber(): Subscriber + { + $subscriber = m::mock(Subscriber::class); + $subscriber->prefix = 'prefix:'; + $subscriber->closed = false; + + return $subscriber; + } +} + +class RedisPubSubProviderProbe extends RedisPubSubProvider +{ + public int $reconnectCount = 0; + + public function subscriberForTest(): ?Subscriber + { + return $this->subscriber; + } + + public function queuedPublishesForTest(): array + { + return $this->queuedPublishes; + } + + protected function reconnect(): void + { + ++$this->reconnectCount; + } +} From 69e9aa563e0ae1bb18bad75d1ddb917543693b3b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:15:51 +0000 Subject: [PATCH 21/40] Keep compiled route caches collection local Move named Route object caching from process-global static state onto the CompiledRouteCollection that owns the route attributes. Replacing a collection now establishes object identity without requiring a global flush while retaining worker-lifetime reuse for the active collection. Remove obsolete reset and warmup guidance and prove that collections sharing a route name cannot exchange domain, port, URI, action, or object identity. --- src/routing/src/CompiledRouteCollection.php | 25 +++--------- src/routing/src/Router.php | 5 +-- tests/Routing/RoutePortTest.php | 44 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/routing/src/CompiledRouteCollection.php b/src/routing/src/CompiledRouteCollection.php index edafba7f5..0e1a1e91b 100644 --- a/src/routing/src/CompiledRouteCollection.php +++ b/src/routing/src/CompiledRouteCollection.php @@ -43,14 +43,11 @@ class CompiledRouteCollection extends AbstractRouteCollection protected Container $container; /** - * Pre-built Route objects keyed by name. - * - * Cached for the worker lifetime — routes are built once, reused forever. - * Bounded by route count (known at boot), no per-request growth. + * Pre-built Route objects keyed by name for this collection. * * @var array */ - protected static array $cachedRoutesByName = []; + protected array $cachedRoutesByName = []; /** * Port lookup map for compiled routes, keyed by "METHOD domain+uri". @@ -235,13 +232,12 @@ public function hasNamedRoute(string $name): bool /** * Get a route instance by its name. * - * Returns cached Route objects — routes are built once and reused for the - * worker lifetime. No per-request Route reconstruction. + * Returns cached Route objects for the lifetime of this collection. */ public function getByName(string $name): ?Route { if (isset($this->attributes[$name])) { - return static::$cachedRoutesByName[$name] + return $this->cachedRoutesByName[$name] ??= $this->newRoute($this->attributes[$name]); } @@ -287,7 +283,7 @@ public function getRoutes(): array /** * Get the route instances that should be pre-warmed. * - * Returns the cached Route instances from $cachedRoutesByName — these + * Returns the collection's cached Route instances — these * are the objects actually used during request matching. Unlike * getRoutes() which creates fresh throwaway objects every call. * @@ -365,17 +361,6 @@ protected function newRoute(array $attributes): Route ->withTrashed($attributes['withTrashed'] ?? false); } - /** - * Flush the static route cache. - * - * Boot or tests only. Clears the process-wide name → Route cache shared by - * every coroutine; next named-route lookup rebuilds. - */ - public static function flushCache(): void - { - static::$cachedRoutesByName = []; - } - /** * Set the router instance on the route. * diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index 109fd42f7..799d6bdeb 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -1296,7 +1296,6 @@ public function setCompiledRoutes(array $routes): void */ protected function flushRoutingCaches(): void { - CompiledRouteCollection::flushCache(); ControllerDispatcher::flushState(); CallableDispatcher::flushState(); RouteSignatureParameters::flushCache(); @@ -1305,7 +1304,7 @@ protected function flushRoutingCaches(): void } /** - * Compile routes and pre-warm all static caches. + * Compile routes and pre-warm collection and static caches. * * In dev mode, compiles RouteCollection → CompiledRouteCollection for * CompiledUrlMatcher performance (O(1) hash + single regex). In production @@ -1331,7 +1330,7 @@ public function compileAndWarm(): void } /** - * Pre-warm all static caches for the registered routes. + * Pre-warm all caches for the registered routes. * * Eagerly populates route compilation, middleware resolution, and * reflection caches so they're available before fork. Workers inherit diff --git a/tests/Routing/RoutePortTest.php b/tests/Routing/RoutePortTest.php index cc7cb4efa..674b8e9b5 100644 --- a/tests/Routing/RoutePortTest.php +++ b/tests/Routing/RoutePortTest.php @@ -192,6 +192,50 @@ public function testCompiledRoutePreservesPort() $this->assertSame(8080, $route->getPort()); } + public function testCompiledRouteNameCacheBelongsToItsCollection(): void + { + [$firstRouter, $firstContainer] = $this->getRouter(); + $firstRouter->domain('first.test')->port(8080)->get('/first', [ + 'uses' => 'FirstController@show', + 'as' => 'shared', + ]); + $firstCompiled = $firstRouter->getRoutes()->compile(); + $firstCollection = (new CompiledRouteCollection( + $firstCompiled['compiled'], + $firstCompiled['attributes'], + ))->setRouter($firstRouter)->setContainer($firstContainer); + + [$secondRouter, $secondContainer] = $this->getRouter(); + $secondRouter->domain('second.test')->port(9090)->get('/second', [ + 'uses' => 'SecondController@show', + 'as' => 'shared', + ]); + $secondCompiled = $secondRouter->getRoutes()->compile(); + $secondCollection = (new CompiledRouteCollection( + $secondCompiled['compiled'], + $secondCompiled['attributes'], + ))->setRouter($secondRouter)->setContainer($secondContainer); + + $first = $firstCollection->getByName('shared'); + $second = $secondCollection->getByName('shared'); + + $this->assertNotSame($first, $second); + $this->assertSame($first, $firstCollection->getByName('shared')); + $this->assertSame($second, $secondCollection->getByName('shared')); + $this->assertSame(['first.test', 8080, 'first', 'FirstController@show'], [ + $first->getDomain(), + $first->getPort(), + $first->uri(), + $first->getActionName(), + ]); + $this->assertSame(['second.test', 9090, 'second', 'SecondController@show'], [ + $second->getDomain(), + $second->getPort(), + $second->uri(), + $second->getActionName(), + ]); + } + public function testCompiledRouteCollectionRespectsPort() { [$router, $container] = $this->getRouter(); From b6ff7ebb35276d9cb336ff0895f8f607e96b44de Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:15:59 +0000 Subject: [PATCH 22/40] Isolate programmatic console execution from CLI globals Route Application::call through a dedicated IO configuration and doRun boundary instead of Symfony's root CLI wrapper. Programmatic commands now ignore inherited SHELL_VERBOSITY, avoid process-global exception and terminal mutation, and still honor explicit ANSI, interaction, quiet, and verbosity options. Add behavioral parity coverage for command execution, help, output, events, exceptions, and explicit options while retaining command cloning as the existing concurrent and nested-call isolation boundary. --- src/console/src/Application.php | 70 +++- .../ConsoleApplicationProgrammaticTest.php | 308 ++++++++++++++++++ 2 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 tests/Console/ConsoleApplicationProgrammaticTest.php diff --git a/src/console/src/Application.php b/src/console/src/Application.php index b9bb64d31..b9b087361 100644 --- a/src/console/src/Application.php +++ b/src/console/src/Application.php @@ -148,12 +148,78 @@ public function call(string|SymfonyCommand $command, array $parameters = [], ?Ou throw new CommandNotFoundException(sprintf('The command "%s" does not exist.', $command)); } - return $this->run( + return $this->runProgrammatically( $input, - CoroutineContext::set(self::LAST_OUTPUT_CONTEXT_KEY, $outputBuffer ?: new BufferedOutput) + CoroutineContext::set( + self::LAST_OUTPUT_CONTEXT_KEY, + $outputBuffer ?: new BufferedOutput, + ), ); } + /** + * Run a command without Symfony's process-global CLI wrapper. + * + * Symfony's run() owns root CLI concerns: terminal environment export, + * process-global exception handling, shell-verbosity restoration, and + * auto-exit. Programmatic calls need explicit IO configuration and + * doRun() only. + * Keep this boundary aligned with Symfony through the parity tests. + * + * @see SymfonyApplication::run() + */ + protected function runProgrammatically( + InputInterface $input, + OutputInterface $output, + ): int { + $this->configureProgrammaticIO($input, $output); + + return $this->doRun($input, $output); + } + + /** + * Configure the input and output for a programmatic command. + */ + protected function configureProgrammaticIO(InputInterface $input, OutputInterface $output): void + { + if ($input->hasParameterOption(['--ansi'], true)) { + $output->setDecorated(true); + } elseif ($input->hasParameterOption(['--no-ansi'], true)) { + $output->setDecorated(false); + } + + $shellVerbosity = match (true) { + $input->hasParameterOption(['--silent'], true) => -2, + $input->hasParameterOption(['--quiet', '-q'], true) => -1, + $input->hasParameterOption('-vvv', true) + || $input->hasParameterOption('--verbose=3', true) + || $input->getParameterOption('--verbose', false, true) === 3 => 3, + $input->hasParameterOption('-vv', true) + || $input->hasParameterOption('--verbose=2', true) + || $input->getParameterOption('--verbose', false, true) === 2 => 2, + $input->hasParameterOption('-v', true) + || $input->hasParameterOption('--verbose=1', true) + || $input->hasParameterOption('--verbose', true) + || $input->getParameterOption('--verbose', false, true) => 1, + default => 0, + }; + + $output->setVerbosity(match ($shellVerbosity) { + -2 => OutputInterface::VERBOSITY_SILENT, + -1 => OutputInterface::VERBOSITY_QUIET, + 1 => OutputInterface::VERBOSITY_VERBOSE, + 2 => OutputInterface::VERBOSITY_VERY_VERBOSE, + 3 => OutputInterface::VERBOSITY_DEBUG, + default => $output->getVerbosity(), + }); + + if ($shellVerbosity < 0 + || $input->hasParameterOption(['--no-interaction', '-n'], true) + ) { + $input->setInteractive(false); + } + } + /** * Parse the incoming Artisan command and its input. */ diff --git a/tests/Console/ConsoleApplicationProgrammaticTest.php b/tests/Console/ConsoleApplicationProgrammaticTest.php new file mode 100644 index 000000000..c30ccac39 --- /dev/null +++ b/tests/Console/ConsoleApplicationProgrammaticTest.php @@ -0,0 +1,308 @@ +executeBoth([ + 'argument' => 'value', + '--flag' => 'option', + '--no-interaction' => true, + '--ansi' => true, + ]); + + $this->assertSame($result['runCode'], $result['callCode']); + $this->assertSame($result['runOutput']->fetch(), $result['callOutput']->fetch()); + $this->assertSame(ProgrammaticParityCommand::EXIT_CODE, $result['callCode']); + $this->assertTrue($result['runOutput']->isDecorated()); + $this->assertTrue($result['callOutput']->isDecorated()); + } + + public function testRunAndCallShareQuietAndExplicitVerbositySemantics(): void + { + foreach ([ + ['--quiet' => true], + ['-v' => true], + ['-vv' => true], + ['-vvv' => true], + ] as $parameters) { + $result = $this->executeBoth($parameters); + + $this->assertSame($result['runCode'], $result['callCode']); + $this->assertSame($result['runOutput']->getVerbosity(), $result['callOutput']->getVerbosity()); + $this->assertSame($result['runOutput']->fetch(), $result['callOutput']->fetch()); + } + } + + public function testRunAndCallShareConsoleEventOrdering(): void + { + $runEvents = []; + $callEvents = []; + [$runApplication, $runDispatcher] = $this->createConsoleApplication(); + [$callApplication, $callDispatcher] = $this->createConsoleApplication(); + $this->recordConsoleEvents($runDispatcher, $runEvents); + $this->recordConsoleEvents($callDispatcher, $callEvents); + + $runApplication->run( + new ArrayInput(['command' => 'test:programmatic-parity']), + new BufferedOutput, + ); + $callApplication->call('test:programmatic-parity', [], new BufferedOutput); + + $this->assertSame(['command', 'terminate'], $runEvents); + $this->assertSame($runEvents, $callEvents); + } + + public function testRunAndCallShareExceptionPropagationAndErrorEvents(): void + { + $runEvents = []; + $callEvents = []; + [$runApplication, $runDispatcher] = $this->createConsoleApplication(throwing: true); + [$callApplication, $callDispatcher] = $this->createConsoleApplication(throwing: true); + $this->recordConsoleEvents($runDispatcher, $runEvents); + $this->recordConsoleEvents($callDispatcher, $callEvents); + + try { + $runApplication->run( + new ArrayInput(['command' => 'test:programmatic-throws']), + new BufferedOutput, + ); + $this->fail('Expected the command to throw.'); + } catch (RuntimeException $exception) { + $this->assertSame('programmatic command failed', $exception->getMessage()); + } + + try { + $callApplication->call('test:programmatic-throws', [], new BufferedOutput); + $this->fail('Expected the command to throw.'); + } catch (RuntimeException $exception) { + $this->assertSame('programmatic command failed', $exception->getMessage()); + } + + $this->assertSame(['command', 'error', 'terminate'], $runEvents); + $this->assertSame($runEvents, $callEvents); + } + + public function testRunAndCallShareHelpHandling(): void + { + [$runApplication] = $this->createConsoleApplication(); + [$callApplication] = $this->createConsoleApplication(); + $runOutput = new BufferedOutput; + $callOutput = new BufferedOutput; + + $runCode = $runApplication->run( + new ArrayInput([ + 'command' => 'test:programmatic-parity', + '--help' => true, + ]), + $runOutput, + ); + $callCode = $callApplication->call('test:programmatic-parity', [ + '--help' => true, + ], $callOutput); + + $this->assertSame($runCode, $callCode); + $this->assertSame($runOutput->fetch(), $callOutput->fetch()); + } + + public function testCallIgnoresInheritedVerbosityWithoutMutatingGlobalStores(): void + { + $previousEnvironment = $_ENV['SHELL_VERBOSITY'] ?? null; + $hadEnvironment = array_key_exists('SHELL_VERBOSITY', $_ENV); + $previousServer = $_SERVER['SHELL_VERBOSITY'] ?? null; + $hadServer = array_key_exists('SHELL_VERBOSITY', $_SERVER); + $previousProcess = getenv('SHELL_VERBOSITY'); + $_ENV['SHELL_VERBOSITY'] = '2'; + $_SERVER['SHELL_VERBOSITY'] = '1'; + putenv('SHELL_VERBOSITY=3'); + + try { + [$application] = $this->createConsoleApplication(); + $output = new BufferedOutput; + + $application->call('test:programmatic-parity', [], $output); + + $this->assertSame(OutputInterface::VERBOSITY_NORMAL, $output->getVerbosity()); + $this->assertSame('2', $_ENV['SHELL_VERBOSITY']); + $this->assertSame('1', $_SERVER['SHELL_VERBOSITY']); + $this->assertSame('3', getenv('SHELL_VERBOSITY')); + } finally { + $this->restoreEnvironmentValue($_ENV, $hadEnvironment, $previousEnvironment); + $this->restoreEnvironmentValue($_SERVER, $hadServer, $previousServer); + putenv($previousProcess === false + ? 'SHELL_VERBOSITY' + : 'SHELL_VERBOSITY=' . $previousProcess); + } + } + + /** + * Execute the parity command through root and programmatic paths. + * + * @return array{runCode: int, callCode: int, runOutput: BufferedOutput, callOutput: BufferedOutput} + */ + private function executeBoth(array $parameters): array + { + [$runApplication] = $this->createConsoleApplication(); + [$callApplication] = $this->createConsoleApplication(); + $runOutput = new BufferedOutput; + $callOutput = new BufferedOutput; + + [$runCode, $callCode] = $this->withoutShellVerbosity(fn (): array => [ + $runApplication->run( + new ArrayInput(['command' => 'test:programmatic-parity', ...$parameters]), + $runOutput, + ), + $callApplication->call( + 'test:programmatic-parity', + $parameters, + $callOutput, + ), + ]); + + return compact('runCode', 'callCode', 'runOutput', 'callOutput'); + } + + /** + * Create an application and an observable Symfony event dispatcher. + * + * @return array{ConsoleApplication, EventDispatcher} + */ + private function createConsoleApplication(bool $throwing = false): array + { + $application = new ConsoleApplication( + $this->app, + $this->app->make('events'), + '1.0', + ); + $dispatcher = new EventDispatcher; + $application->setDispatcher($dispatcher); + $application->addCommand($throwing + ? new ProgrammaticThrowingCommand + : new ProgrammaticParityCommand); + + return [$application, $dispatcher]; + } + + /** + * Run a callback without inherited shell verbosity. + * + * @template TReturn + * @param Closure(): TReturn $callback + * @return TReturn + */ + private function withoutShellVerbosity(Closure $callback): mixed + { + $previousEnvironment = $_ENV['SHELL_VERBOSITY'] ?? null; + $hadEnvironment = array_key_exists('SHELL_VERBOSITY', $_ENV); + $previousServer = $_SERVER['SHELL_VERBOSITY'] ?? null; + $hadServer = array_key_exists('SHELL_VERBOSITY', $_SERVER); + $previousProcess = getenv('SHELL_VERBOSITY'); + + unset($_ENV['SHELL_VERBOSITY'], $_SERVER['SHELL_VERBOSITY']); + putenv('SHELL_VERBOSITY'); + + try { + return $callback(); + } finally { + $this->restoreEnvironmentValue($_ENV, $hadEnvironment, $previousEnvironment); + $this->restoreEnvironmentValue($_SERVER, $hadServer, $previousServer); + putenv($previousProcess === false + ? 'SHELL_VERBOSITY' + : 'SHELL_VERBOSITY=' . $previousProcess); + } + } + + /** + * Record command lifecycle event ordering. + * + * @param array $events + */ + private function recordConsoleEvents(EventDispatcher $dispatcher, array &$events): void + { + $dispatcher->addListener( + ConsoleEvents::COMMAND, + static function (ConsoleCommandEvent $event) use (&$events): void { + $events[] = 'command'; + }, + ); + $dispatcher->addListener( + ConsoleEvents::ERROR, + static function (ConsoleErrorEvent $event) use (&$events): void { + $events[] = 'error'; + }, + ); + $dispatcher->addListener( + ConsoleEvents::TERMINATE, + static function (ConsoleTerminateEvent $event) use (&$events): void { + $events[] = 'terminate'; + }, + ); + } + + /** + * Restore one process-global PHP environment array entry. + * + * @param array $store + */ + private function restoreEnvironmentValue( + array &$store, + bool $existed, + mixed $value, + ): void { + if ($existed) { + $store['SHELL_VERBOSITY'] = $value; + + return; + } + + unset($store['SHELL_VERBOSITY']); + } +} + +class ProgrammaticParityCommand extends Command +{ + public const EXIT_CODE = 17; + + protected ?string $signature = 'test:programmatic-parity {argument=default} {--flag=}'; + + public function handle(): int + { + $this->line((string) json_encode([ + 'argument' => $this->argument('argument'), + 'flag' => $this->option('flag'), + 'interactive' => $this->input->isInteractive(), + 'verbosity' => $this->output->getVerbosity(), + 'decorated' => $this->output->isDecorated(), + ], JSON_THROW_ON_ERROR)); + + return self::EXIT_CODE; + } +} + +class ProgrammaticThrowingCommand extends Command +{ + protected ?string $signature = 'test:programmatic-throws'; + + public function handle(): never + { + throw new RuntimeException('programmatic command failed'); + } +} From 380e978491449fced3574f7e94a448681afe956b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:16:10 +0000 Subject: [PATCH 23/40] Make polling watcher drivers own one lifecycle Standardize driver watch as a blocking operation that returns only after terminal completion or idempotent stop. Replace detached coordinator timers with one lazily owned stop channel so polling cadence remains coroutine-friendly while stop wakes an interval immediately and scan failures reach the Watcher owner. Preserve FindNewer's reference-file and in-flight scan cleanup rules and cover real interval blocking, immediate stop, deferred cleanup, and propagated scan failures across every polling driver. --- src/watcher/src/Driver/AbstractDriver.php | 52 +++++++++---- src/watcher/src/Driver/DriverInterface.php | 5 +- src/watcher/src/Driver/FindDriver.php | 2 +- src/watcher/src/Driver/FindNewerDriver.php | 11 ++- src/watcher/src/Driver/ScanFileDriver.php | 2 +- tests/Watcher/Driver/FindDriverTest.php | 14 +++- tests/Watcher/Driver/FindNewerDriverTest.php | 74 +++++++++++-------- tests/Watcher/Driver/ScanFileDriverTest.php | 24 +++++- .../Watcher/Fixtures/FindNewerDriverStub.php | 3 + 9 files changed, 127 insertions(+), 60 deletions(-) diff --git a/src/watcher/src/Driver/AbstractDriver.php b/src/watcher/src/Driver/AbstractDriver.php index c524c789f..e18bb75eb 100644 --- a/src/watcher/src/Driver/AbstractDriver.php +++ b/src/watcher/src/Driver/AbstractDriver.php @@ -4,25 +4,19 @@ namespace Hypervel\Watcher\Driver; -use Hypervel\Coordinator\Timer; +use Hypervel\Engine\Channel; use Hypervel\Watcher\Option; use RuntimeException; use Swoole\Coroutine\System; abstract class AbstractDriver implements DriverInterface { - protected Timer $timer; + protected ?Channel $stopSignal = null; - protected ?int $timerId = null; + protected bool $stopping = false; public function __construct(protected Option $option) { - $this->timer = new Timer; - } - - public function __destruct() - { - $this->stop(); } /** @@ -34,13 +28,45 @@ public function isDarwin(): bool } /** - * Stop the file watcher timer. + * Stop the active watch lifecycle. */ public function stop(): void { - if ($this->timerId) { - $this->timer->clear($this->timerId); - $this->timerId = null; + if ($this->stopping) { + return; + } + + $this->stopping = true; + $this->stopSignal?->close(); + } + + /** + * Run a polling scan until the driver is stopped. + */ + protected function watchAtInterval(float $seconds, callable $scan): void + { + if ($this->stopping) { + return; + } + + $stopSignal = $this->stopSignal = new Channel(1); + + try { + while (true) { + $signal = $stopSignal->pop($seconds); + + if ($signal !== false || ! $stopSignal->isTimeout()) { + return; + } + + $scan(); + } + } finally { + if (! $stopSignal->isClosing()) { + $stopSignal->close(); + } + + $this->stopSignal = null; } } diff --git a/src/watcher/src/Driver/DriverInterface.php b/src/watcher/src/Driver/DriverInterface.php index 6b1ffb9f7..779adc5d6 100644 --- a/src/watcher/src/Driver/DriverInterface.php +++ b/src/watcher/src/Driver/DriverInterface.php @@ -11,9 +11,8 @@ interface DriverInterface /** * Run the watch loop, pushing changed file paths into the channel. * - * Deterministic shutdown is stop(): it releases the driver's resources and - * unblocks suspended I/O. A closing channel is observed opportunistically - * and is not guaranteed to interrupt blocked I/O. + * This method blocks for one driver lifecycle. It returns only after + * terminal completion or stop() releases the driver's suspended work. */ public function watch(Channel $channel): void; diff --git a/src/watcher/src/Driver/FindDriver.php b/src/watcher/src/Driver/FindDriver.php index 687987024..b6af89aa2 100644 --- a/src/watcher/src/Driver/FindDriver.php +++ b/src/watcher/src/Driver/FindDriver.php @@ -44,7 +44,7 @@ public function watch(Channel $channel): void $this->startTime = time(); $seconds = $this->option->getScanIntervalSeconds(); - $this->timerId = $this->timer->tick($seconds, function () use ($channel) { + $this->watchAtInterval($seconds, function () use ($channel): void { [$this->fileModifyTimes, $changedFiles] = $this->scan($this->fileModifyTimes, $this->getScanIntervalMinutes()); foreach ($changedFiles as $file) { diff --git a/src/watcher/src/Driver/FindNewerDriver.php b/src/watcher/src/Driver/FindNewerDriver.php index 4c257d25d..caa648e3d 100644 --- a/src/watcher/src/Driver/FindNewerDriver.php +++ b/src/watcher/src/Driver/FindNewerDriver.php @@ -18,8 +18,6 @@ class FindNewerDriver extends AbstractDriver protected bool $scanning = false; - protected bool $stopping = false; - protected int $count = 0; public function __construct(protected Option $option) @@ -41,11 +39,14 @@ public function watch(Channel $channel): void throw new RuntimeException('Cannot restart the find-newer watcher while its previous scan is still stopping.'); } - $this->stopping = false; + if ($this->stopping) { + return; + } + $this->ensureReferenceFiles(); $seconds = $this->option->getScanIntervalSeconds(); - $this->timerId = $this->timer->tick($seconds, function () use ($channel) { + $this->watchAtInterval($seconds, function () use ($channel): void { if ($this->scanning || $this->stopping) { return; } @@ -87,8 +88,6 @@ public function watch(Channel $channel): void */ public function stop(): void { - $this->stopping = true; - parent::stop(); if (! $this->scanning) { diff --git a/src/watcher/src/Driver/ScanFileDriver.php b/src/watcher/src/Driver/ScanFileDriver.php index d2710b1c5..f96dbd7e6 100644 --- a/src/watcher/src/Driver/ScanFileDriver.php +++ b/src/watcher/src/Driver/ScanFileDriver.php @@ -35,7 +35,7 @@ public function __construct( public function watch(Channel $channel): void { $seconds = $this->option->getScanIntervalSeconds(); - $this->timerId = $this->timer->tick($seconds, function () use ($channel) { + $this->watchAtInterval($seconds, function () use ($channel): void { $this->processFileHashes($channel, $this->getWatchFileHashes()); }); } diff --git a/tests/Watcher/Driver/FindDriverTest.php b/tests/Watcher/Driver/FindDriverTest.php index 6383f5f98..7c50c9b93 100644 --- a/tests/Watcher/Driver/FindDriverTest.php +++ b/tests/Watcher/Driver/FindDriverTest.php @@ -4,7 +4,9 @@ namespace Hypervel\Tests\Watcher\Driver; +use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; +use Hypervel\Engine\Coroutine; use Hypervel\Tests\TestCase; use Hypervel\Tests\Watcher\Fixtures\FindDriverStub; use Hypervel\Watcher\Driver\FindDriver; @@ -30,7 +32,14 @@ public function testWatch(): void try { $driver = new FindDriverStub($option); - $driver->watch($channel); + $finished = new WaitGroup(1); + Coroutine::create(function () use ($channel, $driver, $finished): void { + try { + $driver->watch($channel); + } finally { + $finished->done(); + } + }); $this->assertSame('.env', $channel->pop($option->getScanIntervalSeconds() + 0.1)); } catch (InvalidArgumentException $e) { @@ -42,6 +51,9 @@ public function testWatch(): void if (isset($driver)) { $driver->stop(); } + if (isset($finished)) { + $this->assertTrue($finished->wait(0.1)); + } $channel->close(); } } diff --git a/tests/Watcher/Driver/FindNewerDriverTest.php b/tests/Watcher/Driver/FindNewerDriverTest.php index c138d2217..afd3fb849 100644 --- a/tests/Watcher/Driver/FindNewerDriverTest.php +++ b/tests/Watcher/Driver/FindNewerDriverTest.php @@ -4,8 +4,9 @@ namespace Hypervel\Tests\Watcher\Driver; -use Hypervel\Coordinator\Timer; +use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; +use Hypervel\Engine\Coroutine; use Hypervel\Tests\TestCase; use Hypervel\Tests\Watcher\Fixtures\FindNewerDriverStub; use Hypervel\Watcher\Driver\FindNewerDriver; @@ -13,7 +14,6 @@ use Hypervel\Watcher\WatchPath; use Hypervel\Watcher\WatchPathType; use InvalidArgumentException; -use Psr\Log\NullLogger; use RuntimeException; class FindNewerDriverTest extends TestCase @@ -33,7 +33,14 @@ public function testWatch(): void try { $driver = new FindNewerDriverStub($option); - $driver->watch($channel); + $finished = new WaitGroup(1); + Coroutine::create(function () use ($channel, $driver, $finished): void { + try { + $driver->watch($channel); + } finally { + $finished->done(); + } + }); $this->assertSame('.env', $channel->pop($option->getScanIntervalSeconds() + 0.1)); } catch (InvalidArgumentException $e) { if (str_contains($e->getMessage(), 'find not exists')) { @@ -44,11 +51,14 @@ public function testWatch(): void if (isset($driver)) { $driver->stop(); } + if (isset($finished)) { + $this->assertTrue($finished->wait(0.1)); + } $channel->close(); } } - public function testRecoveryAfterScanException(): void + public function testScanExceptionTerminatesTheWatchLifecycle(): void { $option = new Option( driver: FindNewerDriver::class, @@ -58,17 +68,7 @@ public function testRecoveryAfterScanException(): void scanInterval: 1, ); - // Stub that throws on first scan(), succeeds on second, then returns empty. - // exec() is stubbed to bypass the find availability check. $driver = new class($option) extends FindNewerDriver { - private int $scanCallCount = 0; - - public function __construct(Option $option) - { - parent::__construct($option); - $this->timer = new Timer(new NullLogger); - } - protected function exec(string $command): array { return ['code' => 0, 'output' => '/usr/bin/find']; @@ -76,21 +76,16 @@ protected function exec(string $command): array protected function scan(): array { - return match (++$this->scanCallCount) { - 1 => throw new RuntimeException('Simulated scan failure'), - 2 => ['/tmp/recovered.php'], - default => [], - }; + throw new RuntimeException('Simulated scan failure'); } }; $channel = new Channel(10); - $driver->watch($channel); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Simulated scan failure'); try { - // First tick throws (recovered via finally), second tick succeeds. - $file = $channel->pop(0.2); - $this->assertSame('/tmp/recovered.php', $file); + $driver->watch($channel); } finally { $driver->stop(); $channel->close(); @@ -123,6 +118,8 @@ protected function scan(): array return ['/tmp/a.php', '/tmp/b.php', '/tmp/c.php']; } + $this->stop(); + return []; } }; @@ -274,7 +271,7 @@ public function testReferenceFilesAreUniquePerDriverAndRemovedOnStop(): void } } - public function testWatchRecreatesReferenceFilesAfterStop(): void + public function testStoppedDriverCannotStartAnotherWatchLifecycle(): void { $driver = $this->referenceFileDriver(); $oldFiles = $driver->referenceFilesForTest(); @@ -283,13 +280,10 @@ public function testWatchRecreatesReferenceFilesAfterStop(): void try { $driver->watch($channel); - $newFiles = $driver->referenceFilesForTest(); - - $this->assertCount(2, $newFiles); - $this->assertSame([], array_intersect($oldFiles, $newFiles)); + $this->assertSame([], $driver->referenceFilesForTest()); - foreach ($newFiles as $file) { - $this->assertFileExists($file); + foreach ($oldFiles as $file) { + $this->assertFileDoesNotExist($file); } } finally { $driver->stop(); @@ -488,15 +482,23 @@ public function referenceFilesForTest(): array return $this->referenceFiles; } }; + $finished = new WaitGroup(1); try { - $driver->watch($output); + Coroutine::create(function () use ($driver, $finished, $output): void { + try { + $driver->watch($output); + } finally { + $finished->done(); + } + }); $this->assertTrue($entered->pop(0.2)); $driver->stop(); $resume->push(true); $this->assertTrue($removed->pop(0.2)); + $this->assertTrue($finished->wait(0.2)); $this->assertSame(1, $driver->updateCount); $this->assertFalse($driver->scanCalled); $this->assertSame([], $driver->referenceFilesForTest()); @@ -563,9 +565,16 @@ public function referenceFilesForTest(): array return $this->referenceFiles; } }; + $finished = new WaitGroup(1); try { - $driver->watch($output); + Coroutine::create(function () use ($driver, $finished, $output): void { + try { + $driver->watch($output); + } finally { + $finished->done(); + } + }); $this->assertTrue($entered->pop(0.2)); $files = $driver->referenceFilesForTest(); @@ -587,6 +596,7 @@ public function referenceFilesForTest(): array $resume->push(true); $this->assertTrue($removed->pop(0.2)); + $this->assertTrue($finished->wait(0.2)); $this->assertSame(1, $driver->updateCount); $this->assertSame([], $driver->referenceFilesForTest()); diff --git a/tests/Watcher/Driver/ScanFileDriverTest.php b/tests/Watcher/Driver/ScanFileDriverTest.php index d6b797fd0..64b84f477 100644 --- a/tests/Watcher/Driver/ScanFileDriverTest.php +++ b/tests/Watcher/Driver/ScanFileDriverTest.php @@ -4,7 +4,9 @@ namespace Hypervel\Tests\Watcher\Driver; +use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; +use Hypervel\Engine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Tests\TestCase; use Hypervel\Tests\Watcher\Fixtures\ContainerStub; @@ -30,13 +32,21 @@ public function testWatch() $channel = new Channel(10); $driver = new ScanFileDriverStub($option, ContainerStub::getLogger()); + $finished = new WaitGroup(1); - $driver->watch($channel); + Coroutine::create(function () use ($channel, $driver, $finished): void { + try { + $driver->watch($channel); + } finally { + $finished->done(); + } + }); try { - $this->assertStringEndsWith('.env', $channel->pop($option->getScanIntervalSeconds() + 0.1)); + $this->assertStringEndsWith('.env', $channel->pop(($option->getScanIntervalSeconds() * 2) + 0.1)); } finally { $driver->stop(); + $this->assertTrue($finished->wait(0.1)); $channel->close(); } } @@ -70,7 +80,14 @@ protected function getWatchFileHashes(): array }; $channel = new Channel(10); - $driver->watch($channel); + $finished = new WaitGroup(1); + Coroutine::create(function () use ($channel, $driver, $finished): void { + try { + $driver->watch($channel); + } finally { + $finished->done(); + } + }); try { // Wait for two ticks to fire (baseline + detection). @@ -88,6 +105,7 @@ protected function getWatchFileHashes(): array $this->assertCount(2, $pushed); } finally { $driver->stop(); + $this->assertTrue($finished->wait(0.1)); $channel->close(); } } diff --git a/tests/Watcher/Fixtures/FindNewerDriverStub.php b/tests/Watcher/Fixtures/FindNewerDriverStub.php index bf7744647..881c0a5b0 100644 --- a/tests/Watcher/Fixtures/FindNewerDriverStub.php +++ b/tests/Watcher/Fixtures/FindNewerDriverStub.php @@ -14,6 +14,9 @@ public function watch(Channel $channel): void foreach ($this->scan() as $file) { $channel->push($file); } + + $this->watchAtInterval(60, static function (): void { + }); } protected function scan(): array From 99deff1bbc5eb3ac56bc4d69ad8cf7a2ad6acd84 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:16:20 +0000 Subject: [PATCH 24/40] Preserve framed filesystem watcher output Process fswatch paths inline in the owned driver coroutine, retain incomplete newline-delimited tails across reads, and deliver complete paths in order under bounded channel backpressure. Terminal read and matching failures now propagate through the driver's lifecycle instead of detached raw coroutines. Compile immutable WatchPath glob patterns once and add regressions for split paths, multi-record chunks, final buffered records, ordered batches, failure propagation, and explicit process teardown. --- src/watcher/src/Driver/FswatchDriver.php | 77 ++++++--- src/watcher/src/WatchPath.php | 8 +- tests/Watcher/Driver/FswatchDriverTest.php | 163 ++++++++++++++++++- tests/Watcher/Fixtures/FswatchDriverStub.php | 5 +- 4 files changed, 226 insertions(+), 27 deletions(-) diff --git a/src/watcher/src/Driver/FswatchDriver.php b/src/watcher/src/Driver/FswatchDriver.php index 5e1b6241e..eb208640f 100644 --- a/src/watcher/src/Driver/FswatchDriver.php +++ b/src/watcher/src/Driver/FswatchDriver.php @@ -5,7 +5,6 @@ namespace Hypervel\Watcher\Driver; use Hypervel\Engine\Channel; -use Hypervel\Engine\Coroutine; use Hypervel\Watcher\Option; use Hypervel\Watcher\WatchPath; use InvalidArgumentException; @@ -32,6 +31,10 @@ public function __construct(protected Option $option) */ public function watch(Channel $channel): void { + if ($this->stopping) { + return; + } + $this->openProcess(); try { @@ -41,8 +44,9 @@ public function watch(Channel $channel): void throw new RuntimeException('The fswatch process did not provide an output pipe.'); } - $basePath = null; - $watchPaths = null; + $basePath = base_path(); + $watchPaths = $this->option->getWatchPaths(); + $buffer = ''; while (true) { if ($this->shouldStopWatching($channel)) { @@ -55,35 +59,64 @@ public function watch(Channel $channel): void return; } - if ($result === '' && feof($pipe)) { - throw new RuntimeException('The fswatch process exited unexpectedly.'); + if ($result === false) { + throw new RuntimeException('Unable to read output from the fswatch process.'); } - if ($result === false || $result === '') { + if ($result === '') { + if (feof($pipe)) { + $this->processOutput($buffer, '', $channel, $basePath, $watchPaths, final: true); + + throw new RuntimeException('The fswatch process exited unexpectedly.'); + } + throw new RuntimeException('Unable to read output from the fswatch process.'); } - $basePath ??= base_path(); - $watchPaths ??= $this->option->getWatchPaths(); - - Coroutine::create(function () use ($result, $channel, $basePath, $watchPaths) { - $files = array_filter(explode("\n", $result)); - foreach ($files as $file) { - $relativePath = substr($file, strlen($basePath) + 1); - foreach ($watchPaths as $watchPath) { - if ($watchPath->matches($relativePath)) { - $channel->push($file); - break; - } - } - } - }); + $this->processOutput($buffer, $result, $channel, $basePath, $watchPaths); } } finally { $this->stop(); } } + /** + * Process complete newline-delimited paths while retaining a partial tail. + * + * @param list $watchPaths + */ + protected function processOutput( + string &$buffer, + string $chunk, + Channel $channel, + string $basePath, + array $watchPaths, + bool $final = false, + ): void { + $lines = explode("\n", $buffer . $chunk); + $buffer = array_pop($lines); + + if ($final && $buffer !== '') { + $lines[] = $buffer; + $buffer = ''; + } + + foreach ($lines as $file) { + if ($file === '') { + continue; + } + + $relativePath = substr($file, strlen($basePath) + 1); + + foreach ($watchPaths as $watchPath) { + if ($watchPath->matches($relativePath)) { + $channel->push($file); + break; + } + } + } + } + /** * Stop the fswatch process. */ @@ -135,7 +168,7 @@ protected function openProcess(): void */ protected function shouldStopWatching(Channel $channel): bool { - return $channel->isClosing() || $this->process === null; + return $this->stopping || $channel->isClosing() || $this->process === null; } /** diff --git a/src/watcher/src/WatchPath.php b/src/watcher/src/WatchPath.php index d14a8b540..09ee918d4 100644 --- a/src/watcher/src/WatchPath.php +++ b/src/watcher/src/WatchPath.php @@ -8,6 +8,8 @@ readonly class WatchPath { + private ?string $regex; + /** * @param string $path Relative base path (e.g., 'app', 'config', '.env') * @param WatchPathType $type Whether this entry represents a directory or a file @@ -18,6 +20,9 @@ public function __construct( public WatchPathType $type, public ?string $pattern = null, ) { + $this->regex = $type === WatchPathType::Directory && $pattern !== null + ? Glob::toRegex($pattern, strictLeadingDot: false) + : null; } /** @@ -37,7 +42,8 @@ public function matches(string $relativePath): bool return str_starts_with($relativePath, $this->path . '/'); } - $regex = Glob::toRegex($this->pattern, strictLeadingDot: false); + /** @var string $regex */ + $regex = $this->regex; return (bool) preg_match($regex, $relativePath); } diff --git a/tests/Watcher/Driver/FswatchDriverTest.php b/tests/Watcher/Driver/FswatchDriverTest.php index c676d920b..6fc58d076 100644 --- a/tests/Watcher/Driver/FswatchDriverTest.php +++ b/tests/Watcher/Driver/FswatchDriverTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Watcher\Driver; +use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; use Hypervel\Testbench\TestCase; @@ -33,7 +34,14 @@ public function testWatch(): void try { $driver = new FswatchDriverStub($option); - $driver->watch($channel); + $finished = new WaitGroup(1); + Coroutine::create(function () use ($channel, $driver, $finished): void { + try { + $driver->watch($channel); + } finally { + $finished->done(); + } + }); $this->assertSame('.env', $channel->pop($option->getScanIntervalSeconds() + 0.1)); } catch (InvalidArgumentException $e) { @@ -45,6 +53,9 @@ public function testWatch(): void if (isset($driver)) { $driver->stop(); } + if (isset($finished)) { + $this->assertTrue($finished->wait(0.1)); + } $channel->close(); } } @@ -284,6 +295,114 @@ public function commandForTest(): array $this->assertSame($expected, $driver->commandForTest()); } + public function testWatchDeliversEachBatchInOrderWithoutDetachedChildren(): void + { + $option = new Option( + driver: FswatchDriver::class, + watchPaths: [ + new WatchPath('first.php', WatchPathType::File), + new WatchPath('second.php', WatchPathType::File), + ], + scanInterval: 1, + ); + $driver = new OutputFswatchDriver( + $option, + base_path('first.php') . "\n" . base_path('second.php') . "\n", + ); + $channel = new Channel(2); + + try { + $driver->watch($channel); + $this->fail('Expected the completed fswatch process to exit.'); + } catch (RuntimeException $exception) { + $this->assertSame('The fswatch process exited unexpectedly.', $exception->getMessage()); + } finally { + $driver->stop(); + } + + $this->assertSame(base_path('first.php'), $channel->pop()); + $this->assertSame(base_path('second.php'), $channel->pop()); + $this->assertSame(0, $channel->getLength()); + $this->assertTrue($driver->resourcesAreClosed()); + + $channel->close(); + } + + public function testWatchPreservesPathsSplitAcrossReads(): void + { + $option = new Option( + driver: FswatchDriver::class, + watchPaths: [ + new WatchPath('first.php', WatchPathType::File), + new WatchPath('second.php', WatchPathType::File), + new WatchPath('third.php', WatchPathType::File), + ], + scanInterval: 1, + ); + $driver = new class($option) extends FswatchDriver { + protected function exec(string $command): array + { + return ['code' => 0, 'output' => '/usr/bin/fswatch']; + } + + public function processChunks(Channel $channel, array $chunks): void + { + $buffer = ''; + $watchPaths = $this->option->getWatchPaths(); + + foreach ($chunks as $chunk) { + $this->processOutput($buffer, $chunk, $channel, base_path(), $watchPaths); + } + + $this->processOutput($buffer, '', $channel, base_path(), $watchPaths, final: true); + } + }; + $channel = new Channel(3); + $second = base_path('second.php'); + + try { + $driver->processChunks($channel, [ + base_path('first.php') . "\n" . substr($second, 0, -3), + substr($second, -3) . "\n" . base_path('third.php'), + ]); + + $this->assertSame(base_path('first.php'), $channel->pop()); + $this->assertSame(base_path('second.php'), $channel->pop()); + $this->assertSame(base_path('third.php'), $channel->pop()); + $this->assertSame(0, $channel->getLength()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testWatchPathFailureEscapesThroughTheOwnedDriverCoroutine(): void + { + $failure = new RuntimeException('expected match failure'); + $option = new Option( + driver: FswatchDriver::class, + watchPaths: [new ThrowingWatchPath($failure)], + scanInterval: 1, + ); + $driver = new OutputFswatchDriver( + $option, + base_path('throw.php') . "\n", + ); + $channel = new Channel(1); + + try { + $driver->watch($channel); + $this->fail('Expected path matching to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } finally { + $driver->stop(); + $channel->close(); + } + + $this->assertTrue($driver->resourcesAreClosed()); + } + /** * Create the standard fswatch test options. */ @@ -299,6 +418,48 @@ private function option(): Option } } +class OutputFswatchDriver extends FswatchDriver +{ + public function __construct( + Option $option, + protected string $output, + ) { + parent::__construct($option); + } + + protected function exec(string $command): array + { + return ['code' => 0, 'output' => '/usr/bin/fswatch']; + } + + protected function getCommand(): array + { + return [ + PHP_BINARY, + '-r', + 'fwrite(STDOUT, ' . var_export($this->output, true) . ');', + ]; + } + + public function resourcesAreClosed(): bool + { + return ! is_resource($this->process) && $this->pipes === []; + } +} + +readonly class ThrowingWatchPath extends WatchPath +{ + public function __construct(public RuntimeException $failure) + { + parent::__construct('', WatchPathType::Directory); + } + + public function matches(string $relativePath): bool + { + throw $this->failure; + } +} + class FswatchDriverStreamState { public int $readCount = 0; diff --git a/tests/Watcher/Fixtures/FswatchDriverStub.php b/tests/Watcher/Fixtures/FswatchDriverStub.php index 9c57942b1..a9138168c 100644 --- a/tests/Watcher/Fixtures/FswatchDriverStub.php +++ b/tests/Watcher/Fixtures/FswatchDriverStub.php @@ -11,9 +11,8 @@ class FswatchDriverStub extends FswatchDriver { public function watch(Channel $channel): void { - $seconds = $this->option->getScanIntervalSeconds(); - $this->timerId = $this->timer->tick($seconds, function () use ($channel) { - $channel->push('.env'); + $channel->push('.env'); + $this->watchAtInterval(60, static function (): void { }); } } From 383f1de612be77b383ae4fbc0f9ba2d140880f1d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:16:36 +0000 Subject: [PATCH 25/40] Make watcher teardown observable and bounded Own the blocking driver in one joined coroutine, propagate its terminal failure, drain synchronous final batches, and perform driver, strategy, channel, and bounded-join cleanup without replacing the primary operation error. Add an idempotent restart-strategy stop contract, restore server launch tokens in finally, report asynchronous native launch failures safely, and validate positive PID files before any event or POSIX signal. Cover full-channel shutdown, debounce tails, restart failures, managed-process cleanup, and corrupted PID input. --- .../src/Console/HorizonRestartStrategy.php | 2 +- src/watcher/README.md | 5 +- .../src/Events/BeforeServerRestart.php | 2 +- src/watcher/src/RestartStrategy.php | 7 + src/watcher/src/ServerRestartStrategy.php | 99 ++++++--- src/watcher/src/Watcher.php | 82 +++++-- .../Horizon/Feature/ListenCommandTest.php | 12 ++ tests/Watcher/ServerRestartStrategyTest.php | 204 ++++++++++++++++++ tests/Watcher/WatcherTest.php | 194 +++++++++++++++++ 9 files changed, 564 insertions(+), 43 deletions(-) create mode 100644 tests/Watcher/WatcherTest.php diff --git a/src/horizon/src/Console/HorizonRestartStrategy.php b/src/horizon/src/Console/HorizonRestartStrategy.php index 86103bd64..edb915325 100644 --- a/src/horizon/src/Console/HorizonRestartStrategy.php +++ b/src/horizon/src/Console/HorizonRestartStrategy.php @@ -62,7 +62,7 @@ public function restart(): void /** * Stop the currently running Horizon process. */ - protected function stop(): void + public function stop(): void { if ($this->horizonProcess?->isRunning()) { $this->horizonProcess->stop(); diff --git a/src/watcher/README.md b/src/watcher/README.md index 2ac94d18b..e3858ccdd 100644 --- a/src/watcher/README.md +++ b/src/watcher/README.md @@ -63,8 +63,8 @@ php artisan watch --no-restart The watcher separates three concerns: - **`Option`** — Parses watch configuration into typed `WatchPath` objects -- **Drivers** (`DriverInterface`) — Detect file changes and push paths to a Channel -- **Restart Strategies** (`RestartStrategy`) — Define what happens when changes are detected +- **Drivers** (`DriverInterface`) — Own one blocking watch lifecycle, push changed paths to a channel, and unblock when stopped +- **Restart Strategies** (`RestartStrategy`) — Own the managed process lifecycle around detected changes ### Restart Strategies @@ -75,6 +75,7 @@ interface RestartStrategy { public function start(): void; public function restart(): void; + public function stop(): void; } ``` diff --git a/src/watcher/src/Events/BeforeServerRestart.php b/src/watcher/src/Events/BeforeServerRestart.php index 6ef08060b..9f69516d5 100644 --- a/src/watcher/src/Events/BeforeServerRestart.php +++ b/src/watcher/src/Events/BeforeServerRestart.php @@ -6,7 +6,7 @@ class BeforeServerRestart { - public function __construct(public readonly string $pid) + public function __construct(public readonly int $pid) { } } diff --git a/src/watcher/src/RestartStrategy.php b/src/watcher/src/RestartStrategy.php index 984ee5941..50777fd4c 100644 --- a/src/watcher/src/RestartStrategy.php +++ b/src/watcher/src/RestartStrategy.php @@ -15,4 +15,11 @@ public function start(): void; * Restart the managed process (stop current instance, start new). */ public function restart(): void; + + /** + * Stop the managed process. + * + * Idempotent — teardown paths may invoke it multiple times. + */ + public function stop(): void; } diff --git a/src/watcher/src/ServerRestartStrategy.php b/src/watcher/src/ServerRestartStrategy.php index 48eadc2e8..5f64b2faa 100644 --- a/src/watcher/src/ServerRestartStrategy.php +++ b/src/watcher/src/ServerRestartStrategy.php @@ -6,11 +6,12 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Filesystem\FileNotFoundException; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; -use Hypervel\Engine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Watcher\Events\BeforeServerRestart; use InvalidArgumentException; +use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -78,27 +79,37 @@ public function start(): void */ public function restart(): void { - $this->stopServer(); + $this->stop(); $this->launchServer(); } /** * Stop the currently running server process. */ - protected function stopServer(): void + public function stop(): void { if (! $this->filesystem->exists($this->pidFile)) { return; } - $pid = $this->filesystem->get($this->pidFile); + $pidContents = trim($this->filesystem->get($this->pidFile)); + + if ($pidContents === '' || ! ctype_digit($pidContents)) { + return; + } + + $pid = (int) $pidContents; + + if ($pid <= 0) { + return; + } try { $this->output->writeln('Stop server...'); $this->container->make('events') ->dispatch(new BeforeServerRestart($pid)); - if (posix_kill((int) $pid, 0)) { - posix_kill((int) $pid, SIGTERM); + if ($this->signalProcess($pid, 0)) { + $this->signalProcess($pid, SIGTERM); } } catch (Throwable) { $this->output->writeln('Stop server failed.'); @@ -110,28 +121,30 @@ protected function stopServer(): void */ protected function launchServer(): void { - Coroutine::create(function () { + Coroutine::create(function (): void { $this->channel->pop(); - $this->output->writeln('Start server ...'); - - $descriptorSpec = [ - 0 => STDIN, - 1 => STDOUT, - 2 => STDERR, - ]; - - $process = proc_open( - command: $this->serverCommand(), - descriptor_spec: $descriptorSpec, - pipes: $pipes - ); - - if (is_resource($process)) { - proc_close($process); - } - $this->output->writeln('Server exited.'); - $this->channel->push(1); + try { + $this->output->writeln('Start server ...'); + + $descriptorSpec = [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ]; + $pipes = []; + + $process = $this->openProcess($descriptorSpec, $pipes); + + if (! is_resource($process)) { + throw new RuntimeException('Unable to launch the watched server process.'); + } + + $this->closeProcess($process); + $this->output->writeln('Server exited.'); + } finally { + $this->channel->push(1); + } }); } @@ -147,4 +160,38 @@ protected function serverCommand(): array return [$this->bin, base_path($script), ...$arguments]; } + + /** + * Open the watched server process. + * + * @param array $descriptorSpec + * @param array $pipes + * @return false|resource + */ + protected function openProcess(array $descriptorSpec, array &$pipes): mixed + { + return proc_open( + command: $this->serverCommand(), + descriptor_spec: $descriptorSpec, + pipes: $pipes, + ); + } + + /** + * Close the watched server process. + * + * @param resource $process + */ + protected function closeProcess(mixed $process): int + { + return proc_close($process); + } + + /** + * Send a signal to a server process. + */ + protected function signalProcess(int $pid, int $signal): bool + { + return posix_kill($pid, $signal); + } } diff --git a/src/watcher/src/Watcher.php b/src/watcher/src/Watcher.php index e7618a4bb..5e1b5ad44 100644 --- a/src/watcher/src/Watcher.php +++ b/src/watcher/src/Watcher.php @@ -4,10 +4,13 @@ namespace Hypervel\Watcher; +use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; use Hypervel\Watcher\Driver\DriverInterface; +use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; class Watcher { @@ -23,25 +26,78 @@ public function __construct( */ public function run(): void { - $this->strategy?->start(); - $channel = new Channel(999); - Coroutine::create(function () use ($channel) { - $this->driver->watch($channel); - }); - + $driverFinished = new WaitGroup(1); + $driverFailure = null; + $driverStarted = false; + $exception = null; $result = []; - while (true) { /** @phpstan-ignore while.alwaysTrue */ - $file = $channel->pop(0.001); - if ($file === false) { - if (count($result) > 0) { - $result = []; - $this->strategy?->restart(); + $capture = static function (callable $callback) use (&$exception): void { + try { + $callback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + }; + + try { + $this->strategy?->start(); + + Coroutine::create(function () use ($channel, $driverFinished, &$driverFailure): void { + try { + $this->driver->watch($channel); + } catch (Throwable $throwable) { + $driverFailure = $throwable; + } finally { + $driverFinished->done(); } - } else { + }); + $driverStarted = true; + + while ($driverFinished->count() > 0) { + $file = $channel->pop(0.001); + + if ($file === false) { + if ($result !== []) { + $result = []; + $this->strategy?->restart(); + } + + continue; + } + $this->output->writeln('File changed: ' . $file); $result[] = $file; } + + if ($driverFailure !== null) { + throw $driverFailure; + } + + while ($channel->getLength() > 0) { + $file = $channel->pop(); + $this->output->writeln('File changed: ' . $file); + $result[] = $file; + } + + if ($result !== []) { + $this->strategy?->restart(); + } + } catch (Throwable $throwable) { + $exception = $throwable; + } finally { + $capture(fn () => $this->driver->stop()); + $capture(fn () => $this->strategy?->stop()); + $capture(fn () => $channel->close()); + $capture(function () use ($driverStarted, $driverFinished): void { + if ($driverStarted && ! $driverFinished->wait(1.0)) { + throw new RuntimeException('The file watcher did not stop within one second.'); + } + }); + } + + if ($exception !== null) { + throw $exception; } } } diff --git a/tests/Integration/Horizon/Feature/ListenCommandTest.php b/tests/Integration/Horizon/Feature/ListenCommandTest.php index 78f916a86..f806b6c61 100644 --- a/tests/Integration/Horizon/Feature/ListenCommandTest.php +++ b/tests/Integration/Horizon/Feature/ListenCommandTest.php @@ -65,6 +65,10 @@ public function start(): void public function restart(): void { } + + public function stop(): void + { + } }; }); @@ -89,6 +93,10 @@ public function start(): void public function restart(): void { } + + public function stop(): void + { + } }; }); @@ -113,6 +121,10 @@ public function start(): void public function restart(): void { } + + public function stop(): void + { + } }; }); diff --git a/tests/Watcher/ServerRestartStrategyTest.php b/tests/Watcher/ServerRestartStrategyTest.php index 02fb75e07..afceac13a 100644 --- a/tests/Watcher/ServerRestartStrategyTest.php +++ b/tests/Watcher/ServerRestartStrategyTest.php @@ -5,11 +5,16 @@ namespace Hypervel\Tests\Watcher; use Hypervel\Config\Repository; +use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Filesystem\FileNotFoundException; +use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\TestCase; +use Hypervel\Watcher\Events\BeforeServerRestart; use Hypervel\Watcher\ServerRestartStrategy; use InvalidArgumentException; +use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use Symfony\Component\Console\Output\NullOutput; class ServerRestartStrategyTest extends TestCase @@ -109,4 +114,203 @@ public static function invalidCommandProvider(): array 'non-string argument' => [['artisan', 1]], ]; } + + #[DataProvider('invalidPidProvider')] + public function testStopNeverSignalsAnInvalidPid(string $contents): void + { + $strategy = $this->createProbeStrategy(); + $strategy->useFilesystem(new PidFileFilesystem(true, $contents)); + + $strategy->stop(); + + $this->assertSame([], $strategy->signals); + } + + public static function invalidPidProvider(): array + { + return [ + 'empty' => [''], + 'whitespace' => [" \n\t"], + 'zero' => ['0'], + 'negative' => ['-1'], + 'non-numeric' => ['not-a-pid'], + 'numeric prefix' => ['123garbage'], + ]; + } + + public function testStopProbesButDoesNotTerminateAnAlreadyStoppedProcess(): void + { + $strategy = $this->createProbeStrategy(); + $strategy->useFilesystem(new PidFileFilesystem(true, '123')); + $strategy->processIsRunning = false; + + $strategy->stop(); + + $this->assertSame([[123, 0]], $strategy->signals); + } + + public function testStopDispatchesAnIntegerPidAndTerminatesALiveProcess(): void + { + $strategy = $this->createProbeStrategy(); + $strategy->useFilesystem(new PidFileFilesystem(true, " 123\n")); + $events = []; + $this->app->make('events')->listen( + BeforeServerRestart::class, + function (BeforeServerRestart $event) use (&$events): void { + $events[] = $event; + }, + ); + + $strategy->stop(); + + $this->assertSame([[123, 0], [123, SIGTERM]], $strategy->signals); + $this->assertCount(1, $events); + $this->assertSame(123, $events[0]->pid); + } + + public function testStopIsIdempotentWhenThePidFileDoesNotExist(): void + { + $strategy = $this->createProbeStrategy(); + $strategy->useFilesystem(new PidFileFilesystem(false, '')); + + $strategy->stop(); + $strategy->stop(); + + $this->assertSame([], $strategy->signals); + } + + public function testOpenFailureRestoresTheLaunchTokenAndAllowsRetry(): void + { + $failure = m::mock(ExceptionHandlerContract::class); + $failure->shouldReceive('report') + ->twice() + ->with(m::on( + fn (mixed $exception): bool => $exception instanceof RuntimeException + && $exception->getMessage() === 'Unable to launch the watched server process.', + )); + $this->app->instance(ExceptionHandlerContract::class, $failure); + $strategy = $this->createProbeStrategy(); + $strategy->openMode = 'fail'; + + $strategy->start(); + $this->assertSame(1, $strategy->launchTokenCount()); + + $strategy->start(); + + $this->assertSame(2, $strategy->openCalls); + $this->assertSame(1, $strategy->launchTokenCount()); + } + + public function testCloseFailureRestoresTheLaunchTokenAndAllowsRetry(): void + { + $failure = new RuntimeException('expected close failure'); + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldReceive('report')->twice()->with($failure); + $this->app->instance(ExceptionHandlerContract::class, $handler); + $strategy = $this->createProbeStrategy(); + $strategy->closeFailure = $failure; + + $strategy->start(); + $this->assertSame(1, $strategy->launchTokenCount()); + + $strategy->start(); + + $this->assertSame(2, $strategy->openCalls); + $this->assertSame(1, $strategy->launchTokenCount()); + } + + public function testNormalServerExitRestoresTheLaunchToken(): void + { + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldNotReceive('report'); + $this->app->instance(ExceptionHandlerContract::class, $handler); + $strategy = $this->createProbeStrategy(); + + $strategy->start(); + + $this->assertSame(1, $strategy->openCalls); + $this->assertSame(1, $strategy->launchTokenCount()); + } + + private function createProbeStrategy(): ServerRestartStrategyProbe + { + $this->app->instance('config', new Repository([ + 'server' => ['settings' => ['pid_file' => '/tmp/test.pid', 'daemonize' => false]], + 'watcher' => ['bin' => PHP_BINARY, 'command' => ['artisan', 'serve']], + ])); + + return new ServerRestartStrategyProbe($this->app, new NullOutput); + } +} + +class ServerRestartStrategyProbe extends ServerRestartStrategy +{ + /** @var list */ + public array $signals = []; + + public bool $processIsRunning = true; + + public int $openCalls = 0; + + public string $openMode = 'success'; + + public ?RuntimeException $closeFailure = null; + + public function useFilesystem(Filesystem $filesystem): void + { + $this->filesystem = $filesystem; + } + + public function launchTokenCount(): int + { + return $this->channel->getLength(); + } + + protected function openProcess(array $descriptorSpec, array &$pipes): mixed + { + ++$this->openCalls; + + if ($this->openMode === 'fail') { + return false; + } + + return fopen('php://temp', 'r+'); + } + + protected function closeProcess(mixed $process): int + { + fclose($process); + + if ($this->closeFailure !== null) { + throw $this->closeFailure; + } + + return 0; + } + + protected function signalProcess(int $pid, int $signal): bool + { + $this->signals[] = [$pid, $signal]; + + return $signal === 0 ? $this->processIsRunning : true; + } +} + +class PidFileFilesystem extends Filesystem +{ + public function __construct( + protected bool $pidFileExists, + protected string $contents, + ) { + } + + public function exists(string $path): bool + { + return $this->pidFileExists; + } + + public function get(string $path, bool $lock = false): string + { + return $this->contents; + } } diff --git a/tests/Watcher/WatcherTest.php b/tests/Watcher/WatcherTest.php new file mode 100644 index 000000000..41304b240 --- /dev/null +++ b/tests/Watcher/WatcherTest.php @@ -0,0 +1,194 @@ +push('first.php'); + $channel->push('second.php'); + }); + $strategy = new WatcherRestartStrategy; + $output = new BufferedOutput; + + (new Watcher($driver, $output, $strategy))->run(); + + $this->assertSame(1, $strategy->startCount); + $this->assertSame(1, $strategy->restartCount); + $this->assertSame(1, $strategy->stopCount); + $this->assertSame(1, $driver->stopCount); + $this->assertSame( + "File changed: first.php\nFile changed: second.php\n", + $output->fetch(), + ); + } + + public function testCleanEmptyDriverReturnDoesNotRestart(): void + { + $driver = new WatcherDriver(static function (Channel $channel): void { + }); + $strategy = new WatcherRestartStrategy; + + (new Watcher($driver, new BufferedOutput, $strategy))->run(); + + $this->assertSame(0, $strategy->restartCount); + $this->assertSame(1, $driver->stopCount); + $this->assertSame(1, $strategy->stopCount); + } + + public function testDriverFailureIsPrimaryAndDoesNotDrainOrRestart(): void + { + $failure = new RuntimeException('driver failed'); + $driver = new WatcherDriver(static function (Channel $channel) use ($failure): never { + $channel->push('ignored.php'); + + throw $failure; + }); + $strategy = new WatcherRestartStrategy; + $output = new BufferedOutput; + + try { + (new Watcher($driver, $output, $strategy))->run(); + $this->fail('Expected the watcher driver to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame('', $output->fetch()); + $this->assertSame(0, $strategy->restartCount); + $this->assertSame(1, $driver->stopCount); + $this->assertSame(1, $strategy->stopCount); + } + + public function testDebouncedBatchAndFinalTailEachRestartOnce(): void + { + $driver = new WatcherDriver(static function (Channel $channel): void { + $channel->push('first.php'); + usleep(10_000); + $channel->push('tail.php'); + }); + $strategy = new WatcherRestartStrategy; + $output = new BufferedOutput; + + (new Watcher($driver, $output, $strategy))->run(); + + $contents = $output->fetch(); + $this->assertSame(2, $strategy->restartCount); + $this->assertStringContainsString('first.php', $contents); + $this->assertStringContainsString('tail.php', $contents); + $this->assertSame(1, $driver->stopCount); + $this->assertSame(1, $strategy->stopCount); + } + + public function testRestartFailureStillStopsDriverAndStrategy(): void + { + $failure = new RuntimeException('restart failed'); + $driver = new WatcherDriver(static function (Channel $channel): void { + $channel->push('changed.php'); + usleep(10_000); + }); + $strategy = new WatcherRestartStrategy($failure); + + try { + (new Watcher($driver, new BufferedOutput, $strategy))->run(); + $this->fail('Expected restart to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $driver->stopCount); + $this->assertSame(1, $strategy->stopCount); + } + + public function testCleanupUnblocksADriverPushingToTheFullChangeChannel(): void + { + $failure = new RuntimeException('output failed'); + $driverCompleted = false; + $driver = new WatcherDriver(static function (Channel $channel) use (&$driverCompleted): void { + try { + for ($index = 0; $index < 1_000; ++$index) { + $channel->push("changed-{$index}.php"); + } + } finally { + $driverCompleted = true; + } + }); + $output = m::mock(BufferedOutput::class); + $output->shouldReceive('writeln')->once()->andThrow($failure); + + try { + (new Watcher($driver, $output))->run(); + $this->fail('Expected output handling to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($driverCompleted); + $this->assertSame(1, $driver->stopCount); + } +} + +class WatcherDriver implements DriverInterface +{ + public int $stopCount = 0; + + public function __construct(protected Closure $watch) + { + } + + public function watch(Channel $channel): void + { + ($this->watch)($channel); + } + + public function stop(): void + { + ++$this->stopCount; + } +} + +class WatcherRestartStrategy implements RestartStrategy +{ + public int $startCount = 0; + + public int $restartCount = 0; + + public int $stopCount = 0; + + public function __construct(protected ?RuntimeException $restartFailure = null) + { + } + + public function start(): void + { + ++$this->startCount; + } + + public function restart(): void + { + ++$this->restartCount; + + if ($this->restartFailure !== null) { + throw $this->restartFailure; + } + } + + public function stop(): void + { + ++$this->stopCount; + } +} From 41851c12ec9ee2be207c0b926607e3ce15549aee Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:16:47 +0000 Subject: [PATCH 26/40] Bound cache concurrency test IPC Give every forked cache-test child an explicit PID owner, nonblocking length-prefixed result channel, monotonic deadline, maximum frame size, and exhaustive failure cleanup. Retry interrupted reaping, accept only the owned PID or ECHILD, and never fall back to a global or blocking wait. Add deterministic early-exit, incomplete-payload, child-error, and stall coverage so process failures report promptly instead of hanging a parallel worker. --- .../Cache/CacheSwooleStoreConcurrencyTest.php | 440 ++++++++++++++++-- 1 file changed, 410 insertions(+), 30 deletions(-) diff --git a/tests/Cache/CacheSwooleStoreConcurrencyTest.php b/tests/Cache/CacheSwooleStoreConcurrencyTest.php index 876ded211..6a7f23103 100644 --- a/tests/Cache/CacheSwooleStoreConcurrencyTest.php +++ b/tests/Cache/CacheSwooleStoreConcurrencyTest.php @@ -11,12 +11,20 @@ use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionMethod; +use RuntimeException; use Swoole\Atomic; use Swoole\Process; use Throwable; +use function Hypervel\Coroutine\go; +use function Hypervel\Coroutine\run; + class CacheSwooleStoreConcurrencyTest extends TestCase { + private const FRAME_HEADER_BYTES = 4; + + private const MAX_FRAME_BYTES = 1_048_576; + protected bool $runTestsInCoroutine = false; public function testConcurrentAddHasExactlyOneWinner(): void @@ -94,19 +102,153 @@ public function testConcurrentLockAcquireHasExactlyOneWinner(): void $this->assertCount(1, array_filter($results, fn (array $result): bool => $result['won'])); } + public function testContendedStateLockBacksOffAndAcquiresAfterRelease(): void + { + $state = $this->createLockState(); + $state->holdLockFor('key'); + $called = false; + + run(function () use ($state, &$called): void { + go(function () use ($state): void { + usleep(5_000); + $state->releaseLockFor('key'); + }); + + $state->withRowLock('key', function () use (&$called): void { + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testStateLockFailureIsBoundedAndDescriptive(): void + { + $state = $this->createLockState(); + + try { + $this->runConcurrentProcesses( + $state, + 1, + function (int $id, SwooleStore $store, LockTestSwooleTableState $state): bool { + $state->holdLockFor('key'); + $state->withRowLock('key', fn (): bool => true); + + return true; + }, + timeout: 0.25, + ); + + $this->fail('The pre-locked stripe should time out.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString( + 'Timed out acquiring a Swoole table state lock.', + $exception->getMessage(), + ); + } + } + + public function testAllStripeFailureReleasesEarlierAcquisitions(): void + { + $state = new FailingAllStripeSwooleTableState( + $this->createState()->table(), + 12345, + ); + + try { + $state->withAllRowLocks(fn (): bool => true); + $this->fail('The third stripe acquisition should fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Synthetic stripe acquisition failure.', $exception->getMessage()); + } + + $this->assertTrue($state->firstAcquiredStripesAreReleased()); + } + + public function testChildExitBeforeReadyFailsWithinTheHarnessDeadline(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('exited before reaching the start barrier'); + + $this->runConcurrentProcesses( + $this->createState(), + 1, + fn (): bool => true, + beforeReady: fn () => posix_kill(getmypid(), SIGKILL), + timeout: 0.25, + ); + } + + public function testChildExitBeforePayloadFailsWithinTheHarnessDeadline(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('exited before sending a complete payload'); + + $this->runConcurrentProcesses( + $this->createState(), + 1, + fn () => posix_kill(getmypid(), SIGKILL), + timeout: 0.25, + ); + } + + public function testChildThrowableIsReturnedAsAnErrorPayload(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Child process failed: expected child failure'); + + $this->runConcurrentProcesses( + $this->createState(), + 1, + fn () => throw new RuntimeException('expected child failure'), + ); + } + + public function testStalledChildFailsWithinTheHarnessDeadline(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Timed out waiting for cache concurrency child'); + + $this->runConcurrentProcesses( + $this->createState(), + 1, + function (): bool { + usleep(1_000_000); + + return true; + }, + timeout: 0.05, + ); + } + /** * Run callbacks in forked processes sharing one pre-created Swoole table state. */ - private function runConcurrentProcesses(SwooleTableState $state, int $count, callable $callback): array - { + private function runConcurrentProcesses( + SwooleTableState $state, + int $count, + callable $callback, + ?callable $beforeReady = null, + float $timeout = 5.0, + ): array { $ready = new Atomic(0); $start = new Atomic(0); $processes = []; + $pids = []; + $reaped = []; for ($i = 0; $i < $count; ++$i) { - $processes[] = new Process(function (Process $process) use ($state, $ready, $start, $callback, $i): void { + $processes[] = new Process(function (Process $process) use ( + $state, + $ready, + $start, + $callback, + $beforeReady, + $i, + ): void { $store = $this->createStore($state); + $beforeReady?->__invoke($i, $store, $state); $ready->add(1); while ($start->get() === 0) { @@ -114,48 +256,69 @@ private function runConcurrentProcesses(SwooleTableState $state, int $count, cal } try { - $process->write(serialize([ + $payload = [ 'ok' => true, 'result' => $callback($i, $store, $state), - ])); + ]; } catch (Throwable $exception) { - $process->write(serialize([ + $payload = [ 'ok' => false, - 'error' => $exception::class . ': ' . $exception->getMessage(), - ])); + 'error' => $exception->getMessage(), + ]; } + $this->writeChildPayload($process, $payload); + // The fork inherits PHPUnit/Testbench shutdown handlers from the parent process. // Exiting normally would let a child delete the parent's disposable runtime app. posix_kill(getmypid(), SIGKILL); }, false, SOCK_STREAM); } - foreach ($processes as $process) { - $process->start(); - } - - $this->waitForReadyProcesses($ready, $count); + $results = []; + $exception = null; + $deadline = hrtime(true) + (int) ($timeout * 1_000_000_000); - $start->set(1); + try { + foreach ($processes as $process) { + $pid = $process->start(); - $results = []; + if ($pid === false) { + throw new RuntimeException('Unable to start cache concurrency child.'); + } - foreach ($processes as $process) { - $message = $process->read(); + $pids[$pid] = $process; + $process->setBlocking(false); + } - $this->assertIsString($message); + $this->waitForReadyProcesses($ready, $count, $pids, $reaped, $deadline); + $start->set(1); - $payload = unserialize($message); + foreach ($pids as $pid => $process) { + $payload = $this->readChildPayload($process, $pid, $reaped, $deadline); - $this->assertIsArray($payload); - $this->assertTrue($payload['ok'], $payload['error'] ?? 'Child process failed.'); + if (($payload['ok'] ?? false) !== true) { + throw new RuntimeException( + 'Child process failed: ' . ($payload['error'] ?? 'unknown error'), + ); + } - $results[] = $payload['result']; + $results[] = $payload['result'] ?? null; + } + } catch (Throwable $throwable) { + $exception = $throwable; + } finally { + $start->set(1); + + try { + $this->cleanupChildProcesses($pids, $reaped); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } } - for ($i = 0; $i < $count; ++$i) { - Process::wait(); + if ($exception !== null) { + throw $exception; } return $results; @@ -164,25 +327,207 @@ private function runConcurrentProcesses(SwooleTableState $state, int $count, cal /** * Wait until every child process reaches the start barrier. */ - private function waitForReadyProcesses(Atomic $ready, int $count): void - { - $deadline = microtime(true) + 5; - + private function waitForReadyProcesses( + Atomic $ready, + int $count, + array $pids, + array &$reaped, + int $deadline, + ): void { while ($ready->get() < $count) { - if (microtime(true) > $deadline) { - $this->fail("Only {$ready->get()} of {$count} child processes reached the start barrier."); + foreach ($pids as $pid => $process) { + if ($this->reapIfExited($pid, $reaped)) { + throw new RuntimeException( + "Cache concurrency child [{$pid}] exited before reaching the start barrier.", + ); + } + } + + if (hrtime(true) >= $deadline) { + throw new RuntimeException( + "Only {$ready->get()} of {$count} child processes reached the start barrier.", + ); } usleep(100); } } + /** + * Write one length-prefixed payload to the parent process. + */ + private function writeChildPayload(Process $process, array $payload): void + { + $serialized = serialize($payload); + + if (strlen($serialized) > self::MAX_FRAME_BYTES) { + throw new RuntimeException('Cache concurrency child payload exceeds the maximum frame size.'); + } + + $frame = pack('N', strlen($serialized)) . $serialized; + $offset = 0; + + while ($offset < strlen($frame)) { + $written = $process->write(substr($frame, $offset)); + + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write cache concurrency child payload.'); + } + + $offset += $written; + } + } + + /** + * Read one complete length-prefixed child payload. + */ + private function readChildPayload( + Process $process, + int $pid, + array &$reaped, + int $deadline, + ): array { + $buffer = ''; + $frameLength = null; + + while (hrtime(true) < $deadline) { + $chunk = $process->read(8192); + + if (is_string($chunk) && $chunk !== '') { + $buffer .= $chunk; + + if ($frameLength === null && strlen($buffer) >= self::FRAME_HEADER_BYTES) { + $header = unpack('Nlength', substr($buffer, 0, self::FRAME_HEADER_BYTES)); + $frameLength = $header['length']; + + if ($frameLength > self::MAX_FRAME_BYTES) { + throw new RuntimeException( + "Cache concurrency child [{$pid}] sent an oversized payload.", + ); + } + } + + if ($frameLength !== null + && strlen($buffer) >= self::FRAME_HEADER_BYTES + $frameLength + ) { + $frameSize = self::FRAME_HEADER_BYTES + $frameLength; + + if (strlen($buffer) !== $frameSize) { + throw new RuntimeException( + "Cache concurrency child [{$pid}] sent trailing payload data.", + ); + } + + $payload = unserialize( + substr($buffer, self::FRAME_HEADER_BYTES, $frameLength), + ['allowed_classes' => false], + ); + + if (! is_array($payload)) { + throw new RuntimeException( + "Cache concurrency child [{$pid}] sent an invalid payload.", + ); + } + + return $payload; + } + } + + if ($this->reapIfExited($pid, $reaped)) { + throw new RuntimeException( + "Cache concurrency child [{$pid}] exited before sending a complete payload.", + ); + } + + usleep(1_000); + } + + throw new RuntimeException("Timed out waiting for cache concurrency child [{$pid}]."); + } + + /** + * Stop, close, and reap every child process owned by this harness. + */ + private function cleanupChildProcesses(array $pids, array &$reaped): void + { + foreach ($pids as $pid => $process) { + if (! isset($reaped[$pid]) && Process::kill($pid, 0)) { + Process::kill($pid, SIGKILL); + } + + $process->close(); + } + + $deadline = hrtime(true) + 1_000_000_000; + + foreach (array_keys($pids) as $pid) { + while (! isset($reaped[$pid])) { + if ($this->reapIfExited($pid, $reaped)) { + break; + } + + if (hrtime(true) >= $deadline) { + throw new RuntimeException( + "Timed out reaping cache concurrency child [{$pid}].", + ); + } + + usleep(1_000); + } + } + } + + /** + * Reap an owned child if it has exited. + */ + private function reapIfExited(int $pid, array &$reaped): bool + { + if (isset($reaped[$pid])) { + return true; + } + + $status = 0; + $result = pcntl_waitpid($pid, $status, WNOHANG); + + if ($result === $pid) { + $reaped[$pid] = true; + + return true; + } + + if ($result === -1) { + $error = pcntl_get_last_error(); + + if ($error === PCNTL_ECHILD) { + $reaped[$pid] = true; + + return true; + } + + if ($error !== PCNTL_EINTR) { + throw new RuntimeException( + "Unable to reap cache concurrency child [{$pid}]: " . pcntl_strerror($error), + ); + } + } + + return false; + } + private function createState(): SwooleTableState { return (new SwooleTableManager(m::mock(Container::class))) ->createState(128, 10240, 0.2, 12345); } + private function createLockState(): LockTestSwooleTableState + { + return new LockTestSwooleTableState( + $this->createState()->table(), + 12345, + ); + } + private function createStore(SwooleTableState $state): SwooleStore { return new SwooleStore($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); @@ -196,3 +541,38 @@ private function tableKey(SwooleStore $store, string $method, string $key): stri return $reflection->invoke($store, $key); } } + +class LockTestSwooleTableState extends SwooleTableState +{ + protected const LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 50_000_000; + + public function holdLockFor(string $key): void + { + $this->lockFor($key)->set(1); + } + + public function releaseLockFor(string $key): void + { + $this->lockFor($key)->set(0); + } +} + +class FailingAllStripeSwooleTableState extends SwooleTableState +{ + private int $acquisitions = 0; + + protected function acquire(Atomic $lock): void + { + if (++$this->acquisitions === 3) { + throw new RuntimeException('Synthetic stripe acquisition failure.'); + } + + parent::acquire($lock); + } + + public function firstAcquiredStripesAreReleased(): bool + { + return $this->rowLocks[0]->get() === 0 + && $this->rowLocks[1]->get() === 0; + } +} From 725a182dfcd4c248ce6aa25dfe500631ef6cbd86 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:17:33 +0000 Subject: [PATCH 27/40] Own asynchronous integration test resources Replace an unbounded hand-built renderer coroutine join with the framework parallel primitive so child exceptions propagate through an owned wait group. Wrap explicitly created Redis subscribers and raw publish connections in exhaustive cleanup so assertion failures cannot leave background receive work alive in the test worker. --- .../Renderer/ListenerContextIsolationTest.php | 81 +++--- .../Redis/RedisSubscribeIntegrationTest.php | 92 ++++--- .../Subscriber/SubscriberIntegrationTest.php | 243 ++++++++++-------- 3 files changed, 214 insertions(+), 202 deletions(-) diff --git a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php index 17704f633..9464efda2 100644 --- a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php +++ b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php @@ -9,8 +9,8 @@ use Hypervel\Foundation\Exceptions\Renderer\Listener; use Hypervel\Tests\TestCase; use Mockery as m; -use Swoole\Coroutine; -use Swoole\Coroutine\Channel; + +use function Hypervel\Coroutine\parallel; class ListenerContextIsolationTest extends TestCase { @@ -33,58 +33,39 @@ public function testQueryCapStopsAtMaxQueries() public function testQueriesAreIsolatedBetweenCoroutines() { - $channel = new Channel(2); - - Coroutine::create(function () use ($channel) { - $listener = new Listener; - - $connection = m::mock(Connection::class); - $connection->shouldReceive('getName')->andReturn('conn-a'); - $connection->shouldReceive('prepareBindings')->andReturn([]); - - $listener->onQueryExecuted( - new QueryExecuted('SELECT a1', [], 1.0, $connection) - ); - $listener->onQueryExecuted( - new QueryExecuted('SELECT a2', [], 2.0, $connection) - ); - - $channel->push([ - 'count' => count($listener->queries()), - 'sqls' => array_column($listener->queries(), 'sql'), - ]); - }); - - Coroutine::create(function () use ($channel) { - $listener = new Listener; + $results = parallel([ + 'a' => fn (): array => $this->recordQueries('conn-a', ['SELECT a1', 'SELECT a2']), + 'b' => fn (): array => $this->recordQueries('conn-b', ['SELECT b1']), + ]); + + $this->assertSame(2, $results['a']['count']); + $this->assertSame(['SELECT a1', 'SELECT a2'], $results['a']['sqls']); + $this->assertSame(1, $results['b']['count']); + $this->assertSame(['SELECT b1'], $results['b']['sqls']); + } - $connection = m::mock(Connection::class); - $connection->shouldReceive('getName')->andReturn('conn-b'); - $connection->shouldReceive('prepareBindings')->andReturn([]); + /** + * Record queries in the current coroutine context. + * + * @param string[] $queries + * @return array{count: int, sqls: string[]} + */ + private function recordQueries(string $connectionName, array $queries): array + { + $listener = new Listener; + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->andReturn($connectionName); + $connection->shouldReceive('prepareBindings')->andReturn([]); + foreach ($queries as $index => $query) { $listener->onQueryExecuted( - new QueryExecuted('SELECT b1', [], 3.0, $connection) + new QueryExecuted($query, [], (float) ($index + 1), $connection), ); + } - $channel->push([ - 'count' => count($listener->queries()), - 'sqls' => array_column($listener->queries(), 'sql'), - ]); - }); - - $results = []; - $results[] = $channel->pop(); - $results[] = $channel->pop(); - - // Sort by count so assertions are deterministic - usort($results, fn ($a, $b) => $a['count'] <=> $b['count']); - - // Coroutine B: 1 query, only its own - $this->assertSame(1, $results[0]['count']); - $this->assertSame(['SELECT b1'], $results[0]['sqls']); - - // Coroutine A: 2 queries, only its own - $this->assertSame(2, $results[1]['count']); - $this->assertSame(['SELECT a1', 'SELECT a2'], $results[1]['sqls']); + return [ + 'count' => count($listener->queries()), + 'sqls' => array_column($listener->queries(), 'sql'), + ]; } } diff --git a/tests/Integration/Redis/RedisSubscribeIntegrationTest.php b/tests/Integration/Redis/RedisSubscribeIntegrationTest.php index 1396c0ba3..c5855d940 100644 --- a/tests/Integration/Redis/RedisSubscribeIntegrationTest.php +++ b/tests/Integration/Redis/RedisSubscribeIntegrationTest.php @@ -10,6 +10,7 @@ use Hypervel\Redis\Subscriber\Message; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; +use Redis as PhpRedis; use RuntimeException; use function Hypervel\Coroutine\go; @@ -33,21 +34,25 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set('database.redis.default.options.prefix', ''); } - public function testSubscribeExitsCleanlyWithNoMessages() + public function testSubscribeExitsCleanlyWithNoMessages(): void { $channelName = 'test_redis_noop_' . uniqid(); $subscriber = Redis::connection()->subscriber(); - $subscriber->subscribe($channelName); + try { + $subscriber->subscribe($channelName); - usleep(100_000); + usleep(100_000); - $subscriber->close(); + $subscriber->close(); - $this->assertTrue($subscriber->closed); + $this->assertTrue($subscriber->closed); + } finally { + $subscriber->close(); + } } - public function testSubscribeReceivesMessageViaCallback() + public function testSubscribeReceivesMessageViaCallback(): void { $channelName = 'test_redis_sub_' . uniqid(); $resultChannel = new Channel(1); @@ -76,7 +81,7 @@ public function testSubscribeReceivesMessageViaCallback() $this->assertTrue($doneChannel->pop(5.0)); } - public function testPsubscribeReceivesMessageViaCallback() + public function testPsubscribeReceivesMessageViaCallback(): void { $pattern = 'test_redis_psub_' . uniqid() . ':*'; $publishChannel = str_replace('*', 'specific', $pattern); @@ -106,7 +111,7 @@ public function testPsubscribeReceivesMessageViaCallback() $this->assertTrue($doneChannel->pop(5.0)); } - public function testSubscribeAcceptsStringChannel() + public function testSubscribeAcceptsStringChannel(): void { $channelName = 'test_redis_string_' . uniqid(); $resultChannel = new Channel(1); @@ -134,49 +139,53 @@ public function testSubscribeAcceptsStringChannel() $this->assertTrue($doneChannel->pop(5.0)); } - public function testSubscriberReturnsChannelBasedApi() + public function testSubscriberReturnsChannelBasedApi(): void { $channelName = 'test_redis_subscriber_' . uniqid(); $subscriber = Redis::connection()->subscriber(); - $subscriber->subscribe($channelName); - - go(function () use ($channelName) { - usleep(50_000); - $this->publishViaRawClient($channelName, 'channel_api'); - }); + try { + $subscriber->subscribe($channelName); - $message = $subscriber->channel()->pop(5.0); + go(function () use ($channelName) { + usleep(50_000); + $this->publishViaRawClient($channelName, 'channel_api'); + }); - $this->assertInstanceOf(Message::class, $message); - $this->assertSame($channelName, $message->channel); - $this->assertSame('channel_api', $message->payload); + $message = $subscriber->channel()->pop(5.0); - $subscriber->close(); + $this->assertInstanceOf(Message::class, $message); + $this->assertSame($channelName, $message->channel); + $this->assertSame('channel_api', $message->payload); + } finally { + $subscriber->close(); + } } - public function testSubscriberWithPrefix() + public function testSubscriberWithPrefix(): void { $prefix = 'myprefix:'; $connectionName = $this->createRedisConnectionWithPrefix($prefix); $channelName = 'test_redis_prefixed_' . uniqid(); $subscriber = Redis::connection($connectionName)->subscriber(); - $subscriber->subscribe($channelName); + try { + $subscriber->subscribe($channelName); - go(function () use ($channelName, $prefix) { - usleep(50_000); - // Publish to the full prefixed channel name - $this->publishViaRawClient($prefix . $channelName, 'prefixed_data'); - }); + go(function () use ($channelName, $prefix) { + usleep(50_000); + // Publish to the full prefixed channel name + $this->publishViaRawClient($prefix . $channelName, 'prefixed_data'); + }); - $message = $subscriber->channel()->pop(5.0); + $message = $subscriber->channel()->pop(5.0); - $this->assertInstanceOf(Message::class, $message); - $this->assertSame($prefix . $channelName, $message->channel); - $this->assertSame('prefixed_data', $message->payload); - - $subscriber->close(); + $this->assertInstanceOf(Message::class, $message); + $this->assertSame($prefix . $channelName, $message->channel); + $this->assertSame('prefixed_data', $message->payload); + } finally { + $subscriber->close(); + } } /** @@ -184,19 +193,22 @@ public function testSubscriberWithPrefix() */ private function publishViaRawClient(string $channel, string $message): void { - $client = new \Redis; + $client = new PhpRedis; $client->connect( env('REDIS_HOST', '127.0.0.1'), (int) env('REDIS_PORT', 6379) ); - $auth = env('REDIS_PASSWORD'); - if ($auth) { - $client->auth($auth); - } + try { + $auth = env('REDIS_PASSWORD'); + if ($auth) { + $client->auth($auth); + } - $client->publish($channel, $message); - $client->close(); + $client->publish($channel, $message); + } finally { + $client->close(); + } } } diff --git a/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php b/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php index 42347e79e..a510a1c0c 100644 --- a/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php +++ b/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php @@ -21,108 +21,116 @@ class SubscriberIntegrationTest extends TestCase { use InteractsWithRedis; - public function testSubscribeReceivesMessage() + public function testSubscribeReceivesMessage(): void { $channelName = 'test_sub_' . uniqid(); $subscriber = $this->createTestSubscriber(); - $subscriber->subscribe($channelName); + try { + $subscriber->subscribe($channelName); - go(function () use ($channelName) { - usleep(50_000); - $this->publishViaRawClient($channelName, 'hello'); - }); + go(function () use ($channelName) { + usleep(50_000); + $this->publishViaRawClient($channelName, 'hello'); + }); - $message = $subscriber->channel()->pop(5.0); + $message = $subscriber->channel()->pop(5.0); - $this->assertNotFalse($message, 'Timed out waiting for message'); - $this->assertSame($channelName, $message->channel); - $this->assertSame('hello', $message->payload); - $this->assertNull($message->pattern); - - $subscriber->close(); + $this->assertNotFalse($message, 'Timed out waiting for message'); + $this->assertSame($channelName, $message->channel); + $this->assertSame('hello', $message->payload); + $this->assertNull($message->pattern); + } finally { + $subscriber->close(); + } } - public function testSubscribeToMultipleChannels() + public function testSubscribeToMultipleChannels(): void { $channel1 = 'test_multi_a_' . uniqid(); $channel2 = 'test_multi_b_' . uniqid(); $subscriber = $this->createTestSubscriber(); - $subscriber->subscribe($channel1, $channel2); - - go(function () use ($channel1, $channel2) { - usleep(50_000); - $this->publishViaRawClient($channel1, 'msg1'); - $this->publishViaRawClient($channel2, 'msg2'); - }); + try { + $subscriber->subscribe($channel1, $channel2); - $message1 = $subscriber->channel()->pop(5.0); - $this->assertNotFalse($message1, 'Timed out waiting for message 1'); + go(function () use ($channel1, $channel2) { + usleep(50_000); + $this->publishViaRawClient($channel1, 'msg1'); + $this->publishViaRawClient($channel2, 'msg2'); + }); - $message2 = $subscriber->channel()->pop(5.0); - $this->assertNotFalse($message2, 'Timed out waiting for message 2'); + $message1 = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message1, 'Timed out waiting for message 1'); - $channels = [$message1->channel, $message2->channel]; - $payloads = [$message1->payload, $message2->payload]; + $message2 = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message2, 'Timed out waiting for message 2'); - $this->assertContains($channel1, $channels); - $this->assertContains($channel2, $channels); - $this->assertContains('msg1', $payloads); - $this->assertContains('msg2', $payloads); + $channels = [$message1->channel, $message2->channel]; + $payloads = [$message1->payload, $message2->payload]; - $subscriber->close(); + $this->assertContains($channel1, $channels); + $this->assertContains($channel2, $channels); + $this->assertContains('msg1', $payloads); + $this->assertContains('msg2', $payloads); + } finally { + $subscriber->close(); + } } - public function testUnsubscribeStopsReceivingFromChannel() + public function testUnsubscribeStopsReceivingFromChannel(): void { $channel1 = 'test_unsub_keep_' . uniqid(); $channel2 = 'test_unsub_drop_' . uniqid(); $subscriber = $this->createTestSubscriber(); - $subscriber->subscribe($channel1, $channel2); - $subscriber->unsubscribe($channel2); - - go(function () use ($channel1, $channel2) { - usleep(50_000); - // Publish to unsubscribed channel first — should be ignored - $this->publishViaRawClient($channel2, 'dropped'); - // Then publish to subscribed channel - $this->publishViaRawClient($channel1, 'kept'); - }); - - $message = $subscriber->channel()->pop(5.0); - $this->assertNotFalse($message, 'Timed out waiting for message'); - $this->assertSame($channel1, $message->channel); - $this->assertSame('kept', $message->payload); - - $subscriber->close(); + try { + $subscriber->subscribe($channel1, $channel2); + $subscriber->unsubscribe($channel2); + + go(function () use ($channel1, $channel2) { + usleep(50_000); + // Publish to unsubscribed channel first — should be ignored + $this->publishViaRawClient($channel2, 'dropped'); + // Then publish to subscribed channel + $this->publishViaRawClient($channel1, 'kept'); + }); + + $message = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message, 'Timed out waiting for message'); + $this->assertSame($channel1, $message->channel); + $this->assertSame('kept', $message->payload); + } finally { + $subscriber->close(); + } } - public function testPsubscribeReceivesMatchingMessage() + public function testPsubscribeReceivesMatchingMessage(): void { $pattern = 'test_psub_' . uniqid() . ':*'; $publishChannel = str_replace('*', 'specific', $pattern); $subscriber = $this->createTestSubscriber(); - $subscriber->psubscribe($pattern); - - go(function () use ($publishChannel) { - usleep(50_000); - $this->publishViaRawClient($publishChannel, 'pattern_data'); - }); + try { + $subscriber->psubscribe($pattern); - $message = $subscriber->channel()->pop(5.0); + go(function () use ($publishChannel) { + usleep(50_000); + $this->publishViaRawClient($publishChannel, 'pattern_data'); + }); - $this->assertNotFalse($message, 'Timed out waiting for pmessage'); - $this->assertSame($publishChannel, $message->channel); - $this->assertSame('pattern_data', $message->payload); - $this->assertSame($pattern, $message->pattern); + $message = $subscriber->channel()->pop(5.0); - $subscriber->close(); + $this->assertNotFalse($message, 'Timed out waiting for pmessage'); + $this->assertSame($publishChannel, $message->channel); + $this->assertSame('pattern_data', $message->payload); + $this->assertSame($pattern, $message->pattern); + } finally { + $subscriber->close(); + } } - public function testPunsubscribeStopsReceivingFromPattern() + public function testPunsubscribeStopsReceivingFromPattern(): void { $pattern1 = 'test_punsub_keep_' . uniqid() . ':*'; $pattern2 = 'test_punsub_drop_' . uniqid() . ':*'; @@ -130,48 +138,52 @@ public function testPunsubscribeStopsReceivingFromPattern() $channel2 = str_replace('*', 'event', $pattern2); $subscriber = $this->createTestSubscriber(); - $subscriber->psubscribe($pattern1, $pattern2); - $subscriber->punsubscribe($pattern2); - - go(function () use ($channel1, $channel2) { - usleep(50_000); - $this->publishViaRawClient($channel2, 'dropped'); - $this->publishViaRawClient($channel1, 'kept'); - }); - - $message = $subscriber->channel()->pop(5.0); - $this->assertNotFalse($message, 'Timed out waiting for pmessage'); - $this->assertSame($channel1, $message->channel); - $this->assertSame('kept', $message->payload); - $this->assertSame($pattern1, $message->pattern); - - $subscriber->close(); + try { + $subscriber->psubscribe($pattern1, $pattern2); + $subscriber->punsubscribe($pattern2); + + go(function () use ($channel1, $channel2) { + usleep(50_000); + $this->publishViaRawClient($channel2, 'dropped'); + $this->publishViaRawClient($channel1, 'kept'); + }); + + $message = $subscriber->channel()->pop(5.0); + $this->assertNotFalse($message, 'Timed out waiting for pmessage'); + $this->assertSame($channel1, $message->channel); + $this->assertSame('kept', $message->payload); + $this->assertSame($pattern1, $message->pattern); + } finally { + $subscriber->close(); + } } - public function testSubscribeWithPrefixPrependsToChannels() + public function testSubscribeWithPrefixPrependsToChannels(): void { $rawChannel = 'test_prefix_' . uniqid(); $prefix = 'myapp:'; $subscriber = $this->createTestSubscriber(prefix: $prefix); - $subscriber->subscribe($rawChannel); + try { + $subscriber->subscribe($rawChannel); - go(function () use ($rawChannel, $prefix) { - usleep(50_000); - // Publish to the full prefixed channel name - $this->publishViaRawClient($prefix . $rawChannel, 'prefixed'); - }); + go(function () use ($rawChannel, $prefix) { + usleep(50_000); + // Publish to the full prefixed channel name + $this->publishViaRawClient($prefix . $rawChannel, 'prefixed'); + }); - $message = $subscriber->channel()->pop(5.0); + $message = $subscriber->channel()->pop(5.0); - $this->assertNotFalse($message, 'Timed out waiting for prefixed message'); - $this->assertSame($prefix . $rawChannel, $message->channel); - $this->assertSame('prefixed', $message->payload); - - $subscriber->close(); + $this->assertNotFalse($message, 'Timed out waiting for prefixed message'); + $this->assertSame($prefix . $rawChannel, $message->channel); + $this->assertSame('prefixed', $message->payload); + } finally { + $subscriber->close(); + } } - public function testPingReturnsPongWhileSubscribed() + public function testPingReturnsPongWhileSubscribed(): void { $channelName = 'test_ping_' . uniqid(); $subscriber = $this->createTestSubscriber(); @@ -179,28 +191,32 @@ public function testPingReturnsPongWhileSubscribed() // Must subscribe first — Redis only responds to PING with a multi-bulk // pong in subscribe mode. In normal mode it sends +PONG which the // RESP parser doesn't handle. - $subscriber->subscribe($channelName); - - $result = $subscriber->ping(5.0); + try { + $subscriber->subscribe($channelName); - $this->assertSame('pong', $result); - - $subscriber->close(); + $this->assertSame('pong', $subscriber->ping(5.0)); + } finally { + $subscriber->close(); + } } - public function testCloseStopsReceivingMessages() + public function testCloseStopsReceivingMessages(): void { $channelName = 'test_close_' . uniqid(); $subscriber = $this->createTestSubscriber(); - $subscriber->subscribe($channelName); + try { + $subscriber->subscribe($channelName); - $this->assertFalse($subscriber->closed); + $this->assertFalse($subscriber->closed); - $subscriber->close(); + $subscriber->close(); - $this->assertTrue($subscriber->closed); - $this->assertFalse($subscriber->channel()->pop(0.1)); + $this->assertTrue($subscriber->closed); + $this->assertFalse($subscriber->channel()->pop(0.1)); + } finally { + $subscriber->close(); + } } /** @@ -228,12 +244,15 @@ private function publishViaRawClient(string $channel, string $message): void (int) env('REDIS_PORT', 6379) ); - $auth = env('REDIS_PASSWORD'); - if ($auth) { - $client->auth($auth); - } + try { + $auth = env('REDIS_PASSWORD'); + if ($auth) { + $client->auth($auth); + } - $client->publish($channel, $message); - $client->close(); + $client->publish($channel, $message); + } finally { + $client->close(); + } } } From 9e0bfbedc33f9ed83fb1fd400ccf40b395e96edf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:17:43 +0000 Subject: [PATCH 28/40] Add exhaustive test cleanup actions Introduce a small test-only cleanup primitive that runs every supplied owner callback and rethrows the first failure afterward. This gives file-producing integration tests one deterministic way to preserve primary failures without skipping independent restoration or parent teardown. --- tests/Testing/CleanupActionsTest.php | 42 +++++++++++++++++++++++ tests/Testing/Fixtures/CleanupActions.php | 30 ++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/Testing/CleanupActionsTest.php create mode 100644 tests/Testing/Fixtures/CleanupActions.php diff --git a/tests/Testing/CleanupActionsTest.php b/tests/Testing/CleanupActionsTest.php new file mode 100644 index 000000000..a229dff6d --- /dev/null +++ b/tests/Testing/CleanupActionsTest.php @@ -0,0 +1,42 @@ +fail('Expected cleanup to rethrow its first failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($first, $exception); + } + + $this->assertSame(['first', 'second', 'third'], $actions); + } +} diff --git a/tests/Testing/Fixtures/CleanupActions.php b/tests/Testing/Fixtures/CleanupActions.php new file mode 100644 index 000000000..f2f09642a --- /dev/null +++ b/tests/Testing/Fixtures/CleanupActions.php @@ -0,0 +1,30 @@ + Date: Sun, 12 Jul 2026 08:17:54 +0000 Subject: [PATCH 29/40] Propagate resolved cache paths to subprocesses Pass each application's resolved config and route cache path into the fresh subprocess that rebuilds it. PHP array-only environment overrides are not inherited automatically, so this prevents a child from reading or writing a stale default cache that differs from its parent. Add alternate-path regressions and make the route-cache suite exhaustively own its generated sources and caches while asserting that worker Testbench state is pristine before each run. --- .../src/Console/ConfigCacheCommand.php | 1 + .../src/Console/RouteCacheCommand.php | 1 + .../Console/ConfigCacheCommandTest.php | 30 +++++++ .../Console/RouteCacheCommandTest.php | 89 +++++++++++++++++-- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/foundation/src/Console/ConfigCacheCommand.php b/src/foundation/src/Console/ConfigCacheCommand.php index eb5268882..99af09d9f 100644 --- a/src/foundation/src/Console/ConfigCacheCommand.php +++ b/src/foundation/src/Console/ConfigCacheCommand.php @@ -117,6 +117,7 @@ protected function getFreshConfigurationCacheContentsFromSubprocess(): string ], $this->hypervel->basePath(), [ + 'APP_CONFIG_CACHE' => $this->hypervel->getCachedConfigPath(), 'HYPERVEL_AUTOLOAD_PATH' => $this->resolveSubprocessAutoloadPath(), ], ); diff --git a/src/foundation/src/Console/RouteCacheCommand.php b/src/foundation/src/Console/RouteCacheCommand.php index 3d0eee587..5f3fe744f 100644 --- a/src/foundation/src/Console/RouteCacheCommand.php +++ b/src/foundation/src/Console/RouteCacheCommand.php @@ -121,6 +121,7 @@ protected function getFreshCompiledRoutesFromSubprocess(): array ], $this->hypervel->basePath(), [ + 'APP_ROUTES_CACHE' => $this->hypervel->getCachedRoutesPath(), 'HYPERVEL_AUTOLOAD_PATH' => $this->resolveSubprocessAutoloadPath(), ], ); diff --git a/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php b/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php index b45eabf91..d2c99c258 100644 --- a/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php +++ b/tests/Integration/Foundation/Console/ConfigCacheCommandTest.php @@ -177,4 +177,34 @@ public function testConfigurationCacheRebuildsFromSourceWhenApplicationBootedWit $this->assertSame('beta', $cached['testconfig']['value']); } + + public function testConfigCacheSubprocessUsesTheParentsResolvedCachePath(): void + { + $files = new Filesystem; + $previousCachePath = $_SERVER['APP_CONFIG_CACHE'] ?? null; + $hadCachePath = array_key_exists('APP_CONFIG_CACHE', $_SERVER); + $defaultCachePath = $this->app->bootstrapPath('cache/config.php'); + $alternateCachePath = $this->app->bootstrapPath('cache/config-alternate.php'); + $this->files[] = 'bootstrap/cache/config-alternate.php'; + $files->put($defaultCachePath, " true];\n"); + $files->put( + $this->app->configPath('testconfig.php'), + " 'source'];\n", + ); + $_SERVER['APP_CONFIG_CACHE'] = $alternateCachePath; + + try { + $this->artisan('config:cache')->assertSuccessful(); + + $cached = require $alternateCachePath; + $this->assertSame('source', $cached['testconfig']['value']); + $this->assertArrayNotHasKey('stale', $cached); + } finally { + if ($hadCachePath) { + $_SERVER['APP_CONFIG_CACHE'] = $previousCachePath; + } else { + unset($_SERVER['APP_CONFIG_CACHE']); + } + } + } } diff --git a/tests/Integration/Foundation/Console/RouteCacheCommandTest.php b/tests/Integration/Foundation/Console/RouteCacheCommandTest.php index 452c419bf..b9d1696f9 100644 --- a/tests/Integration/Foundation/Console/RouteCacheCommandTest.php +++ b/tests/Integration/Foundation/Console/RouteCacheCommandTest.php @@ -7,6 +7,10 @@ use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; use Hypervel\Routing\CompiledRouteCollection; +use Hypervel\Tests\Testing\Fixtures\CleanupActions; +use RuntimeException; + +use function Hypervel\Testbench\testbench_path; class RouteCacheCommandTest extends \Hypervel\Testbench\TestCase { @@ -20,23 +24,46 @@ class RouteCacheCommandTest extends \Hypervel\Testbench\TestCase */ protected array $routeFiles = []; + /** @var array */ + protected array $cacheFiles = []; + protected function setUp(): void { parent::setUp(); $this->files = new Filesystem; - $this->assertNoLeakedTestbenchRouteFiles(); + $this->assertCleanTestbenchRouteSources(); } protected function tearDown(): void { + $actions = []; + foreach ($this->routeFiles as $routeFile) { - $this->files->delete($routeFile); + $actions[] = function () use ($routeFile): void { + if ($this->files->isFile($routeFile) && ! $this->files->delete($routeFile)) { + throw new RuntimeException("Unable to delete owned route cache test file [{$routeFile}]."); + } + }; } - $this->files->delete($this->app->getCachedRoutesPath()); + foreach ($this->cacheFiles as $cacheFile) { + $actions[] = function () use ($cacheFile): void { + if ($this->files->isFile($cacheFile) && ! $this->files->delete($cacheFile)) { + throw new RuntimeException("Unable to delete owned route cache test file [{$cacheFile}]."); + } + }; + } - parent::tearDown(); + $cachePath = $this->app->getCachedRoutesPath(); + $actions[] = function () use ($cachePath): void { + if ($this->files->isFile($cachePath) && ! $this->files->delete($cachePath)) { + throw new RuntimeException("Unable to delete owned route cache file [{$cachePath}]."); + } + }; + $actions[] = fn () => parent::tearDown(); + + CleanupActions::run(...$actions); } public function testRouteCacheSucceedsWithSourceRoutes() @@ -183,6 +210,39 @@ public function testRouteCacheRebuildsFromSourceWhenApplicationBootedWithExistin $this->assertSame('beta', $route->uri()); } + public function testRouteCacheSubprocessUsesTheParentsResolvedCachePath(): void + { + $previousCachePath = $_SERVER['APP_ROUTES_CACHE'] ?? null; + $hadCachePath = array_key_exists('APP_ROUTES_CACHE', $_SERVER); + $defaultCachePath = $this->app->bootstrapPath('cache/routes-v7.php'); + $alternateCachePath = $this->app->bootstrapPath('cache/routes-alternate.php'); + $this->cacheFiles[] = $defaultCachePath; + $this->cacheFiles[] = $alternateCachePath; + $this->files->put( + $defaultCachePath, + 'defineTestbenchRoutes( + <<<'PHP' + Route::get('/alternate', fn () => 'alternate')->name('alternate.index'); + PHP + ); + + $this->artisan('route:cache')->assertSuccessful(); + + $this->assertFileExists($alternateCachePath); + } finally { + if ($hadCachePath) { + $_SERVER['APP_ROUTES_CACHE'] = $previousCachePath; + } else { + unset($_SERVER['APP_ROUTES_CACHE']); + } + } + } + /** * Write a testbench route file into the cloned skeleton's routes dir. * @@ -216,16 +276,27 @@ protected function defineTestbenchRoutes(string $routeDefinitions): void } /** - * Assert no testbench route files leaked from an earlier test in this worker. + * Assert route-producing skeleton state is pristine for this worker. */ - protected function assertNoLeakedTestbenchRouteFiles(): void + protected function assertCleanTestbenchRouteSources(): void { - $leakedRouteFiles = glob($this->app->basePath('routes/testbench-*.php')) ?: []; + foreach (['bootstrap/app.php', 'bootstrap/providers.php'] as $relativePath) { + $runtimePath = $this->app->basePath($relativePath); + $pristinePath = testbench_path('hypervel/' . $relativePath); + + $this->assertSame( + $this->files->get($pristinePath), + $this->files->get($runtimePath), + "Testbench runtime file [{$runtimePath}] differs from its pristine skeleton source.", + ); + } + + $routeFiles = $this->files->glob($this->app->basePath('routes/*.php')); $this->assertSame( [], - $leakedRouteFiles, - 'Leaked testbench route files found: ' . implode(', ', $leakedRouteFiles) + $routeFiles, + 'Unexpected Testbench runtime route files found: ' . implode(', ', $routeFiles), ); } } From 84111bef41c91006ab7eb28a92a4989258c47203 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:18:01 +0000 Subject: [PATCH 30/40] Make Testbench file owners restore exhaustively Convert every remaining route- and provider-producing Testbench suite to checked, atomic restoration and deletion through CleanupActions. Keep each test's owned resource list local, preserve the first cleanup failure, and still run every later file repair and parent teardown action to prevent worker contamination. --- tests/Horizon/Console/InstallCommandTest.php | 40 +++++++++++------ .../Console/ApiInstallCommandTest.php | 24 ++++++---- .../BroadcastingInstallCommandTest.php | 44 ++++++++++++------- .../Generators/ProviderMakeCommandTest.php | 29 +++++++++--- .../Telescope/Console/InstallCommandTest.php | 40 +++++++++++------ 5 files changed, 120 insertions(+), 57 deletions(-) diff --git a/tests/Horizon/Console/InstallCommandTest.php b/tests/Horizon/Console/InstallCommandTest.php index 871e42104..559cbac59 100644 --- a/tests/Horizon/Console/InstallCommandTest.php +++ b/tests/Horizon/Console/InstallCommandTest.php @@ -6,9 +6,12 @@ use Hypervel\Console\Command as HypervelCommand; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Filesystem\Filesystem; use Hypervel\Horizon\Console\InstallCommand; use Hypervel\Horizon\HorizonServiceProvider; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Testing\Fixtures\CleanupActions; +use RuntimeException; use Symfony\Component\Console\Command\Command as SymfonyCommand; class InstallCommandTest extends TestCase @@ -20,32 +23,43 @@ protected function setUp(): void parent::setUp(); $path = $this->app->getBootstrapProvidersPath(); + $files = new Filesystem; - if (file_exists($path)) { - $contents = file_get_contents($path); - - if ($contents !== false) { - $this->originalProvidersContents = $contents; - } + if ($files->isFile($path)) { + $this->originalProvidersContents = $files->get($path); } } protected function tearDown(): void { + $files = new Filesystem; + $providersPath = $this->app->getBootstrapProvidersPath(); + $actions = []; + if ($this->originalProvidersContents !== null) { - file_put_contents( - $this->app->getBootstrapProvidersPath(), - $this->originalProvidersContents + $actions[] = fn () => $files->replace( + $providersPath, + $this->originalProvidersContents, ); + } else { + $actions[] = static function () use ($files, $providersPath): void { + if ($files->isFile($providersPath) && ! $files->delete($providersPath)) { + throw new RuntimeException("Unable to delete the owned Horizon providers file [{$providersPath}]."); + } + }; } foreach ($this->publishedFiles() as $file) { - if (is_file($file)) { - unlink($file); - } + $actions[] = static function () use ($file, $files): void { + if ($files->isFile($file) && ! $files->delete($file)) { + throw new RuntimeException("Unable to delete owned Horizon install test file [{$file}]."); + } + }; } - parent::tearDown(); + $actions[] = fn () => parent::tearDown(); + + CleanupActions::run(...$actions); } protected function getPackageProviders(ApplicationContract $app): array diff --git a/tests/Integration/Foundation/Console/ApiInstallCommandTest.php b/tests/Integration/Foundation/Console/ApiInstallCommandTest.php index 34ea1a9c4..346c90d8c 100644 --- a/tests/Integration/Foundation/Console/ApiInstallCommandTest.php +++ b/tests/Integration/Foundation/Console/ApiInstallCommandTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\Integration\Foundation\Console; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Console\ApiInstallCommand; use Hypervel\Process\PendingProcess; use Hypervel\Support\Facades\Process; +use Hypervel\Tests\Testing\Fixtures\CleanupActions; +use RuntimeException; class ApiInstallCommandTest extends \Hypervel\Testbench\TestCase { @@ -54,20 +57,23 @@ protected function setUp(): void protected function tearDown(): void { - // Restore the original bootstrap/app.php. - file_put_contents( + $files = new Filesystem; + $actions = [fn () => $files->replace( $this->app->bootstrapPath('app.php'), - $this->originalBootstrapContent - ); + $this->originalBootstrapContent, + )]; - // Clean up any files created during tests. foreach ($this->createdFiles as $file) { - if (is_file($file)) { - unlink($file); - } + $actions[] = static function () use ($file, $files): void { + if ($files->isFile($file) && ! $files->delete($file)) { + throw new RuntimeException("Unable to delete owned API install test file [{$file}]."); + } + }; } - parent::tearDown(); + $actions[] = fn () => parent::tearDown(); + + CleanupActions::run(...$actions); } public function testCreatesApiRoutesFile() diff --git a/tests/Integration/Foundation/Console/BroadcastingInstallCommandTest.php b/tests/Integration/Foundation/Console/BroadcastingInstallCommandTest.php index 00ed646df..2423f1134 100644 --- a/tests/Integration/Foundation/Console/BroadcastingInstallCommandTest.php +++ b/tests/Integration/Foundation/Console/BroadcastingInstallCommandTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\Integration\Foundation\Console; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Console\BroadcastingInstallCommand; use Hypervel\Process\PendingProcess; use Hypervel\Support\Facades\Process; +use Hypervel\Tests\Testing\Fixtures\CleanupActions; +use RuntimeException; class BroadcastingInstallCommandTest extends \Hypervel\Testbench\TestCase { @@ -75,34 +78,43 @@ protected function setUp(): void protected function tearDown(): void { - // Restore the original bootstrap/app.php. - file_put_contents( + $files = new Filesystem; + $actions = [fn () => $files->replace( $this->app->bootstrapPath('app.php'), - $this->originalBootstrapContent - ); + $this->originalBootstrapContent, + )]; - // Restore or remove .env. $envPath = $this->app->basePath('.env'); + if ($this->originalEnvContent === null) { - @unlink($envPath); + $actions[] = static function () use ($envPath, $files): void { + if ($files->isFile($envPath) && ! $files->delete($envPath)) { + throw new RuntimeException("Unable to delete the owned broadcasting test environment file [{$envPath}]."); + } + }; } else { - file_put_contents($envPath, $this->originalEnvContent); + $actions[] = fn () => $files->replace($envPath, $this->originalEnvContent); } - // Clean up published config file (config:publish broadcasting creates this). $broadcastingConfig = $this->app->configPath('broadcasting.php'); - if (is_file($broadcastingConfig)) { - @unlink($broadcastingConfig); - } - // Clean up any files created during tests. - foreach ($this->createdFiles as $file) { - if (is_file($file)) { - unlink($file); + $actions[] = static function () use ($broadcastingConfig, $files): void { + if ($files->isFile($broadcastingConfig) && ! $files->delete($broadcastingConfig)) { + throw new RuntimeException("Unable to delete the owned broadcasting config file [{$broadcastingConfig}]."); } + }; + + foreach ($this->createdFiles as $file) { + $actions[] = static function () use ($file, $files): void { + if ($files->isFile($file) && ! $files->delete($file)) { + throw new RuntimeException("Unable to delete owned broadcasting install test file [{$file}]."); + } + }; } - parent::tearDown(); + $actions[] = fn () => parent::tearDown(); + + CleanupActions::run(...$actions); } public function testCreatesChannelsRouteFile() diff --git a/tests/Integration/Generators/ProviderMakeCommandTest.php b/tests/Integration/Generators/ProviderMakeCommandTest.php index 19dde715a..0efc99da1 100644 --- a/tests/Integration/Generators/ProviderMakeCommandTest.php +++ b/tests/Integration/Generators/ProviderMakeCommandTest.php @@ -4,6 +4,10 @@ namespace Hypervel\Tests\Integration\Generators; +use Hypervel\Filesystem\Filesystem; +use Hypervel\Tests\Testing\Fixtures\CleanupActions; +use RuntimeException; + class ProviderMakeCommandTest extends TestCase { protected $files = [ @@ -17,22 +21,35 @@ protected function setUp(): void parent::setUp(); $path = $this->app->getBootstrapProvidersPath(); + $files = new Filesystem; - if (file_exists($path)) { - $this->originalProvidersContents = file_get_contents($path); + if ($files->isFile($path)) { + $this->originalProvidersContents = $files->get($path); } } protected function tearDown(): void { + $files = new Filesystem; + $providersPath = $this->app->getBootstrapProvidersPath(); + if ($this->originalProvidersContents !== null) { - file_put_contents( - $this->app->getBootstrapProvidersPath(), - $this->originalProvidersContents + $restore = fn () => $files->replace( + $providersPath, + $this->originalProvidersContents, ); + } else { + $restore = static function () use ($files, $providersPath): void { + if ($files->isFile($providersPath) && ! $files->delete($providersPath)) { + throw new RuntimeException("Unable to delete the owned generated providers file [{$providersPath}]."); + } + }; } - parent::tearDown(); + CleanupActions::run( + $restore, + fn () => parent::tearDown(), + ); } public function testItCanGenerateServiceProviderFile() diff --git a/tests/Telescope/Console/InstallCommandTest.php b/tests/Telescope/Console/InstallCommandTest.php index 2c9ce2567..c58513313 100644 --- a/tests/Telescope/Console/InstallCommandTest.php +++ b/tests/Telescope/Console/InstallCommandTest.php @@ -6,9 +6,12 @@ use Hypervel\Console\Command as HypervelCommand; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Filesystem\Filesystem; use Hypervel\Telescope\Console\InstallCommand; use Hypervel\Telescope\TelescopeServiceProvider; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Testing\Fixtures\CleanupActions; +use RuntimeException; use Symfony\Component\Console\Command\Command as SymfonyCommand; class InstallCommandTest extends TestCase @@ -20,32 +23,43 @@ protected function setUp(): void parent::setUp(); $path = $this->app->getBootstrapProvidersPath(); + $files = new Filesystem; - if (file_exists($path)) { - $contents = file_get_contents($path); - - if ($contents !== false) { - $this->originalProvidersContents = $contents; - } + if ($files->isFile($path)) { + $this->originalProvidersContents = $files->get($path); } } protected function tearDown(): void { + $files = new Filesystem; + $providersPath = $this->app->getBootstrapProvidersPath(); + $actions = []; + if ($this->originalProvidersContents !== null) { - file_put_contents( - $this->app->getBootstrapProvidersPath(), - $this->originalProvidersContents + $actions[] = fn () => $files->replace( + $providersPath, + $this->originalProvidersContents, ); + } else { + $actions[] = static function () use ($files, $providersPath): void { + if ($files->isFile($providersPath) && ! $files->delete($providersPath)) { + throw new RuntimeException("Unable to delete the owned Telescope providers file [{$providersPath}]."); + } + }; } foreach ($this->publishedFiles() as $file) { - if (is_file($file)) { - unlink($file); - } + $actions[] = static function () use ($file, $files): void { + if ($files->isFile($file) && ! $files->delete($file)) { + throw new RuntimeException("Unable to delete owned Telescope install test file [{$file}]."); + } + }; } - parent::tearDown(); + $actions[] = fn () => parent::tearDown(); + + CleanupActions::run(...$actions); } protected function getPackageProviders(ApplicationContract $app): array From ca89c47ed0d86d4485a599a8e91192094a2a388f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:18:12 +0000 Subject: [PATCH 31/40] Verify Testbench process incarnation before cleanup Write a private runtime marker containing the creating PID and operating-system process start identity, then require PID-file, orphan parent, serve command, and incarnation agreement before signaling a live process. Refuse cleanup when identity cannot be proven while retaining safe dead-PID removal. Support Linux procfs and macOS process metadata without launch-path tokens, so direct and remote Testbench serve commands share the same identity model. Add malformed, reused, unreadable, command-mismatch, and runtime-marker regressions. --- src/testbench/src/Bootstrapper.php | 145 +++++++++- tests/Testbench/BootstrapperTest.php | 257 +++++++++++++++++- .../Foundation/Process/RemoteCommandTest.php | 7 +- 3 files changed, 398 insertions(+), 11 deletions(-) diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 1d5ebf8b2..4e9484c74 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -9,10 +9,13 @@ use Hypervel\Testbench\Foundation\Config; use Hypervel\Testbench\Foundation\Env; use Hypervel\Testbench\Foundation\EnvironmentFile; +use JsonException; use UnexpectedValueException; class Bootstrapper { + protected const RUNTIME_PROCESS_MARKER = '.testbench-process'; + protected static ?ConfigContract $configuration = null; protected static ?Filesystem $filesystem = null; @@ -135,8 +138,8 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP // Purge stale dirs for this worker token from previous crashed runs. // A dir is stale when its owning PID is either dead or orphaned // (PPID=1, meaning the test process that spawned it exited). Orphaned - // serve processes (confirmed via hypervel.pid) are killed before their - // dirs are removed. + // serve processes (confirmed by PID, command, and process incarnation) + // are killed before their dirs are removed. foreach (glob($tempDir . "/hypervel-components-testbench-{$token}-*") as $staleDir) { if (! $filesystem->isDirectory($staleDir)) { continue; @@ -162,6 +165,19 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP static::copyPackageEnvironmentFile($filesystem, $runtimePath, $workingPath); } + $startIdentity = static::processStartIdentity($pid); + + if ($startIdentity !== null) { + $filesystem->replace( + join_paths($runtimePath, static::RUNTIME_PROCESS_MARKER), + json_encode([ + 'pid' => $pid, + 'started_at' => $startIdentity, + ], JSON_THROW_ON_ERROR), + 0600, + ); + } + static::$runtimePath = $runtimePath; register_shutdown_function(static function () { @@ -280,13 +296,16 @@ protected static function runtimeDirectoryExists(Filesystem $filesystem, string /** * Determine if the given PID is an orphaned serve process. * - * A process is considered an orphaned serve process when its parent is - * PID 1 (re-parented by init after the original parent exited) and the - * runtime directory contains a Swoole PID file, confirming it was a - * serve command that started a server. + * A process is considered an orphaned serve process only when its parent + * is init and its PID, command, and process incarnation all match the + * runtime directory. */ protected static function isOrphanedServeProcess(int $pid, string $runtimeDir): bool { + if ($pid <= 0 || ! posix_kill($pid, 0)) { + return false; + } + // Check PPID = 1 (orphaned) via /proc on Linux. $statusFile = "/proc/{$pid}/status"; @@ -310,8 +329,118 @@ protected static function isOrphanedServeProcess(int $pid, string $runtimeDir): } } - // Confirm this was a serve process by checking for the PID file. - return is_file("{$runtimeDir}/storage/framework/hypervel.pid"); + return static::matchesServeProcessIdentity($pid, $runtimeDir); + } + + /** + * Determine whether a process identity matches a Testbench serve runtime. + */ + protected static function matchesServeProcessIdentity(int $pid, string $runtimeDir): bool + { + $pidFile = join_paths($runtimeDir, 'storage/framework/hypervel.pid'); + $pidContents = @file_get_contents($pidFile); + + if ($pidContents === false + || ! ctype_digit($pidContents = trim($pidContents)) + || (int) $pidContents !== $pid + ) { + return false; + } + + $command = static::processCommand($pid); + + if ($command === null + || preg_match( + '/(?:^|\s)\S*(?:testbench|hypervel)(?:\.php)?\s+serve(?:\s|$)/i', + $command, + ) !== 1 + ) { + return false; + } + + $marker = @file_get_contents(join_paths($runtimeDir, static::RUNTIME_PROCESS_MARKER)); + + if ($marker === false) { + return false; + } + + try { + $identity = json_decode($marker, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return false; + } + + if (! is_array($identity) + || count($identity) !== 2 + || ($identity['pid'] ?? null) !== $pid + || ! is_string($startedAt = $identity['started_at'] ?? null) + || $startedAt === '' + ) { + return false; + } + + $currentStartIdentity = static::processStartIdentity($pid); + + return $currentStartIdentity !== null + && hash_equals($startedAt, $currentStartIdentity); + } + + /** + * Read the command line for a process. + */ + protected static function processCommand(int $pid): ?string + { + if (is_dir('/proc')) { + $path = "/proc/{$pid}/cmdline"; + + if (! is_readable($path) + || ($contents = @file_get_contents($path)) === false + || $contents === '' + ) { + return null; + } + + return trim(str_replace("\0", ' ', $contents)); + } + + $output = []; + exec("ps -ww -p {$pid} -o command= 2>/dev/null", $output); + $command = trim(implode("\n", $output)); + + return $command !== '' ? $command : null; + } + + /** + * Read the OS identity of the process incarnation. + * + * Linux exposes the start clock tick exactly. macOS `lstart` has one-second + * resolution, which is sufficient alongside the PID and validated command. + */ + protected static function processStartIdentity(int $pid): ?string + { + if (is_dir('/proc')) { + $path = "/proc/{$pid}/stat"; + + if (! is_readable($path) + || ($contents = @file_get_contents($path)) === false + || ($commandEnd = strrpos($contents, ')')) === false + ) { + return null; + } + + $fields = preg_split('/\s+/', trim(substr($contents, $commandEnd + 1))); + $startedAt = $fields[19] ?? null; + + return is_string($startedAt) && ctype_digit($startedAt) + ? $startedAt + : null; + } + + $output = []; + exec("ps -p {$pid} -o lstart= 2>/dev/null", $output); + $startedAt = trim(implode(' ', $output)); + + return $startedAt !== '' ? $startedAt : null; } /** diff --git a/tests/Testbench/BootstrapperTest.php b/tests/Testbench/BootstrapperTest.php index 356011e6d..2c289afa7 100644 --- a/tests/Testbench/BootstrapperTest.php +++ b/tests/Testbench/BootstrapperTest.php @@ -17,7 +17,7 @@ class BootstrapperTest extends TestCase { #[Test] - public function testFlushStateKeepsRuntimePathForShutdownCleanup() + public function testFlushStateKeepsRuntimePathForShutdownCleanup(): void { $reflection = new ReflectionClass(Bootstrapper::class); $runtimePath = '/tmp/hypervel-components-testbench-flush-state'; @@ -142,6 +142,191 @@ public function itDoesNotCopyPackageOrSkeletonEnvironmentFilesOutsidePackageTest } } + #[Test] + public function itWritesAPrivateProcessIncarnationMarker(): void + { + $packagePath = $this->temporaryDirectory('identity-package'); + $sourcePath = $this->temporaryDirectory('identity-skeleton'); + $runtimePath = null; + + mkdir($packagePath, 0777, true); + mkdir($sourcePath, 0777, true); + + try { + $this->withRuntimeCopyEnvironment( + 'bootstrapper-identity', + false, + function () use ( + $sourcePath, + $packagePath, + &$runtimePath, + ): void { + $runtimePath = $this->createRuntimeCopy($sourcePath, $packagePath); + }, + ); + + $marker = $runtimePath . DIRECTORY_SEPARATOR . '.testbench-process'; + $identity = json_decode((string) file_get_contents($marker), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame([ + 'pid' => getmypid(), + 'started_at' => BootstrapperIdentityProbe::startIdentity(getmypid()), + ], $identity); + $this->assertSame(0600, fileperms($marker) & 0777); + } finally { + $this->deleteDirectory($packagePath); + $this->deleteDirectory($sourcePath); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itRecognizesAMatchingServeProcessIdentity(): void + { + [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + + try { + BootstrapperIdentityProbe::setProcessIdentity( + '/usr/bin/php /workspace/src/testbench/bin/testbench serve --host=127.0.0.1', + 'start-identity', + ); + + $this->assertTrue( + BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), + ); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itRejectsACommandThatIsNotTestbenchServe(): void + { + [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + + try { + BootstrapperIdentityProbe::setProcessIdentity( + '/usr/bin/php /workspace/artisan queue:work', + 'start-identity', + ); + + $this->assertFalse( + BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), + ); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itRejectsAReusedPidWithADifferentStartIdentity(): void + { + [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + + try { + BootstrapperIdentityProbe::setProcessIdentity( + '/usr/bin/php /workspace/src/testbench/bin/testbench serve', + 'different-start-identity', + ); + + $this->assertFalse( + BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), + ); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itRejectsAMalformedProcessMarker(): void + { + [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + + try { + file_put_contents($runtimePath . '/.testbench-process', '{invalid'); + BootstrapperIdentityProbe::setProcessIdentity( + '/usr/bin/php /workspace/src/testbench/bin/testbench serve', + 'start-identity', + ); + + $this->assertFalse( + BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), + ); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itRejectsAnUnreadableProcessIdentity(): void + { + [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + + try { + BootstrapperIdentityProbe::setProcessIdentity(null, null); + + $this->assertFalse( + BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), + ); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itRejectsAMismatchedRuntimePidFile(): void + { + [$runtimePath, $pid] = $this->createServeIdentityRuntime(); + + try { + file_put_contents( + $runtimePath . '/storage/framework/hypervel.pid', + (string) ($pid + 1), + ); + BootstrapperIdentityProbe::setProcessIdentity( + '/usr/bin/php /workspace/src/testbench/bin/testbench serve', + 'start-identity', + ); + + $this->assertFalse( + BootstrapperIdentityProbe::matchesServeProcess($pid, $runtimePath), + ); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itRejectsADeadPidBeforeInspectingItsIdentity(): void + { + [$runtimePath] = $this->createServeIdentityRuntime(); + $deadPid = 2_000_000_000; + + try { + file_put_contents( + $runtimePath . '/storage/framework/hypervel.pid', + (string) $deadPid, + ); + BootstrapperIdentityProbe::setProcessIdentity( + '/usr/bin/php /workspace/src/testbench/bin/testbench serve', + 'start-identity', + ); + + $this->assertFalse( + BootstrapperIdentityProbe::isOrphanedServe($deadPid, $runtimePath), + ); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($runtimePath); + } + } + /** * Create a temporary test directory. */ @@ -211,6 +396,32 @@ private function withRuntimeCopyEnvironment(string $token, bool $packageTester, } } + /** + * Create a runtime directory containing matching serve identity markers. + * + * @return array{string, int} + */ + private function createServeIdentityRuntime(): array + { + $runtimePath = $this->temporaryDirectory('serve-identity'); + $pid = getmypid(); + + mkdir($runtimePath . '/storage/framework', 0777, true); + file_put_contents( + $runtimePath . '/storage/framework/hypervel.pid', + (string) $pid, + ); + file_put_contents( + $runtimePath . '/.testbench-process', + json_encode([ + 'pid' => $pid, + 'started_at' => 'start-identity', + ], JSON_THROW_ON_ERROR), + ); + + return [$runtimePath, $pid]; + } + /** * Set an isolated test token for runtime copy paths. */ @@ -291,6 +502,50 @@ private function deleteDirectory(?string $path): void } } +class BootstrapperIdentityProbe extends Bootstrapper +{ + protected static ?string $command = null; + + protected static ?string $startIdentity = null; + + public static function setProcessIdentity(?string $command, ?string $startIdentity): void + { + static::$command = $command; + static::$startIdentity = $startIdentity; + } + + public static function resetProcessIdentity(): void + { + static::$command = null; + static::$startIdentity = null; + } + + public static function matchesServeProcess(int $pid, string $runtimeDir): bool + { + return parent::matchesServeProcessIdentity($pid, $runtimeDir); + } + + public static function isOrphanedServe(int $pid, string $runtimeDir): bool + { + return parent::isOrphanedServeProcess($pid, $runtimeDir); + } + + protected static function processCommand(int $pid): ?string + { + return static::$command; + } + + public static function startIdentity(int $pid): ?string + { + return parent::processStartIdentity($pid); + } + + protected static function processStartIdentity(int $pid): ?string + { + return static::$startIdentity; + } +} + class RuntimeDirectoryVanishedFilesystem extends Filesystem { public int $deleteAttempts = 0; diff --git a/tests/Testbench/Foundation/Process/RemoteCommandTest.php b/tests/Testbench/Foundation/Process/RemoteCommandTest.php index 353412237..6f812e3ab 100644 --- a/tests/Testbench/Foundation/Process/RemoteCommandTest.php +++ b/tests/Testbench/Foundation/Process/RemoteCommandTest.php @@ -59,8 +59,11 @@ public function itDoesNotForwardTheParentRuntimeCopyToServeCommands(): void $serveProcess = remote('serve --help'); $aboutProcess = remote('about --json'); - $this->assertArrayNotHasKey('TESTBENCH_BASE_PATH', $this->processEnvironment($serveProcess)); - $this->assertSame(BASE_PATH, $this->processEnvironment($aboutProcess)['TESTBENCH_BASE_PATH'] ?? null); + $serveEnvironment = $this->processEnvironment($serveProcess); + $aboutEnvironment = $this->processEnvironment($aboutProcess); + + $this->assertArrayNotHasKey('TESTBENCH_BASE_PATH', $serveEnvironment); + $this->assertSame(BASE_PATH, $aboutEnvironment['TESTBENCH_BASE_PATH'] ?? null); }); } From ccc941819c1b9c909223fbc0a53d3928dd7efb10 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:18:24 +0000 Subject: [PATCH 32/40] Define empty pool frequency samples Return zero for a freshly constructed frequency window instead of dividing by an empty sample. Preserve the existing average across populated and zero-filled seconds and add a regression for construction within the current second. --- src/pool/src/Frequency.php | 10 ++++------ tests/Pool/FrequencyTest.php | 5 +++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pool/src/Frequency.php b/src/pool/src/Frequency.php index 9f58c3ccb..8b95c9a9e 100644 --- a/src/pool/src/Frequency.php +++ b/src/pool/src/Frequency.php @@ -74,15 +74,13 @@ public function frequency(): float { $this->flush(); - $hits = 0; - $count = 0; + $sampleCount = count($this->hits); - foreach ($this->hits as $hit) { - ++$count; - $hits += $hit; + if ($sampleCount === 0) { + return 0.0; } - return floatval($hits / $count); + return array_sum($this->hits) / $sampleCount; } /** diff --git a/tests/Pool/FrequencyTest.php b/tests/Pool/FrequencyTest.php index 7d2288470..e3d4ac300 100644 --- a/tests/Pool/FrequencyTest.php +++ b/tests/Pool/FrequencyTest.php @@ -13,6 +13,11 @@ class FrequencyTest extends TestCase { + public function testFrequencyIsZeroBeforeTheFirstSample(): void + { + $this->assertSame(0.0, (new FrequencyStub)->frequency()); + } + public function testFrequencyHit(): void { $frequency = new FrequencyStub; From ba8cfee0eba7fdcea7bc5909a14e79c8c2665a3f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:18:52 +0000 Subject: [PATCH 33/40] Document asynchronous test ownership Explain that tests must close or join child coroutines, subscribers, processes, servers, and similar resources through exception-safe ownership. Recommend the framework parallel primitive over unbounded hand-built channel joins when channel behavior is not under test. Document that Hypervel verifies and closes Mockery automatically through framework base cases with a global subscriber fallback, so application and package tests must not call Mockery::close themselves. --- src/boost/docs/testing.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/boost/docs/testing.md b/src/boost/docs/testing.md index bc1847061..4ea1257cb 100644 --- a/src/boost/docs/testing.md +++ b/src/boost/docs/testing.md @@ -4,6 +4,7 @@ - [Environment](#environment) - [Creating Tests](#creating-tests) - [Running Tests in Coroutines](#running-tests-in-coroutines) + - [Owning Asynchronous Test Resources](#owning-asynchronous-test-resources) - [Test State Cleanup](#test-state-cleanup) - [Macro State](#macro-state) - [Using Pest](#using-pest) @@ -170,11 +171,20 @@ By default, Hypervel copies coroutine context values prepared outside the test m protected bool $copyNonCoroutineContext = false; ``` + +### Owning Asynchronous Test Resources + +Tests that create child coroutines, subscribers, processes, servers, or other asynchronous resources must close or join those resources in a `finally` block. This ensures that assertion failures and other exceptions cannot leave work running after the test has finished. + +When a test needs results or exceptions from child coroutines, prefer the `parallel` helper instead of coordinating them with unbounded channel reads. Use channels directly when channel behavior is what the test is exercising. + ### Test State Cleanup Hypervel applications keep framework objects, static caches, macros, and manager state in memory for the life of the PHP process. During tests, Hypervel's PHPUnit extension flushes framework-owned state after every test method. +Hypervel also verifies and closes Mockery automatically. Framework base test cases perform verification during teardown so unmet expectations are attributed to the test that created them, while the PHPUnit extension provides fallback verification for tests using another base case. Individual tests should not call `Mockery::close()` themselves. + If your application has its own worker-lifetime state, add its cleanup to `tests/Support/TestState.php`. Use this class as one application-level entry point that aggregates the cleanup for any stateful classes your app owns: ```php From b06bd62829c88f669a222cdb1349b7ada8a5cbdc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:06:37 +0000 Subject: [PATCH 34/40] Honor the queue worker monitor interval Use WorkerOptions as the single source of truth for timeout-monitor cadence so the documented queue:work --monitor-interval option reaches the owned Timer. Remove the redundant Worker constructor interval that was never populated by the service provider and always forced the one-second default. Record the requested timeout in the Timer test seam and prove that a non-default option is used during daemon startup. Update the lifecycle plan with the pre-existing wiring bug, final design, and regression coverage. --- ...7-12-test-suite-lifecycle-and-concurrency-robustness.md | 4 +++- src/queue/src/Worker.php | 4 +--- tests/Queue/QueueWorkerTest.php | 7 ++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md b/docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md index 04a3f985a..b3fc64e20 100644 --- a/docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md +++ b/docs/plans/2026-07-12-test-suite-lifecycle-and-concurrency-robustness.md @@ -105,6 +105,7 @@ The exact scheduler interleaving that made an otherwise inline fake job park in 24. Child-reaping tests treat every `waitpid()` error as success or fall back to an unbounded blocking wait, so interruption can hide or create an owned-process leak. 25. Reverb commits a live Redis subscriber before spawning its consumer. A failed spawn leaks that subscriber, and resetting the retry counter before the subscription handshake makes its retry limit unreachable. 26. Horizon can signal a newly forked worker or supervisor before that child installs its handlers. An early SIGUSR2 keeps its default terminating disposition, producing the intermittent paused-supervisor failure that strict child reaping exposes. +27. The queue worker accepts `--monitor-interval` and stores it on `WorkerOptions`, but the timeout monitor reads a separate constructor property that always retains its one-second default, so the documented option has no effect. ## Design principles @@ -583,7 +584,7 @@ Queue worker tests install real signal handlers and detached timers through dupl #### Decision -Inject one optional `Coordinator\Timer` dependency into `Worker`; default it to a real timer. Remove both monitor callables: the constructor `$monitorTimeoutJobs` and `daemon()`'s optional callback. Store the timer and its returned ID. +Inject one optional `Coordinator\Timer` dependency into `Worker`; default it to a real timer. Remove both monitor callables: the constructor `$monitorTimeoutJobs` and `daemon()`'s optional callback. Store the timer and its returned ID. The monitor interval has one source of truth: use `WorkerOptions::$monitorInterval`, which is populated by the documented `--monitor-interval` command option, and remove the redundant Worker constructor interval. ```php public function __construct( @@ -1557,6 +1558,7 @@ Keep one isolated test per distinct bookkeeping or boundary invariant. Consolida - run a daemon with a deliberately yielding fake job and concurrency one using `InsomniacWorker`; assert it completes and the fake records sleeps; - assert the fake does not install process signal handlers; - inject a fake Timer and verify its exact monitor ID is cleared on every daemon return and throw path; +- set a non-default monitor interval through `WorkerOptions` and assert the owned Timer receives it; - make timeout scanning throw once and assert `monitorLocked` is reset so the next tick can run; - assert `kill()` dispatches the stopping event and reaches the kill seam without waiting for unrelated active jobs; - with concurrency greater than one, assert a hard timeout terminates immediately rather than waiting for a healthy sibling job; diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 70a1af941..5851c3945 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -153,14 +153,12 @@ class Worker * @param Dispatcher $events the event dispatcher instance * @param ExceptionHandlerContract $exceptions the exception handler instance * @param callable $isDownForMaintenance the callback used to determine if the application is in maintenance mode - * @param int $monitorInterval the monitor interval */ public function __construct( protected QueueManager $manager, protected Dispatcher $events, protected ExceptionHandlerContract $exceptions, callable $isDownForMaintenance, - protected int $monitorInterval = 1, ?Timer $timer = null, ) { $this->isDownForMaintenance = $isDownForMaintenance; @@ -286,7 +284,7 @@ protected function monitorTimeoutJobs(WorkerOptions $options): void return; } - $this->monitorId = $this->timer->tick($this->monitorInterval, function () use ($options): void { + $this->monitorId = $this->timer->tick($options->monitorInterval, function () use ($options): void { $this->withCoroutineContext($options, function () use ($options): void { if ($this->monitorLocked) { return; diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 2cb290a4f..035438dce 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -135,6 +135,7 @@ public function testWorkerCanMonitorTimeoutJobs() { $workerOptions = new WorkerOptions; $workerOptions->stopWhenEmpty = true; + $workerOptions->monitorInterval = 5; $timer = new QueueWorkerTimer; $worker = $this->getWorker('default', ['queue' => [ @@ -144,6 +145,7 @@ public function testWorkerCanMonitorTimeoutJobs() $status = $worker->daemon('default', 'queue', $workerOptions); $this->assertSame([1], $timer->registered); + $this->assertSame([5.0], $timer->timeouts); $this->assertSame([1], $timer->cleared); $this->assertTrue($firstJob->fired); @@ -826,7 +828,6 @@ private function workerDependencies( $isInMaintenanceMode ?? function () { return false; }, - 1, $timer, ]; } @@ -963,6 +964,9 @@ public function daemonShouldRunForTest(WorkerOptions $options, string $connectio class QueueWorkerTimer extends Timer { + /** @var float[] */ + public array $timeouts = []; + /** @var array */ public array $callbacks = []; @@ -979,6 +983,7 @@ public function tick( ): int { $id = count($this->registered) + 1; $this->registered[] = $id; + $this->timeouts[] = $timeout; $this->callbacks[$id] = $closure; return $id; From 5698f65a06068b1958c17a83750140eea7481aa8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:49 +0000 Subject: [PATCH 35/40] Honor filesystem read failures Validate native file reads before returning from the typed filesystem API and convert unreadable or vanished files into the existing framework exception contract.\n\nPreserve the file session driver's empty-session semantics when a session is concurrently removed between its metadata check and locked read. Add regressions for both locked and unlocked filesystem races and the session-handler adaptation. --- src/filesystem/src/Filesystem.php | 46 +++++++++++++++--------- src/session/src/FileSessionHandler.php | 7 +++- tests/Filesystem/FilesystemTest.php | 31 ++++++++++++++++ tests/Session/FileSessionHandlerTest.php | 16 +++++++++ 4 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php index 5f1dc19ae..92c7415b0 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -49,7 +49,13 @@ public function missing(string $path): bool public function get(string $path, bool $lock = false): string { if ($this->isFile($path)) { - return $lock ? $this->sharedGet($path) : file_get_contents($path); + $contents = $lock ? $this->sharedGet($path) : @file_get_contents($path); + + if ($contents === false) { + throw new FileNotFoundException("Unable to read file at path {$path}."); + } + + return $contents; } throw new FileNotFoundException("File does not exist at path {$path}."); @@ -71,23 +77,31 @@ public function json(string $path, int $flags = 0, bool $lock = false): mixed public function sharedGet(string $path): string { return $this->atomic($path, function ($path) { - $contents = ''; - $handle = fopen($path, 'rb'); - if ($handle) { - $wouldBlock = false; + $handle = @fopen($path, 'rb'); + + if ($handle === false) { + throw new FileNotFoundException("Unable to read file at path {$path}."); + } + + $wouldBlock = false; + flock($handle, LOCK_SH | LOCK_NB, $wouldBlock); + while ($wouldBlock) { + usleep(1000); flock($handle, LOCK_SH | LOCK_NB, $wouldBlock); - while ($wouldBlock) { - usleep(1000); - flock($handle, LOCK_SH | LOCK_NB, $wouldBlock); - } - try { - clearstatcache(true, $path); - $contents = stream_get_contents($handle); - } finally { - flock($handle, LOCK_UN); - fclose($handle); - } } + + try { + clearstatcache(true, $path); + $contents = @stream_get_contents($handle); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + + if ($contents === false) { + throw new FileNotFoundException("Unable to read file at path {$path}."); + } + return $contents; }); } diff --git a/src/session/src/FileSessionHandler.php b/src/session/src/FileSessionHandler.php index e28747564..9ae78e8ef 100644 --- a/src/session/src/FileSessionHandler.php +++ b/src/session/src/FileSessionHandler.php @@ -4,6 +4,7 @@ namespace Hypervel\Session; +use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Carbon; use SessionHandlerInterface; @@ -40,7 +41,11 @@ public function read(string $sessionId): false|string if ($this->files->isFile($path = $this->path . '/' . $sessionId) && $this->files->lastModified($path) >= Carbon::now()->subMinutes($this->minutes)->getTimestamp() ) { - return $this->files->sharedGet($path); + try { + return $this->files->sharedGet($path); + } catch (FileNotFoundException) { + return ''; + } } return ''; diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index 110b4a1d7..40dcbc72e 100755 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -391,6 +391,26 @@ public function testGetThrowsExceptionNonexisitingFile() (new Filesystem)->get($this->tempDir . '/unknown-file.txt'); } + public function testGetThrowsAFrameworkExceptionWhenTheFileDisappearsBeforeReading(): void + { + $path = $this->tempDir . '/vanished.txt'; + + $this->expectException(FileNotFoundException::class); + $this->expectExceptionMessage("Unable to read file at path {$path}."); + + (new VanishingReadFilesystem)->get($path); + } + + public function testSharedGetThrowsAFrameworkExceptionWhenTheFileDisappearsBeforeOpening(): void + { + $path = $this->tempDir . '/vanished.txt'; + + $this->expectException(FileNotFoundException::class); + $this->expectExceptionMessage("Unable to read file at path {$path}."); + + (new VanishingReadFilesystem)->get($path, true); + } + public function testGetRequireReturnsProperly() { file_put_contents($this->tempDir . '/file.php', ''); @@ -796,3 +816,14 @@ private function temporaryFilesWithPrefix(string $prefix): array return $files; } } + +class VanishingReadFilesystem extends Filesystem +{ + /** + * Simulate a file disappearing after the initial metadata check. + */ + public function isFile(string $file): bool + { + return true; + } +} diff --git a/tests/Session/FileSessionHandlerTest.php b/tests/Session/FileSessionHandlerTest.php index 71525b9d8..54ba06917 100644 --- a/tests/Session/FileSessionHandlerTest.php +++ b/tests/Session/FileSessionHandlerTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Session; +use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Session\FileSessionHandler; use Hypervel\Support\Carbon; @@ -82,6 +83,21 @@ public function testReadReturnsEmptyStringWhenFileDoesNotExist() $this->assertSame('', $result); } + public function testReadReturnsEmptyStringWhenTheSessionFileDisappearsBeforeReading(): void + { + $sessionId = 'vanished_session_id'; + $path = '/path/to/sessions/' . $sessionId; + Carbon::setTestNow(Carbon::parse('2025-02-02 01:30:00')); + + $this->files->shouldReceive('isFile')->with($path)->andReturnTrue(); + $this->files->shouldReceive('lastModified')->with($path)->andReturn(Carbon::now()->getTimestamp()); + $this->files->shouldReceive('sharedGet')->with($path)->once()->andThrow( + new FileNotFoundException("Unable to read file at path {$path}.") + ); + + $this->assertSame('', $this->sessionHandler->read($sessionId)); + } + public function testWriteStoresData() { $sessionId = 'session_id'; From 49b64a9fab5753530bebc64fc9f7f98f66fd2fba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:49 +0000 Subject: [PATCH 36/40] Make Testbench runtime creation transactional Treat skeleton copying, environment setup, and process-marker creation as one initialization transaction.\n\nReject partial directory copies, roll back unpublished runtime directories on every creation failure without masking the primary exception, and verify both copy and marker failures through the existing filesystem seam. --- src/testbench/src/Bootstrapper.php | 44 ++++++--- tests/Testbench/BootstrapperTest.php | 143 ++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 18 deletions(-) diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 4e9484c74..4478da40c 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -10,6 +10,8 @@ use Hypervel\Testbench\Foundation\Env; use Hypervel\Testbench\Foundation\EnvironmentFile; use JsonException; +use RuntimeException; +use Throwable; use UnexpectedValueException; class Bootstrapper @@ -159,23 +161,35 @@ protected static function createRuntimeCopy(string $sourcePath, string $workingP static::deleteRuntimeDirectory($staleDir); } - $filesystem->copyDirectory($sourcePath, $runtimePath); + try { + if (! $filesystem->copyDirectory($sourcePath, $runtimePath)) { + throw new RuntimeException("Unable to create the Testbench runtime copy at [{$runtimePath}]."); + } - if (Env::has('TESTBENCH_PACKAGE_TESTER')) { - static::copyPackageEnvironmentFile($filesystem, $runtimePath, $workingPath); - } + if (Env::has('TESTBENCH_PACKAGE_TESTER')) { + static::copyPackageEnvironmentFile($filesystem, $runtimePath, $workingPath); + } + + $startIdentity = static::processStartIdentity($pid); + + if ($startIdentity !== null) { + $filesystem->replace( + join_paths($runtimePath, static::RUNTIME_PROCESS_MARKER), + json_encode([ + 'pid' => $pid, + 'started_at' => $startIdentity, + ], JSON_THROW_ON_ERROR), + 0600, + ); + } + } catch (Throwable $exception) { + try { + static::deleteRuntimeDirectory($runtimePath); + } catch (Throwable) { + // Preserve the runtime-creation failure when rollback also fails. + } - $startIdentity = static::processStartIdentity($pid); - - if ($startIdentity !== null) { - $filesystem->replace( - join_paths($runtimePath, static::RUNTIME_PROCESS_MARKER), - json_encode([ - 'pid' => $pid, - 'started_at' => $startIdentity, - ], JSON_THROW_ON_ERROR), - 0600, - ); + throw $exception; } static::$runtimePath = $runtimePath; diff --git a/tests/Testbench/BootstrapperTest.php b/tests/Testbench/BootstrapperTest.php index 2c289afa7..ad84581cc 100644 --- a/tests/Testbench/BootstrapperTest.php +++ b/tests/Testbench/BootstrapperTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\Test; use ReflectionClass; use ReflectionMethod; +use RuntimeException; use UnexpectedValueException; class BootstrapperTest extends TestCase @@ -63,6 +64,80 @@ public function itRethrowsRuntimeDirectoryDeletionFailuresWhenTheDirectoryRemain } } + #[Test] + public function itRollsBackAPartialRuntimeCopyWhenDirectoryCopyingFails(): void + { + $packagePath = $this->temporaryDirectory('failed-copy-package'); + $sourcePath = $this->temporaryDirectory('failed-copy-source'); + $filesystem = new FailedRuntimeCopyFilesystem; + + mkdir($packagePath, 0777, true); + mkdir($sourcePath, 0777, true); + + try { + $this->withRuntimeCopyEnvironment('bootstrapper-failed-copy', false, function () use ($filesystem, $sourcePath, $packagePath): void { + $this->withBootstrapperFilesystem($filesystem, function () use ($filesystem, $sourcePath, $packagePath): void { + $reflection = new ReflectionClass(Bootstrapper::class); + $previousRuntimePath = $reflection->getStaticPropertyValue('runtimePath'); + + try { + $this->createRuntimeCopy($sourcePath, $packagePath); + $this->fail('Expected runtime copy creation to fail.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('Unable to create the Testbench runtime copy', $exception->getMessage()); + } + + $this->assertNotNull($filesystem->runtimePath); + $this->assertDirectoryDoesNotExist($filesystem->runtimePath); + $this->assertSame($previousRuntimePath, $reflection->getStaticPropertyValue('runtimePath')); + }); + }); + } finally { + $this->deleteDirectory($packagePath); + $this->deleteDirectory($sourcePath); + $this->deleteDirectory($filesystem->runtimePath); + } + } + + #[Test] + public function itRollsBackTheRuntimeCopyWhenProcessMarkerCreationFails(): void + { + $packagePath = $this->temporaryDirectory('failed-marker-package'); + $sourcePath = $this->temporaryDirectory('failed-marker-source'); + $failure = new RuntimeException('marker creation failed'); + $filesystem = new FailedRuntimeMarkerFilesystem($failure); + + mkdir($packagePath, 0777, true); + mkdir($sourcePath, 0777, true); + + BootstrapperIdentityProbe::setProcessIdentity(null, 'start-identity'); + + try { + $this->withRuntimeCopyEnvironment('bootstrapper-failed-marker', false, function () use ($filesystem, $sourcePath, $packagePath, $failure): void { + $this->withBootstrapperFilesystem($filesystem, function () use ($filesystem, $sourcePath, $packagePath, $failure): void { + $reflection = new ReflectionClass(Bootstrapper::class); + $previousRuntimePath = $reflection->getStaticPropertyValue('runtimePath'); + + try { + $this->createRuntimeCopy($sourcePath, $packagePath, BootstrapperIdentityProbe::class); + $this->fail('Expected process marker creation to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertNotNull($filesystem->runtimePath); + $this->assertDirectoryDoesNotExist($filesystem->runtimePath); + $this->assertSame($previousRuntimePath, $reflection->getStaticPropertyValue('runtimePath')); + }); + }); + } finally { + BootstrapperIdentityProbe::resetProcessIdentity(); + $this->deleteDirectory($packagePath); + $this->deleteDirectory($sourcePath); + $this->deleteDirectory($filesystem->runtimePath); + } + } + #[Test] public function itCopiesThePackageEnvironmentFileIntoTheRuntimeCopy(): void { @@ -339,14 +414,33 @@ private function temporaryDirectory(string $name): string /** * Create a runtime copy through Bootstrapper's protected method. */ - private function createRuntimeCopy(string $sourcePath, string $workingPath): string - { - $method = new ReflectionMethod(Bootstrapper::class, 'createRuntimeCopy'); + private function createRuntimeCopy( + string $sourcePath, + string $workingPath, + string $bootstrapper = Bootstrapper::class, + ): string { + $method = new ReflectionMethod($bootstrapper, 'createRuntimeCopy'); $method->setAccessible(true); return $method->invoke(null, $sourcePath, $workingPath); } + /** + * Run a callback with a specific Bootstrapper filesystem. + */ + private function withBootstrapperFilesystem(Filesystem $filesystem, callable $callback): void + { + $reflection = new ReflectionClass(Bootstrapper::class); + $previousFilesystem = $reflection->getStaticPropertyValue('filesystem'); + + try { + $reflection->setStaticPropertyValue('filesystem', $filesystem); + $callback(); + } finally { + $reflection->setStaticPropertyValue('filesystem', $previousFilesystem); + } + } + /** * Delete a runtime directory through Bootstrapper with a fake filesystem. */ @@ -591,3 +685,46 @@ public function deleteDirectory(string $directory, bool $preserve = false): bool throw new UnexpectedValueException('runtime directory still present'); } } + +class FailedRuntimeCopyFilesystem extends Filesystem +{ + public ?string $runtimePath = null; + + /** + * Create a partial destination before reporting copy failure. + */ + public function copyDirectory(string $directory, string $destination, ?int $options = null): bool + { + $this->runtimePath = $destination; + mkdir($destination, 0777, true); + + return false; + } +} + +class FailedRuntimeMarkerFilesystem extends Filesystem +{ + public ?string $runtimePath = null; + + public function __construct(private RuntimeException $failure) + { + } + + /** + * Capture the runtime destination while copying it normally. + */ + public function copyDirectory(string $directory, string $destination, ?int $options = null): bool + { + $this->runtimePath = $destination; + + return parent::copyDirectory($directory, $destination, $options); + } + + /** + * Simulate process-marker creation failure. + */ + public function replace(string $path, string $content, ?int $mode = null): void + { + throw $this->failure; + } +} From 3041e507272d0a735d9378a195e65fa838938a35 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:49 +0000 Subject: [PATCH 37/40] Terminate forked test children on every exit path Move child self-termination into finally blocks for the cache concurrency harness and Reverb lock regression.\n\nThis prevents exceptional child setup or payload writes from reaching PHPUnit and Testbench shutdown handlers inherited from the parent process and deleting shared test infrastructure. --- .../Cache/CacheSwooleStoreConcurrencyTest.php | 45 ++++++++++--------- .../SwooleTableSharedStateLockTest.php | 22 +++++---- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/tests/Cache/CacheSwooleStoreConcurrencyTest.php b/tests/Cache/CacheSwooleStoreConcurrencyTest.php index 6a7f23103..438657f72 100644 --- a/tests/Cache/CacheSwooleStoreConcurrencyTest.php +++ b/tests/Cache/CacheSwooleStoreConcurrencyTest.php @@ -246,32 +246,33 @@ private function runConcurrentProcesses( $beforeReady, $i, ): void { - $store = $this->createStore($state); - - $beforeReady?->__invoke($i, $store, $state); - $ready->add(1); + try { + $store = $this->createStore($state); - while ($start->get() === 0) { - usleep(100); - } + $beforeReady?->__invoke($i, $store, $state); + $ready->add(1); - try { - $payload = [ - 'ok' => true, - 'result' => $callback($i, $store, $state), - ]; - } catch (Throwable $exception) { - $payload = [ - 'ok' => false, - 'error' => $exception->getMessage(), - ]; - } + while ($start->get() === 0) { + usleep(100); + } - $this->writeChildPayload($process, $payload); + try { + $payload = [ + 'ok' => true, + 'result' => $callback($i, $store, $state), + ]; + } catch (Throwable $exception) { + $payload = [ + 'ok' => false, + 'error' => $exception->getMessage(), + ]; + } - // The fork inherits PHPUnit/Testbench shutdown handlers from the parent process. - // Exiting normally would let a child delete the parent's disposable runtime app. - posix_kill(getmypid(), SIGKILL); + $this->writeChildPayload($process, $payload); + } finally { + // Never run PHPUnit/Testbench shutdown handlers inherited from the parent. + posix_kill(getmypid(), SIGKILL); + } }, false, SOCK_STREAM); } diff --git a/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php index 20917bf9b..d2b940319 100644 --- a/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php +++ b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php @@ -41,17 +41,21 @@ public function testAbandonedStripeFailsWithinTheAcquisitionDeadline(): void { $state = $this->createState(LockTestSharedState::class); $process = new Process(function (Process $process) use ($state): void { - $state->holdLockFor('key'); - try { - $state->withLockFor('key', fn (): bool => true); - $message = 'lock unexpectedly acquired'; - } catch (RuntimeException $exception) { - $message = $exception->getMessage(); - } + $state->holdLockFor('key'); - $process->write($message); - posix_kill(getmypid(), SIGKILL); + try { + $state->withLockFor('key', fn (): bool => true); + $message = 'lock unexpectedly acquired'; + } catch (RuntimeException $exception) { + $message = $exception->getMessage(); + } + + $process->write($message); + } finally { + // Never run PHPUnit/Testbench shutdown handlers inherited from the parent. + posix_kill(getmypid(), SIGKILL); + } }, false, SOCK_STREAM); $pid = $process->start(); From c61e1d94da6142dcdf5046ce8b9dcbf24c8d4989 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:49 +0000 Subject: [PATCH 38/40] Preserve test cleanup failure ordering Keep the earliest teardown failure primary when aggregate framework state cleanup also fails, while retaining terminal database wrapper cleanup as an independently captured resource operation.\n\nClarify the resolver's process-global test-only lifecycle, use the imported resolver at the subscriber boundary, and restore a swapped global container reliably from test failures. --- .../Testing/DatabaseConnectionResolver.php | 6 ++++ .../src/PHPUnit/AfterEachTestSubscriber.php | 9 +++-- .../DatabaseConnectionResolverTest.php | 16 +++++---- .../PHPUnit/AfterEachTestSubscriberTest.php | 35 +++++++++++++++++++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 2f865b4d4..7ab9c7495 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -64,6 +64,9 @@ class DatabaseConnectionResolver extends ConnectionResolver implements Flushable * connections are flushed since they hold references to the old container's * services. A rebinding hook is registered so Event::fake() automatically * updates cached connections with the new dispatcher. + * + * Tests only. The cached connections are process-global, so runtime use can + * reset or discard a connection borrowed by another coroutine. */ public static function resetCachedConnections(): void { @@ -87,6 +90,9 @@ public static function resetCachedConnections(): void /** * Discard all cached connections and clear resolver lifecycle state. + * + * Tests only. The cached connections are process-global, so runtime use can + * discard a connection borrowed by another coroutine. */ public static function flushCachedConnections(): void { diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 432f30362..c0ce773a4 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -4,6 +4,7 @@ namespace Hypervel\Testing\PHPUnit; +use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Mockery; use PHPUnit\Event\Test\Finished; use PHPUnit\Event\Test\FinishedSubscriber; @@ -46,12 +47,16 @@ public function flushStateAfterTest(): void } try { - \Hypervel\Foundation\Testing\DatabaseConnectionResolver::flushCachedConnections(); + DatabaseConnectionResolver::flushCachedConnections(); } catch (Throwable $throwable) { $exception ??= $throwable; } - $this->flushFrameworkState(); + try { + $this->flushFrameworkState(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } if ($exception !== null) { throw $exception; diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index cdeece7e9..d12487674 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -41,16 +41,18 @@ public function testResetCachedConnectionsDiscardsConnectionsFromAnOldContainer( // Simulate a container change by creating a new container with a different object ID. // resetCachedConnections() detects this via spl_object_id and should disconnect // all cached connections before clearing them. - $newContainer = new Container; - Container::setInstance($newContainer); + $originalContainer = Container::getInstance(); - DatabaseConnectionResolver::resetCachedConnections(); + try { + Container::setInstance(new Container); - // The old connection should have been disconnected - $this->assertNull($connection->getRawPdo()); + DatabaseConnectionResolver::resetCachedConnections(); - // Restore original container - Container::setInstance($this->app); + // The old connection should have been disconnected + $this->assertNull($connection->getRawPdo()); + } finally { + Container::setInstance($originalContainer); + } } public function testCachedWriteConnectionReappliesWriteReadRoutingAfterReset(): void diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index d46948cad..c3b2ca591 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -144,6 +144,41 @@ protected function flushFrameworkState(): void $this->assertTrue($subscriber->frameworkStateFlushed); } + public function testEarlierCleanupFailureRemainsPrimaryWhenFrameworkCleanupAlsoFails(): void + { + $frameworkFailure = new RuntimeException('framework cleanup failed'); + $subscriber = new class($frameworkFailure) extends AfterEachTestSubscriber { + public bool $frameworkStateFlushed = false; + + public function __construct(private RuntimeException $failure) + { + } + + /** + * Flush framework-owned static state. + */ + protected function flushFrameworkState(): void + { + $this->frameworkStateFlushed = true; + + throw $this->failure; + } + }; + $expectedException = new RuntimeException('custom cleanup failed'); + AfterEachTestCleanup::flushUsing('vendor/package', static function () use ($expectedException): never { + throw $expectedException; + }); + + try { + $subscriber->flushStateAfterTest(); + $this->fail('Expected cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($expectedException, $exception); + } + + $this->assertTrue($subscriber->frameworkStateFlushed); + } + public function testDatabaseCleanupFailureDoesNotSkipFrameworkStateReset(): void { $expectedException = new RuntimeException('database cleanup failed'); From 0b3fdcc591c86666d83cda4cf8ea5d3d3355ec5d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:49 +0000 Subject: [PATCH 39/40] Harden process restart lifecycle events Eliminate the watcher pid-file check/read race and tolerate a pidfile disappearing while the server exits.\n\nGuard all optional watcher and Horizon restart events through the typed dispatcher so event construction and dispatch are skipped without listeners, while preserving EventFake and registered-listener behavior. Add focused listener and disappearance regressions. --- src/horizon/src/WorkerProcess.php | 15 ++++++- .../src/Events/BeforeServerRestart.php | 3 ++ src/watcher/src/ServerRestartStrategy.php | 16 ++++--- .../Horizon/Feature/WorkerProcessTest.php | 27 ++++++++++++ tests/Watcher/ServerRestartStrategyTest.php | 42 +++++++++++++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/horizon/src/WorkerProcess.php b/src/horizon/src/WorkerProcess.php index 42023352e..266db82c9 100644 --- a/src/horizon/src/WorkerProcess.php +++ b/src/horizon/src/WorkerProcess.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Closure; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Horizon\Events\UnableToLaunchProcess; use Hypervel\Horizon\Events\WorkerProcessRestarting; use RuntimeException; @@ -120,7 +121,12 @@ public function monitor(): void protected function restart(): void { if ($this->process->isStarted()) { - event(new WorkerProcessRestarting($this)); + /** @var Dispatcher $events */ + $events = app('events'); + + if ($events->hasListeners(WorkerProcessRestarting::class)) { + $events->dispatch(new WorkerProcessRestarting($this)); + } } $this->start($this->output); @@ -183,7 +189,12 @@ protected function cooldown(): void : null; if (! $this->process->isRunning()) { - event(new UnableToLaunchProcess($this)); + /** @var Dispatcher $events */ + $events = app('events'); + + if ($events->hasListeners(UnableToLaunchProcess::class)) { + $events->dispatch(new UnableToLaunchProcess($this)); + } } } else { $this->restartAgainAt = CarbonImmutable::now()->addSecond(); diff --git a/src/watcher/src/Events/BeforeServerRestart.php b/src/watcher/src/Events/BeforeServerRestart.php index 9f69516d5..80dbbd3f5 100644 --- a/src/watcher/src/Events/BeforeServerRestart.php +++ b/src/watcher/src/Events/BeforeServerRestart.php @@ -6,6 +6,9 @@ class BeforeServerRestart { + /** + * Create a new event instance. + */ public function __construct(public readonly int $pid) { } diff --git a/src/watcher/src/ServerRestartStrategy.php b/src/watcher/src/ServerRestartStrategy.php index 5f64b2faa..60cd6fe91 100644 --- a/src/watcher/src/ServerRestartStrategy.php +++ b/src/watcher/src/ServerRestartStrategy.php @@ -5,6 +5,7 @@ namespace Hypervel\Watcher; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; @@ -88,12 +89,12 @@ public function restart(): void */ public function stop(): void { - if (! $this->filesystem->exists($this->pidFile)) { + try { + $pidContents = trim($this->filesystem->get($this->pidFile)); + } catch (FileNotFoundException) { return; } - $pidContents = trim($this->filesystem->get($this->pidFile)); - if ($pidContents === '' || ! ctype_digit($pidContents)) { return; } @@ -106,8 +107,13 @@ public function stop(): void try { $this->output->writeln('Stop server...'); - $this->container->make('events') - ->dispatch(new BeforeServerRestart($pid)); + /** @var Dispatcher $events */ + $events = $this->container->make('events'); + + if ($events->hasListeners(BeforeServerRestart::class)) { + $events->dispatch(new BeforeServerRestart($pid)); + } + if ($this->signalProcess($pid, 0)) { $this->signalProcess($pid, SIGTERM); } diff --git a/tests/Integration/Horizon/Feature/WorkerProcessTest.php b/tests/Integration/Horizon/Feature/WorkerProcessTest.php index 1e2a69220..88c31c9bb 100644 --- a/tests/Integration/Horizon/Feature/WorkerProcessTest.php +++ b/tests/Integration/Horizon/Feature/WorkerProcessTest.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Closure; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Horizon\Events\UnableToLaunchProcess; use Hypervel\Horizon\Events\WorkerProcessRestarting; use Hypervel\Horizon\MasterSupervisor; @@ -17,11 +18,37 @@ use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; use Mockery as m; use ReflectionClass; +use ReflectionMethod; use RuntimeException; use Symfony\Component\Process\Process; class WorkerProcessTest extends IntegrationTestCase { + public function testWorkerProcessSkipsOptionalEventsWhenTheyHaveNoListeners(): void + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(WorkerProcessRestarting::class)->andReturnFalse(); + $events->shouldReceive('hasListeners')->once()->with(UnableToLaunchProcess::class)->andReturnFalse(); + $events->shouldNotReceive('dispatch'); + $this->app->instance('events', $events); + + $restartProcess = m::mock(Process::class); + $restartProcess->shouldReceive('isStarted')->once()->andReturnTrue(); + $restartProcess->shouldReceive('start')->once(); + $restartWorker = new WorkerProcess($restartProcess); + $restartWorker->handleOutputUsing(static function (): void { + }); + (new ReflectionMethod(WorkerProcess::class, 'restart'))->invoke($restartWorker); + + $failedProcess = m::mock(Process::class); + $failedProcess->shouldReceive('isRunning')->twice()->andReturnFalse(); + $failedWorker = new WorkerProcess($failedProcess); + $failedWorker->restartAgainAt = CarbonImmutable::now()->subSecond(); + (new ReflectionMethod(WorkerProcess::class, 'cooldown'))->invoke($failedWorker); + + $this->addToAssertionCount(1); + } + public function testControlSignalSentDuringBootstrapIsDeliveredAfterHandlerInstallation(): void { $script = <<<'PHP' diff --git a/tests/Watcher/ServerRestartStrategyTest.php b/tests/Watcher/ServerRestartStrategyTest.php index afceac13a..9f42961ed 100644 --- a/tests/Watcher/ServerRestartStrategyTest.php +++ b/tests/Watcher/ServerRestartStrategyTest.php @@ -6,6 +6,7 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\TestCase; @@ -168,6 +169,30 @@ function (BeforeServerRestart $event) use (&$events): void { $this->assertSame(123, $events[0]->pid); } + public function testStopSkipsTheRestartEventWhenItHasNoListeners(): void + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(BeforeServerRestart::class)->andReturnFalse(); + $events->shouldNotReceive('dispatch'); + $this->app->instance('events', $events); + $strategy = $this->createProbeStrategy(); + $strategy->useFilesystem(new PidFileFilesystem(true, '123')); + + $strategy->stop(); + + $this->assertSame([[123, 0], [123, SIGTERM]], $strategy->signals); + } + + public function testStopToleratesThePidFileDisappearingBeforeItIsRead(): void + { + $strategy = $this->createProbeStrategy(); + $strategy->useFilesystem(new DisappearingPidFileFilesystem); + + $strategy->stop(); + + $this->assertSame([], $strategy->signals); + } + public function testStopIsIdempotentWhenThePidFileDoesNotExist(): void { $strategy = $this->createProbeStrategy(); @@ -311,6 +336,23 @@ public function exists(string $path): bool public function get(string $path, bool $lock = false): string { + if (! $this->pidFileExists) { + throw new FileNotFoundException("File does not exist at path {$path}."); + } + return $this->contents; } } + +class DisappearingPidFileFilesystem extends Filesystem +{ + public function exists(string $path): bool + { + return true; + } + + public function get(string $path, bool $lock = false): string + { + throw new FileNotFoundException("File does not exist at path {$path}."); + } +} From 88b864e8c765661dfdafcae20f3d251293c381a6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:18:49 +0000 Subject: [PATCH 40/40] Tighten lifecycle regression tests Give the timer callback regression enough scheduling margin under parallel CI without weakening its no-reschedule assertion.\n\nComplete the required void return types on the modified coroutine-isolation and queue-worker monitor tests. --- tests/Coordinator/TimerTest.php | 2 +- .../Exceptions/Renderer/ListenerContextIsolationTest.php | 2 +- tests/Queue/QueueWorkerTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Coordinator/TimerTest.php b/tests/Coordinator/TimerTest.php index 7160a7a98..ef34b7ebd 100644 --- a/tests/Coordinator/TimerTest.php +++ b/tests/Coordinator/TimerTest.php @@ -140,7 +140,7 @@ public function testTickClearedFromItsCallbackDoesNotWaitAnotherInterval(): void $called->push(true); }, uniqid()); - $this->assertTrue($called->pop(0.2)); + $this->assertTrue($called->pop(1.0)); usleep(10_000); $this->assertSame(['num' => 0, 'round' => 0], Timer::stats()); } diff --git a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php index 9464efda2..3098fa542 100644 --- a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php +++ b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php @@ -31,7 +31,7 @@ public function testQueryCapStopsAtMaxQueries() $this->assertCount(101, $listener->queries()); } - public function testQueriesAreIsolatedBetweenCoroutines() + public function testQueriesAreIsolatedBetweenCoroutines(): void { $results = parallel([ 'a' => fn (): array => $this->recordQueries('conn-a', ['SELECT a1', 'SELECT a2']), diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 035438dce..49c7c8076 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -131,7 +131,7 @@ public function testJobPopEventsAreSkippedWhenNoListenersAreRegistered() $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessed::class))->once(); } - public function testWorkerCanMonitorTimeoutJobs() + public function testWorkerCanMonitorTimeoutJobs(): void { $workerOptions = new WorkerOptions; $workerOptions->stopWhenEmpty = true;