From ac588f8bf8ed0c9d4c3fb9a2a6dfe989250b7958 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:51:13 +0000 Subject: [PATCH 01/30] fix(process): preserve supported fake values Normalize constructor-seeded fake sequences through the same conversion boundary used by pushed values so supported strings and output arrays always resolve to valid process results. Treat only truly empty fake output as empty, preserving the supported string value zero, and narrow the public sequence types to the values the factory accepts. Document fake controls as test-only worker state so long-lived factory mutations have an explicit lifecycle boundary. --- src/process/src/Factory.php | 8 +++++++- src/process/src/FakeProcessResult.php | 2 +- src/process/src/FakeProcessSequence.php | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/process/src/Factory.php b/src/process/src/Factory.php index 890f00914..33dce94e8 100644 --- a/src/process/src/Factory.php +++ b/src/process/src/Factory.php @@ -66,7 +66,7 @@ public function describe(): FakeProcessDescription /** * Begin describing a fake process sequence. * - * @param array $processes + * @param array $processes */ public function sequence(array $processes = []): FakeProcessSequence { @@ -75,6 +75,8 @@ public function sequence(array $processes = []): FakeProcessSequence /** * Indicate that the process factory should fake processes. + * + * Tests only. Fake handlers and recordings persist on the container-resolved factory for the worker lifetime and affect later calls. */ public function fake(array|Closure|null $callback = null): static { @@ -123,6 +125,8 @@ public function recordIfRecording(PendingProcess $process, ProcessResultContract /** * Record the given process. + * + * Tests only. Recorded processes are retained on the worker factory and affect later assertions and memory retention. */ public function record(PendingProcess $process, ProcessResultContract $result): static { @@ -133,6 +137,8 @@ public function record(PendingProcess $process, ProcessResultContract $result): /** * Indicate that an exception should be thrown if any process is not faked. + * + * Tests only. This setting persists on the worker factory and can reject every later unmatched process. */ public function preventStrayProcesses(bool $prevent = true): static { diff --git a/src/process/src/FakeProcessResult.php b/src/process/src/FakeProcessResult.php index 0bdb0e6fd..1234f823f 100644 --- a/src/process/src/FakeProcessResult.php +++ b/src/process/src/FakeProcessResult.php @@ -43,7 +43,7 @@ public function __construct( */ protected function normalizeOutput(array|string $output): string { - if (empty($output)) { + if ($output === '' || $output === []) { return ''; } if (is_string($output)) { diff --git a/src/process/src/FakeProcessSequence.php b/src/process/src/FakeProcessSequence.php index 6df657e47..197668616 100644 --- a/src/process/src/FakeProcessSequence.php +++ b/src/process/src/FakeProcessSequence.php @@ -25,10 +25,13 @@ class FakeProcessSequence /** * Create a new fake process sequence instance. * - * @param array $processes + * @param array $processes */ public function __construct(protected array $processes = []) { + foreach ($this->processes as $key => $process) { + $this->processes[$key] = $this->toProcessResult($process); + } } /** From 50bc819896e47278cfac1ab5a49d5d9a930c5513 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:51:21 +0000 Subject: [PATCH 02/30] fix(process): reject empty process pipelines Fail at the Process pipeline boundary when its builder produces no commands instead of allowing collection reduction to violate the declared process-result contract. Keep empty process pools valid; only pipelines require a final process whose result can be returned. --- src/process/src/Pipe.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/process/src/Pipe.php b/src/process/src/Pipe.php index 6199838f3..270fe8c16 100644 --- a/src/process/src/Pipe.php +++ b/src/process/src/Pipe.php @@ -48,6 +48,10 @@ public function run(?callable $output = null): ProcessResultContract { call_user_func($this->callback, $this); + if ($this->pendingProcesses === []) { + throw new InvalidArgumentException('Process pipe must contain at least one pending process.'); + } + return (new Collection($this->pendingProcesses)) ->reduce(function ($previousProcessResult, $pendingProcess, $key) use ($output) { if (! $pendingProcess instanceof PendingProcess) { // @phpstan-ignore instanceof.alwaysTrue (defensive validation) From 35acd695c476ac62bf78a1deabd9e760700a2ca6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:51:34 +0000 Subject: [PATCH 03/30] fix(process): make invoked lifecycles failure-safe Guard synchronous and asynchronous output callbacks at the owning process boundary. Stop the exact child when user output handling fails, contain teardown failure, and preserve the original exception without adding callback-free allocations. Make pool startup transactional and pool waiting and stopping exhaustive. Retain exact child handles, preserve result keys, clean every owned sibling after failure, and keep the earliest failure primary. Complete timeout and nullable-wait behavior across the invoked-process contract and fake, align fake PID, output, callable, readiness, and terminal-state semantics with real processes, and preserve supported falsy command and input values. Restore the current Laravel Macroable surface on real invoked processes and register authoritative test cleanup for its worker-static registry. Publish precise environment and fake-sequence facade types and document asynchronous ownership boundaries. --- src/contracts/src/Process/InvokedProcess.php | 5 ++ .../src/Concerns/GuardsProcessOutput.php | 47 +++++++++++ src/process/src/FakeInvokedProcess.php | 82 +++++++++++++------ src/process/src/InvokedProcess.php | 21 ++++- src/process/src/InvokedProcessPool.php | 57 +++++++++++-- src/process/src/PendingProcess.php | 19 +++-- src/process/src/Pool.php | 43 +++++++--- src/support/src/Facades/Process.php | 6 +- .../src/PHPUnit/AfterEachTestSubscriber.php | 1 + 9 files changed, 229 insertions(+), 52 deletions(-) create mode 100644 src/process/src/Concerns/GuardsProcessOutput.php diff --git a/src/contracts/src/Process/InvokedProcess.php b/src/contracts/src/Process/InvokedProcess.php index 72217c5b4..4a77ca50d 100644 --- a/src/contracts/src/Process/InvokedProcess.php +++ b/src/contracts/src/Process/InvokedProcess.php @@ -51,6 +51,11 @@ public function latestOutput(): string; */ public function latestErrorOutput(): string; + /** + * Ensure that the process has not timed out. + */ + public function ensureNotTimedOut(): void; + /** * Wait for the process to finish. */ diff --git a/src/process/src/Concerns/GuardsProcessOutput.php b/src/process/src/Concerns/GuardsProcessOutput.php new file mode 100644 index 000000000..80342496b --- /dev/null +++ b/src/process/src/Concerns/GuardsProcessOutput.php @@ -0,0 +1,47 @@ +stop(0); + } catch (Throwable) { + // Preserve the output callback failure. + } + + throw $exception; + } + }; + } +} diff --git a/src/process/src/FakeInvokedProcess.php b/src/process/src/FakeInvokedProcess.php index 5d6e8ad68..8f2549326 100644 --- a/src/process/src/FakeInvokedProcess.php +++ b/src/process/src/FakeInvokedProcess.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; +use Throwable; class FakeInvokedProcess implements InvokedProcessContract { @@ -27,6 +28,11 @@ class FakeInvokedProcess implements InvokedProcessContract */ protected ?Closure $outputHandler = null; + /** + * Indicates that the output handler has failed. + */ + protected bool $outputHandlerFailed = false; + /** * The current output's index. */ @@ -53,7 +59,9 @@ public function id(): ?int { $this->invokeOutputHandlerWithNextLineOfOutput(); - return $this->process->processId; + $this->remainingRunIterations ??= $this->process->runIterations; + + return $this->remainingRunIterations === 0 ? null : $this->process->processId; } /** @@ -91,7 +99,7 @@ public function stop(float $timeout = 10, ?int $signal = null): ?int */ public function hasReceivedSignal(int $signal): bool { - return in_array($signal, $this->receivedSignals); + return in_array($signal, $this->receivedSignals, true); } /** @@ -123,7 +131,9 @@ public function running(): bool */ protected function invokeOutputHandlerWithNextLineOfOutput(): array|false { - if (! $this->outputHandler) { + $outputHandler = $this->outputHandler; + + if ($outputHandler === null || $this->outputHandlerFailed) { return false; } @@ -136,13 +146,13 @@ protected function invokeOutputHandlerWithNextLineOfOutput(): array|false $currentOutput = $this->process->output[$i]; if ($currentOutput['type'] === 'out' && $i >= $this->nextOutputIndex) { - call_user_func($this->outputHandler, 'out', $currentOutput['buffer']); + $this->callOutputHandler($outputHandler, 'out', $currentOutput['buffer']); $this->nextOutputIndex = $i + 1; return $currentOutput; } if ($currentOutput['type'] === 'err' && $i >= $this->nextErrorOutputIndex) { - call_user_func($this->outputHandler, 'err', $currentOutput['buffer']); + $this->callOutputHandler($outputHandler, 'err', $currentOutput['buffer']); $this->nextErrorOutputIndex = $i + 1; return $currentOutput; @@ -152,6 +162,22 @@ protected function invokeOutputHandlerWithNextLineOfOutput(): array|false return false; } + /** + * Invoke the output handler for the given output. + */ + protected function callOutputHandler(Closure $outputHandler, string $type, string $buffer): void + { + try { + $outputHandler($type, $buffer); + } catch (Throwable $exception) { + // Match the real process's terminal stop and suppress delivery after callback failure. + $this->outputHandlerFailed = true; + $this->remainingRunIterations = 0; + + throw $exception; + } + } + /** * Get the standard output for the process. */ @@ -167,7 +193,7 @@ public function output(): string } } - return rtrim(implode('', $output), "\n") . "\n"; + return $output === [] ? '' : rtrim(implode('', $output), "\n") . "\n"; } /** @@ -185,7 +211,7 @@ public function errorOutput(): string } } - return rtrim(implode('', $output), "\n") . "\n"; + return $output === [] ? '' : rtrim(implode('', $output), "\n") . "\n"; } /** @@ -230,12 +256,21 @@ public function latestErrorOutput(): string return $output ?? ''; } + /** + * Ensure that the process has not timed out. + */ + public function ensureNotTimedOut(): void + { + } + /** * Wait for the process to finish. */ public function wait(?callable $output = null): ProcessResultContract { - $this->outputHandler = $output ?: $this->outputHandler; + if ($output !== null) { + $this->outputHandler = Closure::fromCallable($output); + } if (! $this->outputHandler) { $this->remainingRunIterations = 0; @@ -255,25 +290,24 @@ public function wait(?callable $output = null): ProcessResultContract */ public function waitUntil(?callable $output = null): ProcessResultContract { + if ($output === null) { + return $this->wait(); + } + $shouldStop = false; + $outputHandler = $this->outputHandler; - $this->outputHandler = $output - ? function ($type, $buffer) use ($output, &$shouldStop) { - $shouldStop = call_user_func($output, $type, $buffer); - } - : $this->outputHandler; + $this->outputHandler = function ($type, $buffer) use ($output, &$shouldStop) { + return $shouldStop = (bool) call_user_func($output, $type, $buffer); + }; - if (! $this->outputHandler) { - $this->remainingRunIterations = 0; + try { + while ($this->running() && ! $shouldStop); - return $this->predictProcessResult(); + return $this->process->toProcessResult($this->command); + } finally { + $this->outputHandler = $outputHandler; } - - while ($this->running() && ! $shouldStop); - - $this->remainingRunIterations = 0; - - return $this->process->toProcessResult($this->command); } /** @@ -289,7 +323,9 @@ public function predictProcessResult(): ProcessResultContract */ public function withOutputHandler(?callable $outputHandler): static { - $this->outputHandler = $outputHandler; + $this->outputHandler = $outputHandler === null + ? null + : Closure::fromCallable($outputHandler); return $this; } diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php index 23701bad0..676e98aab 100644 --- a/src/process/src/InvokedProcess.php +++ b/src/process/src/InvokedProcess.php @@ -6,12 +6,17 @@ use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; +use Hypervel\Process\Concerns\GuardsProcessOutput; use Hypervel\Process\Exceptions\ProcessTimedOutException; +use Hypervel\Support\Traits\Macroable; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; use Symfony\Component\Process\Process; class InvokedProcess implements InvokedProcessContract { + use GuardsProcessOutput; + use Macroable; + /** * Create a new invoked process instance. * @@ -117,7 +122,7 @@ public function ensureNotTimedOut(): void public function wait(?callable $output = null): ProcessResultContract { try { - $this->process->wait($output); + $this->process->wait($this->guardProcessOutput($this->process, $output)); return new ProcessResult($this->process); } catch (SymfonyTimeoutException $e) { @@ -132,12 +137,24 @@ public function wait(?callable $output = null): ProcessResultContract */ public function waitUntil(?callable $output = null): ProcessResultContract { + if ($output === null) { + return $this->wait(); + } + try { - $this->process->waitUntil($output); + $this->process->waitUntil($this->guardProcessOutput($this->process, $output)); return new ProcessResult($this->process); } catch (SymfonyTimeoutException $e) { throw new ProcessTimedOutException($e, new ProcessResult($this->process)); } } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::flushMacros(); + } } diff --git a/src/process/src/InvokedProcessPool.php b/src/process/src/InvokedProcessPool.php index 8f4f6bbf4..a1e1625cd 100644 --- a/src/process/src/InvokedProcessPool.php +++ b/src/process/src/InvokedProcessPool.php @@ -7,6 +7,7 @@ use Countable; use Hypervel\Contracts\Process\InvokedProcess; use Hypervel\Support\Collection; +use Throwable; class InvokedProcessPool implements Countable { @@ -32,7 +33,39 @@ public function signal(int $signal): Collection */ public function stop(float $timeout = 10, ?int $signal = null): Collection { - return $this->running()->each->stop($timeout, $signal); + $running = []; + $exception = null; + + foreach ($this->invokedProcesses as $process) { + // An inspection failure leaves ownership uncertain, so still attempt terminal cleanup. + $shouldStop = true; + + try { + $shouldStop = $process->running(); + + if ($shouldStop) { + $running[] = $process; + } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if (! $shouldStop) { + continue; + } + + try { + $process->stop($timeout, $signal); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + + return new Collection($running); } /** @@ -49,10 +82,24 @@ public function running(): Collection */ public function wait(): ProcessPoolResults { - return new ProcessPoolResults( - /* @phpstan-ignore-next-line */ - (new Collection($this->invokedProcesses))->map->wait()->all() - ); + $results = []; + + try { + foreach ($this->invokedProcesses as $key => $process) { + $results[$key] = $process->wait(); + } + } catch (Throwable $exception) { + try { + // Stopping bounds cleanup when a sibling depends on external progress that may never arrive. + $this->stop(0); + } catch (Throwable) { + // Preserve the wait failure. + } + + throw $exception; + } + + return new ProcessPoolResults($results); } /** diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index 07351a0b7..8bdf30f40 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -8,12 +8,14 @@ use Closure; use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; +use Hypervel\Process\Concerns\GuardsProcessOutput; use Hypervel\Process\Exceptions\ProcessTimedOutException; use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Support\Traits\Conditionable; use LogicException; use RuntimeException; +use Stringable; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; use Symfony\Component\Process\Process; use Throwable; @@ -22,6 +24,7 @@ class PendingProcess { use Conditionable; + use GuardsProcessOutput; /** * The process factory instance. @@ -53,7 +56,7 @@ class PendingProcess /** * The additional environment variables for the process. * - * @var array + * @var array */ public array $environment = []; @@ -149,7 +152,7 @@ public function forever(): static /** * Set the additional environment variables for the process. * - * @param array $environment + * @param array $environment */ public function env(array $environment): static { @@ -210,7 +213,7 @@ public function options(array $options): static */ public function run(array|string|null $command = null, ?callable $output = null): ProcessResultContract { - $this->command = $command ?: $this->command; + $this->command = $command ?? $this->command; $process = $this->toSymfonyProcess($command); try { @@ -222,7 +225,7 @@ public function run(array|string|null $command = null, ?callable $output = null) throw new RuntimeException('Attempted process [' . $command . '] without a matching fake.'); } - return new ProcessResult(tap($process)->run($output)); // @phpstan-ignore method.notFound (tap proxy __call) + return new ProcessResult(tap($process)->run($this->guardProcessOutput($process, $output))); // @phpstan-ignore method.notFound (tap proxy __call) } catch (SymfonyTimeoutException $e) { throw new ProcessTimedOutException($e, new ProcessResult($process)); } @@ -231,11 +234,13 @@ public function run(array|string|null $command = null, ?callable $output = null) /** * Start the process in the background. * + * The caller must wait for or stop the process before its owning coroutine exits. + * * @throws RuntimeException */ public function start(array|string|null $command = null, ?callable $output = null): InvokedProcessContract { - $this->command = $command ?: $this->command; + $this->command = $command ?? $this->command; $process = $this->toSymfonyProcess($command); @@ -248,7 +253,7 @@ public function start(array|string|null $command = null, ?callable $output = nul throw new RuntimeException('Attempted process [' . $command . '] without a matching fake.'); } - return new InvokedProcess(tap($process)->start($output)); // @phpstan-ignore method.notFound (tap proxy __call) + return new InvokedProcess(tap($process)->start($this->guardProcessOutput($process, $output))); // @phpstan-ignore method.notFound (tap proxy __call) } /** @@ -269,7 +274,7 @@ protected function toSymfonyProcess(array|string|null $command): Process $process->setIdleTimeout($this->idleTimeout); } - if ($this->input) { + if ($this->input !== null) { $process->setInput($this->input); } diff --git a/src/process/src/Pool.php b/src/process/src/Pool.php index 8f9f9f23a..7a7e21c87 100644 --- a/src/process/src/Pool.php +++ b/src/process/src/Pool.php @@ -5,8 +5,8 @@ namespace Hypervel\Process; use Closure; -use Hypervel\Support\Collection; use InvalidArgumentException; +use Throwable; /** * @mixin \Hypervel\Process\Factory @@ -45,24 +45,41 @@ public function as(string $key): PendingProcess /** * Start all of the processes in the pool. + * + * The caller must wait for or stop the pool before its owning coroutine exits. */ public function start(?callable $output = null): InvokedProcessPool { call_user_func($this->callback, $this); - return new InvokedProcessPool( - (new Collection($this->pendingProcesses)) - ->each(function ($pendingProcess) { - if (! $pendingProcess instanceof PendingProcess) { // @phpstan-ignore instanceof.alwaysTrue (defensive validation) - throw new InvalidArgumentException('Process pool must only contain pending processes.'); - } - })->mapWithKeys(function ($pendingProcess, $key) use ($output) { - return [$key => $pendingProcess->start(output: $output ? function ($type, $buffer) use ($key, $output) { + foreach ($this->pendingProcesses as $pendingProcess) { + if (! $pendingProcess instanceof PendingProcess) { // @phpstan-ignore instanceof.alwaysTrue (defensive validation) + throw new InvalidArgumentException('Process pool must only contain pending processes.'); + } + } + + $invokedProcesses = []; + + try { + foreach ($this->pendingProcesses as $key => $pendingProcess) { + $invokedProcesses[$key] = $pendingProcess->start( + output: $output ? function ($type, $buffer) use ($key, $output) { $output($type, $buffer, $key); - } : null)]; - }) - ->all() - ); + } : null + ); + } + } catch (Throwable $exception) { + try { + // Keep partial construction transactional instead of abandoning already-started children. + (new InvokedProcessPool($invokedProcesses))->stop(0); + } catch (Throwable) { + // Preserve the process creation failure. + } + + throw $exception; + } + + return new InvokedProcessPool($invokedProcesses); } /** diff --git a/src/support/src/Facades/Process.php b/src/support/src/Facades/Process.php index 273af1046..3ea1bfd43 100644 --- a/src/support/src/Facades/Process.php +++ b/src/support/src/Facades/Process.php @@ -13,7 +13,7 @@ * @method static \Hypervel\Process\PendingProcess timeout(\Carbon\CarbonInterval|int $timeout) * @method static \Hypervel\Process\PendingProcess idleTimeout(\Carbon\CarbonInterval|int $timeout) * @method static \Hypervel\Process\PendingProcess forever() - * @method static \Hypervel\Process\PendingProcess env(array $environment) + * @method static \Hypervel\Process\PendingProcess env(array $environment) * @method static \Hypervel\Process\PendingProcess input(null|bool|float|int|resource|string|\Traversable $input) * @method static \Hypervel\Process\PendingProcess quietly() * @method static \Hypervel\Process\PendingProcess tty(bool $tty = true) @@ -26,7 +26,7 @@ * @method static \Hypervel\Process\PendingProcess|mixed unless(null|\Closure|mixed $value = null, null|callable $callback = null, null|callable $default = null) * @method static \Hypervel\Process\FakeProcessResult result(array|string $output = '', array|string $errorOutput = '', int $exitCode = 0) * @method static \Hypervel\Process\FakeProcessDescription describe() - * @method static \Hypervel\Process\FakeProcessSequence sequence(array $processes = []) + * @method static \Hypervel\Process\FakeProcessSequence sequence(array $processes = []) * @method static bool isRecording() * @method static \Hypervel\Process\Factory recordIfRecording(\Hypervel\Process\PendingProcess $process, \Hypervel\Contracts\Process\ProcessResult $result) * @method static \Hypervel\Process\Factory record(\Hypervel\Process\PendingProcess $process, \Hypervel\Contracts\Process\ProcessResult $result) @@ -62,6 +62,8 @@ protected static function getFacadeAccessor(): string /** * Indicate that the process factory should fake processes. + * + * Tests only. Fake handlers and recordings persist on the container-resolved factory for the worker lifetime and affect later calls. */ public static function fake(array|Closure|null $callback = null): Factory { diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 214e9dbca..7d438f399 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -235,6 +235,7 @@ protected function flushFrameworkState(): void \Hypervel\Pagination\AbstractPaginator::flushState(); \Hypervel\Pipeline\Pipeline::flushState(); \Hypervel\Process\Factory::flushState(); + \Hypervel\Process\InvokedProcess::flushState(); \Hypervel\Prompts\Prompt::flushState(); \Hypervel\Prompts\Terminal::flushState(); \Hypervel\Queue\Capsule\Manager::flushState(); From ea1d45dbde9c01d8ebb804329591e9a009fdcbd4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:51:43 +0000 Subject: [PATCH 04/30] test(process): cover lifecycle ownership and fake fidelity Add deterministic real-process coverage for callback failures, exact-PID teardown, stop-time callback re-entry, nullable waits, timeout checks, and contract-level stopping. Exercise transactional pool startup, exhaustive sibling cleanup, first-failure preservation, keyed and empty results, and descriptive empty-pipeline failures. Cover fake readiness, handler restoration, callable normalization, terminal PID and output behavior, falsy values, seeded sequences, invoked-process macros, and authoritative static-state cleanup. --- tests/Process/ProcessTest.php | 459 +++++++++++++++++- .../PHPUnit/AfterEachTestSubscriberTest.php | 2 + 2 files changed, 459 insertions(+), 2 deletions(-) diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index e6fd25c5e..ce1778792 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -10,10 +10,18 @@ use Hypervel\Process\Exceptions\ProcessFailedException; use Hypervel\Process\Exceptions\ProcessTimedOutException; use Hypervel\Process\Factory; +use Hypervel\Process\FakeInvokedProcess; +use Hypervel\Process\FakeProcessDescription; +use Hypervel\Process\InvokedProcess; +use Hypervel\Process\InvokedProcessPool; +use Hypervel\Process\PendingProcess; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use OutOfBoundsException; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use RuntimeException; +use Symfony\Component\Process\Process as SymfonyProcess; +use Throwable; class ProcessTest extends TestCase { @@ -55,6 +63,15 @@ public function testProcessPool() $this->assertTrue($results->successful()); } + public function testEmptyProcessPoolReturnsEmptyResults(): void + { + $pool = (new Factory)->pool(static function (): void { + })->start(); + + $this->assertCount(0, $pool); + $this->assertCount(0, $pool->wait()->collect()); + } + public function testProcessPoolFailed() { $factory = new Factory; @@ -96,6 +113,171 @@ public function testInvokedProcessPoolCount() $pool->wait(); } + public function testProcessPoolRollsBackProcessesStartedBeforeCreationFailure(): void + { + $factory = new class extends Factory { + /** @var list */ + public array $pendingProcesses = []; + + public function newPendingProcess(): PendingProcess + { + return array_shift($this->pendingProcesses); + } + }; + $startedProcess = new FakeInvokedProcess( + 'first', + (new FakeProcessDescription)->runsFor(iterations: 10) + ); + $creationFailure = new RuntimeException('Unable to start the second process.'); + $factory->pendingProcesses = [ + new class($factory, $startedProcess) extends PendingProcess { + public function __construct(Factory $factory, protected InvokedProcessContract $invokedProcess) + { + parent::__construct($factory); + } + + public function start(array|string|null $command = null, ?callable $output = null): InvokedProcessContract + { + return $this->invokedProcess; + } + }, + new class($factory, $creationFailure) extends PendingProcess { + public function __construct(Factory $factory, protected RuntimeException $failure) + { + parent::__construct($factory); + } + + public function start(array|string|null $command = null, ?callable $output = null): InvokedProcessContract + { + throw $this->failure; + } + }, + ]; + + try { + $factory->pool(function ($pool): void { + $pool->command('first'); + $pool->command('second'); + })->start(); + + $this->fail('The pool creation failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($creationFailure, $exception); + } + + $this->assertFalse($startedProcess->running()); + } + + public function testProcessPoolWaitStopsEverySiblingAndPreservesTheWaitFailure(): void + { + $waitFailure = new RuntimeException('Unable to wait for the first process.'); + $cleanupFailure = new RuntimeException('Unable to stop the first process.'); + $failingProcess = new class($waitFailure, $cleanupFailure) extends FakeInvokedProcess { + public bool $stopAttempted = false; + + public function __construct( + protected RuntimeException $waitFailure, + protected RuntimeException $cleanupFailure + ) { + parent::__construct('first', (new FakeProcessDescription)->runsFor(iterations: 10)); + } + + public function wait(?callable $output = null): ProcessResult + { + throw $this->waitFailure; + } + + public function stop(float $timeout = 10, ?int $signal = null): ?int + { + $this->stopAttempted = true; + parent::stop($timeout, $signal); + + throw $this->cleanupFailure; + } + }; + $siblingProcess = new class extends FakeInvokedProcess { + public bool $stopAttempted = false; + + public function __construct() + { + parent::__construct('second', (new FakeProcessDescription)->runsFor(iterations: 10)); + } + + public function stop(float $timeout = 10, ?int $signal = null): ?int + { + $this->stopAttempted = true; + + return parent::stop($timeout, $signal); + } + }; + $pool = new InvokedProcessPool([$failingProcess, $siblingProcess]); + + try { + $pool->wait(); + + $this->fail('The pool wait failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($waitFailure, $exception); + } + + $this->assertTrue($failingProcess->stopAttempted); + $this->assertTrue($siblingProcess->stopAttempted); + $this->assertFalse($siblingProcess->running()); + } + + public function testProcessPoolStopContinuesAfterARunningCheckFailure(): void + { + $runningFailure = new RuntimeException('Unable to inspect the first process.'); + $failingProcess = new class($runningFailure) extends FakeInvokedProcess { + public bool $stopAttempted = false; + + public function __construct(protected RuntimeException $failure) + { + parent::__construct('first', (new FakeProcessDescription)->runsFor(iterations: 10)); + } + + public function running(): bool + { + throw $this->failure; + } + + public function stop(float $timeout = 10, ?int $signal = null): ?int + { + $this->stopAttempted = true; + + return parent::stop($timeout, $signal); + } + }; + $siblingProcess = new class extends FakeInvokedProcess { + public bool $stopAttempted = false; + + public function __construct() + { + parent::__construct('second', (new FakeProcessDescription)->runsFor(iterations: 10)); + } + + public function stop(float $timeout = 10, ?int $signal = null): ?int + { + $this->stopAttempted = true; + + return parent::stop($timeout, $signal); + } + }; + $pool = new InvokedProcessPool([$failingProcess, $siblingProcess]); + + try { + $pool->stop(0); + + $this->fail('The running check failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($runningFailure, $exception); + } + + $this->assertTrue($failingProcess->stopAttempted); + $this->assertTrue($siblingProcess->stopAttempted); + $this->assertFalse($siblingProcess->running()); + } + public function testProcessPoolCanReceiveOutputForEachProcessViaStartMethod() { $factory = new Factory; @@ -169,15 +351,104 @@ public function testOutputCanBeRetrievedViaWaitCallback() $this->assertTrue(str_contains(implode('', $output), 'ProcessTest.php')); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testSynchronousOutputCallbackFailureStopsTheExactProcess(): void + { + $factory = new Factory; + $failure = new RuntimeException('Output callback failed.'); + $processId = null; + $callbackInvocations = 0; + $command = <<<'SH' + printf '%s\n' "$$"; trap 'printf "stopping\n"; exit 0' TERM; while :; do sleep 1; done + SH; + + try { + $factory->forever()->run($command, function (string $type, string $buffer) use (&$processId, &$callbackInvocations, $failure): void { + ++$callbackInvocations; + + if (preg_match('/\d+/', $buffer, $matches) === 1) { + $processId = (int) $matches[0]; + } + + throw $failure; + }); + + $this->fail('The output callback failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + $this->assertNotNull($processId); + $this->assertFalse($this->processIsRunning($processId)); + } finally { + $this->reapProcess($processId); + } + + $this->assertSame(1, $callbackInvocations); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testInvokedProcessWaitCallbackFailureStopsTheExactProcess(): void + { + $factory = new Factory; + $failure = new RuntimeException('Wait callback failed.'); + $process = $factory->forever()->start("printf 'ready\\n'; sleep 60"); + $processId = $process->id(); + + try { + $process->wait(static function () use ($failure): void { + throw $failure; + }); + + $this->fail('The wait callback failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + $this->assertNotNull($processId); + $this->assertFalse($this->processIsRunning($processId)); + $this->assertFalse($process->running()); + } finally { + $this->reapProcess($processId, $process); + } + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testRealInvokedProcessWaitUntilWithoutCallbackWaitsForCompletion(): void + { + $process = (new Factory)->start("printf 'done\\n'"); + + $result = $process->waitUntil(); + + $this->assertTrue($result->successful()); + $this->assertSame("done\n", $result->output()); + $this->assertFalse($process->running()); + } + + public function testInvokedProcessSupportsMacros(): void + { + InvokedProcess::macro('summary', function (): string { + return 'Running: ' . $this->command(); + }); + $process = new InvokedProcess(SymfonyProcess::fromShellCommandline('echo hello')); + + $this->assertSame('Running: echo hello', $process->summary()); + } + #[RequiresOperatingSystem('Linux|Darwin')] public function testRealInvokedProcessCanBeStoppedThroughContract(): void { $factory = new Factory; $process = $factory->start('sleep 60'); + $processId = $process->id(); - $this->stopProcess($process); + try { + $process->ensureNotTimedOut(); + $this->assertNotNull($processId); - $this->assertFalse($process->running()); + $this->stopProcess($process); + + $this->assertFalse($process->running()); + $this->assertNull($process->id()); + } finally { + $this->reapProcess($processId, $process); + } } public function testFakeInvokedProcessCanBeStoppedThroughContract(): void @@ -188,11 +459,14 @@ public function testFakeInvokedProcessCanBeStoppedThroughContract(): void ]); $process = $factory->start('sleep 60'); + $process->ensureNotTimedOut(); $this->assertTrue($process->running()); + $this->assertNotNull($process->id()); $this->stopProcess($process); $this->assertFalse($process->running()); + $this->assertNull($process->id()); } public function testBasicProcessFake() @@ -329,6 +603,22 @@ public function testBasicProcessFakeWithCustomOutput() $this->assertEquals("line 1\n\nline 2\n", $result->output()); } + public function testProcessFakePreservesZeroCommandAndOutput(): void + { + $factory = new Factory; + $factory->fake(fn () => '0'); + + $result = $factory->run('0'); + + $this->assertSame("0\n", $result->output()); + $factory->assertRan(fn ($process): bool => $process->command === '0'); + + $process = $factory->start('0'); + + $this->assertSame('0', $process->command()); + $this->assertSame("0\n", $process->wait()->output()); + } + public function testProcessFakeWithErrorOutput() { $factory = new Factory; @@ -392,6 +682,17 @@ public function testProcessFakeSequences() $this->assertEquals("cat command\n", $result->output()); } + public function testProcessFakeSequenceNormalizesSeededResults(): void + { + $factory = new Factory; + $factory->fake([ + 'echo *' => $factory->sequence(['first', ['second', 'third']]), + ]); + + $this->assertSame("first\n", $factory->run('echo value')->output()); + $this->assertSame("second\nthird\n", $factory->run('echo value')->output()); + } + public function testProcessFakeSequencesCanReturnEmptyResultsWhenSequenceIsEmpty() { $factory = new Factory; @@ -733,6 +1034,14 @@ public function testRealProcessesCanUseStandardInput() $this->assertSame('foobar', $result->output()); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testRealProcessesPreserveZeroStandardInput(): void + { + $result = (new Factory)->input('0')->run('cat'); + + $this->assertSame('0', $result->output()); + } + #[RequiresOperatingSystem('Linux|Darwin')] public function testProcessPipe() { @@ -797,6 +1106,23 @@ public function testProcessSimplePipeFailed() $this->assertTrue($pipe->failed()); } + public function testEmptyCallbackPipeFailsDescriptively(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Process pipe must contain at least one pending process.'); + + (new Factory)->pipe(static function (): void { + }); + } + + public function testEmptyArrayPipeFailsDescriptively(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Process pipe must contain at least one pending process.'); + + (new Factory)->pipe([]); + } + public function testFakeInvokedProcessOutputWithLatestOutput() { $factory = new Factory; @@ -857,6 +1183,116 @@ public function testFakeInvokedProcessWaitUntil() $this->assertContains("READY\n", $callbackInvoked); } + public function testFakeInvokedProcessWaitUntilLeavesProcessRunningAndRestoresOutputHandler(): void + { + $factory = new Factory; + $factory->fake(fn () => $factory->describe() + ->output('WAITING') + ->output('READY') + ->output('DONE') + ->runsFor(iterations: 5)); + $generalOutput = []; + $process = $factory->start('long-running-command', function (string $type, string $buffer) use (&$generalOutput): void { + $generalOutput[] = $buffer; + }); + + $process->waitUntil(static fn (string $type, string $buffer): bool => str_contains($buffer, 'READY')); + + $this->assertNotNull($process->id()); + $this->assertTrue($process->running()); + $this->assertSame(["DONE\n"], $generalOutput); + + $process->stop(0); + } + + public function testFakeInvokedProcessReturnsEmptyOutputWhenNothingWasProduced(): void + { + $factory = new Factory; + $factory->fake(fn () => $factory->describe()->runsFor(iterations: 1)); + $process = $factory->start('silent-command'); + + $this->assertSame('', $process->output()); + $this->assertSame('', $process->errorOutput()); + + $process->stop(0); + } + + public function testFakeInvokedProcessCallbackFailureIsTerminalAcrossEntryPoints(): void + { + $factory = new Factory; + $factory->fake(fn () => $factory->describe() + ->output('OUTPUT') + ->runsFor(iterations: 3)); + $runningFailure = new RuntimeException('Running callback failed.'); + $runningInvocations = 0; + $runningProcess = $factory->start('running-command', function () use ($runningFailure, &$runningInvocations): void { + ++$runningInvocations; + + throw $runningFailure; + }); + + try { + $runningProcess->running(); + + $this->fail('The running callback failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($runningFailure, $exception); + } + + $this->assertFalse($runningProcess->running()); + $this->assertNull($runningProcess->id()); + $this->assertSame(1, $runningInvocations); + + $waitFailure = new RuntimeException('Wait callback failed.'); + $waitInvocations = 0; + $waitProcess = $factory->start('wait-command'); + + try { + $waitProcess->wait(function () use ($waitFailure, &$waitInvocations): void { + ++$waitInvocations; + + throw $waitFailure; + }); + + $this->fail('The wait callback failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($waitFailure, $exception); + } + + $this->assertFalse($waitProcess->running()); + $this->assertNull($waitProcess->id()); + $this->assertSame(1, $waitInvocations); + } + + public function testFakeInvokedProcessNormalizesCallableOutputHandlers(): void + { + $factory = new Factory; + $factory->fake(fn () => $factory->describe() + ->output('OUTPUT') + ->runsFor(iterations: 2)); + $handler = new class { + /** @var list */ + public array $received = []; + + public function __invoke(string $type, string $buffer): void + { + $this->received[] = [$type, $buffer]; + } + }; + $runningProcess = $factory->start('running-command', $handler); + + $this->assertTrue($runningProcess->running()); + $runningProcess->stop(0); + + $waitProcess = $factory->start('wait-command'); + $waitProcess->wait($handler); + + $this->assertSame([ + ['out', "OUTPUT\n"], + ['out', "OUTPUT\n"], + ], $handler->received); + } + public function testFakeInvokedProcessWaitUntilWithNoCallback() { $factory = new Factory; @@ -1188,6 +1624,25 @@ protected function stopProcess(InvokedProcessContract $process): ?int return $process->stop(0); } + protected function processIsRunning(int $processId): bool + { + return posix_kill($processId, 0); + } + + protected function reapProcess(?int $processId, ?InvokedProcessContract $process = null): void + { + if ($processId !== null && $this->processIsRunning($processId)) { + posix_kill($processId, SIGKILL); + } + + try { + $process?->stop(0); + } catch (Throwable) { + } + + gc_collect_cycles(); + } + protected function ls(): string { return windows_os() ? 'dir' : 'ls'; diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index 801d2f6d9..bed2eb613 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -18,6 +18,7 @@ use Hypervel\Http\Resources\JsonApi\JsonApiResource; use Hypervel\Http\Response as HttpResponse; use Hypervel\Http\UploadedFile; +use Hypervel\Process\InvokedProcess; use Hypervel\Support\Testing\Fakes\NotificationFake; use Hypervel\Testing\PHPUnit\AfterEachTestCleanup; use Hypervel\Testing\PHPUnit\AfterEachTestSubscriber; @@ -58,6 +59,7 @@ public function testFrameworkCleanupFlushesEveryMacroableRegistry(): void JsonApiResource::class, HttpResponse::class, UploadedFile::class, + InvokedProcess::class, NotificationFake::class, ]; $macro = 'testingStaticStateProbe'; From 2968aa95562499f6f628a93a972939172b3f68d5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:51:48 +0000 Subject: [PATCH 05/30] docs(process): record Laravel package provenance Identify the current Laravel Process source branch used as the package parity reference so future ports and audits begin from the correct upstream boundary. Retain the existing package introduction and DeepWiki link while restoring the missing trailing newline. --- src/process/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/process/README.md b/src/process/README.md index 202a4b8e9..4dade543a 100644 --- a/src/process/README.md +++ b/src/process/README.md @@ -1,4 +1,6 @@ Process for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/process) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/process) + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Process From 03ea63df33c0d2f945b5e201dcb6a8833184148f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:51:57 +0000 Subject: [PATCH 06/30] docs(process): document secure lifecycle usage Explain the shell boundary for string commands, recommend argv arrays for dynamic values and direct signals, and show escaped environment placeholders when shell features are required. Document result and output callback failures, graceful stop arguments, wait callbacks, active asynchronous timeout enforcement, coroutine ownership, and transactional pool cleanup. Describe invoked-process macros as boot-only worker-static extensions and state the deliberate real-versus-fake macro boundary without exposing internal cleanup machinery. --- src/boost/docs/processes.md | 78 ++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/boost/docs/processes.md b/src/boost/docs/processes.md index 788f753d5..d04b5afcb 100644 --- a/src/boost/docs/processes.md +++ b/src/boost/docs/processes.md @@ -10,6 +10,7 @@ - [Process IDs and Signals](#process-ids-and-signals) - [Asynchronous Process Output](#asynchronous-process-output) - [Asynchronous Process Timeouts](#asynchronous-process-timeouts) + - [Extending Invoked Processes](#extending-invoked-processes) - [Concurrent Processes](#concurrent-processes) - [Pool Results](#pool-results) - [Naming Pool Processes](#naming-pool-processes) @@ -62,6 +63,15 @@ Instead of passing a shell command string to the `run` method, you may pass the $result = Process::run(['php', 'artisan', 'inspire']); ``` +String commands are interpreted by your operating system's shell, while array commands keep the executable and arguments separate. Therefore, you should use array commands when including dynamic or untrusted values, or when you need to signal the executable directly. + +If you need shell features such as pipes or conditional execution, you may safely include dynamic values using environment placeholders: + +```php +$result = Process::env(['NAME' => $name]) + ->run('echo "${:NAME}"'); +``` + #### Throwing Exceptions @@ -73,6 +83,17 @@ $result = Process::run('ls -la')->throw(); $result = Process::run('ls -la')->throwIf($condition); ``` +You may pass a closure to the `throw` or `throwIf` methods to perform additional work before the exception is thrown. The closure receives the process result and the `ProcessFailedException` instance: + +```php +use Hypervel\Contracts\Process\ProcessResult; +use Hypervel\Process\Exceptions\ProcessFailedException; + +$result->throw(function (ProcessResult $result, ProcessFailedException $exception) { + report($exception); +}); +``` + ### Process Options @@ -196,6 +217,8 @@ $result = Process::run('ls -la', function (string $type, string $output) { }); ``` +If an output callback throws an exception, Hypervel will stop the affected process and rethrow the same exception. + Hypervel also offers the `seeInOutput` and `seeInErrorOutput` methods, which provide a convenient way to determine if a given string was contained in the process' output: ```php @@ -290,6 +313,8 @@ $process = Process::timeout(120)->start('bash import.sh'); $result = $process->wait(); ``` +An asynchronously started process remains owned by the coroutine that started it. Before that coroutine exits, you must call `wait` to let the process finish or `stop` to terminate it. Returning from `waitUntil` only stops waiting; it does not stop the process. + ### Process IDs and Signals @@ -313,6 +338,12 @@ You may use the `stop` method to stop a running process: $process->stop(); ``` +By default, Hypervel sends `SIGTERM` and waits up to 10 seconds for the process to exit. If the process is still running, Hypervel sends `SIGKILL`. You may customize the grace period and fallback signal by passing them to the `stop` method: + +```php +$process->stop(timeout: 5, signal: SIGKILL); +``` + ### Asynchronous Process Output @@ -339,6 +370,16 @@ $process = Process::start('bash import.sh', function (string $type, string $outp $result = $process->wait(); ``` +Alternatively, you may pass the output closure to the `wait` method: + +```php +$process = Process::start('bash import.sh'); + +$result = $process->wait(function (string $type, string $output) { + echo $output; +}); +``` + Instead of waiting until the process has finished, you may use the `waitUntil` method to stop waiting based on the output of the process. Hypervel will stop waiting for the process to finish when the closure given to the `waitUntil` method returns `true`: ```php @@ -352,7 +393,7 @@ $process->waitUntil(function (string $type, string $output) { ### Asynchronous Process Timeouts -While an asynchronous process is running, you may verify that the process has not timed out using the `ensureNotTimedOut` method. This method will throw a [timeout exception](#timeouts) if the process has timed out: +While an asynchronous process is running, you may verify that the process has not timed out using the `ensureNotTimedOut` method. This method will throw a [timeout exception](#timeouts) if the process has timed out. The `running` method alone does not enforce asynchronous process timeouts. Therefore, you should invoke `ensureNotTimedOut` regularly while polling a process, or call `wait`, which will also enforce the configured timeouts: ```php $process = Process::timeout(120)->start('bash import.sh'); @@ -366,6 +407,35 @@ while ($process->running()) { } ``` + +### Extending Invoked Processes + +The `Hypervel\Process\InvokedProcess` class is macroable, allowing you to add custom methods to invoked processes. You should register these macros within the `boot` method of your application's `App\Providers\AppServiceProvider` class: + +```php +use Hypervel\Process\InvokedProcess; + +public function boot(): void +{ + InvokedProcess::macro('summary', function () { + return 'Running: ' . $this->command(); + }); +} +``` + +Once the macro has been registered, you may invoke it on a running process: + +```php +$process = Process::start('bash import.sh'); + +return $process->summary(); +``` + +> [!WARNING] +> Macros should only be registered during worker boot. Macro registrations are stored in static state for the worker lifetime and affect every subsequent invoked process. + +Macros are only available on real invoked processes; fake invoked processes returned when using `Process::fake()` do not support registered macros. + ## Concurrent Processes @@ -392,6 +462,10 @@ while ($pool->running()->isNotEmpty()) { $results = $pool->wait(); ``` +An asynchronously started pool remains owned by the coroutine that started it. Before that coroutine exits, you must call `wait` to let every process finish or `stop` to terminate the running processes. + +If the pool fails while starting or waiting, Hypervel stops any processes it has already started or still owns before rethrowing the original exception. + If you do not need to inspect the pool while its processes are running, you may use the `run` method to start the process pool and immediately wait on its results: ```php @@ -473,6 +547,8 @@ You may use the `stop` method to stop all running processes within the pool: $pool->stop(); ``` +The `stop` method accepts the same grace period and fallback signal arguments as an individual process. + ## Testing From a352b57cdfa56a8693c5c813c243aa5a70cef165 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:52:07 +0000 Subject: [PATCH 07/30] chore(audit): complete Process lifecycle review Record the verified Process findings, final ownership boundaries, rejected speculative mechanisms, originating Laravel pull-request trace, compatibility result, and performance assessment. Capture focused and full-suite validation, independent review completion, and Concurrency revalidation against transactional pool startup, exhaustive cleanup, empty pools, and keyed results. Mark Process complete, retain the process-02 cross-package revalidation record, and route the next audit work to Server Process. --- ...-coroutine-state-lifecycle-audit-ledger.md | 31 ++++++++++++++++++- ...amework-coroutine-state-lifecycle-audit.md | 8 ++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md index 271d7ed3e..6f3d76dd7 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md @@ -627,6 +627,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Implementation:** The hidden command now base64-wraps successful serialized values, contains reporter failure, and emits either a structurally verified exception envelope or one fixed ASCII fallback. ProcessDriver and Testbench's ProcessResult trim appended gzip output, decode JSON and base64 strictly, reconstruct named constructor state, and contain missing, invalid, non-Throwable, or failing parent-side classes as `RuntimeException`. The Driver contract, facade, and all drivers accept Laravel's optional timeout, with Process alone applying it. The environment regression restores exact prior state, the public guide states the real copied-context semantics and timeout behavior, the package README records provenance and material runtime differences, and the intentional ForkDriver omission is marked at its upstream source slot. - **Regression tests:** The command and both decoders cover binary success values, gzip suffixes, malformed protocol data, reporter failure, UTF-8 and JSON edge cases, ASCII fallback, falsey and floating constructor values, defaulted and required hidden state, zero-argument and variadic constructors, inherited constructors, type-changing object/enum/binary state, missing and failing parent constructors, and non-Throwable advertised classes. Additional coverage verifies Process-only timeout application, current coroutine and enum behavior, exact environment restoration, and the real Testbench subprocess round trip. - **Validation and review:** Every changed test file and the affected Concurrency, Foundation command, Coroutine, and Testbench process groups are green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, a fresh caller/callee, protocol, API, performance, stale-code, and overengineering review, and independent code review are complete. The final review independently reran the three central test files and signed off after verifying the untrusted non-Throwable guard and cross-package revalidation records. +- **Process revalidation:** `ProcessDriver` remains correct with `process-02` pool startup rollback, exhaustive failure cleanup, empty-pool support, and keyed result preservation; all 47 focused Concurrency tests pass against the corrected Process lifecycle. - **Laravel-facing result:** Current Laravel's optional process-task timeout API is restored without changing existing calls; Coroutine and Sync retain their established behavior. Public configuration, documented call shapes, and conventional extension patterns are otherwise unchanged. The binary transport and exception-reconstruction corrections harden shared upstream behavior without removing or renaming any public API. - **Assessment:** The final design fixes demonstrated transport, failure-reporting, reconstruction, timeout-parity, documentation, and test-isolation defects at the three existing protocol boundaries. It adds no DTO, registry, shared decoder service, retry, lock, context state, cancellation framework, or normal request/job/coroutine cost. The accepted process-only encoding and scan work is bounded to explicit process results and is the smallest complete correction. @@ -736,5 +737,33 @@ Append package entries in checklist order. Keep each entry compact but complete | `process-01` | Defect | Minor | High | `PendingProcess::start()` returns `Contracts\Process\InvokedProcess`, but that contract omits `stop()` even though both real and fake implementations provide the same method | Add the existing typed `stop()` signature to the contract and prove both implementations can be stopped through the contract type | - **Contract rationale:** `PendingProcess::start()` returns the contract, so lifecycle-managed callers must be able to stop the returned process without depending on a concrete implementation. +- **Upstream feature trace:** Laravel framework PR `#52959` added `stop()` only to the concrete `InvokedProcess` and `InvokedProcessPool`; it changed no contract, test, fixture, configuration, or documentation file, and its discussion explicitly left process-stop behavior to Symfony coverage. Current Laravel 13.x retains both concrete methods while still omitting `stop()` from its invoked-process contract. Hypervel's contract addition is therefore a deliberate contract-completeness correction under the rule that every conforming invoked process must expose lifecycle behavior returned callers require, not a claim that Laravel added the contract method. - **Compatibility and performance:** This is an additive contract correction with no runtime path or implementation change. Both existing implementations already satisfy the exact signature. -- **Regression and revalidation:** `ProcessTest` covers real and fake stopping through the contract. The later full Process audit must retain this method and repeat the complete implementer search rather than treating this contract correction as the Process package audit. +- **Regression and revalidation:** `ProcessTest` covers real and fake stopping through the contract. The full Process audit repeated the repository-wide implementer search, retained the method, and confirmed that the real and fake invoked processes remain the only implementations. + +### Make Process callbacks and pools failure-safe + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `process-02` | Defect | Major | High | Exceptions from native output callbacks leave exact child processes running, partial pool startup leaves earlier children unowned, and a failed pool wait abandons live siblings whose external progress may never complete | Stop the exact native child when its callback throws, make pool creation transactional, and stop every sibling after a failed wait while preserving the original failure | +| `process-03` | Defect | Minor | High | The invoked-process contract omits `ensureNotTimedOut()`, and the real implementation forwards nullable `waitUntil()` callbacks to Symfony even though Symfony requires a callable | Complete the contract and make a null `waitUntil()` follow the documented wait-to-completion behavior in both implementations | +| `process-04` | Defect | Minor | High | The fake invoked-process lifecycle reports a PID after stopping, marks a readiness-matched process finished, permanently replaces its output handler, remains live after callback failure, rejects valid non-Closure callables despite its callable API, returns a newline for absent output, and compares signals loosely | Preserve the fake's existing observable scheduling while making PID, readiness, callback assignment/failure, handler restoration, empty output, and signal checks match the real lifecycle | +| `process-05` | Defect | Minor | High | Truthiness checks discard the supported string command, input, and fake output value `"0"`, while environment docblocks exclude Symfony-supported `Stringable` and `false` values | Use null/explicit-empty checks and publish the precise environment type without changing the runtime API | +| `process-06` | Defect | Minor | High | Values passed to `FakeProcessSequence` at construction bypass the normalization used by `push()`, so supported seeded strings and arrays later violate the sequence return contract | Normalize constructor seeds through the existing conversion boundary and narrow every public sequence docblock to the accepted union | +| `process-07` | Defect | Minor | High | Hypervel's current Laravel-ported `InvokedProcess` is missing Laravel's public `Macroable` surface and corresponding test cleanup | Add `Macroable` only to the real public implementation, expose a pure `flushState()`, and register it with framework test cleanup | +| `process-08` | Defect | Minor | High | Running an empty pipe reaches a native return-type failure instead of a useful framework diagnostic | Reject an empty pipe after its builder callback with a descriptive `InvalidArgumentException`; empty process pools remain valid | +| `process-09` | Userland footgun | Minor | High | Test-only fake, recording, and stray-process controls mutate the container-resolved worker singleton, while successfully started asynchronous children remain caller-owned across coroutine teardown | Add concrete test-only worker-lifetime warnings and document that callers must wait for or stop successful asynchronous processes and pools before their owning coroutine exits | + +- **Architecture and ownership:** A package-local `Concerns\GuardsProcessOutput` trait owns the one non-trivial repeated callback boundary used by synchronous runs, asynchronous starts, invoked waits, and readiness waits. It captures the exact Symfony process, suppresses callback re-entry while `stop(0)` drains output, stops only after the user callback throws, swallows only cleanup failure, and rethrows the original exception. Its failure bit is closure-local static state: it survives synchronous re-entry within one wrapper while remaining isolated between process operations and avoids both a worker-shared instance property and an analyzer-only state-object allocation. Pool startup validates every pending process before acquiring children, retains unpublished exact handles until all starts succeed, and rolls them back exhaustively on partial failure. Pool wait retains result keys on success and stops every exact sibling after the first wait failure because waiting for unrelated external processes could hang without a bound. +- **Fake and API fidelity:** The real and fake invoked-process implementations both satisfy `ensureNotTimedOut()` and nullable `waitUntil()`. A readiness match leaves a fake process running and restores its prior general output handler; natural exhaustion and explicit stop still finish it, and existing output-draining behavior remains independent of remaining-run iterations. Valid callable handlers are normalized into the fake's precise Closure property at its two raw assignment boundaries. A handler failure marks that fake process terminal and suppresses later delivery, matching the real exact-stop transition without native cleanup simulation or per-method catches. `InvokedProcess` gains current Laravel `Macroable` behavior without putting static registries on the contract or fake. +- **Types, diagnostics, and documentation:** Command and input presence use null semantics; fake output treats only `''` and `[]` as empty; sequence seeds share the existing normalization path; environment and sequence docblocks describe their real supported values. Empty pipes fail explicitly. Process package provenance is recorded, successful asynchronous ownership is documented, and the test-only worker mutations name their concrete long-lived-container risk. +- **Rejected concerns:** Do not add automatic coroutine defers, process registries, destructors, shutdown hooks, retry or cancellation layers, configurable cleanup, `WeakReference`, Symfony subclasses or patches, signal rewrites, fake live-result abstractions, negative fake-iteration validation, pool-results mutation APIs, container-scoping changes, or enforced cleanup of successfully transferred handles. Async fake integer/throwable shorthand is unnecessary because supported explicit equivalents exist. Generic fake resolution for arbitrary `ProcessResult` contracts and a speculative null-exit guard have no demonstrated supported failure. Hyperf parity is not required. +- **Performance and compatibility:** Callback wrapping allocates one closure only when a user output callback exists; null callbacks remain unchanged. Transaction bookkeeping uses local arrays only while starting a pool, and exhaustive cleanup runs only after failure or explicit stop. Normal command execution, fake resolution, and result access gain no container lookup, lock, context state, retry, poll, yield, or retained worker registry. Laravel public API, configuration, documented behavior, and conventional extension patterns remain compatible; the corrections affect undocumented failure paths, complete a current upstream API, and make fake behavior faithful. +- **Upstream feature trace:** Laravel framework PR `#54912` added `ensureNotTimedOut()` only to the concrete `InvokedProcess`; it changed no contract, fake, test, fixture, or configuration file. Laravel docs PR `#10231` changed only `processes.md` to add the asynchronous-timeout section. Current Laravel 13.x concrete source and documentation retain those exact surfaces, both of which Hypervel already carried; this audit completes the method on Hypervel's contract and fake because every conforming invoked process must support the documented timeout check. Laravel framework PR `#60392` changed only `InvokedProcess.php` to add `Macroable`; its intermediate `getProcess()` proposal was removed before merge, and it added no tests or documentation. The current 13.x source still contains only the macro trait addition, which is the surface ported here with Hypervel's required worker-static test cleanup. The current upstream Process tests contain no stop, timeout-check, or invoked-process macro regression, so the focused Hypervel coverage is additive rather than an omitted upstream test port. +- **Regression strategy:** Preserve the existing fake wait-then-wait output drain, explicit stop, pool return types, and natural exhaustion tests. Add deterministic real-process regressions that independently reap exact child PIDs in `finally`, failure-injection coverage for partial pool startup and exhaustive wait/stop cleanup with primary-failure preservation, contract coverage for real and fake timeout checks, nullable waits, fake readiness/handler restoration/PID/empty output, falsy command/input/output, seeded sequences, macros and cleanup, and the empty-pipe diagnostic. Run each changed test file immediately, focused Process and Concurrency coverage, `composer fix`, a fresh lifecycle/API/performance/overengineering review, and independent code review. +- **Implementation:** Real output callbacks are guarded at the four Symfony execution/wait boundaries by one package-local trait that stops the exact child on callback failure and suppresses stop-time re-entry without sharing state across operations. Pool startup is transactional, pool wait and stop clean up exact handles exhaustively with deterministic failure precedence, and empty pools remain valid. The real and fake invoked processes now share the complete timeout/wait contract; fake terminal, readiness, handler, callable, PID, output, and signal behavior matches the real lifecycle. Falsy supported values and seeded sequences retain their data, empty pipes fail descriptively, the real invoked process regains Laravel macros with authoritative test cleanup, and worker-state and asynchronous-ownership documentation names the actual lifecycle boundaries. +- **Regression tests:** Deterministic coverage exercises exact-PID teardown after synchronous and invoked callback failure, stop-time callback re-entry, partial pool rollback, exhaustive sibling cleanup, uncertain inspection, primary failure preservation, keyed and empty pool results, nullable waits, real and fake contract calls, fake readiness liveness and handler restoration, terminal callback failure through multiple entry points, both callable assignment boundaries, terminal PID and empty output, falsy command/input/output, seeded sequences, macros and cleanup, and both empty-pipe entry forms. Existing fake exhaustion, wait-drain, explicit-stop, and pool result-type coverage remains green. +- **Cross-package revalidation:** `ProcessDriver` in the completed Concurrency package remains correct with partial-start rollback, exhaustive failure cleanup, empty-pool support, and keyed result preservation; all 47 focused Concurrency tests pass. The prior `process-01` contract correction was revalidated across both repository implementations. +- **Validation and review:** The final changed Process suite passes with blocking real-process coverage enabled: 75 tests and 223 assertions. Focused testing cleanup passes with 16 tests and 71 assertions, and focused Concurrency coverage passes with 47 tests and 92 assertions. PHP CS Fixer changed none of 5,571 files; both PHPStan configurations pass; the complete components suite passes with 23,215 tests, 66,114 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; and `git diff --check` is clean. Fresh lifecycle, API, failure-precedence, performance, stale-code, and overengineering review is complete, and independent code review signed off after the empty-pool contract and Concurrency revalidation records were added. +- **Laravel-facing result:** Current Laravel concrete API parity is restored through the real invoked-process macro surface, while the already-ported concrete stop and timeout-check methods remain current. Hypervel deliberately exposes stop and timeout checking through its contract because both real and fake implementations must provide lifecycle behavior to callers typed against the value `start()` returns; Laravel's contract omits those methods. Existing call shapes and public configuration are unchanged, and the remaining lifecycle corrections harden undocumented failure paths and make fakes honor the advertised contract. +- **Assessment:** Every mechanism has a demonstrated job at its lowest owner. Callback-free paths remain allocation-free, normal execution gains no registry, context slot, lock, retry, poll, yield, container lookup, or retained worker state, and exhaustive work is limited to failure or explicit cleanup. The rejected registry, defer, destructor, shutdown, weak-reference, cancellation, retry, fake-abstraction, and compatibility proposals remain unnecessary. diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md index 32fc621a6..820f91292 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `process` +- **Active package or work unit:** `server-process` - **Ledger entries required for the active work:** None. -- **Pending revalidation carried into the active work:** None for Process. Later package revalidation remains tracked in the cross-package dependency index. +- **Pending revalidation carried into the active work:** None for Server Process. Later package revalidation remains tracked in the cross-package dependency index. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1052,7 +1052,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts` and `support` (revalidation complete), `filesystem`, `foundation`; later full `filesystem` and `foundation` audits | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | | `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | -| `process-01` | `process` | later full `process` audit | `Expose process stopping through the contract`; finding `process-01` | +| `process-02` | `process` | `concurrency` (revalidation complete) | `Make Process callbacks and pools failure-safe`; finding `process-02` | ## Package checklist @@ -1103,7 +1103,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `signal` - [x] `pool` - [x] `object-pool` -- [ ] `process` +- [x] `process` - [ ] `server-process` - [ ] `filesystem` From be817d35b80d4ad3ba98e392e1d2bf1e7a712c1c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:06:53 +0000 Subject: [PATCH 08/30] docs(contributing): define incremental upstream updates Require originating implementation and documentation pull requests to establish the reason for an upstream change and the complete file surface affected when it was introduced. Use historical pull-request diffs only for discovery, then port source, tests, and documentation from the current upstream default or development branch so follow-up fixes are retained. Search current upstream for the added symbols, apply the established Hypervel adaptations, and add proportionate user documentation when upstream omitted it. --- AGENTS.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 677411d3e..ff99d63ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,16 @@ Before editing, identify the work as one of: - An update from an existing package's upstream. - A new package port. -This determines which upstream source and tests to compare. Upstream updates and new ports follow the workflows under Porting Packages; bug fixes and enhancements follow the workflows below. The same rules apply to all four. In every case, read the package README for the upstream reference and read the relevant Hypervel source and tests before editing. +This determines which upstream source and tests to compare. Incremental updates from an existing upstream follow the workflow below together with the applicable Porting Policy and rules. New package ports follow the full workflow under Porting Packages. Bug fixes and enhancements follow the workflows below. In every case, read the package README for the upstream reference and the relevant Hypervel source and tests before editing. + +### Incremental upstream updates + +When bringing an existing upstream feature, fix, or API change into Hypervel: + +1. Find the originating implementation pull request and any corresponding documentation pull request. Use them to understand the reason for the change and identify the complete set of files changed when it was introduced. +2. Inspect every file changed by those pull requests, including source, contracts, tests, fixtures, configuration, package metadata, and documentation. Search the current upstream branch for the added symbols as well, since later changes may have introduced additional consumers or coverage. +3. Treat the historical pull-request diffs as discovery and history only. Port the actual source, tests, and documentation from the current checked-out upstream default or development branch. Follow-up fixes and documentation improvements may have changed the final implementation or coverage. +4. Compare that current upstream surface with the Hypervel implementation and apply the approved Hypervel adaptations under Porting Packages. If the upstream feature has no user-facing documentation, add proportionate Hypervel documentation at its natural public surface. ### Audit what you modify From 81c36771dbfc7054500821d21a9acee47aef14d3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:07:00 +0000 Subject: [PATCH 09/30] docs(audit): reference incremental upstream workflow Replace the audit-local copy of incremental porting instructions with the authoritative AGENTS.md workflow so the two sources cannot drift. Retain the audit-specific requirement to record originating implementation and documentation pull-request surfaces together with the current-branch result in the investigation and ledger. --- .../2026-07-12-framework-coroutine-state-lifecycle-audit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md index 820f91292..35e8cdda1 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md @@ -159,7 +159,7 @@ Use local sources rather than online lookup when available. In particular, inspe Inspect a package README only for an upstream reference, a `Differences From Laravel` section, architecture notes, or package-specific constraints. Do not spend audit time reading badges, installation boilerplate, license text, or empty descriptions. -When the audit finds an upstream feature that Hypervel has not yet ported, use the originating pull request only to identify the complete changed-file surface and the reason for the change. Inspect every source file, test, fixture, configuration file, and documentation file named by that pull request, then port from the current checked-out upstream default/development branch rather than from the historical pull-request diff. Later upstream corrections may have changed the implementation or coverage. Search the corresponding current upstream documentation repository as part of the same work and port the user-facing documentation; if upstream has no documentation, add proportionate Hypervel documentation at the natural public surface. +When the audit finds an upstream feature that Hypervel has not yet ported, follow the **Incremental upstream updates** workflow in `AGENTS.md`. Record the originating implementation and documentation pull-request surface and the current-branch result in the package investigation and companion ledger so the completeness check remains auditable. ### Inherited upstream internals From ac28cefb2d93946f6e1774a744aa90addc2bc0c3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:22:19 +0000 Subject: [PATCH 10/30] fix(server-process): harden native process lifecycles Check native process registration and socket export at their owning boundaries, clean up rejected process pipes, and publish only successfully attached handles. Make listener ownership and callback teardown exception-safe while preserving the earliest failure, retaining valid falsy IPC payloads, and applying fakeable restart backoff before rethrowing. Remove unreachable exception surface and add deterministic coverage for registration rollback, socket export, channel closure, teardown precedence, IPC fidelity, and resource release. --- src/server-process/src/AbstractProcess.php | 157 +++++++--- .../src/Exceptions/ServerInvalidException.php | 11 - .../src/Exceptions/SocketAcceptException.php | 8 - tests/ServerProcess/AbstractProcessTest.php | 225 +++++++++++--- tests/ServerProcess/ExceptionTest.php | 28 +- .../Fixtures/ListenableProcess.php | 8 +- .../ListenCreationFailureTest.php | 41 +++ tests/ServerProcess/ListenMethodTest.php | 276 +++++++++++++----- 8 files changed, 560 insertions(+), 194 deletions(-) delete mode 100644 src/server-process/src/Exceptions/ServerInvalidException.php create mode 100644 tests/ServerProcess/ListenCreationFailureTest.php diff --git a/src/server-process/src/AbstractProcess.php b/src/server-process/src/AbstractProcess.php index 0e041ddab..a845e8313 100644 --- a/src/server-process/src/AbstractProcess.php +++ b/src/server-process/src/AbstractProcess.php @@ -12,10 +12,13 @@ use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; +use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\ServerProcess\Events\AfterProcessHandle; use Hypervel\ServerProcess\Events\BeforeProcessHandle; use Hypervel\ServerProcess\Events\PipeMessage; use Hypervel\ServerProcess\Exceptions\SocketAcceptException; +use Hypervel\Support\Sleep; +use RuntimeException; use Swoole\Coroutine\Socket; use Swoole\Process as SwooleProcess; use Swoole\Server; @@ -26,7 +29,7 @@ abstract class AbstractProcess implements ProcessInterface { public string $name = 'process'; - public int $nums = 1; + public int $processCount = 1; public bool $redirectStdinStdout = false; @@ -38,9 +41,9 @@ abstract class AbstractProcess implements ProcessInterface protected ?SwooleProcess $process = null; - protected int $recvLength = 65535; + protected int $receiveLength = 65535; - protected float $recvTimeout = 10.0; + protected float $receiveTimeout = 10.0; protected int $restartInterval = 5; @@ -64,9 +67,10 @@ public function isEnabled(Server $server): bool */ public function bind(Server $server): void { - $num = $this->nums; - for ($i = 0; $i < $num; ++$i) { + for ($i = 0; $i < $this->processCount; ++$i) { $process = new SwooleProcess(function (SwooleProcess $process) use ($i) { + $exception = null; + try { $this->event?->dispatch(new BeforeProcessHandle($this, $i)); @@ -77,19 +81,66 @@ public function bind(Server $server): void } $this->handle(); } catch (Throwable $throwable) { - $this->logThrowable($throwable); + try { + $this->logThrowable($throwable); + } catch (Throwable $reportingException) { + $exception = $reportingException; + } } finally { - $this->event?->dispatch(new AfterProcessHandle($this, $i)); + try { + $this->event?->dispatch(new AfterProcessHandle($this, $i)); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + if (isset($quit)) { - $quit->push(true); + try { + $quit->push(true); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + try { + Timer::clearAll(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + Sleep::sleep($this->restartInterval); + } catch (Throwable $throwable) { + $exception ??= $throwable; } - Timer::clearAll(); - CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); - sleep($this->restartInterval); + } + + if ($exception !== null) { + throw $exception; } }, $this->redirectStdinStdout, $this->pipeType, $this->enableCoroutine); $process->setBlocking(false); - $server->addProcess($process); + + try { + if ($server->addProcess($process) === false) { + throw new RuntimeException(sprintf('Unable to register server process [%s.%d].', $this->name, $i)); + } + } catch (Throwable $exception) { + if ($this->pipeType !== 0) { + // Preserve the registration failure if native pipe cleanup also fails. + try { + @$process->close(); + } catch (Throwable) { + } + } + + throw $exception; + } if ($this->enableCoroutine) { ProcessCollector::add($this->name, $process); @@ -102,48 +153,74 @@ public function bind(Server $server): void */ protected function listen(Channel $quit): void { - Coroutine::create(function () use ($quit) { - while ($quit->pop(0.001) !== true) { + try { + Coroutine::create(function () use ($quit) { try { - $sock = $this->getListenSocket(); - $recv = $sock->recv($this->recvLength, $this->recvTimeout); - - // Empty string means the peer closed the pipe — permanent, stop listening. - if ($recv === '') { - throw new SocketAcceptException('Socket is closed', $sock->errCode, permanent: true); - } + try { + $socket = $this->getListenSocket(); - if ($recv === false && $sock->errCode !== SOCKET_ETIMEDOUT) { - // Signal interruption or temporarily unavailable — transient, retry. - $transient = $sock->errCode === SOCKET_EINTR || $sock->errCode === SOCKET_EAGAIN; + if ($socket === false) { + throw new SocketAcceptException('Unable to export process IPC socket', permanent: true); + } + } catch (Throwable $exception) { + $this->logThrowable($exception); - throw new SocketAcceptException( - $transient ? 'Socket recv error' : 'Socket is closed', - $sock->errCode, - permanent: ! $transient, - ); + return; } - if ($this->event && $recv !== false && $data = unserialize($recv)) { - $this->event->dispatch(new PipeMessage($data)); - } - } catch (Throwable $exception) { - $this->logThrowable($exception); - if ($exception instanceof SocketAcceptException && $exception->isPermanent()) { - break; + while ($quit->pop(0.001) !== true) { + try { + $received = $socket->recv($this->receiveLength, $this->receiveTimeout); + + // Empty string means the peer closed the pipe — permanent, stop listening. + if ($received === '') { + throw new SocketAcceptException('Socket is closed', $socket->errCode, permanent: true); + } + + if ($received === false && $socket->errCode !== SOCKET_ETIMEDOUT) { + // Signal interruption or temporarily unavailable — transient, retry. + $transient = $socket->errCode === SOCKET_EINTR || $socket->errCode === SOCKET_EAGAIN; + + throw new SocketAcceptException( + $transient ? 'Socket recv error' : 'Socket is closed', + $socket->errCode, + permanent: ! $transient, + ); + } + + if ($received === false || ! $this->event) { + continue; + } + + $data = unserialize($received); + + if ($data !== false || $received === 'b:0;') { + $this->event->dispatch(new PipeMessage($data)); + } + } catch (Throwable $exception) { + $this->logThrowable($exception); + if ($exception instanceof SocketAcceptException && $exception->isPermanent()) { + break; + } + } } + } finally { + $quit->close(); } - } + }); + } catch (CoroutineCreateException $exception) { $quit->close(); - }); + + throw $exception; + } } /** * Get the socket for the IPC pipe listener. */ - protected function getListenSocket(): Socket + protected function getListenSocket(): Socket|false { - return $this->process->exportSocket(); + return @$this->process->exportSocket(); } /** diff --git a/src/server-process/src/Exceptions/ServerInvalidException.php b/src/server-process/src/Exceptions/ServerInvalidException.php deleted file mode 100644 index a2e42108a..000000000 --- a/src/server-process/src/Exceptions/ServerInvalidException.php +++ /dev/null @@ -1,11 +0,0 @@ -permanent; } - - /** - * Determine if the exception was caused by a socket timeout. - */ - public function isTimeout(): bool - { - return $this->getCode() === SOCKET_ETIMEDOUT; - } } diff --git a/tests/ServerProcess/AbstractProcessTest.php b/tests/ServerProcess/AbstractProcessTest.php index ac143800d..2c1174ce5 100644 --- a/tests/ServerProcess/AbstractProcessTest.php +++ b/tests/ServerProcess/AbstractProcessTest.php @@ -7,18 +7,29 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; +use Hypervel\Coordinator\Constants; +use Hypervel\Coordinator\CoordinatorManager; use Hypervel\ServerProcess\AbstractProcess; use Hypervel\ServerProcess\Events\AfterProcessHandle; use Hypervel\ServerProcess\Events\BeforeProcessHandle; +use Hypervel\ServerProcess\ProcessCollector; +use Hypervel\Support\Sleep; use Hypervel\Tests\ServerProcess\Fixtures\FooProcess; use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionClass; use RuntimeException; +use Swoole\Event as SwooleEvent; +use Swoole\Process as SwooleProcess; use Swoole\Server; +use Swoole\Timer; +use Throwable; class AbstractProcessTest extends TestCase { + /** @var SwooleProcess[] */ + private array $nativeProcesses = []; + // SwooleProcess creation fails with "unable to create Swoole\Process with async-io // threads" when the test runs inside a coroutine and multiple ParaTest workers create // processes simultaneously. These tests only verify callback logic and event dispatch, @@ -27,12 +38,19 @@ class AbstractProcessTest extends TestCase protected function tearDown(): void { + foreach ($this->nativeProcesses as $process) { + @$process->close(); + } + + Timer::clearAll(); + // Drain the reactor so process pipe cleanup is not deferred to PHP shutdown. + SwooleEvent::wait(); FooProcess::$handled = false; parent::tearDown(); } - public function testIsEnabledReturnsTrueByDefault() + public function testIsEnabledReturnsTrueByDefault(): void { $container = m::mock(ContainerContract::class); $container->shouldReceive('has')->andReturn(false); @@ -44,7 +62,7 @@ public function testIsEnabledReturnsTrueByDefault() $this->assertTrue($process->isEnabled($server)); } - public function testDefaultPropertyValues() + public function testDefaultPropertyValues(): void { $container = m::mock(ContainerContract::class); $container->shouldReceive('has')->andReturn(false); @@ -53,12 +71,12 @@ public function testDefaultPropertyValues() $process = new FooProcess($container); $this->assertSame('process', $process->name); - $this->assertSame(1, $process->nums); + $this->assertSame(1, $process->processCount); $this->assertFalse($process->redirectStdinStdout); $this->assertSame(SOCK_DGRAM, $process->pipeType); } - public function testBindCreatesProcessAndAddsToServer() + public function testBindCreatesProcessAndAddsToServer(): void { $container = m::mock(ContainerContract::class); $container->shouldReceive('bound')->with('events')->andReturn(false); @@ -66,10 +84,10 @@ public function testBindCreatesProcessAndAddsToServer() $process = new FooProcess($container); $server = m::mock(Server::class); - $server->shouldReceive('addProcess')->once()->andReturnUsing(function ($swooleProcess) { - // Execute the callback to verify it runs - $ref = new ReflectionClass($swooleProcess); - $property = $ref->getProperty('callback'); + $server->shouldReceive('addProcess')->once()->andReturnUsing(function (SwooleProcess $swooleProcess) { + $this->nativeProcesses[] = $swooleProcess; + $reflection = new ReflectionClass($swooleProcess); + $property = $reflection->getProperty('callback'); $callback = $property->getValue($swooleProcess); $callback($swooleProcess); return 1; @@ -80,17 +98,18 @@ public function testBindCreatesProcessAndAddsToServer() $this->assertTrue(FooProcess::$handled); } - public function testBindCreatesMultipleProcessesWhenNumsGreaterThanOne() + public function testBindCreatesMultipleProcessesWhenProcessCountGreaterThanOne(): void { $container = m::mock(ContainerContract::class); $container->shouldReceive('bound')->with('events')->andReturn(false); $process = new FooProcess($container); - $process->nums = 3; + $process->processCount = 3; $addCount = 0; $server = m::mock(Server::class); - $server->shouldReceive('addProcess')->times(3)->andReturnUsing(function () use (&$addCount) { + $server->shouldReceive('addProcess')->times(3)->andReturnUsing(function (SwooleProcess $swooleProcess) use (&$addCount) { + $this->nativeProcesses[] = $swooleProcess; return ++$addCount; }); @@ -99,7 +118,7 @@ public function testBindCreatesMultipleProcessesWhenNumsGreaterThanOne() $this->assertSame(3, $addCount); } - public function testBindDispatchesBeforeAndAfterEvents() + public function testBindDispatchesBeforeAndAfterEvents(): void { $dispatched = []; $dispatcher = m::mock(DispatcherContract::class); @@ -114,9 +133,10 @@ public function testBindDispatchesBeforeAndAfterEvents() $process = new FooProcess($container); $server = m::mock(Server::class); - $server->shouldReceive('addProcess')->andReturnUsing(function ($swooleProcess) { - $ref = new ReflectionClass($swooleProcess); - $callback = $ref->getProperty('callback')->getValue($swooleProcess); + $server->shouldReceive('addProcess')->andReturnUsing(function (SwooleProcess $swooleProcess) { + $this->nativeProcesses[] = $swooleProcess; + $reflection = new ReflectionClass($swooleProcess); + $callback = $reflection->getProperty('callback')->getValue($swooleProcess); $callback($swooleProcess); return 1; }); @@ -130,7 +150,7 @@ public function testBindDispatchesBeforeAndAfterEvents() $this->assertSame(0, $dispatched[0]->index); } - public function testBindDispatchesEventsWithCorrectIndices() + public function testBindDispatchesEventsWithCorrectIndices(): void { $dispatched = []; $dispatcher = m::mock(DispatcherContract::class); @@ -143,12 +163,13 @@ public function testBindDispatchesEventsWithCorrectIndices() $container->shouldReceive('make')->with('events')->andReturn($dispatcher); $process = new FooProcess($container); - $process->nums = 2; + $process->processCount = 2; $server = m::mock(Server::class); - $server->shouldReceive('addProcess')->andReturnUsing(function ($swooleProcess) { - $ref = new ReflectionClass($swooleProcess); - $callback = $ref->getProperty('callback')->getValue($swooleProcess); + $server->shouldReceive('addProcess')->andReturnUsing(function (SwooleProcess $swooleProcess) { + $this->nativeProcesses[] = $swooleProcess; + $reflection = new ReflectionClass($swooleProcess); + $callback = $reflection->getProperty('callback')->getValue($swooleProcess); $callback($swooleProcess); return 1; }); @@ -163,7 +184,7 @@ public function testBindDispatchesEventsWithCorrectIndices() $this->assertSame(1, $dispatched[3]->index); // After process 1 } - public function testLogThrowableReportsViaExceptionHandler() + public function testLogThrowableReportsViaExceptionHandler(): void { $handler = m::mock(ExceptionHandlerContract::class); $handler->shouldReceive('report')->once(); @@ -185,9 +206,10 @@ public function handle(): void }; $server = m::mock(Server::class); - $server->shouldReceive('addProcess')->andReturnUsing(function ($swooleProcess) { - $ref = new ReflectionClass($swooleProcess); - $callback = $ref->getProperty('callback')->getValue($swooleProcess); + $server->shouldReceive('addProcess')->andReturnUsing(function (SwooleProcess $swooleProcess) { + $this->nativeProcesses[] = $swooleProcess; + $reflection = new ReflectionClass($swooleProcess); + $callback = $reflection->getProperty('callback')->getValue($swooleProcess); $callback($swooleProcess); return 1; }); @@ -195,7 +217,7 @@ public function handle(): void $process->bind($server); } - public function testLogThrowableSilentlyIgnoresWhenNoExceptionHandler() + public function testLogThrowableSilentlyIgnoresWhenNoExceptionHandler(): void { $container = m::mock(ContainerContract::class); $container->shouldReceive('bound')->with('events')->andReturn(false); @@ -213,9 +235,10 @@ public function handle(): void }; $server = m::mock(Server::class); - $server->shouldReceive('addProcess')->andReturnUsing(function ($swooleProcess) { - $ref = new ReflectionClass($swooleProcess); - $callback = $ref->getProperty('callback')->getValue($swooleProcess); + $server->shouldReceive('addProcess')->andReturnUsing(function (SwooleProcess $swooleProcess) { + $this->nativeProcesses[] = $swooleProcess; + $reflection = new ReflectionClass($swooleProcess); + $callback = $reflection->getProperty('callback')->getValue($swooleProcess); $callback($swooleProcess); return 1; }); @@ -225,7 +248,7 @@ public function handle(): void $this->assertTrue(true); } - public function testConstructorResolvesEventDispatcherIfAvailable() + public function testConstructorResolvesEventDispatcherIfAvailable(): void { $dispatcher = m::mock(DispatcherContract::class); $container = m::mock(ContainerContract::class); @@ -234,20 +257,150 @@ public function testConstructorResolvesEventDispatcherIfAvailable() $process = new FooProcess($container); - $ref = new ReflectionClass($process); - $prop = $ref->getProperty('event'); - $this->assertSame($dispatcher, $prop->getValue($process)); + $reflection = new ReflectionClass($process); + $property = $reflection->getProperty('event'); + $this->assertSame($dispatcher, $property->getValue($process)); } - public function testConstructorSetsEventToNullWhenNotAvailable() + public function testConstructorSetsEventToNullWhenNotAvailable(): void { $container = m::mock(ContainerContract::class); $container->shouldReceive('bound')->with('events')->andReturn(false); $process = new FooProcess($container); - $ref = new ReflectionClass($process); - $prop = $ref->getProperty('event'); - $this->assertNull($prop->getValue($process)); + $reflection = new ReflectionClass($process); + $property = $reflection->getProperty('event'); + $this->assertNull($property->getValue($process)); + } + + public function testBindRejectsFailedNativeProcessRegistration(): void + { + $container = m::mock(ContainerContract::class); + $container->shouldReceive('bound')->with('events')->andReturn(false); + + $process = new FooProcess($container); + $process->enableCoroutine = true; + $server = m::mock(Server::class); + $server->shouldReceive('addProcess')->once()->andReturnUsing(function (SwooleProcess $swooleProcess): false { + $this->nativeProcesses[] = $swooleProcess; + + return false; + }); + + try { + $process->bind($server); + $this->fail('Expected failed native registration to throw.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to register server process [process.0].', $exception->getMessage()); + } + + $this->assertTrue(ProcessCollector::isEmpty()); + $this->assertFalse(@$this->nativeProcesses[0]->write('closed')); + } + + public function testTeardownContinuesAfterAfterProcessEventFailure(): void + { + Sleep::fake(); + CoordinatorManager::initialize(Constants::WORKER_EXIT); + $timerId = Timer::after(60_000, static fn (): null => null); + $afterFailure = new RuntimeException('after event failed'); + + $dispatcher = m::mock(DispatcherContract::class); + $dispatcher->shouldReceive('dispatch')->with(m::type(BeforeProcessHandle::class))->once(); + $dispatcher->shouldReceive('dispatch')->with(m::type(AfterProcessHandle::class))->once()->andThrow($afterFailure); + + $container = m::mock(ContainerContract::class); + $container->shouldReceive('bound')->with('events')->andReturn(true); + $container->shouldReceive('make')->with('events')->andReturn($dispatcher); + + $process = new FooProcess($container); + $server = m::mock(Server::class); + $server->shouldReceive('addProcess')->once()->andReturnUsing(function (SwooleProcess $swooleProcess): int { + $this->nativeProcesses[] = $swooleProcess; + $reflection = new ReflectionClass($swooleProcess); + $callback = $reflection->getProperty('callback')->getValue($swooleProcess); + $callback($swooleProcess); + + return 1; + }); + + $thrown = null; + + try { + $process->bind($server); + } catch (Throwable $exception) { + $thrown = $exception; + } + + $this->assertSame($afterFailure, $thrown); + $this->assertFalse(Timer::exists($timerId)); + $this->assertTrue(CoordinatorManager::until(Constants::WORKER_EXIT)->isClosing()); + Sleep::assertSleptTimes(1); + } + + public function testTeardownPreservesReporterFailureAfterLaterCleanupFailure(): void + { + Sleep::fake(); + $sleepFailure = new RuntimeException('sleep failed'); + Sleep::whenFakingSleep(static function () use ($sleepFailure): void { + throw $sleepFailure; + }); + CoordinatorManager::initialize(Constants::WORKER_EXIT); + $timerId = Timer::after(60_000, static fn (): null => null); + $operationFailure = new RuntimeException('operation failed'); + $reporterFailure = new RuntimeException('reporter failed'); + + $dispatcher = m::mock(DispatcherContract::class); + $dispatcher->shouldReceive('dispatch')->with(m::type(BeforeProcessHandle::class))->once(); + $dispatcher->shouldReceive('dispatch')->with(m::type(AfterProcessHandle::class))->once(); + + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldReceive('report')->with($operationFailure)->once()->andThrow($reporterFailure); + + $container = m::mock(ContainerContract::class); + $container->shouldReceive('bound')->with('events')->andReturn(true); + $container->shouldReceive('make')->with('events')->andReturn($dispatcher); + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturn(true); + $container->shouldReceive('make')->with(ExceptionHandlerContract::class)->andReturn($handler); + + $process = new class($container, $operationFailure) extends AbstractProcess { + public bool $enableCoroutine = false; + + public int $restartInterval = 1; + + public function __construct(ContainerContract $container, private Throwable $failure) + { + parent::__construct($container); + } + + public function handle(): void + { + throw $this->failure; + } + }; + + $server = m::mock(Server::class); + $server->shouldReceive('addProcess')->once()->andReturnUsing(function (SwooleProcess $swooleProcess): int { + $this->nativeProcesses[] = $swooleProcess; + $reflection = new ReflectionClass($swooleProcess); + $callback = $reflection->getProperty('callback')->getValue($swooleProcess); + $callback($swooleProcess); + + return 1; + }); + + $thrown = null; + + try { + $process->bind($server); + } catch (Throwable $exception) { + $thrown = $exception; + } + + $this->assertSame($reporterFailure, $thrown); + $this->assertFalse(Timer::exists($timerId)); + $this->assertTrue(CoordinatorManager::until(Constants::WORKER_EXIT)->isClosing()); + Sleep::assertSleptTimes(1); } } diff --git a/tests/ServerProcess/ExceptionTest.php b/tests/ServerProcess/ExceptionTest.php index 2ad665326..043dd0bc3 100644 --- a/tests/ServerProcess/ExceptionTest.php +++ b/tests/ServerProcess/ExceptionTest.php @@ -4,41 +4,29 @@ namespace Hypervel\Tests\ServerProcess; -use Hypervel\ServerProcess\Exceptions\ServerInvalidException; use Hypervel\ServerProcess\Exceptions\SocketAcceptException; use Hypervel\Tests\TestCase; use RuntimeException; class ExceptionTest extends TestCase { - public function testServerInvalidExceptionExtendsRuntimeException() - { - $exception = new ServerInvalidException('test'); - $this->assertInstanceOf(RuntimeException::class, $exception); - $this->assertSame('test', $exception->getMessage()); - } - - public function testSocketAcceptExceptionExtendsRuntimeException() + public function testSocketAcceptExceptionExtendsRuntimeException(): void { $exception = new SocketAcceptException('test'); $this->assertInstanceOf(RuntimeException::class, $exception); } - public function testSocketAcceptExceptionIsTimeoutWithTimeoutCode() + public function testSocketAcceptExceptionIsTransientByDefault(): void { - $exception = new SocketAcceptException('Socket timed out', SOCKET_ETIMEDOUT); - $this->assertTrue($exception->isTimeout()); - } + $exception = new SocketAcceptException('Socket temporarily unavailable'); - public function testSocketAcceptExceptionIsNotTimeoutWithOtherCode() - { - $exception = new SocketAcceptException('Socket closed', SOCKET_ECONNRESET); - $this->assertFalse($exception->isTimeout()); + $this->assertFalse($exception->isPermanent()); } - public function testSocketAcceptExceptionIsNotTimeoutWithZeroCode() + public function testSocketAcceptExceptionCanBePermanent(): void { - $exception = new SocketAcceptException('Socket closed', 0); - $this->assertFalse($exception->isTimeout()); + $exception = new SocketAcceptException('Socket closed', permanent: true); + + $this->assertTrue($exception->isPermanent()); } } diff --git a/tests/ServerProcess/Fixtures/ListenableProcess.php b/tests/ServerProcess/Fixtures/ListenableProcess.php index e2275bcf6..1dd99bcd3 100644 --- a/tests/ServerProcess/Fixtures/ListenableProcess.php +++ b/tests/ServerProcess/Fixtures/ListenableProcess.php @@ -17,7 +17,9 @@ class ListenableProcess extends AbstractProcess /** * The fake socket to use in listen(). */ - public ?FakeSocket $fakeSocket = null; + public FakeSocket|false $fakeSocket; + + public int $socketExports = 0; public function handle(): void { @@ -35,8 +37,10 @@ public function callListen(Channel $quit): void /** * Return the fake socket instead of calling exportSocket(). */ - protected function getListenSocket(): Socket + protected function getListenSocket(): Socket|false { + ++$this->socketExports; + return $this->fakeSocket; } } diff --git a/tests/ServerProcess/ListenCreationFailureTest.php b/tests/ServerProcess/ListenCreationFailureTest.php new file mode 100644 index 000000000..f71ba958f --- /dev/null +++ b/tests/ServerProcess/ListenCreationFailureTest.php @@ -0,0 +1,41 @@ + 1]); + + SwooleCoroutine\run(function (): void { + $container = m::mock(ContainerContract::class); + $container->shouldReceive('bound')->with('events')->andReturn(false); + + $process = new ListenableProcess($container); + $quit = new Channel(1); + + try { + $process->callListen($quit); + $this->fail('Expected listener coroutine creation to fail.'); + } catch (CoroutineCreateException) { + $this->assertTrue($quit->isClosing()); + $this->assertSame(0, $process->socketExports); + } + }); + } +} diff --git a/tests/ServerProcess/ListenMethodTest.php b/tests/ServerProcess/ListenMethodTest.php index 3a4d2ff29..f4635a6ae 100644 --- a/tests/ServerProcess/ListenMethodTest.php +++ b/tests/ServerProcess/ListenMethodTest.php @@ -4,9 +4,11 @@ namespace Hypervel\Tests\ServerProcess; +use Closure; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\ServerProcess\Events\PipeMessage; use Hypervel\ServerProcess\Exceptions\SocketAcceptException; @@ -14,171 +16,291 @@ use Hypervel\Tests\ServerProcess\Fixtures\ListenableProcess; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; class ListenMethodTest extends TestCase { - public function testListenerContinuesAfterSignalInterruption() + public function testListenerContinuesAfterSignalInterruption(): void { $dispatched = []; $dispatcher = m::mock(DispatcherContract::class); - $dispatcher->shouldReceive('dispatch')->andReturnUsing(function ($event) use (&$dispatched) { + $dispatcher->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$dispatched): void { $dispatched[] = $event; }); $handler = m::mock(ExceptionHandlerContract::class); $handler->shouldReceive('report')->with(m::type(SocketAcceptException::class))->once(); - $container = m::mock(ContainerContract::class); - $container->shouldReceive('bound')->with('events')->andReturn(true); - $container->shouldReceive('make')->with('events')->andReturn($dispatcher); - $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturn(true); - $container->shouldReceive('make')->with(ExceptionHandlerContract::class)->andReturn($handler); - - $process = new ListenableProcess($container); - $process->fakeSocket = new FakeSocket([ + $process = new ListenableProcess($this->makeContainer($dispatcher, $handler)); + $socket = new FakeSocket([ [false, SOCKET_EINTR], // 1st: signal interruption (transient) [serialize(['hello' => 'world']), 0], // 2nd: valid data ]); + $process->fakeSocket = $socket; $quit = new Channel(1); - $process->callListen($quit); - // Give the coroutine time to process both recv() calls. - usleep(50_000); + try { + $process->callListen($quit); + $this->waitUntil(function () use (&$dispatched, $socket): bool { + return count($dispatched) === 1 && $socket->getCallCount() >= 2; + }); + } finally { + $this->stopListener($quit); + $socket->close(); + } - $quit->push(true); - usleep(10_000); - - // The listener should have survived the transient error and dispatched the message. $this->assertCount(1, $dispatched); $this->assertInstanceOf(PipeMessage::class, $dispatched[0]); $this->assertSame(['hello' => 'world'], $dispatched[0]->data); - $this->assertGreaterThanOrEqual(2, $process->fakeSocket->getCallCount()); + $this->assertGreaterThanOrEqual(2, $socket->getCallCount()); + $this->assertSame(1, $process->socketExports); } - public function testListenerContinuesAfterEagain() + public function testListenerContinuesAfterEagain(): void { $dispatched = []; $dispatcher = m::mock(DispatcherContract::class); - $dispatcher->shouldReceive('dispatch')->andReturnUsing(function ($event) use (&$dispatched) { + $dispatcher->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$dispatched): void { $dispatched[] = $event; }); $handler = m::mock(ExceptionHandlerContract::class); $handler->shouldReceive('report')->with(m::type(SocketAcceptException::class))->once(); - $container = m::mock(ContainerContract::class); - $container->shouldReceive('bound')->with('events')->andReturn(true); - $container->shouldReceive('make')->with('events')->andReturn($dispatcher); - $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturn(true); - $container->shouldReceive('make')->with(ExceptionHandlerContract::class)->andReturn($handler); - - $process = new ListenableProcess($container); - $process->fakeSocket = new FakeSocket([ + $process = new ListenableProcess($this->makeContainer($dispatcher, $handler)); + $socket = new FakeSocket([ [false, SOCKET_EAGAIN], // 1st: temporarily unavailable (transient) [serialize(['data' => 'value']), 0], // 2nd: valid data ]); + $process->fakeSocket = $socket; $quit = new Channel(1); - $process->callListen($quit); - - usleep(50_000); - $quit->push(true); - usleep(10_000); + try { + $process->callListen($quit); + $this->waitUntil(function () use (&$dispatched): bool { + return count($dispatched) === 1; + }); + } finally { + $this->stopListener($quit); + $socket->close(); + } $this->assertCount(1, $dispatched); $this->assertInstanceOf(PipeMessage::class, $dispatched[0]); $this->assertSame(['data' => 'value'], $dispatched[0]->data); } - public function testListenerStopsOnPermanentSocketClosure() + public function testListenerStopsOnPermanentSocketClosure(): void { $dispatched = []; $dispatcher = m::mock(DispatcherContract::class); - $dispatcher->shouldReceive('dispatch')->andReturnUsing(function ($event) use (&$dispatched) { + $dispatcher->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$dispatched): void { $dispatched[] = $event; }); $handler = m::mock(ExceptionHandlerContract::class); $handler->shouldReceive('report')->with(m::type(SocketAcceptException::class))->once(); - $container = m::mock(ContainerContract::class); - $container->shouldReceive('bound')->with('events')->andReturn(true); - $container->shouldReceive('make')->with('events')->andReturn($dispatcher); - $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturn(true); - $container->shouldReceive('make')->with(ExceptionHandlerContract::class)->andReturn($handler); - - $process = new ListenableProcess($container); - $process->fakeSocket = new FakeSocket([ + $process = new ListenableProcess($this->makeContainer($dispatcher, $handler)); + $socket = new FakeSocket([ ['', 0], // Permanent closure (empty string) [serialize(['should' => 'not reach']), 0], // Should never be called ]); + $process->fakeSocket = $socket; $quit = new Channel(1); - $process->callListen($quit); - usleep(50_000); + try { + $process->callListen($quit); + $this->waitUntil(fn (): bool => $quit->isClosing()); + } finally { + $this->stopListener($quit); + $socket->close(); + } - // The listener should have stopped — no PipeMessage dispatched, only 1 recv() call. $this->assertCount(0, $dispatched); - $this->assertSame(1, $process->fakeSocket->getCallCount()); - - $quit->push(true); - usleep(10_000); + $this->assertSame(1, $socket->getCallCount()); } - public function testListenerStopsOnConnectionReset() + public function testListenerStopsOnConnectionReset(): void { $handler = m::mock(ExceptionHandlerContract::class); $handler->shouldReceive('report')->with(m::type(SocketAcceptException::class))->once(); - $container = m::mock(ContainerContract::class); - $container->shouldReceive('bound')->with('events')->andReturn(false); - $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturn(true); - $container->shouldReceive('make')->with(ExceptionHandlerContract::class)->andReturn($handler); - - $process = new ListenableProcess($container); - $process->fakeSocket = new FakeSocket([ + $process = new ListenableProcess($this->makeContainer(handler: $handler)); + $socket = new FakeSocket([ [false, SOCKET_ECONNRESET], // Permanent error [serialize(['should' => 'not reach']), 0], // Should never be called ]); + $process->fakeSocket = $socket; $quit = new Channel(1); - $process->callListen($quit); - usleep(50_000); + try { + $process->callListen($quit); + $this->waitUntil(fn (): bool => $quit->isClosing()); + } finally { + $this->stopListener($quit); + $socket->close(); + } - $this->assertSame(1, $process->fakeSocket->getCallCount()); - - $quit->push(true); - usleep(10_000); + $this->assertSame(1, $socket->getCallCount()); } - public function testListenerStopsOnBadFileDescriptor() + public function testListenerStopsOnBadFileDescriptor(): void { $handler = m::mock(ExceptionHandlerContract::class); $handler->shouldReceive('report')->with(m::type(SocketAcceptException::class))->once(); - $container = m::mock(ContainerContract::class); - $container->shouldReceive('bound')->with('events')->andReturn(false); - $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturn(true); - $container->shouldReceive('make')->with(ExceptionHandlerContract::class)->andReturn($handler); - - $process = new ListenableProcess($container); - $process->fakeSocket = new FakeSocket([ + $process = new ListenableProcess($this->makeContainer(handler: $handler)); + $socket = new FakeSocket([ [false, SOCKET_EBADF], // Permanent error [serialize(['should' => 'not reach']), 0], // Should never be called ]); + $process->fakeSocket = $socket; $quit = new Channel(1); - $process->callListen($quit); - usleep(50_000); + try { + $process->callListen($quit); + $this->waitUntil(fn (): bool => $quit->isClosing()); + } finally { + $this->stopListener($quit); + $socket->close(); + } + + $this->assertSame(1, $socket->getCallCount()); + } + + public function testListenerStopsWhenTheProcessSocketCannotBeExported(): void + { + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldReceive('report') + ->with(m::on(fn (SocketAcceptException $exception): bool => $exception->isPermanent() + && $exception->getMessage() === 'Unable to export process IPC socket')) + ->once(); + + $process = new ListenableProcess($this->makeContainer(handler: $handler)); + $process->fakeSocket = false; + $quit = new Channel(1); + + try { + $process->callListen($quit); + $this->waitUntil(fn (): bool => $quit->isClosing()); + } finally { + $this->stopListener($quit); + } + + $this->assertSame(1, $process->socketExports); + } + + public function testListenerClosesItsChannelWhenTheExceptionReporterThrows(): void + { + Coroutine::enableReportException(false); + $reporterFailure = new RuntimeException('reporter failed'); + $handler = m::mock(ExceptionHandlerContract::class); + $handler->shouldReceive('report') + ->with(m::type(SocketAcceptException::class)) + ->once() + ->andThrow($reporterFailure); + + $process = new ListenableProcess($this->makeContainer(handler: $handler)); + $socket = new FakeSocket([['', 0]]); + $process->fakeSocket = $socket; + $quit = new Channel(1); + + try { + $process->callListen($quit); + $this->waitUntil(fn (): bool => $quit->isClosing()); + } finally { + $this->stopListener($quit); + $socket->close(); + } + + $this->assertTrue($quit->isClosing()); + } + + public function testListenerDispatchesEverySerializableFalsyPayload(): void + { + $payloads = [false, null, 0, '', []]; + $dispatched = []; + $dispatcher = m::mock(DispatcherContract::class); + $dispatcher->shouldReceive('dispatch')->andReturnUsing(function (PipeMessage $event) use (&$dispatched): void { + $dispatched[] = $event->data; + }); + + $process = new ListenableProcess($this->makeContainer($dispatcher)); + $socket = new FakeSocket(array_map( + static fn (mixed $payload): array => [serialize($payload), 0], + $payloads, + )); + $process->fakeSocket = $socket; + $quit = new Channel(1); + + try { + $process->callListen($quit); + $this->waitUntil(function () use (&$dispatched, $payloads): bool { + return count($dispatched) === count($payloads); + }); + } finally { + $this->stopListener($quit); + $socket->close(); + } + + $this->assertSame($payloads, $dispatched); + } + + /** + * Create a container for the listener fixture. + */ + private function makeContainer( + ?DispatcherContract $dispatcher = null, + ?ExceptionHandlerContract $handler = null, + ): ContainerContract { + $container = m::mock(ContainerContract::class); + $container->shouldReceive('bound')->with('events')->andReturn($dispatcher !== null); + + if ($dispatcher !== null) { + $container->shouldReceive('make')->with('events')->andReturn($dispatcher); + } + + if ($handler !== null) { + $container->shouldReceive('has')->with(ExceptionHandlerContract::class)->andReturn(true); + $container->shouldReceive('make')->with(ExceptionHandlerContract::class)->andReturn($handler); + } + + return $container; + } + + /** + * Stop the listener and wait for it to release its channel. + */ + private function stopListener(Channel $quit): void + { + if (! $quit->isClosing()) { + $quit->push(true); + } + + $this->waitUntil(fn (): bool => $quit->isClosing()); + } + + /** + * Wait for a condition using a monotonic deadline. + */ + private function waitUntil(Closure $condition, float $timeout = 1.0): void + { + $deadline = hrtime(true) + (int) ($timeout * 1e9); + + while (hrtime(true) < $deadline) { + if ($condition()) { + return; + } - $this->assertSame(1, $process->fakeSocket->getCallCount()); + usleep(1_000); + } - $quit->push(true); - usleep(10_000); + $this->fail('Condition was not met before the deadline.'); } } From d0d9d5ce17d65b2c3b6db88889bac01152c0794a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:22:28 +0000 Subject: [PATCH 11/30] refactor(server-process): clarify process registration Make server.processes the single declarative configuration path and ship its discoverable default alongside the other Swoole server settings. Preserve programmatically registered process instances, remove the duplicate root configuration dependency, and keep registration behavior deterministic across class and instance inputs. Register lifecycle logging as passive observation so reporting failures cannot prevent active Signal setup or teardown, with focused coverage for configuration and listener ordering. --- src/foundation/config/server.php | 13 +++ .../src/Listeners/BootProcessListener.php | 10 +-- .../src/ServerProcessServiceProvider.php | 4 +- .../ServerProcess/BootProcessListenerTest.php | 79 ++++--------------- .../ServerProcessServiceProviderTest.php | 37 +++++++++ 5 files changed, 71 insertions(+), 72 deletions(-) create mode 100644 tests/ServerProcess/ServerProcessServiceProviderTest.php diff --git a/src/foundation/config/server.php b/src/foundation/config/server.php index 0e0853c57..289016342 100644 --- a/src/foundation/config/server.php +++ b/src/foundation/config/server.php @@ -34,6 +34,19 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Server Processes + |-------------------------------------------------------------------------- + | + | Here you may define the custom Swoole processes that should run alongside + | the application server. Each process class will be resolved through the + | service container and attached when the server starts. + | + */ + + 'processes' => [], + /* |-------------------------------------------------------------------------- | Server Settings diff --git a/src/server-process/src/Listeners/BootProcessListener.php b/src/server-process/src/Listeners/BootProcessListener.php index 718c71e83..a0500d712 100644 --- a/src/server-process/src/Listeners/BootProcessListener.php +++ b/src/server-process/src/Listeners/BootProcessListener.php @@ -4,7 +4,6 @@ namespace Hypervel\ServerProcess\Listeners; -use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\ServerProcess\ProcessInterface; use Hypervel\Core\Events\BeforeMainServerStart; @@ -12,10 +11,8 @@ class BootProcessListener { - public function __construct( - private Container $container, - private Repository $config, - ) { + public function __construct(private Container $container) + { } /** @@ -27,11 +24,10 @@ public function handle(BeforeMainServerStart $event): void $serverConfig = $event->serverConfig; $serverProcesses = $serverConfig['processes'] ?? []; - $configProcesses = $this->config->array('processes', []); ProcessManager::setRunning(true); - $processes = array_merge($serverProcesses, $configProcesses, ProcessManager::all()); + $processes = array_merge($serverProcesses, ProcessManager::all()); $seenClasses = []; $seen = []; foreach ($processes as $process) { diff --git a/src/server-process/src/ServerProcessServiceProvider.php b/src/server-process/src/ServerProcessServiceProvider.php index d2ce3d25c..e0ff99a88 100644 --- a/src/server-process/src/ServerProcessServiceProvider.php +++ b/src/server-process/src/ServerProcessServiceProvider.php @@ -25,11 +25,11 @@ public function boot(): void $this->app->make(BootProcessListener::class)->handle($event); }); - $events->listen(AfterProcessHandle::class, function (AfterProcessHandle $event) { + $events->observe(AfterProcessHandle::class, function (AfterProcessHandle $event) { $this->app->make(LogAfterProcessStoppedListener::class)->handle($event); }); - $events->listen(BeforeProcessHandle::class, function (BeforeProcessHandle $event) { + $events->observe(BeforeProcessHandle::class, function (BeforeProcessHandle $event) { $this->app->make(LogBeforeProcessStartListener::class)->handle($event); }); } diff --git a/tests/ServerProcess/BootProcessListenerTest.php b/tests/ServerProcess/BootProcessListenerTest.php index a9ed25960..3649276cc 100644 --- a/tests/ServerProcess/BootProcessListenerTest.php +++ b/tests/ServerProcess/BootProcessListenerTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\ServerProcess; -use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\ServerProcess\ProcessInterface; use Hypervel\Core\Events\BeforeMainServerStart; @@ -16,7 +15,7 @@ class BootProcessListenerTest extends TestCase { - public function testBootsProcessesFromConfig() + public function testBootsProcessesFromServerConfig(): void { $server = m::mock(Server::class); $process = new BootProcessListenerFakeProcess; @@ -27,36 +26,7 @@ public function testBootsProcessesFromConfig() ->with(BootProcessListenerFakeProcess::class) ->andReturn($process); - $config = m::mock(Repository::class); - $config->shouldReceive('array') - ->with('processes', []) - ->andReturn([BootProcessListenerFakeProcess::class]); - - $listener = new BootProcessListener($container, $config); - $event = new BeforeMainServerStart($server, ['processes' => []]); - - $listener->handle($event); - - $this->assertSame(1, $process->enableChecks); - $this->assertSame(1, $process->binds); - $this->assertTrue(ProcessManager::isRunning()); - } - - public function testBootsProcessesFromServerConfig() - { - $server = m::mock(Server::class); - $process = new BootProcessListenerFakeProcess; - - $container = m::mock(ContainerContract::class); - $container->shouldReceive('make') - ->once() - ->with(BootProcessListenerFakeProcess::class) - ->andReturn($process); - - $config = m::mock(Repository::class); - $config->shouldReceive('array')->with('processes', [])->andReturn([]); - - $listener = new BootProcessListener($container, $config); + $listener = new BootProcessListener($container); $event = new BeforeMainServerStart($server, ['processes' => [BootProcessListenerFakeProcess::class]]); $listener->handle($event); @@ -66,7 +36,7 @@ public function testBootsProcessesFromServerConfig() $this->assertTrue(ProcessManager::isRunning()); } - public function testBootsProcessesFromProcessManager() + public function testBootsProcessesFromProcessManager(): void { $server = m::mock(Server::class); @@ -76,10 +46,7 @@ public function testBootsProcessesFromProcessManager() $container = m::mock(ContainerContract::class); $container->shouldNotReceive('make'); - $config = m::mock(Repository::class); - $config->shouldReceive('array')->with('processes', [])->andReturn([]); - - $listener = new BootProcessListener($container, $config); + $listener = new BootProcessListener($container); $event = new BeforeMainServerStart($server, []); $listener->handle($event); @@ -89,7 +56,7 @@ public function testBootsProcessesFromProcessManager() $this->assertTrue(ProcessManager::isRunning()); } - public function testSkipsProcessWhereIsEnabledReturnsFalse() + public function testSkipsProcessWhereIsEnabledReturnsFalse(): void { $server = m::mock(Server::class); $process = new BootProcessListenerFakeProcess(enabled: false); @@ -100,13 +67,8 @@ public function testSkipsProcessWhereIsEnabledReturnsFalse() ->with(BootProcessListenerFakeProcess::class) ->andReturn($process); - $config = m::mock(Repository::class); - $config->shouldReceive('array') - ->with('processes', []) - ->andReturn([BootProcessListenerFakeProcess::class]); - - $listener = new BootProcessListener($container, $config); - $event = new BeforeMainServerStart($server, []); + $listener = new BootProcessListener($container); + $event = new BeforeMainServerStart($server, ['processes' => [BootProcessListenerFakeProcess::class]]); $listener->handle($event); @@ -114,17 +76,14 @@ public function testSkipsProcessWhereIsEnabledReturnsFalse() $this->assertSame(0, $process->binds); } - public function testHandlesMissingProcessesKeyInServerConfig() + public function testHandlesMissingProcessesKeyInServerConfig(): void { $server = m::mock(Server::class); $container = m::mock(ContainerContract::class); $container->shouldNotReceive('make'); - $config = m::mock(Repository::class); - $config->shouldReceive('array')->with('processes', [])->andReturn([]); - - $listener = new BootProcessListener($container, $config); + $listener = new BootProcessListener($container); $event = new BeforeMainServerStart($server, []); $listener->handle($event); @@ -132,7 +91,7 @@ public function testHandlesMissingProcessesKeyInServerConfig() $this->assertTrue(ProcessManager::isRunning()); } - public function testDedupesDuplicateClassStringRegistration() + public function testDedupesDuplicateClassStringRegistration(): void { $server = m::mock(Server::class); $process = new BootProcessListenerFakeProcess; @@ -143,13 +102,10 @@ public function testDedupesDuplicateClassStringRegistration() ->with(BootProcessListenerFakeProcess::class) ->andReturn($process); - $config = m::mock(Repository::class); - $config->shouldReceive('array') - ->with('processes', []) - ->andReturn([BootProcessListenerFakeProcess::class]); - - $listener = new BootProcessListener($container, $config); - $event = new BeforeMainServerStart($server, ['processes' => [BootProcessListenerFakeProcess::class]]); + $listener = new BootProcessListener($container); + $event = new BeforeMainServerStart($server, [ + 'processes' => [BootProcessListenerFakeProcess::class, BootProcessListenerFakeProcess::class], + ]); $listener->handle($event); @@ -157,7 +113,7 @@ public function testDedupesDuplicateClassStringRegistration() $this->assertSame(1, $process->binds); } - public function testDoesNotDedupDistinctInstancesOfSameClass() + public function testDoesNotDedupDistinctInstancesOfSameClass(): void { $server = m::mock(Server::class); @@ -169,10 +125,7 @@ public function testDoesNotDedupDistinctInstancesOfSameClass() $container = m::mock(ContainerContract::class); $container->shouldNotReceive('make'); - $config = m::mock(Repository::class); - $config->shouldReceive('array')->with('processes', [])->andReturn([]); - - $listener = new BootProcessListener($container, $config); + $listener = new BootProcessListener($container); $event = new BeforeMainServerStart($server, []); $listener->handle($event); diff --git a/tests/ServerProcess/ServerProcessServiceProviderTest.php b/tests/ServerProcess/ServerProcessServiceProviderTest.php new file mode 100644 index 000000000..29a2e5e7a --- /dev/null +++ b/tests/ServerProcess/ServerProcessServiceProviderTest.php @@ -0,0 +1,37 @@ +shouldReceive('listen') + ->with(BeforeMainServerStart::class, m::type(Closure::class)) + ->once(); + $events->shouldReceive('observe') + ->with(AfterProcessHandle::class, m::type(Closure::class)) + ->once(); + $events->shouldReceive('observe') + ->with(BeforeProcessHandle::class, m::type(Closure::class)) + ->once(); + + $application = m::mock(Application::class); + $application->shouldReceive('make')->with('events')->once()->andReturn($events); + + (new ServerProcessServiceProvider($application))->boot(); + } +} From 88d37d9e60b2c44e1875b98fd0b29601d0c88230 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:22:35 +0000 Subject: [PATCH 12/30] test(server-process): avoid native handles in registry coverage Use mocked Swoole process handles for ProcessCollector assertions so pure static-registry tests do not consume native process resources. Remove the redundant ProcessTest coverage now subsumed by the deterministic AbstractProcess lifecycle suite, reducing parallel resource pressure without weakening supported behavior coverage. --- tests/ServerProcess/ProcessCollectorTest.php | 31 ++++----- tests/ServerProcess/ProcessTest.php | 66 -------------------- 2 files changed, 16 insertions(+), 81 deletions(-) delete mode 100644 tests/ServerProcess/ProcessTest.php diff --git a/tests/ServerProcess/ProcessCollectorTest.php b/tests/ServerProcess/ProcessCollectorTest.php index 787638ea2..629cb7842 100644 --- a/tests/ServerProcess/ProcessCollectorTest.php +++ b/tests/ServerProcess/ProcessCollectorTest.php @@ -6,18 +6,19 @@ use Hypervel\ServerProcess\ProcessCollector; use Hypervel\Tests\TestCase; +use Mockery as m; use Swoole\Process; class ProcessCollectorTest extends TestCase { - public function testIsEmptyInitially() + public function testIsEmptyInitially(): void { $this->assertTrue(ProcessCollector::isEmpty()); } - public function testAddAndGetByName() + public function testAddAndGetByName(): void { - $process = new Process(function () {}); + $process = m::mock(Process::class); ProcessCollector::add('worker', $process); @@ -25,15 +26,15 @@ public function testAddAndGetByName() $this->assertFalse(ProcessCollector::isEmpty()); } - public function testGetReturnsEmptyArrayForUnknownName() + public function testGetReturnsEmptyArrayForUnknownName(): void { $this->assertSame([], ProcessCollector::get('nonexistent')); } - public function testAddMultipleProcessesUnderSameName() + public function testAddMultipleProcessesUnderSameName(): void { - $process1 = new Process(function () {}); - $process2 = new Process(function () {}); + $process1 = m::mock(Process::class); + $process2 = m::mock(Process::class); ProcessCollector::add('worker', $process1); ProcessCollector::add('worker', $process2); @@ -43,10 +44,10 @@ public function testAddMultipleProcessesUnderSameName() $this->assertSame($process2, ProcessCollector::get('worker')[1]); } - public function testAddProcessesUnderDifferentNames() + public function testAddProcessesUnderDifferentNames(): void { - $process1 = new Process(function () {}); - $process2 = new Process(function () {}); + $process1 = m::mock(Process::class); + $process2 = m::mock(Process::class); ProcessCollector::add('queue', $process1); ProcessCollector::add('scheduler', $process2); @@ -55,11 +56,11 @@ public function testAddProcessesUnderDifferentNames() $this->assertSame([$process2], ProcessCollector::get('scheduler')); } - public function testAllReturnsFlattenedArray() + public function testAllReturnsFlattenedArray(): void { - $process1 = new Process(function () {}); - $process2 = new Process(function () {}); - $process3 = new Process(function () {}); + $process1 = m::mock(Process::class); + $process2 = m::mock(Process::class); + $process3 = m::mock(Process::class); ProcessCollector::add('queue', $process1); ProcessCollector::add('queue', $process2); @@ -72,7 +73,7 @@ public function testAllReturnsFlattenedArray() $this->assertSame($process3, $all[2]); } - public function testAllReturnsEmptyArrayWhenEmpty() + public function testAllReturnsEmptyArrayWhenEmpty(): void { $this->assertSame([], ProcessCollector::all()); } diff --git a/tests/ServerProcess/ProcessTest.php b/tests/ServerProcess/ProcessTest.php deleted file mode 100644 index b7c9bb561..000000000 --- a/tests/ServerProcess/ProcessTest.php +++ /dev/null @@ -1,66 +0,0 @@ - */ - public static array $dispatched = []; - - protected function tearDown(): void - { - parent::tearDown(); - self::$dispatched = []; - } - - public function testEventWhenThrowExceptionInProcess() - { - $container = $this->getContainer(); - $process = new FooProcess($container); - $process->bind($this->getServer()); - - $this->assertInstanceOf(BeforeProcessHandle::class, self::$dispatched[0]); - $this->assertInstanceOf(AfterProcessHandle::class, self::$dispatched[1]); - } - - protected function getContainer(): ContainerContract - { - $container = m::mock(ContainerContract::class); - - $container->shouldReceive('bound')->with('events')->andReturn(true); - $container->shouldReceive('make')->with('events')->andReturnUsing(function () { - $dispatcher = m::mock(DispatcherContract::class); - $dispatcher->shouldReceive('dispatch')->withAnyArgs()->andReturnUsing(function ($event) { - self::$dispatched[] = $event; - }); - return $dispatcher; - }); - - return $container; - } - - protected function getServer(): Server - { - $server = m::mock(Server::class); - $server->shouldReceive('addProcess')->withAnyArgs()->andReturnUsing(function ($process) { - $ref = new ReflectionClass($process); - $property = $ref->getProperty('callback'); - $callback = $property->getValue($process); - $callback($process); - return 1; - }); - return $server; - } -} From 1b6b7b6192e89a9c22bd0a9fe9625147621b121b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:22:43 +0000 Subject: [PATCH 13/30] docs(server-process): document custom server processes Add the package provenance and a concise authoring guide covering process definitions, declarative and programmatic registration, and container resolution. Document lifecycle events, Signal integration, restart behavior, serialized IPC fidelity, trusted-data expectations, and server-owned native handle boundaries. Align the package description with the established server-process terminology. --- src/server-process/README.md | 56 +++++++++++++++++++++++++++++++- src/server-process/composer.json | 2 +- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/server-process/README.md b/src/server-process/README.md index c798b6d2a..daf9f6c6f 100644 --- a/src/server-process/README.md +++ b/src/server-process/README.md @@ -3,4 +3,58 @@ Server Process for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/server-process) -Manages custom Swoole worker processes that run alongside the HTTP server. \ No newline at end of file +Ported from: https://github.com/hyperf/hyperf/tree/master/src/process + +## Defining Server Processes + +Server processes are custom Swoole child processes attached to the application +server. Define one by extending `AbstractProcess` and implementing `handle()`: + +```php +use Hypervel\ServerProcess\AbstractProcess; + +class ReportProcess extends AbstractProcess +{ + public string $name = 'reports'; + + public int $processCount = 2; + + public function handle(): void + { + // Run the process workload... + } +} +``` + +`isEnabled()` may be overridden when a process should only run for a particular +server configuration. + +## Registering Server Processes + +Register process classes in `config/server.php`: + +```php +'processes' => [ + ReportProcess::class, +], +``` + +Classes are resolved through the service container and attached before the +server starts. When multiple distinctly configured instances of the same class +are needed, register those instances with `ProcessManager::register()` from a +service provider during boot. + +## Lifecycle and IPC + +Swoole owns each process after it is attached to the server. Hypervel dispatches +`BeforeProcessHandle` and `AfterProcessHandle` around `handle()`, sends uncaught +exceptions to the framework exception handler, completes child-local timer and +coordinator teardown, and applies `restartInterval` before the process callback +returns. When the Signal package is installed, process-scoped handlers +configured in `signal.handlers` are active for the same lifecycle. + +Coroutine-enabled processes listen for serialized values written through the +native handles exposed by `ProcessCollector`. Each valid value dispatches a +`PipeMessage`, including `false`, `null`, zero, empty strings, and empty arrays. +IPC is an internal application boundary: only write trusted serialized data, +and do not close collected handles owned by the server. diff --git a/src/server-process/composer.json b/src/server-process/composer.json index 212dbf74d..2b29736ca 100644 --- a/src/server-process/composer.json +++ b/src/server-process/composer.json @@ -1,7 +1,7 @@ { "name": "hypervel/server-process", "type": "library", - "description": "Manages custom Swoole worker processes for Hypervel.", + "description": "Manages custom Swoole server processes for Hypervel.", "license": "MIT", "keywords": [ "php", From 8eb159f03ed6d959e544a5cfdb0ebc00c2404e95 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:22:50 +0000 Subject: [PATCH 14/30] docs(audit): complete server process lifecycle audit Record the verified Server Process findings, accepted corrections, rejected speculative machinery, regression coverage, validation results, API outcome, and performance assessment. Mark server-process complete, carry its Foundation configuration dependency forward, and route the audit to filesystem with every required shared finding named explicitly. --- ...-coroutine-state-lifecycle-audit-ledger.md | 30 +++++++++++++++++++ ...amework-coroutine-state-lifecycle-audit.md | 9 +++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md index 6f3d76dd7..869311bf3 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md @@ -767,3 +767,33 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** The final changed Process suite passes with blocking real-process coverage enabled: 75 tests and 223 assertions. Focused testing cleanup passes with 16 tests and 71 assertions, and focused Concurrency coverage passes with 47 tests and 92 assertions. PHP CS Fixer changed none of 5,571 files; both PHPStan configurations pass; the complete components suite passes with 23,215 tests, 66,114 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; and `git diff --check` is clean. Fresh lifecycle, API, failure-precedence, performance, stale-code, and overengineering review is complete, and independent code review signed off after the empty-pool contract and Concurrency revalidation records were added. - **Laravel-facing result:** Current Laravel concrete API parity is restored through the real invoked-process macro surface, while the already-ported concrete stop and timeout-check methods remain current. Hypervel deliberately exposes stop and timeout checking through its contract because both real and fake implementations must provide lifecycle behavior to callers typed against the value `start()` returns; Laravel's contract omits those methods. Existing call shapes and public configuration are unchanged, and the remaining lifecycle corrections harden undocumented failure paths and make fakes honor the advertised contract. - **Assessment:** Every mechanism has a demonstrated job at its lowest owner. Callback-free paths remain allocation-free, normal execution gains no registry, context slot, lock, retry, poll, yield, container lookup, or retained worker state, and exhaustive work is limited to failure or explicit cleanup. The rejected registry, defer, destructor, shutdown, weak-reference, cancellation, retry, fake-abstraction, and compatibility proposals remain unnecessary. + +### Make custom server processes failure-safe + +- **Architecture and inspected risk surfaces:** Server Process is Hyperf-derived, Swoole-only infrastructure for custom child processes attached to the application server. `AbstractProcess` owns native process construction, IPC socket listening, child-local lifecycle events, failure reporting, teardown, and restart backoff; `BootProcessListener` merges declarative and programmatic registrations before server start; `ProcessManager` retains boot-only configured instances; and `ProcessCollector` exposes successfully registered coroutine-enabled native handles. The audit covered every package source and test file, the Server Process contract and split metadata, Server and Signal lifecycle providers/listeners, Events active/passive dispatch semantics, Engine channel and Coroutine creation behavior, Coordinator/Timer/Sleep teardown, all repository consumers, current Hyperf Process source/tests, the Swoole native stubs, focused runtime probes, and the Hypervel default server configuration. + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `server-process-01` | Defect | Major | High | `Swoole\Server::addProcess()` can return `false`, but `AbstractProcess::bind()` ignores it and publishes the rejected process to `ProcessCollector` | Check the exact native result, close the failed process pipe without replacing the registration failure, throw descriptively, and publish only after success; do not build sibling rollback because the propagated failure aborts server boot before accepted siblings start | +| `server-process-02` | Defect | Major | High | `exportSocket()` can return `false`; the declared `Socket` return leaks a `TypeError` into the retry loop, which re-exports and re-reports forever | Make the protected native boundary return `Socket|false`, convert `false` into one permanent socket failure, export once before the receive loop, and reuse the exact socket | +| `server-process-03` | Defect | Major | High | Truthiness-based deserialization drops valid serialized `false`, `null`, `0`, empty-string, and empty-array payloads despite `PipeMessage::$data` being `mixed` | Filter timeout `false` before deserialization, deserialize the narrowed string once, and dispatch every valid value including serialized `false` | +| `server-process-04` | Defect | Minor | High | Coroutine creation failure leaves the untransferred quit channel open, while a throwing listener reporter skips its trailing close | Close the channel on typed creation failure, transfer terminal ownership only after successful spawn, and close from the listener in `finally`; normal push-after-close and repeated-close `false` results are not cleanup failures | +| `server-process-05` | Defect | Major | High | A throwing after-event or failure reporter skips independent quit notification, timer clearing, coordinator resume, and restart backoff, allowing incomplete teardown and immediate respawn | Run fixed teardown steps independently, retain the earliest unhandled failure, use fakeable `Sleep`, complete backoff before rethrow, and avoid a closure-list abstraction | +| `server-process-06` | Defect | Major | High | Active logging listeners run before Signal listeners, so a startup log failure prevents process signal registration for the full incarnation and an exit log failure prevents deregistration | Register both logging callbacks as passive observers, keep Signal lifecycle listeners active, and keep before/after dispatch unconditional | +| `server-process-07` | Defect | Minor | High | Native `Swoole\Process` construction in coroutine/parallel tests fails under resource pressure, pure registry tests acquire native processes, and fixed-sleep listener tests can finish before child-owned cleanup | Remove the fully superseded `ProcessTest`, mock pure registry handles, use bounded monotonic synchronization, await listener closure, and close every real process/socket owned by a test | +| `server-process-08` | Improvement | Improvement | High | `ServerInvalidException` is unreachable under the Swoole-only typed contract, while `SocketAcceptException::isTimeout()` cannot describe any constructed exception and both retain only self-tests | With owner approval, delete both dead surfaces and their self-only coverage without aliases or tombstones | +| `server-process-09` | Userland footgun | Minor | High | The package README lacks provenance and does not explain custom-process definition, registration, lifecycle, IPC, signal, or ownership boundaries | Add concise usage and lifecycle guidance at the package surface without a separate guide or Hyperf-parity claims | +| `server-process-10` | Improvement | Improvement | High | Public/protected Hyperf abbreviations remain in the authoring API, while an unshipped root `processes` input duplicates the typed `server.processes` path and adds a config dependency without distinct capability | With owner approval, use `processCount`, `receiveLength`, and `receiveTimeout`, clean touched locals, make `server.processes` the sole declarative path, keep configured-instance `ProcessManager::register()`, and ship the canonical empty server-config section with Laravel-style guidance | + +- **Laravel-shaped API boundary:** Laravel has no corresponding custom-server-process API. Hypervel's container-resolved class, constructor injection, public options, `handle()`, `isEnabled()`, server configuration, and service-provider registration already follow Laravel authoring conventions. The Swoole-native option names remain because they describe real supported runtime concepts. `AbstractProcess` and `AbstractProcessTest` retain their precise class-to-test mapping; no generic `Process` rename is introduced. +- **Owner-approved improvements and removals:** The owner approved deleting the redundant `ProcessTest`, unreachable `ServerInvalidException`, dead `SocketAcceptException::isTimeout()`, and the root `processes` input plus its source-only test. The owner also approved completing the Hyperf port's missed naming cleanup and adding the discoverable `server.processes` default. Registration capability remains unchanged: declarative classes or instances live under `server.processes`, while `ProcessManager::register()` retains multiple distinctly configured instances of the same class. +- **Important rejected concerns:** Do not add generic or coroutine-server support, annotation/attribute discovery, a facade, another manager, a generator, a fluent descriptor, `handle()` method-injection machinery, a factory, registry, mutex, state machine, retry/reconnect loop, configurable teardown timeout, destructor cleanup, provider-ordering API, Signal fallback, class-string manager overload, compatibility alias, or per-spawn/per-message defensive recheck. Keep the 1 ms quit poll, ProcessCollector, instance-only ProcessManager registration, standalone package boundary, trusted same-application deserialization, and Swoole-native option names. `InitProcessTitleListener` remains active and belongs to the later Server audit. +- **Cross-package implications:** Foundation owns the shipped `config/server.php` default and receives the canonical empty custom-process section as part of `server-process-10`; no completed lower-level assumption changes. +- **Performance and compatibility:** Socket export moves out of the receive loop and removes repeated native work. Registration checks and config consolidation are boot-only; exhaustive cleanup and backoff run only at child exit/failure. The receive path keeps one deserialization per message and gains no allocation, lookup, lock, retry, yield, or retained state. No Laravel public surface changes; Hyperf parity is intentionally reduced where its duplicate config and dead/generic surfaces do not fit Hypervel. +- **Regression strategy:** Prove failed native registration is never collected; socket export failure is one permanent fixture-driven failure and successful export occurs once; every valid falsy IPC payload dispatches; creation/reporter failures close listener-owned channels; double cleanup preserves the reporter failure after every teardown step and backoff; passive logging cannot block active Signal lifecycle; deterministic tests release native resources; and the surviving declarative/programmatic registration paths retain class and distinct-instance behavior. The quit-notification branch is structurally covered by the later teardown assertions rather than a coroutine-enabled callback fixture that would duplicate the same fixed cleanup shape with extra native test machinery. Run each changed test file immediately, the full Server Process package, PHPStan and formatting through `composer fix`, then perform a fresh lifecycle/API/performance/stale-code/overengineering review and independent code review. +- **Implementation:** Native process registration is checked before collector publication, with exact-handle pipe cleanup on failure. IPC socket export is checked once and reused; listener creation and terminal channel ownership are exception-safe; valid falsy payloads reach `PipeMessage`; and callback teardown runs every independent step while preserving the earliest unhandled failure. Logging is passive so it cannot interrupt active Signal lifecycle work. The authoring names, canonical server configuration, metadata, and documentation are Laravel-shaped, while the duplicate root config path and dead exception surfaces are removed completely. +- **Regression tests:** Focused coverage proves failed registration is not collected, socket export failure is permanent and successful export occurs once, every supported falsy payload dispatches, listener-owned channels close on creation and reporting failure, teardown remains exhaustive with deterministic failure precedence and backoff, and passive logging cannot block active lifecycle listeners. Registry tests use mock handles, process/listener tests synchronize on observable completion, and every native process and socket created by a test has explicit cleanup. +- **Cross-package revalidation:** Foundation's default server configuration now owns the canonical empty `server.processes` section. Signal's active lifecycle listeners still run before passive Server Process logging observers, and no completed lower-level package assumption changed. Focused lifecycle coverage and the full suite remain green. +- **Validation and review:** The final focused `AbstractProcessTest` passes with 13 tests and 42 assertions; the serial and 16-worker Server Process suites each pass with 58 tests and 143 assertions. PHP CS Fixer changed none of 5,571 files; both PHPStan configurations pass; the complete components suite passes with 23,219 tests, 66,136 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; package Composer metadata validates; and `git diff --check` is clean. Fresh lifecycle, API, native-boundary, failure-precedence, performance, stale-code, and overengineering review is complete, and independent code review signed off after the collector regression and socket warning boundary were corrected. +- **Laravel-facing result:** Laravel has no corresponding custom-server-process API or configuration. Hypervel's Swoole-only surface now follows Laravel authoring conventions through container-resolved process classes, descriptive option names, service-provider registration, and the canonical `server.processes` configuration. The owner-approved changes remove only Hypervel/Hyperf-derived dead or duplicate surfaces; they do not create a Laravel public or configuration divergence. +- **Assessment:** Every change addresses a verified defect, a documented footgun, or an owner-approved simplification. Normal message handling gains no allocation, container lookup, lock, registry, retry, poll, yield, or retained worker state, while exporting the socket once removes repeated native work. Boot-only checks and failure/exit cleanup have no hot-path cost, and the rejected compatibility, discovery, lifecycle, and generic-server machinery remains unnecessary. diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md index 35e8cdda1..c56d89b5c 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `server-process` -- **Ledger entries required for the active work:** None. -- **Pending revalidation carried into the active work:** None for Server Process. Later package revalidation remains tracked in the cross-package dependency index. +- **Active package or work unit:** `filesystem` +- **Ledger entries required for the active work:** `Harden framework contracts and request-scoped state` (`filesystem-01`); `Make coroutine creation and copied context failure-safe` (`coroutine-05`); `Correct AOP proxy generation and publication` (`filesystem-02`); `Harden encryption rotation, key publication, and global lifecycle state` (`filesystem-03`, `encryption-03`); and `Normalize framework enum identifiers at string boundaries` (`support-02`). +- **Pending revalidation carried into the active work:** Revalidate `filesystem-01`, `coroutine-05`, `filesystem-02`, `filesystem-03`, `encryption-03`, and `support-02` during the full Filesystem audit. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1053,6 +1053,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `encryption-03` | `encryption` | `contracts` and `support` (revalidation complete), `filesystem`, `foundation`; later full `filesystem` and `foundation` audits | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | | `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | | `process-02` | `process` | `concurrency` (revalidation complete) | `Make Process callbacks and pools failure-safe`; finding `process-02` | +| `server-process-10` | `server-process` | `foundation`; later full `foundation` audit | `Make custom server processes failure-safe`; finding `server-process-10` | ## Package checklist @@ -1104,7 +1105,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `pool` - [x] `object-pool` - [x] `process` -- [ ] `server-process` +- [x] `server-process` - [ ] `filesystem` ### Framework dispatch and runtime From 9c9c565ed31330a365074a029e727313d4ff9776 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:07:17 +0000 Subject: [PATCH 15/30] docs(server-process): add application guide Document how application and package authors define, configure, and register custom Swoole server processes, including process options, lifecycle events, signals, and boot-time ownership boundaries. Add source-backed IPC send and receive examples covering collector scope, complete datagram writes, falsy payloads, message sizing, and trusted-data requirements. Link the guide beside the Process facade documentation so the two distinct process APIs are easy to discover. --- src/boost/docs/documentation.md | 1 + src/boost/docs/server-process.md | 206 +++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/boost/docs/server-process.md diff --git a/src/boost/docs/documentation.md b/src/boost/docs/documentation.md index 5dbdaa1a6..86fb69e1a 100644 --- a/src/boost/docs/documentation.md +++ b/src/boost/docs/documentation.md @@ -52,6 +52,7 @@ - [Notifications](/docs/{{version}}/notifications) - [Package Development](/docs/{{version}}/packages) - [Processes](/docs/{{version}}/processes) + - [Server Processes](/docs/{{version}}/server-process) - [Queues](/docs/{{version}}/queues) - [Rate Limiting](/docs/{{version}}/rate-limiting) - [Search](/docs/{{version}}/search) diff --git a/src/boost/docs/server-process.md b/src/boost/docs/server-process.md new file mode 100644 index 000000000..76eb3fb03 --- /dev/null +++ b/src/boost/docs/server-process.md @@ -0,0 +1,206 @@ +# Server Processes + +- [Introduction](#introduction) +- [Defining Server Processes](#defining-server-processes) + - [Process Options](#process-options) + - [Conditionally Enabling Processes](#conditionally-enabling-processes) +- [Registering Server Processes](#registering-server-processes) + - [Configuration](#configuration) + - [Registering Process Instances](#registering-process-instances) +- [Process Lifecycle](#process-lifecycle) + - [Lifecycle Events](#lifecycle-events) + - [Signals](#signals) +- [Inter-Process Communication](#inter-process-communication) + - [Sending Messages](#sending-messages) + - [Receiving Messages](#receiving-messages) + + +## Introduction + +Server processes are custom Swoole child processes that run alongside your Hypervel application server. They are useful for long-running, application-specific workloads that need their own operating system process and should share the server's lifecycle. + +Server processes are different from the [Process facade](/docs/{{version}}/processes), which invokes external commands on demand. For ordinary background jobs, scheduled work, or concurrent subtasks, consider using [queues](/docs/{{version}}/queues), [task scheduling](/docs/{{version}}/scheduling), or [concurrency](/docs/{{version}}/concurrency) instead. + + +## Defining Server Processes + +To define a server process, extend the `AbstractProcess` class and implement its `handle` method. Server process classes are resolved through the service container, so you may use constructor injection: + +```php +consumer->listen($this->queue); + } +} +``` + +The `handle` method is the entry point for each child process. If you define a custom constructor, always call the parent constructor so Hypervel can initialize the process correctly. + + +### Process Options + +You may customize a process by overriding the following properties on your process class: + +| Property | Default | Description | +|---|---:|---| +| `name` | `process` | Name used for process titles, logs, and `ProcessCollector` groups. | +| `processCount` | `1` | Number of child process instances to attach to the server. | +| `redirectStdinStdout` | `false` | Whether Swoole redirects standard input and output to the process pipe. Leave disabled unless you specifically need this native behavior. | +| `pipeType` | `SOCK_DGRAM` | Native Swoole process pipe type. Keep the default when using Hypervel's IPC support. | +| `enableCoroutine` | `true` | Whether the child uses Swoole coroutine mode. Hypervel's IPC listener and collector integration also require this option. | +| `receiveLength` | `65535` | Maximum number of bytes read from one IPC message. | +| `receiveTimeout` | `10.0` | Maximum seconds an IPC receive waits before checking again. | +| `restartInterval` | `5` | Seconds to wait after `handle` finishes or throws before the child callback returns. Swoole restarts the managed process after the callback returns, so this delay throttles the respawn rate. | + + +### Conditionally Enabling Processes + +By default, a registered process is enabled for every application server. You may override the `isEnabled` method when a process should only be attached to a particular `Swoole\Server` instance or application configuration. + + +## Registering Server Processes + + +### Configuration + +Most server processes should be registered by adding their class names to the `processes` array in your application's `config/server.php` file: + +```php +'processes' => [ + App\Processes\ReportProcess::class, +], +``` + +Hypervel resolves each class through the service container and attaches the enabled processes before the server starts. + + +### Registering Process Instances + +When you need multiple differently configured instances of the same process class, you may register each instance through `ProcessManager` from a service provider's `boot` method: + +```php +use App\Processes\ReportProcess; +use Hypervel\ServerProcess\ProcessManager; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + foreach (['critical', 'bulk'] as $queue) { + $process = $this->app->make(ReportProcess::class, [ + 'queue' => $queue, + ]); + + $process->name = "reports.{$queue}"; + + ProcessManager::register($process); + } +} +``` + +Use programmatic registration instead of a configuration entry for these instances, otherwise the default class configuration will be attached as an additional process. + +> [!WARNING] +> Process registration is boot-time configuration. Register processes from a service provider before the server starts, not from requests, jobs, or other runtime application code. + + +## Process Lifecycle + +When the server starts, Hypervel resolves each registered definition, calls its `isEnabled` method, and attaches the configured number of child processes. Each child invokes `handle`. If `handle` throws an exception, Hypervel reports it through the application's exception handler. Once the callback returns, Swoole restarts the managed process while the server remains running. + +Your `handle` method should remain running for the lifetime of its workload and return only when the child is ready to exit and be restarted. + + +### Lifecycle Events + +Hypervel dispatches `Hypervel\ServerProcess\Events\BeforeProcessHandle` before calling `handle` and `Hypervel\ServerProcess\Events\AfterProcessHandle` after it finishes. Both events expose the process definition and its zero-based child index. You may listen for these events using Hypervel's normal [event listeners](/docs/{{version}}/events#registering-events-and-listeners). + + +### Signals + +When the Signal package is installed, process-scoped handlers configured in `signal.handlers` are active while the server process is running. This allows the same configured signal infrastructure to participate in custom child processes without adding signal handling to the process class itself. + + +## Inter-Process Communication + +Coroutine-enabled server processes may receive serialized messages from the application's server workers. Hypervel stores successfully attached process handles in `ProcessCollector`, grouped by the process `name`. + + +### Sending Messages + +`ProcessCollector` is populated when the application server boots and is available to the server's own workers. A separate process, such as a standalone Artisan command, has an empty collector and sends nothing. + +To send a message to every child in a process group, retrieve the handles, export each IPC socket, and send the serialized payload: + +```php +use Hypervel\ServerProcess\ProcessCollector; +use RuntimeException; + +$message = serialize([ + 'type' => 'refresh-reports', +]); +$length = strlen($message); + +foreach (ProcessCollector::get('reports') as $process) { + $socket = $process->exportSocket(); + + if ($socket === false || $socket->send($message) !== $length) { + throw new RuntimeException('Unable to send message to the reports process.'); + } +} +``` + +The loop above broadcasts the message to every child named `reports`. Select a single returned handle instead when only one child should receive the message. + + +### Receiving Messages + +Hypervel deserializes each valid message in the custom child and dispatches a `PipeMessage` event. Register a listener during application boot and inspect the event's `data` property: + +```php +use Hypervel\ServerProcess\Events\PipeMessage; +use Hypervel\Support\Facades\Event; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + Event::listen(PipeMessage::class, function (PipeMessage $event): void { + if ($event->data === ['type' => 'refresh-reports']) { + // Refresh the reports... + } + }); +} +``` + +The listener runs in the custom child process. Since `PipeMessage` contains only the deserialized data, include any routing information your listener needs in the payload. Valid falsy values such as `false`, `null`, `0`, empty strings, and empty arrays are preserved. The serialized message must fit within the process's `receiveLength`. + +> [!WARNING] +> Server-process IPC uses PHP serialization and is an internal application boundary. Only send trusted data. The collected process handles and exported sockets are owned by Swoole and should not be closed by application code. From a9bae7c52146b2e1cc7682b854e867263f60fbaa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:13:22 +0000 Subject: [PATCH 16/30] fix(filesystem): harden native file I/O ownership Replace the worker-local Locker layer with checked native file locks so blocking operations use Swoole's adaptive scheduling while nonblocking calls fail immediately. Make LockableFile creation, metadata, partial writes, flushing, truncation, unlocking, and closing failure-safe with exact resource ownership and earliest-failure precedence. Align local Filesystem native return types, metadata warning handling, recursive deletion, links, replacement, modes, and facade metadata with reachable runtime behavior. Restore FileStore's native nonblocking lock semantics, redact atomic replacement contents at the facade boundary, remove the obsolete worker-gate regression, and add deterministic coverage for contention, cancellation, native failures, partial writes, cleanup, and metadata results. --- src/cache/src/FileStore.php | 2 +- src/filesystem/src/Filesystem.php | 190 ++++++----- src/filesystem/src/LockableFile.php | 117 ++++--- src/support/src/Facades/File.php | 25 +- tests/Cache/CacheFileStoreTest.php | 21 ++ tests/Encryption/SensitiveParameterTest.php | 2 + .../Filesystem/CoroutineLockOwnershipTest.php | 132 -------- tests/Filesystem/FilesystemTest.php | 247 ++++++++++++++- tests/Filesystem/LockableFileTest.php | 298 ++++++++++++++++++ 9 files changed, 751 insertions(+), 283 deletions(-) delete mode 100644 tests/Filesystem/CoroutineLockOwnershipTest.php create mode 100644 tests/Filesystem/LockableFileTest.php diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index d2382ebfd..1f258ade8 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -105,7 +105,7 @@ public function add(string $key, mixed $value, int $seconds): bool try { $file->getExclusiveLock(); - } catch (LockTimeoutException) { // @phpstan-ignore catch.neverThrown (thrown inside closure) + } catch (LockTimeoutException) { $file->close(); return false; diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php index 469e8f906..472c5657d 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -7,8 +7,6 @@ use ErrorException; use FilesystemIterator; use Hypervel\Contracts\Filesystem\FileNotFoundException; -use Hypervel\Coroutine\Coroutine; -use Hypervel\Coroutine\Locker; use Hypervel\Support\LazyCollection; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; @@ -77,34 +75,55 @@ public function json(string $path, int $flags = 0, bool $lock = false): mixed */ public function sharedGet(string $path): string { - return $this->atomic($path, function ($path) { - $handle = @fopen($path, 'rb'); + $handle = @fopen($path, 'rb'); - if ($handle === false) { - throw new FileNotFoundException("Unable to read file at path {$path}."); + if ($handle === false) { + throw new FileNotFoundException("Unable to read file at path {$path}."); + } + + $contents = ''; + $exception = null; + $locked = false; + + try { + if (! @flock($handle, LOCK_SH)) { + throw new RuntimeException("Unable to acquire a shared lock for file at path [{$path}]."); } - $wouldBlock = false; - flock($handle, LOCK_SH | LOCK_NB, $wouldBlock); - while ($wouldBlock) { - usleep(1000); - flock($handle, LOCK_SH | LOCK_NB, $wouldBlock); + $locked = true; + clearstatcache(true, $path); + $contents = @stream_get_contents($handle); + + if ($contents === false) { + throw new FileNotFoundException("Unable to read file at path {$path}."); } + } catch (Throwable $throwable) { + $exception = $throwable; + } + if ($locked) { try { - clearstatcache(true, $path); - $contents = @stream_get_contents($handle); - } finally { - flock($handle, LOCK_UN); - fclose($handle); + if (! @flock($handle, LOCK_UN)) { + throw new RuntimeException("Unable to release the shared lock for file at path [{$path}]."); + } + } catch (Throwable $throwable) { + $exception ??= $throwable; } + } - if ($contents === false) { - throw new FileNotFoundException("Unable to read file at path {$path}."); + try { + if (! @fclose($handle)) { + throw new RuntimeException("Unable to close file at path [{$path}]."); } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } - return $contents; - }); + if ($exception !== null) { + throw $exception; + } + + return $contents; } /** @@ -181,42 +200,17 @@ public function lines(string $path): LazyCollection */ public function hash(string $path, string $algorithm = 'xxh128'): string|false { - return hash_file($algorithm, $path); + return @hash_file($algorithm, $path); } /** * Write the contents of a file. * * @param resource|string $contents - * @return bool|int - */ - public function put(string $path, $contents, bool $lock = false) - { - if ($lock) { - return $this->atomic($path, function ($path) use ($contents) { - $handle = fopen($path, 'c+'); - if ($handle) { - $wouldBlock = false; - flock($handle, LOCK_EX | LOCK_NB, $wouldBlock); - while ($wouldBlock) { - usleep(1000); - flock($handle, LOCK_EX | LOCK_NB, $wouldBlock); - } - try { - ftruncate($handle, 0); - rewind($handle); - - return is_resource($contents) - ? stream_copy_to_stream($contents, $handle) - : fwrite($handle, $contents); - } finally { - flock($handle, LOCK_UN); - fclose($handle); - } - } - }); - } - return file_put_contents($path, $contents); + */ + public function put(string $path, $contents, bool $lock = false): int|false + { + return file_put_contents($path, $contents, $lock ? LOCK_EX : 0); } /** @@ -260,13 +254,23 @@ public function replace(string $path, #[SensitiveParameter] string $content, ?in */ public function replaceInFile(array|string $search, array|string $replace, string $path): void { - file_put_contents($path, str_replace($search, $replace, file_get_contents($path))); + $contents = @file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read file at path [{$path}]."); + } + + $contents = str_replace($search, $replace, $contents); + + if (@file_put_contents($path, $contents) !== strlen($contents)) { + throw new RuntimeException("Unable to write the complete contents of file at path [{$path}]."); + } } /** * Prepend to a file. */ - public function prepend(string $path, string $data): int + public function prepend(string $path, string $data): int|false { if ($this->exists($path)) { return $this->put($path, $data . $this->get($path)); @@ -278,7 +282,7 @@ public function prepend(string $path, string $data): int /** * Append to a file. */ - public function append(string $path, string $data, bool $lock = false): int + public function append(string $path, string $data, bool $lock = false): int|false { return file_put_contents($path, $data, FILE_APPEND | ($lock ? LOCK_EX : 0)); } @@ -288,11 +292,13 @@ public function append(string $path, string $data, bool $lock = false): int */ public function chmod(string $path, ?int $mode = null): string|bool { - if ($mode) { + if ($mode !== null) { return chmod($path, $mode); } - return substr(sprintf('%o', fileperms($path)), -4); + $permissions = @fileperms($path); + + return $permissions === false ? false : substr(sprintf('%o', $permissions), -4); } /** @@ -373,7 +379,9 @@ public function relativeLink(string $target, string $link): void $relativeTarget = (new SymfonyFilesystem)->makePathRelative($target, dirname($link)); - $this->link($this->isFile($target) ? rtrim($relativeTarget, '/') : $relativeTarget, $link); + if ($this->link($this->isFile($target) ? rtrim($relativeTarget, '/') : $relativeTarget, $link) === false) { + throw new RuntimeException("Unable to create a relative link from [{$link}] to [{$target}]."); + } } /** @@ -421,15 +429,17 @@ public function guessExtension(string $path): ?string ); } - return (new MimeTypes)->getExtensions($this->mimeType($path))[0] ?? null; + $mimeType = $this->mimeType($path); + + return $mimeType === false ? null : (new MimeTypes)->getExtensions($mimeType)[0] ?? null; } /** * Get the file type of a given file. */ - public function type(string $path): string + public function type(string $path): string|false { - return filetype($path); + return @filetype($path); } /** @@ -437,23 +447,25 @@ public function type(string $path): string */ public function mimeType(string $path): string|false { - return finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path); + $fileInfo = finfo_open(FILEINFO_MIME_TYPE); + + return $fileInfo === false ? false : finfo_file($fileInfo, $path); } /** * Get the file size of a given file. */ - public function size(string $path): int + public function size(string $path): int|false { - return filesize($path); + return @filesize($path); } /** * Get the file's last modification time. */ - public function lastModified(string $path): int + public function lastModified(string $path): int|false { - return filemtime($path); + return @filemtime($path); } /** @@ -511,9 +523,9 @@ public function isFile(string $file): bool /** * Find path names matching a given pattern. */ - public function glob(string $pattern, int $flags = 0): array + public function glob(string $pattern, int $flags = 0): array|false { - return glob($pattern, $flags); + return @glob($pattern, $flags); } /** @@ -654,30 +666,35 @@ public function deleteDirectory(string $directory, bool $preserve = false): bool } $items = new FilesystemIterator($directory); + $success = true; foreach ($items as $item) { // If the item is a directory, we can just recurse into the function and // delete that sub-directory otherwise we'll just delete the file and // keep iterating through each file until the directory is cleaned. if ($item->isDir() && ! $item->isLink()) { - $this->deleteDirectory($item->getPathname()); + if (! $this->deleteDirectory($item->getPathname())) { + $success = false; + } } // If the item is just a file, we can go ahead and delete it since we're // just looping through and waxing all of the files in this directory // and calling directories recursively, so we delete the real path. else { - $this->delete($item->getPathname()); + if (! $this->delete($item->getPathname())) { + $success = false; + } } } unset($items); - if (! $preserve) { - @rmdir($directory); + if (! $preserve && ! @rmdir($directory)) { + $success = false; } - return true; + return $success; } /** @@ -688,11 +705,15 @@ public function deleteDirectories(string $directory): bool $allDirectories = $this->directories($directory); if (! empty($allDirectories)) { + $success = true; + foreach ($allDirectories as $directoryName) { - $this->deleteDirectory($directoryName); + if (! $this->deleteDirectory($directoryName)) { + $success = false; + } } - return true; + return $success; } return false; @@ -714,29 +735,6 @@ public function clearStatCache(string $path): void clearstatcache(true, $path); } - /** - * Execute a callback with coroutine-safe file locking. - */ - protected function atomic(string $path, callable $callback): mixed - { - if (Coroutine::inCoroutine()) { - $locked = false; - - try { - while (! ($locked = Locker::lock($path))) { - usleep(1000); - } - return $callback($path); - } finally { - if ($locked) { - Locker::unlock($path); - } - } - } - - return $callback($path); - } - /** * Flush all static state. */ diff --git a/src/filesystem/src/LockableFile.php b/src/filesystem/src/LockableFile.php index 16d0fde43..ebf3b28b7 100644 --- a/src/filesystem/src/LockableFile.php +++ b/src/filesystem/src/LockableFile.php @@ -4,11 +4,9 @@ namespace Hypervel\Filesystem; -use Closure; -use Exception; use Hypervel\Contracts\Filesystem\LockTimeoutException; -use Hypervel\Coroutine\Coroutine; -use Hypervel\Coroutine\Locker; +use RuntimeException; +use Throwable; class LockableFile { @@ -45,19 +43,27 @@ public function __construct(string $path, string $mode) */ protected function ensureDirectoryExists(string $path): void { - if (! file_exists(dirname($path))) { - @mkdir(dirname($path), 0777, true); + $directory = dirname($path); + + if (! is_dir($directory) && ! @mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new RuntimeException("Unable to create directory at path [{$directory}]."); } } /** * Create the file resource. * - * @throws Exception + * @throws RuntimeException */ protected function createResource(string $path, string $mode): void { - $this->handle = fopen($path, $mode); + $handle = @fopen($path, $mode); + + if ($handle === false) { + throw new RuntimeException("Unable to open file at path [{$path}]."); + } + + $this->handle = $handle; } /** @@ -65,9 +71,13 @@ protected function createResource(string $path, string $mode): void */ public function read(?int $length = null): string { - clearstatcache(true, $this->path); + $contents = @fread($this->handle, $length ?? ($this->size() ?: 1)); - return fread($this->handle, $length ?? ($this->size() ?: 1)); + if ($contents === false) { + throw new RuntimeException("Unable to read from file at path [{$this->path}]."); + } + + return $contents; } /** @@ -75,7 +85,13 @@ public function read(?int $length = null): string */ public function size(): int { - return filesize($this->path); + $stat = @fstat($this->handle); + + if ($stat === false) { + throw new RuntimeException("Unable to determine the size of file at path [{$this->path}]."); + } + + return $stat['size']; } /** @@ -83,9 +99,19 @@ public function size(): int */ public function write(string $contents): static { - fwrite($this->handle, $contents); + while ($contents !== '') { + $written = @fwrite($this->handle, $contents); - fflush($this->handle); + if ($written === false || $written === 0) { + throw new RuntimeException("Unable to write to file at path [{$this->path}]."); + } + + $contents = substr($contents, $written); + } + + if (! @fflush($this->handle)) { + throw new RuntimeException("Unable to flush file at path [{$this->path}]."); + } return $this; } @@ -95,9 +121,13 @@ public function write(string $contents): static */ public function truncate(): static { - rewind($this->handle); + if (! @rewind($this->handle)) { + throw new RuntimeException("Unable to rewind file at path [{$this->path}]."); + } - ftruncate($this->handle, 0); + if (! @ftruncate($this->handle, 0)) { + throw new RuntimeException("Unable to truncate file at path [{$this->path}]."); + } return $this; } @@ -111,11 +141,9 @@ public function truncate(): static */ public function getSharedLock(bool $block = false): static { - $this->atomic(function () use ($block) { - if (! flock($this->handle, LOCK_SH | ($block ? 0 : LOCK_NB))) { - throw new LockTimeoutException("Unable to acquire file lock at path [{$this->path}]."); - } - }); + if (! @flock($this->handle, LOCK_SH | ($block ? 0 : LOCK_NB))) { + throw new LockTimeoutException("Unable to acquire file lock at path [{$this->path}]."); + } $this->isLocked = true; @@ -131,11 +159,9 @@ public function getSharedLock(bool $block = false): static */ public function getExclusiveLock(bool $block = false): static { - $this->atomic(function () use ($block) { - if (! flock($this->handle, LOCK_EX | ($block ? 0 : LOCK_NB))) { - throw new LockTimeoutException("Unable to acquire file lock at path [{$this->path}]."); - } - }); + if (! @flock($this->handle, LOCK_EX | ($block ? 0 : LOCK_NB))) { + throw new LockTimeoutException("Unable to acquire file lock at path [{$this->path}]."); + } $this->isLocked = true; @@ -147,7 +173,9 @@ public function getExclusiveLock(bool $block = false): static */ public function releaseLock(): static { - $this->atomic(fn () => flock($this->handle, LOCK_UN)); + if (! @flock($this->handle, LOCK_UN)) { + throw new RuntimeException("Unable to release file lock at path [{$this->path}]."); + } $this->isLocked = false; @@ -159,31 +187,30 @@ public function releaseLock(): static */ public function close(): bool { - if ($this->isLocked) { - $this->releaseLock(); - } - - return fclose($this->handle); - } + $exception = null; - protected function atomic(Closure $callback): mixed - { - if (! Coroutine::inCoroutine()) { - return $callback(); + if ($this->isLocked) { + try { + $this->releaseLock(); + } catch (Throwable $throwable) { + $exception = $throwable; + } } - $locked = false; - try { - while (! ($locked = Locker::lock($this->path))) { - usleep(1000); - } + $closed = @fclose($this->handle); - return $callback(); - } finally { - if ($locked) { - Locker::unlock($this->path); + if (! $closed) { + throw new RuntimeException("Unable to close file at path [{$this->path}]."); } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; } + + return true; } } diff --git a/src/support/src/Facades/File.php b/src/support/src/Facades/File.php index 9f5d02f5d..318f6b6ef 100644 --- a/src/support/src/Facades/File.php +++ b/src/support/src/Facades/File.php @@ -4,6 +4,8 @@ namespace Hypervel\Support\Facades; +use SensitiveParameter; + /** * @method static bool exists(string $path) * @method static bool missing(string $path) @@ -14,11 +16,10 @@ * @method static mixed requireOnce(string $path, array $data = []) * @method static \Hypervel\Support\LazyCollection lines(string $path) * @method static string|false hash(string $path, string $algorithm = 'xxh128') - * @method static bool|int put(string $path, resource|string $contents, bool $lock = false) - * @method static void replace(string $path, string $content, int|null $mode = null) + * @method static int|false put(string $path, resource|string $contents, bool $lock = false) * @method static void replaceInFile(array|string $search, array|string $replace, string $path) - * @method static int prepend(string $path, string $data) - * @method static int append(string $path, string $data, bool $lock = false) + * @method static int|false prepend(string $path, string $data) + * @method static int|false append(string $path, string $data, bool $lock = false) * @method static string|bool chmod(string $path, int|null $mode = null) * @method static bool delete(array|string $paths) * @method static bool move(string $path, string $target) @@ -30,17 +31,17 @@ * @method static string dirname(string $path) * @method static string extension(string $path) * @method static string|null guessExtension(string $path) - * @method static string type(string $path) + * @method static string|false type(string $path) * @method static string|false mimeType(string $path) - * @method static int size(string $path) - * @method static int lastModified(string $path) + * @method static int|false size(string $path) + * @method static int|false lastModified(string $path) * @method static bool isDirectory(string $directory) * @method static bool isEmptyDirectory(string $directory, bool $ignoreDotFiles = false) * @method static bool isReadable(string $path) * @method static bool isWritable(string $path) * @method static bool hasSameHash(string $firstFile, string $secondFile) * @method static bool isFile(string $file) - * @method static array glob(string $pattern, int $flags = 0) + * @method static array|false glob(string $pattern, int $flags = 0) * @method static \SplFileInfo[] files(array|string $directory, bool $hidden = false, array|string|int $depth = 0) * @method static \SplFileInfo[] allFiles(array|string $directory, bool $hidden = false) * @method static array directories(array|string $directory, array|string|int $depth = 0) @@ -64,6 +65,14 @@ */ class File extends Facade { + /** + * Write the contents of a file, replacing it atomically if it already exists. + */ + public static function replace(string $path, #[SensitiveParameter] string $content, ?int $mode = null): void + { + static::getFacadeRoot()->replace($path, $content, $mode); + } + protected static function getFacadeAccessor(): string { return 'files'; diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 5bb9eba31..9bc7082bb 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -204,6 +204,27 @@ public function testAddReturnsFalseWhenFileLockCannotBeAcquired(): void } } + public function testRefreshReturnsFalseWhenFileLockCannotBeAcquired(): void + { + $tempDir = ParallelTesting::tempDir('CacheFileStoreTest-refresh'); + mkdir($tempDir, 0777, true); + + $store = new FileStore(new Filesystem, $tempDir); + $path = $store->path('foo'); + mkdir(dirname($path), 0777, true); + file_put_contents($path, (time() + 60) . serialize('owner')); + $lockableFile = new LockableFile($path, 'c+'); + + try { + $lockableFile->getExclusiveLock(); + + $this->assertFalse($store->refreshIfOwned('foo', 'owner', 10)); + } finally { + $lockableFile->close(); + (new Filesystem)->deleteDirectory($tempDir); + } + } + public function testForeversAreStoredWithHighTimestamp() { $files = $this->mockFilesystem(); diff --git a/tests/Encryption/SensitiveParameterTest.php b/tests/Encryption/SensitiveParameterTest.php index 6946a0a29..d25f52891 100644 --- a/tests/Encryption/SensitiveParameterTest.php +++ b/tests/Encryption/SensitiveParameterTest.php @@ -11,6 +11,7 @@ use Hypervel\Encryption\EncryptionServiceProvider; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Crypt; +use Hypervel\Support\Facades\File; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionFunction; @@ -73,6 +74,7 @@ public static function sensitiveParameters(): array 'command set key' => [KeyGenerateCommand::class, 'setKeyInEnvironmentFile', 'key'], 'command write key' => [KeyGenerateCommand::class, 'writeNewEnvironmentFileWith', 'key'], 'atomic replacement contents' => [Filesystem::class, 'replace', 'content'], + 'file facade replacement contents' => [File::class, 'replace', 'content'], 'crypt facade arguments' => [Crypt::class, '__callStatic', 'args'], ]; } diff --git a/tests/Filesystem/CoroutineLockOwnershipTest.php b/tests/Filesystem/CoroutineLockOwnershipTest.php deleted file mode 100644 index 582af5370..000000000 --- a/tests/Filesystem/CoroutineLockOwnershipTest.php +++ /dev/null @@ -1,132 +0,0 @@ -tempDir = ParallelTesting::tempDir('CoroutineLockOwnershipTest'); - mkdir($this->tempDir, 0777, true); - } - - protected function tearDown(): void - { - (new Filesystem)->deleteDirectory($this->tempDir); - - parent::tearDown(); - } - - public function testCanceledFilesystemWaiterDoesNotReleaseTheOwner(): void - { - $filesystem = new CoroutineLockOwnershipFilesystem($this->tempDir . '/filesystem.lock'); - - $this->assertCanceledWaiterDoesNotReleaseTheOwner( - static fn (Closure $callback): mixed => $filesystem->runAtomic($callback), - ); - } - - public function testCanceledLockableFileWaiterDoesNotReleaseTheOwner(): void - { - $file = new CoroutineLockOwnershipFile($this->tempDir . '/lockable-file.lock', 'c+'); - - try { - $this->assertCanceledWaiterDoesNotReleaseTheOwner( - static fn (Closure $callback): mixed => $file->runAtomic($callback), - ); - } finally { - $file->close(); - } - } - - /** - * Assert a canceled waiter cannot release a lock acquired by another coroutine. - */ - private function assertCanceledWaiterDoesNotReleaseTheOwner(Closure $atomic): void - { - $ownerEntered = new Channel(1); - $releaseOwner = new Channel(1); - $ownerExited = new Channel(1); - $thirdEntered = new Channel(1); - - try { - Coroutine::create(static function () use ($atomic, $ownerEntered, $releaseOwner, $ownerExited): void { - try { - $atomic(static function () use ($ownerEntered, $releaseOwner): void { - $ownerEntered->push(true); - $releaseOwner->pop(); - }); - } finally { - $ownerExited->push(true); - } - }); - - $this->assertTrue($ownerEntered->pop(1.0)); - - $waiter = Coroutine::create(static function () use ($atomic): void { - $atomic(static function (): void { - throw new LogicException('The canceled waiter must not acquire the lock.'); - }); - }); - - $this->assertTrue(SwooleCoroutine::cancel($waiter, true)); - - Coroutine::create(static function () use ($atomic, $thirdEntered): void { - $atomic(static fn (): bool => $thirdEntered->push(true)); - }); - - $this->assertFalse($thirdEntered->pop(0.005)); - - $releaseOwner->push(true); - - $this->assertTrue($ownerExited->pop(1.0)); - $this->assertTrue($thirdEntered->pop(1.0)); - } finally { - if ($releaseOwner->getLength() === 0) { - $releaseOwner->push(true, 0.001); - } - } - } -} - -class CoroutineLockOwnershipFilesystem extends Filesystem -{ - public function __construct(private string $lockPath) - { - } - - /** - * Execute a callback through the filesystem atomic boundary. - */ - public function runAtomic(Closure $callback): mixed - { - return $this->atomic($this->lockPath, static fn (): mixed => $callback()); - } -} - -class CoroutineLockOwnershipFile extends LockableFile -{ - /** - * Execute a callback through the lockable-file atomic boundary. - */ - public function runAtomic(Closure $callback): mixed - { - return $this->atomic($callback); - } -} diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index 974639f7c..7eeaca0ae 100755 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -159,6 +159,32 @@ public function testReplaceInFileCorrectlyReplaces() $this->assertStringEqualsFile($tempFile, 'Hello Taylor'); } + public function testReplaceInFileFailsWhenTheSourceCannotBeRead(): void + { + $path = $this->tempDir . '/missing.txt'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unable to read file at path [{$path}]."); + + (new Filesystem)->replaceInFile('old', 'new', $path); + } + + public function testReplaceInFileFailsWhenTheReplacementIsOnlyPartiallyWritten(): void + { + FilesystemTestStreamWrapper::reset('Hello World'); + FilesystemTestStreamWrapper::$zeroWriteAfter = 5; + stream_wrapper_register('hypervel-filesystem-test', FilesystemTestStreamWrapper::class); + + try { + (new Filesystem)->replaceInFile('World', 'Taylor', 'hypervel-filesystem-test://file'); + $this->fail('A partial replacement write should fail.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('Unable to write the complete contents of file', $exception->getMessage()); + } finally { + stream_wrapper_unregister('hypervel-filesystem-test'); + } + } + #[RequiresOperatingSystem('Linux|Darwin')] public function testReplaceWhenUnixSymlinkExists() { @@ -209,6 +235,20 @@ public function testSetChmod() $this->assertEquals($expectedPermissions, $filePermission); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testSetChmodAcceptsZeroMode(): void + { + $path = $this->tempDir . '/zero-mode.txt'; + file_put_contents($path, 'Hello World'); + + try { + $this->assertTrue((new Filesystem)->chmod($path, 0000)); + $this->assertSame(0, $this->getFilePermissions($path)); + } finally { + chmod($path, 0600); + } + } + public function testGetChmod() { file_put_contents($this->tempDir . '/file.txt', 'Hello World'); @@ -220,6 +260,11 @@ public function testGetChmod() $this->assertEquals($expectedPermissions, $filePermission); } + public function testGetChmodReturnsFalseWhenPermissionsCannotBeRead(): void + { + $this->assertFalse((new Filesystem)->chmod($this->tempDir . '/missing.txt')); + } + public function testDeleteRemovesFiles() { file_put_contents($this->tempDir . '/file1.txt', 'Hello World'); @@ -274,6 +319,29 @@ public function testDeleteDirectoryReturnFalseWhenNotADirectory() $this->assertFalse($files->deleteDirectory($this->tempDir . '/bar/file.txt')); } + public function testDeleteDirectoryReportsFailureAfterAttemptingEveryEntry(): void + { + $directory = $this->tempDir . '/exhaustive-delete'; + mkdir($directory); + file_put_contents($directory . '/first.txt', 'first'); + file_put_contents($directory . '/second.txt', 'second'); + $filesystem = new RecordingDeleteFilesystem; + + $this->assertFalse($filesystem->deleteDirectory($directory)); + $this->assertSame( + [$directory . '/first.txt', $directory . '/second.txt'], + $filesystem->deleted + ); + } + + public function testDeleteDirectoriesReportsFailureAfterAttemptingEveryDirectory(): void + { + $filesystem = new RecordingDirectoryDeleteFilesystem; + + $this->assertFalse($filesystem->deleteDirectories('/root')); + $this->assertSame(['/root/first', '/root/second'], $filesystem->deleted); + } + public function testCleanDirectory() { mkdir($this->tempDir . '/baz'); @@ -459,6 +527,23 @@ public function testMoveMovesFiles() $this->assertFileDoesNotExist($this->tempDir . '/foo.txt'); } + public function testRelativeLinkThrowsWhenLinkCreationFails(): void + { + $filesystem = new StubLinkFilesystem(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to create a relative link'); + + $filesystem->relativeLink('/root/target.txt', '/root/link.txt'); + } + + public function testRelativeLinkAcceptsTheWindowsNullResult(): void + { + (new StubLinkFilesystem(null))->relativeLink('/root/target.txt', '/root/link.txt'); + + $this->addToAssertionCount(1); + } + public function testNameReturnsName() { file_put_contents($this->tempDir . '/foobar.txt', 'foo'); @@ -501,6 +586,18 @@ public function testTypeIdentifiesDirectory() $this->assertSame('dir', $files->type($this->tempDir . '/foo-dir')); } + public function testNativeMetadataFailuresReturnFalse(): void + { + $path = $this->tempDir . '/missing.txt'; + $filesystem = new Filesystem; + + $this->assertFalse($filesystem->type($path)); + $this->assertFalse($filesystem->size($path)); + $this->assertFalse($filesystem->lastModified($path)); + $this->assertFalse($filesystem->hash($path)); + $this->assertFalse($filesystem->glob(str_repeat('a', 5000), GLOB_ERR)); + } + public function testSizeOutputsSize() { $size = file_put_contents($this->tempDir . '/foo.txt', 'foo'); @@ -516,6 +613,11 @@ public function testMimeTypeOutputsMimeType() $this->assertSame('text/plain', $files->mimeType($this->tempDir . '/foo.txt')); } + public function testGuessExtensionReturnsNullWhenMimeDetectionFails(): void + { + $this->assertNull((new MissingMimeTypeFilesystem)->guessExtension('/missing.txt')); + } + public function testIsWritable() { if (function_exists('posix_geteuid') && posix_geteuid() === 0) { @@ -787,7 +889,7 @@ public function testDirectoryOperationsWithSubdirectories() $this->assertEquals('test.txt', $allFiles[0]->getFilename()); } - public function testConcurrentCoroutineSharedGetAndPutViaAtomic() + public function testConcurrentCoroutineSharedGetAndLockedPut() { $files = new Filesystem; $path = $this->tempDir . '/concurrent.txt'; @@ -879,3 +981,146 @@ public function makeDirectory(string $path, int $mode = 0755, bool $recursive = return false; } } + +class RecordingDeleteFilesystem extends Filesystem +{ + /** @var list */ + public array $deleted = []; + + public function delete($paths): bool + { + $this->deleted[] = $paths; + + return ! str_ends_with($paths, '/first.txt'); + } +} + +class RecordingDirectoryDeleteFilesystem extends Filesystem +{ + /** @var list */ + public array $deleted = []; + + public function directories(array|string $directory, array|string|int $depth = 0): array + { + return ['/root/first', '/root/second']; + } + + public function deleteDirectory(string $directory, bool $preserve = false): bool + { + $this->deleted[] = $directory; + + return $directory !== '/root/first'; + } +} + +class StubLinkFilesystem extends Filesystem +{ + public function __construct(private ?bool $result) + { + } + + public function link(string $target, string $link): ?bool + { + return $this->result; + } + + public function isFile(string $file): bool + { + return true; + } +} + +class MissingMimeTypeFilesystem extends Filesystem +{ + public function mimeType(string $path): string|false + { + return false; + } +} + +class FilesystemTestStreamWrapper +{ + public mixed $context; + + public static string $contents = ''; + + public static ?int $zeroWriteAfter = null; + + private int $position = 0; + + public static function reset(string $contents): void + { + self::$contents = $contents; + self::$zeroWriteAfter = null; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + if (str_contains($mode, 'w')) { + self::$contents = ''; + } + + return true; + } + + public function stream_read(int $count): string + { + $contents = substr(self::$contents, $this->position, $count); + $this->position += strlen($contents); + + return $contents; + } + + public function stream_write(string $data): int + { + if (self::$zeroWriteAfter !== null && $this->position >= self::$zeroWriteAfter) { + return 0; + } + + $length = self::$zeroWriteAfter === null + ? strlen($data) + : min(strlen($data), self::$zeroWriteAfter - $this->position); + + self::$contents = substr_replace(self::$contents, substr($data, 0, $length), $this->position, $length); + $this->position += $length; + + return $length; + } + + public function stream_eof(): bool + { + return $this->position >= strlen(self::$contents); + } + + public function stream_tell(): int + { + return $this->position; + } + + public function stream_seek(int $offset, int $whence = SEEK_SET): bool + { + $position = match ($whence) { + SEEK_SET => $offset, + SEEK_CUR => $this->position + $offset, + SEEK_END => strlen(self::$contents) + $offset, + }; + + if ($position < 0) { + return false; + } + + $this->position = $position; + + return true; + } + + public function stream_flush(): bool + { + return true; + } + + public function stream_stat(): array + { + return ['size' => strlen(self::$contents)]; + } +} diff --git a/tests/Filesystem/LockableFileTest.php b/tests/Filesystem/LockableFileTest.php new file mode 100644 index 000000000..cc12a9e9c --- /dev/null +++ b/tests/Filesystem/LockableFileTest.php @@ -0,0 +1,298 @@ +tempDir = ParallelTesting::tempDir('LockableFileTest'); + mkdir($this->tempDir, 0777, true); + + LockableFileTestStreamWrapper::reset(); + stream_wrapper_register(self::STREAM_SCHEME, LockableFileTestStreamWrapper::class); + } + + protected function tearDown(): void + { + stream_wrapper_unregister(self::STREAM_SCHEME); + (new Filesystem)->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + public function testBlockingLocksSerializeAccess(): void + { + $path = $this->tempDir . '/blocking.lock'; + $owner = new LockableFile($path, 'c+'); + $owner->getExclusiveLock(); + $started = new Channel(1); + $acquired = new Channel(1); + + try { + Coroutine::create(static function () use ($path, $started, $acquired): void { + $waiter = new LockableFile($path, 'c+'); + $started->push(true); + + try { + $waiter->getExclusiveLock(true); + } finally { + $waiter->close(); + } + + $acquired->push(true); + }); + + $this->assertTrue($started->pop(1.0)); + $this->assertFalse($acquired->pop(0.01)); + + $owner->releaseLock(); + + $this->assertTrue($acquired->pop(1.0)); + } finally { + $owner->close(); + } + } + + public function testNonblockingLocksDoNotWaitBehindBlockingWaiters(): void + { + $path = $this->tempDir . '/nonblocking.lock'; + $owner = new LockableFile($path, 'c+'); + $owner->getExclusiveLock(); + $waiterStarted = new Channel(1); + $waiterFinished = new Channel(1); + $attemptFinished = new Channel(1); + + try { + Coroutine::create(static function () use ($path, $waiterStarted, $waiterFinished): void { + $waiter = new LockableFile($path, 'c+'); + $waiterStarted->push(true); + + try { + $waiter->getExclusiveLock(true); + } finally { + $waiter->close(); + $waiterFinished->push(true); + } + }); + + $this->assertTrue($waiterStarted->pop(1.0)); + usleep(10_000); + + Coroutine::create(static function () use ($path, $attemptFinished): void { + $attempt = new LockableFile($path, 'c+'); + + try { + $attempt->getExclusiveLock(); + $attemptFinished->push(false); + } catch (LockTimeoutException) { + $attemptFinished->push(true); + } finally { + $attempt->close(); + } + }); + + $this->assertTrue($attemptFinished->pop(0.1)); + } finally { + $owner->close(); + } + + $this->assertTrue($waiterFinished->pop(1.0)); + } + + public function testCancelingBlockingWaiterDoesNotReleaseOwnerLock(): void + { + $path = $this->tempDir . '/canceled.lock'; + $owner = new LockableFile($path, 'c+'); + $owner->getExclusiveLock(); + $waiterStarted = new Channel(1); + $waiterFinished = new Channel(1); + + try { + $waiterId = Coroutine::create(static function () use ($path, $waiterStarted, $waiterFinished): void { + $waiter = new LockableFile($path, 'c+'); + $waiterStarted->push(true); + + try { + $waiter->getExclusiveLock(true); + } catch (CanceledException) { + // Expected terminal cancellation while waiting for the native lock. + } finally { + $waiter->close(); + $waiterFinished->push(true); + } + }); + + $this->assertTrue($waiterStarted->pop(1.0)); + usleep(10_000); + $this->assertTrue(SwooleCoroutine::cancel($waiterId, true)); + $this->assertTrue($waiterFinished->pop(1.0)); + + $attempt = new LockableFile($path, 'c+'); + + try { + $this->expectException(LockTimeoutException::class); + $attempt->getExclusiveLock(); + } finally { + $attempt->close(); + } + } finally { + $owner->close(); + } + } + + public function testSizeUsesTheOpenFileHandle(): void + { + $path = $this->tempDir . '/size.txt'; + file_put_contents($path, 'old'); + + $file = new LockableFile($path, 'r+'); + + try { + rename($path, $path . '.open'); + file_put_contents($path, 'replacement'); + + $this->assertSame(3, $file->size()); + } finally { + $file->close(); + } + } + + public function testWriteCompletesPartialWrites(): void + { + LockableFileTestStreamWrapper::$maximumWrite = 2; + $file = new LockableFileWithoutDirectoryCheck(self::STREAM_SCHEME . '://partial', 'w+'); + + $file->write('abcdef')->close(); + + $this->assertSame('abcdef', LockableFileTestStreamWrapper::$contents); + } + + public function testWriteFailsWhenTheStreamMakesNoProgress(): void + { + LockableFileTestStreamWrapper::$maximumWrite = 2; + LockableFileTestStreamWrapper::$zeroWriteAfter = 2; + $file = new LockableFileWithoutDirectoryCheck(self::STREAM_SCHEME . '://zero-write', 'w+'); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to write to file'); + + $file->write('abcdef'); + } finally { + $file->close(); + } + } + + public function testCloseStillClosesTheHandleWhenUnlockFails(): void + { + LockableFileTestStreamWrapper::$failUnlock = true; + $file = new LockableFileWithoutDirectoryCheck(self::STREAM_SCHEME . '://unlock-failure', 'w+'); + $file->getExclusiveLock(); + + try { + $file->close(); + $this->fail('The failed unlock should be reported.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('Unable to release file lock', $exception->getMessage()); + } + + $this->assertTrue(LockableFileTestStreamWrapper::$closed); + } + + public function testConstructorFailsWhenTheDirectoryCannotBeCreated(): void + { + $parent = $this->tempDir . '/file'; + file_put_contents($parent, 'contents'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to create directory'); + + new LockableFile($parent . '/child', 'c+'); + } +} + +class LockableFileWithoutDirectoryCheck extends LockableFile +{ + /** + * Skip directory creation for the test stream wrapper. + */ + protected function ensureDirectoryExists(string $path): void + { + } +} + +class LockableFileTestStreamWrapper +{ + public mixed $context; + + public static string $contents = ''; + + public static int $maximumWrite = PHP_INT_MAX; + + public static ?int $zeroWriteAfter = null; + + public static bool $failUnlock = false; + + public static bool $closed = false; + + public static function reset(): void + { + self::$contents = ''; + self::$maximumWrite = PHP_INT_MAX; + self::$zeroWriteAfter = null; + self::$failUnlock = false; + self::$closed = false; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + return true; + } + + public function stream_write(string $data): int + { + if (self::$zeroWriteAfter !== null && strlen(self::$contents) >= self::$zeroWriteAfter) { + return 0; + } + + $written = min(strlen($data), self::$maximumWrite); + self::$contents .= substr($data, 0, $written); + + return $written; + } + + public function stream_flush(): bool + { + return true; + } + + public function stream_lock(int $operation): bool + { + return $operation !== LOCK_UN || ! self::$failUnlock; + } + + public function stream_close(): void + { + self::$closed = true; + } +} From 79b11b8dfb43d741f9cc5c5561d63964c377b4ba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:13:37 +0000 Subject: [PATCH 17/30] fix(filesystem): complete safe adapter contracts Prevent append and prepend from overwriting an existing object after a failed read, and make putFileAs validate and close its owned source stream on every success and failure path. Publish the complete decoded JSON union, accept the full Laravel file input family, and narrow Storage manager and assertion metadata to their real contracts. Port Laravel's assertEmpty testing API across direct, pooled, and scoped disks without adding an implementation-specific method to the generic Filesystem contract. Add regression coverage for unreadable objects, missing upload sources, exceptional stream cleanup, decoded scalar JSON values, assertion failures, scoped prefixes, and pooled lease release. --- src/contracts/src/Filesystem/Filesystem.php | 7 +- .../InteractsWithPooledFilesystem.php | 12 ++- src/filesystem/src/FilesystemAdapter.php | 54 ++++++++--- src/filesystem/src/ScopedFilesystemProxy.php | 13 ++- src/support/src/Facades/Storage.php | 17 ++-- tests/Filesystem/FilesystemAdapterTest.php | 95 +++++++++++++++++++ tests/Filesystem/FilesystemPoolProxyTest.php | 17 ++++ .../Filesystem/ScopedFilesystemProxyTest.php | 5 +- 8 files changed, 194 insertions(+), 26 deletions(-) diff --git a/src/contracts/src/Filesystem/Filesystem.php b/src/contracts/src/Filesystem/Filesystem.php index 1042669ec..7fa328cae 100644 --- a/src/contracts/src/Filesystem/Filesystem.php +++ b/src/contracts/src/Filesystem/Filesystem.php @@ -4,6 +4,7 @@ namespace Hypervel\Contracts\Filesystem; +use Hypervel\Http\File; use Hypervel\Http\UploadedFile; use Psr\Http\Message\StreamInterface; use RuntimeException; @@ -57,19 +58,19 @@ public function readStreamRange(string $path, ?int $start, ?int $end): mixed; /** * Write the contents of a file. * - * @param resource|StreamInterface|string|UploadedFile $contents + * @param File|resource|StreamInterface|string|UploadedFile $contents */ public function put(string $path, mixed $contents, mixed $options = []): bool|string; /** * Store the uploaded file on the disk. */ - public function putFile(string|UploadedFile $path, array|string|UploadedFile|null $file = null, mixed $options = []): false|string; + public function putFile(string|File|UploadedFile $path, array|string|File|UploadedFile|null $file = null, mixed $options = []): false|string; /** * Store the uploaded file on the disk with a given name. */ - public function putFileAs(string|UploadedFile $path, array|string|UploadedFile|null $file, array|string|null $name = null, mixed $options = []): false|string; + public function putFileAs(string|File|UploadedFile $path, array|string|File|UploadedFile|null $file, array|string|null $name = null, mixed $options = []): false|string; /** * Write a new file using a stream. diff --git a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php index 3179ec7e6..562e6ff81 100644 --- a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php +++ b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php @@ -68,6 +68,16 @@ public function assertDirectoryEmpty(string $path): static return $this; } + /** + * Assert that the disk contains no files. + */ + public function assertEmpty(): static + { + $this->invoke(__FUNCTION__, []); + + return $this; + } + /** * Determine if a file or directory exists. */ @@ -183,7 +193,7 @@ public function get(string $path): ?string /** * Get the contents of a file as decoded JSON. */ - public function json(string $path, int $flags = 0): ?array + public function json(string $path, int $flags = 0): array|bool|float|int|string|null { return $this->invoke(__FUNCTION__, [$path, $flags]); } diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index bfdb185b8..4d8edf387 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -185,6 +185,16 @@ public function assertDirectoryEmpty(string $path): static return $this; } + /** + * Assert that the disk contains no files. + */ + public function assertEmpty(): static + { + PHPUnit::assertEmpty($this->allFiles(), 'Disk is not empty.'); + + return $this; + } + /** * Determine if a file or directory exists. */ @@ -260,7 +270,7 @@ public function get(string $path): ?string /** * Get the contents of a file as decoded JSON. */ - public function json(string $path, int $flags = 0): ?array + public function json(string $path, int $flags = 0): array|bool|float|int|string|null { $content = $this->get($path); @@ -395,19 +405,27 @@ public function putFileAs(string|File|UploadedFile $path, array|string|File|Uplo [$path, $file, $name, $options] = ['', $path, $file, $name ?? []]; } - $stream = fopen(is_string($file) ? $file : $file->getRealPath(), 'r'); + $path = trim($path . '/' . $name, '/'); + $source = is_string($file) ? $file : $file->getRealPath(); + $stream = $source === false ? false : @fopen($source, 'r'); + + if ($stream === false) { + $exception = UnableToWriteFile::atLocation($path, 'Unable to open the source file.'); + + throw_if($this->throwsExceptions(), $exception); + + $this->report($exception); + + return false; + } // Next, we will format the path of the file and store the file using a stream since // they provide better performance than alternatives. Once we write the file this // stream will get closed automatically by us so the developer doesn't have to. - $result = $this->put( - $path = trim($path . '/' . $name, '/'), - $stream, - $options - ); - - if (is_resource($stream)) { - fclose($stream); + try { + $result = $this->put($path, $stream, $options); + } finally { + @fclose($stream); } return $result ? $path : false; @@ -449,7 +467,13 @@ public function setVisibility(string $path, string $visibility): bool public function prepend(string $path, string $data, string $separator = PHP_EOL): bool { if ($this->fileExists($path)) { - return $this->put($path, $data . $separator . $this->get($path)); + $contents = $this->get($path); + + if ($contents === null) { + return false; + } + + return $this->put($path, $data . $separator . $contents); } return $this->put($path, $data); @@ -461,7 +485,13 @@ public function prepend(string $path, string $data, string $separator = PHP_EOL) public function append(string $path, string $data, string $separator = PHP_EOL): bool { if ($this->fileExists($path)) { - return $this->put($path, $this->get($path) . $separator . $data); + $contents = $this->get($path); + + if ($contents === null) { + return false; + } + + return $this->put($path, $contents . $separator . $data); } return $this->put($path, $data); diff --git a/src/filesystem/src/ScopedFilesystemProxy.php b/src/filesystem/src/ScopedFilesystemProxy.php index a9d6ea423..68821f6b7 100644 --- a/src/filesystem/src/ScopedFilesystemProxy.php +++ b/src/filesystem/src/ScopedFilesystemProxy.php @@ -93,6 +93,17 @@ public function assertDirectoryEmpty(string $path): static return $this; } + /** + * Assert that the scoped disk contains no files. + */ + public function assertEmpty(): static + { + $prefix = $this->prefix(); + $this->call('assertDirectoryEmpty', [$prefix]); + + return $this; + } + /** * Get the full path to a scoped file. */ @@ -234,7 +245,7 @@ public function get(string $path): ?string /** * Get the contents of a scoped file as decoded JSON. */ - public function json(string $path, int $flags = 0): ?array + public function json(string $path, int $flags = 0): array|bool|float|int|string|null { $prefix = $this->prefix(); diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 8ccfc6f7f..41cb68f3f 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -11,9 +11,9 @@ use function Hypervel\Support\enum_value; /** - * @method static mixed drive(\UnitEnum|string|null $name = null) - * @method static mixed disk(\UnitEnum|string|null $name = null) - * @method static mixed build(array|string $config) + * @method static \Hypervel\Contracts\Filesystem\Filesystem drive(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Filesystem\Filesystem disk(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Filesystem\Filesystem build(array|string $config) * @method static \Hypervel\Contracts\Filesystem\Filesystem createLocalDriver(array $config, string $name = 'local') * @method static \Hypervel\Contracts\Filesystem\Filesystem createFtpDriver(array $config) * @method static \Hypervel\Contracts\Filesystem\Filesystem createSftpDriver(array $config) @@ -56,16 +56,17 @@ * @method static array allDirectories(string|null $directory = null) * @method static bool makeDirectory(string $path) * @method static bool deleteDirectory(string $directory) - * @method static \Hypervel\Contracts\Filesystem\Filesystem assertExists(array|string $path, string|null $content = null) - * @method static \Hypervel\Contracts\Filesystem\Filesystem assertCount(string $path, int $count, bool $recursive = false) - * @method static \Hypervel\Contracts\Filesystem\Filesystem assertMissing(array|string $path) - * @method static \Hypervel\Contracts\Filesystem\Filesystem assertDirectoryEmpty(string $path) + * @method static \Hypervel\Filesystem\FilesystemAdapter assertExists(array|string $path, string|null $content = null) + * @method static \Hypervel\Filesystem\FilesystemAdapter assertCount(string $path, int $count, bool $recursive = false) + * @method static \Hypervel\Filesystem\FilesystemAdapter assertMissing(array|string $path) + * @method static \Hypervel\Filesystem\FilesystemAdapter assertDirectoryEmpty(string $path) + * @method static \Hypervel\Filesystem\FilesystemAdapter assertEmpty() * @method static bool missing(string $path) * @method static bool fileExists(string $path) * @method static bool fileMissing(string $path) * @method static bool directoryExists(string $path) * @method static bool directoryMissing(string $path) - * @method static array|null json(string $path, int $flags = 0) + * @method static array|bool|float|int|string|null json(string $path, int $flags = 0) * @method static \Hypervel\Http\Response response(string $path, string|null $name = null, array $headers = [], string $disposition = 'inline') * @method static \Hypervel\Http\Response serve(\Hypervel\Http\Request $request, string $path, string|null $name = null, array $headers = []) * @method static \Hypervel\Http\Response download(string $path, string|null $name = null, array $headers = []) diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index 145aae9cc..3f5ddc617 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -31,6 +31,7 @@ use League\Flysystem\UnableToWriteFile; use Mockery as m; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\ExpectationFailedException; use Swoole\Runtime; class FilesystemAdapterTest extends TestCase @@ -290,6 +291,23 @@ public function testJsonReturnsNullIfJsonDataIsInvalid() $this->assertNull($filesystemAdapter->json('file.json')); } + public function testJsonReturnsDecodedScalarData(): void + { + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + foreach ([ + 'boolean.json' => ['true', true], + 'float.json' => ['1.5', 1.5], + 'integer.json' => ['42', 42], + 'null.json' => ['null', null], + 'string.json' => ['"value"', 'value'], + ] as $path => [$json, $expected]) { + $this->filesystem->write($path, $json); + + $this->assertSame($expected, $filesystemAdapter->json($path)); + } + } + public function testMimeTypeNotDetected() { $this->filesystem->write('unknown.mime-type', ''); @@ -312,6 +330,16 @@ public function testPrepend() $this->assertStringEqualsFile($this->tempDir . '/file.txt', 'Hello ' . PHP_EOL . 'World'); } + public function testPrependDoesNotOverwriteAnUnreadableExistingFile(): void + { + $filesystemAdapter = m::mock(FilesystemAdapter::class, [$this->filesystem, $this->adapter])->makePartial(); + $filesystemAdapter->shouldReceive('fileExists')->once()->with('file.txt')->andReturnTrue(); + $filesystemAdapter->shouldReceive('get')->once()->with('file.txt')->andReturnNull(); + $filesystemAdapter->shouldReceive('put')->never(); + + $this->assertFalse($filesystemAdapter->prepend('file.txt', 'Hello ')); + } + public function testAppend() { file_put_contents($this->tempDir . '/file.txt', 'Hello '); @@ -320,6 +348,16 @@ public function testAppend() $this->assertStringEqualsFile($this->tempDir . '/file.txt', 'Hello ' . PHP_EOL . 'Moon'); } + public function testAppendDoesNotOverwriteAnUnreadableExistingFile(): void + { + $filesystemAdapter = m::mock(FilesystemAdapter::class, [$this->filesystem, $this->adapter])->makePartial(); + $filesystemAdapter->shouldReceive('fileExists')->once()->with('file.txt')->andReturnTrue(); + $filesystemAdapter->shouldReceive('get')->once()->with('file.txt')->andReturnNull(); + $filesystemAdapter->shouldReceive('put')->never(); + + $this->assertFalse($filesystemAdapter->append('file.txt', 'Moon')); + } + public function testDelete() { file_put_contents($this->tempDir . '/file.txt', 'Hello World'); @@ -575,6 +613,45 @@ public function testPutFileAsWithoutPath() $this->assertSame('normal file content', $filesystemAdapter->read($storagePath)); } + public function testPutFileAsReturnsFalseWhenTheSourceCannotBeOpened(): void + { + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $this->assertFalse($filesystemAdapter->putFileAs('/', $this->tempDir . '/missing.txt', 'new.txt')); + $this->assertFalse($filesystemAdapter->exists('new.txt')); + } + + public function testPutFileAsThrowsWhenTheSourceCannotBeOpenedAndExceptionsAreEnabled(): void + { + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['throw' => true]); + + $this->expectException(UnableToWriteFile::class); + + $filesystemAdapter->putFileAs('/', $this->tempDir . '/missing.txt', 'new.txt'); + } + + public function testPutFileAsClosesTheSourceWhenWritingThrows(): void + { + file_put_contents($filePath = $this->tempDir . '/foo.txt', 'normal file content'); + + $stream = null; + $filesystemAdapter = m::mock(FilesystemAdapter::class, [$this->filesystem, $this->adapter])->makePartial(); + $filesystemAdapter->shouldReceive('put')->once()->andReturnUsing( + function (string $path, mixed $contents, mixed $options) use (&$stream): never { + $stream = $contents; + + throw UnableToWriteFile::atLocation($path); + } + ); + + try { + $filesystemAdapter->putFileAs('/', $filePath, 'new.txt'); + $this->fail('Expected the write failure to be thrown.'); + } catch (UnableToWriteFile) { + $this->assertFalse(is_resource($stream)); + } + } + public function testPutFile() { file_put_contents($filePath = $this->tempDir . '/foo.txt', 'uploaded file content'); @@ -1128,6 +1205,24 @@ public function testProvidesTemporaryUploadUrlsForAdapterWithoutTemporaryUploadU $this->assertFalse($filesystemAdapter->providesTemporaryUploadUrls()); } + public function testAssertEmpty(): void + { + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $filesystemAdapter->assertEmpty(); + } + + public function testAssertEmptyFailsWhenDiskContainsFiles(): void + { + $this->filesystem->write('foo/file.txt', 'Hello World'); + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage('Disk is not empty.'); + + $filesystemAdapter->assertEmpty(); + } + public function testStreamStopsOnFailedWrite() { // Create a large file that requires multiple chunks (64 KiB each) diff --git a/tests/Filesystem/FilesystemPoolProxyTest.php b/tests/Filesystem/FilesystemPoolProxyTest.php index e7125a518..37eb16195 100644 --- a/tests/Filesystem/FilesystemPoolProxyTest.php +++ b/tests/Filesystem/FilesystemPoolProxyTest.php @@ -75,6 +75,23 @@ public function testSynchronousOperationsUseAndReleaseAWholeDriver(): void $this->assertSame(1, $this->pools->get('filesystem:driver')->getObjectNumberInPool()); } + public function testJsonReturnsScalarDataAndReleasesTheDriver(): void + { + $this->driver->write('value.json', '"value"'); + $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); + + $this->assertSame('value', $proxy->json('value.json')); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + } + + public function testAssertEmptyReturnsTheProxyAndReleasesTheDriver(): void + { + $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); + + $this->assertSame($proxy, $proxy->assertEmpty()); + $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedObjectNumber()); + } + public function testSynchronousFlysystemMethodsAndConditionableUseTheProxyBoundary(): void { $proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem()); diff --git a/tests/Filesystem/ScopedFilesystemProxyTest.php b/tests/Filesystem/ScopedFilesystemProxyTest.php index f55332731..924e3ded3 100644 --- a/tests/Filesystem/ScopedFilesystemProxyTest.php +++ b/tests/Filesystem/ScopedFilesystemProxyTest.php @@ -96,6 +96,7 @@ public static function mappedMethodProvider(): array 'directoryMissing' => ['directoryMissing', ['dir'], ['tenant/dir'], false, false], 'get' => ['get', ['file.txt'], ['tenant/file.txt'], 'contents', 'contents'], 'json' => ['json', ['file.json', JSON_THROW_ON_ERROR], ['tenant/file.json', JSON_THROW_ON_ERROR], ['ok' => true], ['ok' => true]], + 'json scalar' => ['json', ['value.json'], ['tenant/value.json', 0], 'value', 'value'], 'response' => ['response', ['file.txt', 'name.txt', ['X-Test' => 'yes'], 'attachment'], ['tenant/file.txt', 'name.txt', ['X-Test' => 'yes'], 'attachment'], $response, $response], 'serve' => ['serve', [$request, 'file.txt', 'name.txt', ['X-Test' => 'yes']], [$request, 'tenant/file.txt', 'name.txt', ['X-Test' => 'yes']], $response, $response], 'download' => ['download', ['file.txt', 'name.txt', ['X-Test' => 'yes']], ['tenant/file.txt', 'name.txt', ['X-Test' => 'yes']], $response, $response], @@ -251,6 +252,7 @@ public function testEveryAssertionMapsPathsAndReturnsTheScopedProxy(): void $inner->shouldReceive('assertMissing')->once()->with(['tenant/c.txt', 'tenant/d.txt'])->andReturnSelf(); $inner->shouldReceive('assertCount')->once()->with('tenant/dir', 2, true)->andReturnSelf(); $inner->shouldReceive('assertDirectoryEmpty')->once()->with('tenant/empty')->andReturnSelf(); + $inner->shouldReceive('assertDirectoryEmpty')->once()->with('tenant')->andReturnSelf(); $prefixCalls = 0; $proxy = new ScopedFilesystemProxy($inner, function () use (&$prefixCalls): string { ++$prefixCalls; @@ -262,7 +264,8 @@ public function testEveryAssertionMapsPathsAndReturnsTheScopedProxy(): void $this->assertSame($proxy, $proxy->assertMissing(['c.txt', 'd.txt'])); $this->assertSame($proxy, $proxy->assertCount('dir', 2, true)); $this->assertSame($proxy, $proxy->assertDirectoryEmpty('empty')); - $this->assertSame(4, $prefixCalls); + $this->assertSame($proxy, $proxy->assertEmpty()); + $this->assertSame(5, $prefixCalls); } #[DataProvider('emptyPrefixProvider')] From ffd5ca962fa313112c445698ab1e00edeece9c08 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:13:55 +0000 Subject: [PATCH 18/30] perf(filesystem): stream cloud reads lazily by default Enable streamed reads by default for pooled S3 and Google Cloud Storage disks so readStream and readStreamRange expose data lazily instead of spooling complete remote objects before consumers can proceed. Keep ordinary read and get operations on their existing transport path, pass the option through both SDK and Flysystem layers, and retain stream_reads=false as an explicit opt-out for workloads that prioritize connection reuse across many small streams. Add socket-pair regressions that prove lazy consumption and coroutine scheduler progress, plus manager coverage for both defaults and per-disk opt-outs. --- src/filesystem/src/AwsS3V3Adapter.php | 2 +- src/filesystem/src/FilesystemManager.php | 13 +++- .../src/GoogleCloudStorageAdapter.php | 4 +- src/foundation/config/filesystems.php | 5 +- tests/Filesystem/AwsS3V3AdapterTest.php | 69 +++++++++++++++++++ tests/Filesystem/FilesystemManagerTest.php | 47 +++++++++++++ .../GoogleCloudStorageAdapterTest.php | 55 ++++++++++++++- 7 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/filesystem/src/AwsS3V3Adapter.php b/src/filesystem/src/AwsS3V3Adapter.php index 404ffd2b7..0cadf479d 100644 --- a/src/filesystem/src/AwsS3V3Adapter.php +++ b/src/filesystem/src/AwsS3V3Adapter.php @@ -163,7 +163,7 @@ private function readStreamWithOptions(string $path, array $operationOptions = [ $options[$key] = $value; } - if (($this->config['stream_reads'] ?? false) && ! isset($options['@http']['stream'])) { + if (($this->config['stream_reads'] ?? true) && ! isset($options['@http']['stream'])) { $options['@http']['stream'] = true; } diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 1f87c04a9..2d47265f9 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -407,7 +407,7 @@ protected function buildS3Disk(S3Client $client, array $config): AwsS3V3Adapter $config['visibility'] ?? Visibility::PUBLIC ); - $streamReads = $s3Config['stream_reads'] ?? false; + $streamReads = $s3Config['stream_reads']; $adapter = new S3Adapter($client, $s3Config['bucket'], $root, $visibility, null, $config['options'] ?? [], $streamReads); @@ -424,7 +424,10 @@ protected function buildS3Disk(S3Client $client, array $config): AwsS3V3Adapter */ protected function formatS3Config(array $config): array { - $config += ['version' => 'latest']; + $config += [ + 'stream_reads' => true, + 'version' => 'latest', + ]; if (! empty($config['key']) && ! empty($config['secret'])) { $config['credentials'] = Arr::only($config, ['key', 'secret']); @@ -490,7 +493,9 @@ protected function buildGcsDisk(GcsClient $client, array $config): GoogleCloudSt $client->bucket(Arr::get($gcsConfig, 'bucket')), Arr::get($gcsConfig, 'root'), Arr::get($gcsConfig, 'visibilityHandler') ? new $visibilityHandlerClass : null, - $defaultVisibility + $defaultVisibility, + null, + $gcsConfig['stream_reads'], ); return new GoogleCloudStorageAdapter( @@ -506,6 +511,8 @@ protected function buildGcsDisk(GcsClient $client, array $config): GoogleCloudSt */ protected function formatGcsConfig(array $config): array { + $config += ['stream_reads' => true]; + // Google's SDK expects camelCase keys, but we can use snake_case in the config. foreach ($config as $key => $value) { $config[Str::camel($key)] = $value; diff --git a/src/filesystem/src/GoogleCloudStorageAdapter.php b/src/filesystem/src/GoogleCloudStorageAdapter.php index aa21e6958..ce704b5fa 100644 --- a/src/filesystem/src/GoogleCloudStorageAdapter.php +++ b/src/filesystem/src/GoogleCloudStorageAdapter.php @@ -73,7 +73,7 @@ public function readStream(string $path): mixed { return $this->readStreamWithOptions( $path, - ($this->config['stream_reads'] ?? false) ? ['restOptions' => ['stream' => true]] : [] + ($this->config['stream_reads'] ?? true) ? ['restOptions' => ['stream' => true]] : [] ); } @@ -97,7 +97,7 @@ public function readStreamRange(string $path, ?int $start, ?int $end): mixed 'headers' => [ 'Range' => "bytes={$start}-{$end}", ], - ...(($this->config['stream_reads'] ?? false) ? ['stream' => true] : []), + ...(($this->config['stream_reads'] ?? true) ? ['stream' => true] : []), ], ] ); diff --git a/src/foundation/config/filesystems.php b/src/foundation/config/filesystems.php index 7e6384a2d..35f17f09b 100644 --- a/src/foundation/config/filesystems.php +++ b/src/foundation/config/filesystems.php @@ -25,7 +25,7 @@ | may even configure multiple disks for the same driver. Examples for | most supported storage drivers are configured here for reference. | - | Supported drivers: "local", "ftp", "sftp", "s3" + | Supported drivers: "local", "ftp", "sftp", "s3", "gcs" | */ @@ -54,6 +54,7 @@ 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, + 'stream_reads' => true, 'pool' => [ 'min_retained_objects' => 1, 'max_objects' => 10, @@ -77,7 +78,7 @@ 'visibility_handler' => null, // optional: set to \League\Flysystem\GoogleCloudStorage\UniformBucketLevelAccessVisibility::class to enable uniform bucket level access 'metadata' => ['cacheControl' => 'public,max-age=86400'], // optional: default metadata 'throw' => false, - 'stream_reads' => false, + 'stream_reads' => true, 'pool' => [ 'min_retained_objects' => 1, 'max_objects' => 10, diff --git a/tests/Filesystem/AwsS3V3AdapterTest.php b/tests/Filesystem/AwsS3V3AdapterTest.php index fd97f0470..f46f3aa41 100644 --- a/tests/Filesystem/AwsS3V3AdapterTest.php +++ b/tests/Filesystem/AwsS3V3AdapterTest.php @@ -20,6 +20,8 @@ use Psr\Http\Message\StreamInterface; use RuntimeException; +use function Hypervel\Coroutine\parallel; + class AwsS3V3AdapterTest extends TestCase { #[DataProvider('ranges')] @@ -78,6 +80,73 @@ public function testReadStreamRangeWithoutBoundsDelegatesToTheFullStream(): void fclose($stream); } + public function testReadStreamDefaultsToLazyCoroutineSafeStreaming(): void + { + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $this->assertIsArray($sockets); + [$reader, $writer] = $sockets; + $this->assertTrue(stream_set_timeout($reader, 1)); + $captured = null; + $handler = new MockHandler([ + function (CommandInterface $command) use (&$captured, $reader): Result { + $captured = $command; + + return new Result(['Body' => Utils::streamFor($reader)]); + }, + ]); + $adapter = $this->adapter($handler, ['bucket' => 'bucket']); + $stream = null; + + try { + $stream = $adapter->readStream('file.txt'); + + $this->assertIsResource($stream); + $this->assertInstanceOf(CommandInterface::class, $captured); + $this->assertTrue($captured['@http']['stream']); + + $results = parallel([ + 'read' => static fn (): string|false => fread($stream, 8), + 'write' => static fn (): int|false => fwrite($writer, 'streamed'), + ]); + + $this->assertSame('streamed', $results['read']); + $this->assertSame(8, $results['write']); + } finally { + if (is_resource($stream)) { + fclose($stream); + } elseif (is_resource($reader)) { + fclose($reader); + } + + if (is_resource($writer)) { + fclose($writer); + } + } + } + + public function testStreamReadsCanBeDisabled(): void + { + $captured = null; + $handler = new MockHandler([ + function (CommandInterface $command) use (&$captured): Result { + $captured = $command; + + return new Result(['Body' => Utils::streamFor('body')]); + }, + ]); + $adapter = $this->adapter($handler, [ + 'bucket' => 'bucket', + 'stream_reads' => false, + ]); + + $stream = $adapter->readStream('file.txt'); + + $this->assertIsResource($stream); + fclose($stream); + $this->assertInstanceOf(CommandInterface::class, $captured); + $this->assertArrayNotHasKey('stream', $captured['@http'] ?? []); + } + public function testReadStreamRangeRejectsInvalidArgumentsBeforeClientIo(): void { $adapter = $this->adapter(new MockHandler([]), ['bucket' => 'bucket']); diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 6783122ce..7ab5d8256 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -4,12 +4,14 @@ namespace Hypervel\Tests\Filesystem; +use Aws\S3\S3Client; use Google\Cloud\Storage\Bucket; use Google\Cloud\Storage\StorageClient as GcsClient; use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Filesystem\Filesystem; +use Hypervel\Filesystem\AwsS3V3Adapter; use Hypervel\Filesystem\ClientPooledFilesystem; use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Filesystem\FilesystemManager; @@ -20,7 +22,9 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use League\Flysystem\AwsS3V3\AwsS3V3Adapter as FlysystemS3Adapter; use League\Flysystem\Filesystem as Flysystem; +use League\Flysystem\GoogleCloudStorage\GoogleCloudStorageAdapter as FlysystemGcsAdapter; use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\PathPrefixing\PathPrefixedAdapter; use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter; @@ -845,6 +849,26 @@ public function testS3ClientConfigSelectsOnlySdkArgumentsAndExplicitBlockWins(): $this->assertArrayNotHasKey('secret', $config); } + public function testS3DiskDefaultsStreamingReadsAndAllowsOptOut(): void + { + $filesystem = new InspectableFilesystemManager($this->getContainer()); + $client = new S3Client([ + 'credentials' => false, + 'region' => 'us-east-1', + 'version' => 'latest', + ]); + + $default = $filesystem->buildS3DiskForTest($client, ['bucket' => 'documents']); + $disabled = $filesystem->buildS3DiskForTest($client, [ + 'bucket' => 'documents', + 'stream_reads' => false, + ]); + + $this->assertInstanceOf(FlysystemS3Adapter::class, $default->getAdapter()); + $this->assertTrue((new ReflectionProperty(FlysystemS3Adapter::class, 'streamReads'))->getValue($default->getAdapter())); + $this->assertFalse((new ReflectionProperty(FlysystemS3Adapter::class, 'streamReads'))->getValue($disabled->getAdapter())); + } + public function testGcsClientConfigSupportsFlatKeysAndTheFullExplicitSdkSurface(): void { $filesystem = new InspectableFilesystemManager($this->getContainer()); @@ -919,6 +943,24 @@ public function testGcsDiskUsesTheSharedFlysystemStackConfiguration(): void $this->assertArrayNotHasKey('throw', $driverConfig); } + public function testGcsDiskDefaultsStreamingReadsAndAllowsOptOut(): void + { + $bucket = m::mock(Bucket::class); + $client = m::mock(GcsClient::class); + $client->shouldReceive('bucket')->twice()->with('documents')->andReturn($bucket); + $filesystem = new InspectableFilesystemManager($this->getContainer()); + + $default = $filesystem->buildGcsDiskForTest($client, ['bucket' => 'documents']); + $disabled = $filesystem->buildGcsDiskForTest($client, [ + 'bucket' => 'documents', + 'stream_reads' => false, + ]); + + $this->assertInstanceOf(FlysystemGcsAdapter::class, $default->getAdapter()); + $this->assertTrue((new ReflectionProperty(FlysystemGcsAdapter::class, 'streamReads'))->getValue($default->getAdapter())); + $this->assertFalse((new ReflectionProperty(FlysystemGcsAdapter::class, 'streamReads'))->getValue($disabled->getAdapter())); + } + public function testUnknownExplicitClientOptionsAreRejected(): void { $filesystem = new InspectableFilesystemManager($this->getContainer()); @@ -1056,6 +1098,11 @@ public function gcsClientConfigForTest(array $config): array return $this->gcsClientConfig($config); } + public function buildS3DiskForTest(S3Client $client, array $config): AwsS3V3Adapter + { + return $this->buildS3Disk($client, $config); + } + public function buildGcsDiskForTest(GcsClient $client, array $config): GoogleCloudStorageAdapter { return $this->buildGcsDisk($client, $config); diff --git a/tests/Filesystem/GoogleCloudStorageAdapterTest.php b/tests/Filesystem/GoogleCloudStorageAdapterTest.php index 1da451b54..74abc6c0e 100644 --- a/tests/Filesystem/GoogleCloudStorageAdapterTest.php +++ b/tests/Filesystem/GoogleCloudStorageAdapterTest.php @@ -18,6 +18,8 @@ use Psr\Http\Message\StreamInterface; use RuntimeException; +use function Hypervel\Coroutine\parallel; + class GoogleCloudStorageAdapterTest extends TestCase { public function testUrlUsesTheConfiguredApiUriOrBucketEndpoint(): void @@ -83,7 +85,10 @@ public function testReadStreamRangeSupportsOpenAndSuffixRangesWithoutForcingStre $bucket->shouldReceive('object')->twice()->with('file.txt')->andReturn($object); $client = m::mock(StorageClient::class); $client->shouldReceive('bucket')->twice()->with('bucket')->andReturn($bucket); - $adapter = $this->adapter($client, ['bucket' => 'bucket']); + $adapter = $this->adapter($client, [ + 'bucket' => 'bucket', + 'stream_reads' => false, + ]); $open = $adapter->readStreamRange('file.txt', 3, null); $suffix = $adapter->readStreamRange('file.txt', null, 3); @@ -98,7 +103,10 @@ public function testReadStreamRangeWithoutBoundsDelegatesToTheFullStream(): void { $body = Utils::streamFor('full-body'); $object = m::mock(StorageObject::class); - $object->shouldReceive('downloadAsStream')->once()->with([])->andReturn($body); + $object->shouldReceive('downloadAsStream') + ->once() + ->with(['restOptions' => ['stream' => true]]) + ->andReturn($body); $bucket = m::mock(Bucket::class); $bucket->shouldReceive('object')->once()->with('file.txt')->andReturn($object); $client = m::mock(StorageClient::class); @@ -112,6 +120,49 @@ public function testReadStreamRangeWithoutBoundsDelegatesToTheFullStream(): void fclose($stream); } + public function testReadStreamDefaultsToLazyCoroutineSafeStreaming(): void + { + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $this->assertIsArray($sockets); + [$reader, $writer] = $sockets; + $this->assertTrue(stream_set_timeout($reader, 1)); + $object = m::mock(StorageObject::class); + $object->shouldReceive('downloadAsStream') + ->once() + ->with(['restOptions' => ['stream' => true]]) + ->andReturn(Utils::streamFor($reader)); + $bucket = m::mock(Bucket::class); + $bucket->shouldReceive('object')->once()->with('file.txt')->andReturn($object); + $client = m::mock(StorageClient::class); + $client->shouldReceive('bucket')->once()->with('bucket')->andReturn($bucket); + $adapter = $this->adapter($client, ['bucket' => 'bucket']); + $stream = null; + + try { + $stream = $adapter->readStream('file.txt'); + + $this->assertIsResource($stream); + + $results = parallel([ + 'read' => static fn (): string|false => fread($stream, 8), + 'write' => static fn (): int|false => fwrite($writer, 'streamed'), + ]); + + $this->assertSame('streamed', $results['read']); + $this->assertSame(8, $results['write']); + } finally { + if (is_resource($stream)) { + fclose($stream); + } elseif (is_resource($reader)) { + fclose($reader); + } + + if (is_resource($writer)) { + fclose($writer); + } + } + } + public function testReadStreamRangeRejectsInvalidArgumentsBeforeClientIo(): void { $adapter = $this->adapter(m::mock(StorageClient::class), ['bucket' => 'bucket']); From 05ae196ab8384876e1d320c89749c420bfa13146 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:14:09 +0000 Subject: [PATCH 19/30] fix(filesystem): harden temporary file transfer routes Encode each signed local-storage path segment while preserving directory separators so URI delimiters cannot alter the signed route or hide an expired signature. Treat failed upload persistence as an internal server error instead of reporting a successful empty response, and rely on the merged filesystem configuration rather than a drifting call-site default when registering served disks. Cover delimiter-bearing and nested download and upload paths, expired signatures, failed storage writes, and cleanup of every generated fixture. --- .../src/FilesystemServiceProvider.php | 2 +- src/filesystem/src/LocalFilesystemAdapter.php | 4 +- src/filesystem/src/ReceiveFile.php | 5 +- .../Filesystem/ReceiveFileTest.php | 58 ++++++++++++++++++- .../Integration/Filesystem/ServeFileTest.php | 41 ++++++++++++- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/filesystem/src/FilesystemServiceProvider.php b/src/filesystem/src/FilesystemServiceProvider.php index 7dc2aefce..901f1222b 100644 --- a/src/filesystem/src/FilesystemServiceProvider.php +++ b/src/filesystem/src/FilesystemServiceProvider.php @@ -68,7 +68,7 @@ protected function serveFiles(): void $served = []; - foreach ($this->app->make('config')->array('filesystems.disks', []) as $disk => $config) { + foreach ($this->app->make('config')->array('filesystems.disks') as $disk => $config) { if (! $this->shouldServeFiles($config)) { continue; } diff --git a/src/filesystem/src/LocalFilesystemAdapter.php b/src/filesystem/src/LocalFilesystemAdapter.php index a1ae34eb2..89ceb25f5 100644 --- a/src/filesystem/src/LocalFilesystemAdapter.php +++ b/src/filesystem/src/LocalFilesystemAdapter.php @@ -72,7 +72,7 @@ public function temporaryUrl(string $path, DateTimeInterface $expiration, array return $url->to($url->temporarySignedRoute( 'storage.' . $this->disk, $expiration, - ['path' => $path], + ['path' => strtr(rawurlencode($path), ['%2F' => '/'])], absolute: false )); } @@ -102,7 +102,7 @@ public function temporaryUploadUrl(string $path, DateTimeInterface $expiration, 'url' => $url->to($url->temporarySignedRoute( 'storage.' . $this->disk . '.upload', $expiration, - ['path' => $path, 'upload' => true], + ['path' => strtr(rawurlencode($path), ['%2F' => '/']), 'upload' => true], absolute: false )), 'headers' => [], diff --git a/src/filesystem/src/ReceiveFile.php b/src/filesystem/src/ReceiveFile.php index 8b953506d..c034552a9 100644 --- a/src/filesystem/src/ReceiveFile.php +++ b/src/filesystem/src/ReceiveFile.php @@ -32,7 +32,10 @@ public function __invoke(Request $request, string $path): Response ); try { - Storage::disk($this->disk)->put($path, $request->getContent()); + abort_unless( + Storage::disk($this->disk)->put($path, $request->getContent()), + 500, + ); return response()->noContent(); } catch (PathTraversalDetected) { diff --git a/tests/Integration/Filesystem/ReceiveFileTest.php b/tests/Integration/Filesystem/ReceiveFileTest.php index d607fd61c..3fa8f92fd 100644 --- a/tests/Integration/Filesystem/ReceiveFileTest.php +++ b/tests/Integration/Filesystem/ReceiveFileTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\Integration\Filesystem; +use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\Support\Facades\Storage; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use Mockery as m; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; #[WithConfig('filesystems.disks.local.serve', true)] class ReceiveFileTest extends TestCase @@ -14,7 +17,11 @@ class ReceiveFileTest extends TestCase protected function setUp(): void { $this->beforeApplicationDestroyed(function () { - Storage::delete('receive-file-test.txt'); + Storage::delete([ + 'receive-file-test.txt', + 'receive-file-test.txt?pad=x', + 'nested/folder/receive-file-test.txt', + ]); }); parent::setUp(); @@ -30,6 +37,23 @@ public function testItCanReceiveAFile() Storage::assertExists('receive-file-test.txt', 'Hello World'); } + public function testStorageFailureReturnsServerError(): void + { + $result = Storage::temporaryUploadUrl('receive-file-test.txt', now()->addMinutes(1)); + $disk = m::mock(Filesystem::class); + $disk->shouldReceive('put')->once()->with('receive-file-test.txt', 'Hello World')->andReturnFalse(); + $manager = Storage::getFacadeRoot(); + + try { + Storage::shouldReceive('disk')->once()->with('local')->andReturn($disk); + $response = $this->call('PUT', $result['url'], [], [], [], [], 'Hello World'); + } finally { + Storage::swap($manager); + } + + $response->assertInternalServerError(); + } + public function testItWill403OnWrongSignature() { $result = Storage::temporaryUploadUrl('receive-file-test.txt', now()->addMinutes(1)); @@ -74,4 +98,36 @@ public function testUploadUrlCannotBeUsedForDownload() $response->assertForbidden(); } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testItCanReceiveAFileWithUriDelimitersInThePath(): void + { + $result = Storage::temporaryUploadUrl('receive-file-test.txt?pad=x', now()->addMinutes(1)); + + $response = $this->call('PUT', $result['url'], [], [], [], [], 'Hello Question'); + + $response->assertNoContent(); + Storage::assertExists('receive-file-test.txt?pad=x', 'Hello Question'); + Storage::assertMissing('receive-file-test.txt'); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testTemporaryUploadUrlPreservesPathSeparatorsInNestedPaths(): void + { + $result = Storage::temporaryUploadUrl('nested/folder/receive-file-test.txt', now()->addMinutes(1)); + + $this->assertStringContainsString('nested/folder/receive-file-test.txt', $result['url']); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testUriDelimitersInThePathCannotHideAnExpiredUploadUrl(): void + { + $result = Storage::temporaryUploadUrl('receive-file-test.txt?pad=x', now()->subMinutes(1)); + + $response = $this->call('PUT', $result['url'], [], [], [], [], 'Hello Question'); + + $response->assertForbidden(); + Storage::assertMissing('receive-file-test.txt'); + Storage::assertMissing('receive-file-test.txt?pad=x'); + } } diff --git a/tests/Integration/Filesystem/ServeFileTest.php b/tests/Integration/Filesystem/ServeFileTest.php index 6a01c17a0..913be8057 100644 --- a/tests/Integration/Filesystem/ServeFileTest.php +++ b/tests/Integration/Filesystem/ServeFileTest.php @@ -7,6 +7,7 @@ use Hypervel\Support\Facades\Storage; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; #[WithConfig('filesystems.disks.local.serve', true)] class ServeFileTest extends TestCase @@ -15,10 +16,16 @@ protected function setUp(): void { $this->afterApplicationCreated(function () { Storage::put('serve-file-test.txt', 'Hello World'); + Storage::put('serve-file-test.txt?pad=x', 'Hello Question'); + Storage::put('nested/folder/serve-file-test.txt', 'Hello Nested'); }); $this->beforeApplicationDestroyed(function () { - Storage::delete('serve-file-test.txt'); + Storage::delete([ + 'serve-file-test.txt', + 'serve-file-test.txt?pad=x', + 'nested/folder/serve-file-test.txt', + ]); }); parent::setUp(); @@ -53,6 +60,38 @@ public function testItWill403OnWrongSignature() $response->assertForbidden(); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testItCanServeAFileWithUriDelimitersInThePath(): void + { + $url = Storage::temporaryUrl('serve-file-test.txt?pad=x', now()->addMinutes(1)); + + $response = $this->get($url); + + $this->assertSame('Hello Question', $response->streamedContent()); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testTemporaryUrlPreservesPathSeparatorsInNestedPaths(): void + { + $url = Storage::temporaryUrl('nested/folder/serve-file-test.txt', now()->addMinutes(1)); + + $this->assertStringContainsString('nested/folder/serve-file-test.txt', $url); + + $response = $this->get($url); + + $this->assertSame('Hello Nested', $response->streamedContent()); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testUriDelimitersInThePathCannotHideAnExpiredUrl(): void + { + $url = Storage::temporaryUrl('serve-file-test.txt?pad=x', now()->subMinutes(1)); + + $response = $this->get($url); + + $response->assertForbidden(); + } + public function testHeadRequestSendsHeadersButNoBody() { $url = Storage::temporaryUrl('serve-file-test.txt', now()->addMinutes(1)); From 8e234f3771cd08f633ef01459bf72e214cbd0f07 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:14:25 +0000 Subject: [PATCH 20/30] fix(http): stop iterable streams after disconnect Retain generator-backed response chunks in a dedicated StreamedResponse subtype so the Swoole bridge can write them directly and stop advancing their producer as soon as the peer rejects a write. Emit each server-sent event as one complete chunk, support every callable generator shape, and preserve Symfony's buffered callback path for ordinary streamed responses. Make that fallback capture write failures without throwing from the output handler, clean only removable buffers, still end the response, and preserve the earliest failure. Add deterministic coverage for lazy one-shot iteration, callback replacement, disconnect termination, generator cleanup, SSE encoding, callable normalization, write and end failure precedence, and ordinary callback compatibility. --- src/http-server/src/ResponseBridge.php | 88 ++++++++++-- src/http/src/IterableStreamedResponse.php | 109 +++++++++++++++ src/routing/src/ResponseFactory.php | 83 +++++------ tests/Http/IterableStreamedResponseTest.php | 106 ++++++++++++++ tests/HttpServer/ResponseBridgeTest.php | 98 ++++++++++++- tests/Routing/ResponseFactoryTest.php | 146 ++++++++++++++++++++ 6 files changed, 568 insertions(+), 62 deletions(-) create mode 100644 src/http/src/IterableStreamedResponse.php create mode 100644 tests/Http/IterableStreamedResponseTest.php create mode 100644 tests/Routing/ResponseFactoryTest.php diff --git a/src/http-server/src/ResponseBridge.php b/src/http-server/src/ResponseBridge.php index 27cff7ba9..95354874e 100644 --- a/src/http-server/src/ResponseBridge.php +++ b/src/http-server/src/ResponseBridge.php @@ -4,11 +4,13 @@ namespace Hypervel\HttpServer; +use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\Response as HypervelResponse; use Swoole\Http\Response as SwooleResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; class ResponseBridge { @@ -83,38 +85,102 @@ protected static function sendStatusAndHeaders(Response $response, SwooleRespons /** * Stream a Symfony StreamedResponse through Swoole's write() method. * - * Symfony's StreamedResponse uses echo inside a callback to emit chunks. - * We intercept each echo via ob_start with a callback that routes the - * output to Swoole's write(), sending each chunk to the client immediately. + * Retained iterables are consumed directly so a failed write stops their + * producer. Ordinary callbacks use echo, so their output is routed through + * a non-throwing output-buffer handler instead. * * The chunk_size of 1 means the buffer flushes after every output * operation that produces 1+ bytes (not per byte). A single * `echo "data: ...\n\n"` triggers one write() call with the full string. * - * The try/finally with safe OB level restore is critical in Swoole's - * long-lived workers: if sendContent() throws, the output buffer must - * be cleaned up to prevent OB level leaks across requests. + * Removable output buffers are cleaned without spinning on a non-removable + * user buffer before the earliest callback, write, or end failure is rethrown. */ protected static function sendStreamedContent(StreamedResponse $response, SwooleResponse $swooleResponse): void { + if ($response instanceof IterableStreamedResponse + && static::sendIterableContent($response, $swooleResponse)) { + return; + } + $level = ob_get_level(); + $exception = null; + $writable = true; + + ob_start(function (string $chunk) use ($swooleResponse, &$exception, &$writable): string { + if ($chunk === '' || ! $writable) { + return ''; + } - ob_start(function (string $chunk) use ($swooleResponse): string { - if (strlen($chunk) > 0) { - $swooleResponse->write($chunk); + try { + $writable = $swooleResponse->write($chunk); + } catch (Throwable $throwable) { + $exception ??= $throwable; + $writable = false; } return ''; }, 1); try { - $response->sendContent(); + try { + $response->sendContent(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } } finally { while (ob_get_level() > $level) { + $status = ob_get_status(); + + if (($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) === 0) { + break; + } + ob_end_clean(); } + + try { + $swooleResponse->end(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } + + /** + * Stream retained iterable chunks directly through Swoole. + */ + protected static function sendIterableContent(IterableStreamedResponse $response, SwooleResponse $swooleResponse): bool + { + $exception = null; + + try { + $handled = $response->streamTo( + static fn (string $chunk): bool => $swooleResponse->write($chunk) + ); + } catch (Throwable $throwable) { + $handled = true; + $exception = $throwable; + } + + if (! $handled) { + return false; + } + + try { + $swooleResponse->end(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; } - $swooleResponse->end(); + return true; } } diff --git a/src/http/src/IterableStreamedResponse.php b/src/http/src/IterableStreamedResponse.php new file mode 100644 index 000000000..8e3f881d4 --- /dev/null +++ b/src/http/src/IterableStreamedResponse.php @@ -0,0 +1,109 @@ + */ + private array|Traversable|null $chunks = null; + + /** + * Create a new iterable streamed response. + * + * @param iterable $chunks + */ + public function __construct(iterable $chunks, int $status = 200, array $headers = []) + { + parent::__construct(null, $status, $headers); + + $this->setChunks($chunks); + } + + /** + * Set the chunks associated with the response. + * + * @param iterable $chunks + */ + #[Override] + public function setChunks(iterable $chunks): static + { + $this->chunks = $chunks; + + return parent::setChunks($chunks); + } + + /** + * Set the callback associated with the response. + */ + #[Override] + public function setCallback(callable $callback): static + { + $this->chunks = null; + + return parent::setCallback($callback); + } + + /** + * Send the response content. + */ + #[Override] + public function sendContent(): static + { + if ($this->chunks === null) { + return parent::sendContent(); + } + + try { + return parent::sendContent(); + } finally { + $this->clearChunks(); + } + } + + /** + * Stream retained chunks through the given writer. + * + * @param Closure(string): bool $write + * + * @internal + */ + public function streamTo(Closure $write): bool + { + if ($this->chunks === null) { + return false; + } + + try { + foreach ($this->chunks as $chunk) { + if (! $write($chunk)) { + break; + } + } + } finally { + $this->clearChunks(); + } + + return true; + } + + /** + * Release retained chunks and prevent callback replay. + */ + private function clearChunks(): void + { + $this->chunks = null; + + parent::setCallback(static function (): void { + }); + } +} diff --git a/src/routing/src/ResponseFactory.php b/src/routing/src/ResponseFactory.php index 026e3b2ce..cf95de3c7 100644 --- a/src/routing/src/ResponseFactory.php +++ b/src/routing/src/ResponseFactory.php @@ -8,6 +8,7 @@ use Closure; use Hypervel\Contracts\Routing\ResponseFactory as FactoryContract; use Hypervel\Contracts\View\Factory as ViewFactory; +use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\JsonResponse; use Hypervel\Http\RedirectResponse; use Hypervel\Http\Response; @@ -98,49 +99,11 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven return $this->stream(function () use ($callback, $endStreamWith) { try { foreach ($callback() as $message) { - if (connection_aborted()) { - break; - } - - $event = 'update'; - - if ($message instanceof StreamedEvent) { - $event = $message->event; - $message = $message->data; - } - - if (! is_string($message) && ! is_numeric($message)) { - $message = Js::encode($message); - } - - echo "event: {$event}\n"; - echo 'data: ' . $message; - echo "\n\n"; - - if (ob_get_level() > 0) { - ob_flush(); - } - - flush(); + yield $this->formatStreamedEvent($message); } if (filled($endStreamWith)) { - $endEvent = 'update'; - - if ($endStreamWith instanceof StreamedEvent) { - $endEvent = $endStreamWith->event; - $endStreamWith = $endStreamWith->data; - } - - echo "event: {$endEvent}\n"; - echo 'data: ' . $endStreamWith; - echo "\n\n"; - - if (ob_get_level() > 0) { - ob_flush(); - } - - flush(); + yield $this->formatStreamedEvent($endStreamWith); } } catch (Throwable $e) { report($e); @@ -152,21 +115,43 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven ])); } + /** + * Format a server-sent event as a complete stream chunk. + */ + private function formatStreamedEvent(mixed $message): string + { + $event = 'update'; + + if ($message instanceof StreamedEvent) { + $event = $message->event; + $message = $message->data; + } + + if (! is_string($message) && ! is_numeric($message)) { + $message = Js::encode($message); + } + + return "event: {$event}\ndata: {$message}\n\n"; + } + /** * Create a new streamed response instance. * - * For generator callbacks, the callback is set directly on the StreamedResponse - * without wrapping in a foreach+echo loop. In Swoole's long-lived workers, - * the ResponseBridge handles streaming via ob_start + $swooleResponse->write(). + * Generator callbacks are invoked once and retained as iterable chunks so the + * Swoole bridge can stop production immediately after a failed socket write. */ public function stream(?callable $callback = null, int $status = 200, array $headers = []): StreamedResponse { - if (! is_null($callback) && (new ReflectionFunction($callback))->isGenerator()) { - return (new StreamedResponse( - null, - $status, - array_merge($headers, ['X-Accel-Buffering' => 'no']) - ))->setCallback($callback); + if ($callback !== null) { + $callback = Closure::fromCallable($callback); + + if ((new ReflectionFunction($callback))->isGenerator()) { + return new IterableStreamedResponse( + $callback(), + $status, + array_merge($headers, ['X-Accel-Buffering' => 'no']) + ); + } } return new StreamedResponse($callback, $status, $headers); diff --git a/tests/Http/IterableStreamedResponseTest.php b/tests/Http/IterableStreamedResponseTest.php new file mode 100644 index 000000000..852c15b65 --- /dev/null +++ b/tests/Http/IterableStreamedResponseTest.php @@ -0,0 +1,106 @@ +assertSame(0, $iterations); + $content = ''; + $level = ob_get_level(); + + ob_start(static function (string $chunk) use (&$content): string { + $content .= $chunk; + + return ''; + }, 1); + + try { + $response->sendContent(); + $response->sendContent(); + } finally { + while (ob_get_level() > $level) { + ob_end_clean(); + } + } + + $this->assertSame('firstsecond', $content); + $this->assertSame(1, $iterations); + $this->assertFalse($response->streamTo(static fn (string $chunk): bool => true)); + } + + public function testStreamToStopsAndReleasesChunksWhenTheWriterFails(): void + { + $closed = false; + $chunks = (function () use (&$closed) { + try { + yield 'first'; + yield 'second'; + } finally { + $closed = true; + } + })(); + $response = new IterableStreamedResponse($chunks); + unset($chunks); + $written = []; + + $handled = $response->streamTo(function (string $chunk) use (&$written): bool { + $written[] = $chunk; + + return false; + }); + + $this->assertTrue($handled); + $this->assertSame(['first'], $written); + $this->assertTrue($closed); + $this->assertFalse($response->streamTo(static fn (string $chunk): bool => true)); + } + + public function testSettingACallbackReleasesRetainedChunks(): void + { + $closed = false; + $chunks = (function () use (&$closed) { + try { + yield 'first'; + } finally { + $closed = true; + } + })(); + $chunks->current(); + $response = new IterableStreamedResponse($chunks); + unset($chunks); + + $response->setCallback(static function (): void { + echo 'callback'; + }); + + $this->assertTrue($closed); + $this->assertFalse($response->streamTo(static fn (string $chunk): bool => true)); + + ob_start(); + + try { + $response->sendContent(); + + $this->assertSame('callback', ob_get_contents()); + } finally { + ob_end_clean(); + } + } +} diff --git a/tests/HttpServer/ResponseBridgeTest.php b/tests/HttpServer/ResponseBridgeTest.php index c987c55d3..12efb5915 100644 --- a/tests/HttpServer/ResponseBridgeTest.php +++ b/tests/HttpServer/ResponseBridgeTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\HttpServer; +use Hypervel\Http\IterableStreamedResponse; use Hypervel\Http\Response as HypervelResponse; use Hypervel\HttpServer\ResponseBridge; use Hypervel\Tests\TestCase; @@ -168,6 +169,100 @@ public function testSendStreamedResponse() $this->assertSame('chunk1chunk2', implode('', $chunks)); } + public function testIterableStreamedResponseStopsAfterAFailedWrite(): void + { + $produced = 0; + $closed = false; + $chunks = (function () use (&$produced, &$closed) { + try { + ++$produced; + yield 'first'; + ++$produced; + yield 'second'; + } finally { + $closed = true; + } + })(); + $response = new IterableStreamedResponse($chunks); + unset($chunks); + $swooleResponse = $this->mockSwooleResponse(); + $swooleResponse->shouldReceive('status')->once(); + $swooleResponse->shouldReceive('header')->withAnyArgs(); + $swooleResponse->shouldReceive('write')->once()->with('first')->andReturnFalse(); + $swooleResponse->shouldReceive('end')->once()->withNoArgs(); + + ResponseBridge::send($response, $swooleResponse); + + $this->assertSame(1, $produced); + $this->assertTrue($closed); + } + + public function testIterableStreamedResponsePreservesWriteFailureAndStillEnds(): void + { + $closed = false; + $chunks = (function () use (&$closed) { + try { + yield 'first'; + } finally { + $closed = true; + } + })(); + $response = new IterableStreamedResponse($chunks); + unset($chunks); + $swooleResponse = $this->mockSwooleResponse(); + $swooleResponse->shouldReceive('status')->once(); + $swooleResponse->shouldReceive('header')->withAnyArgs(); + $swooleResponse->shouldReceive('write')->once()->andThrow(new RuntimeException('write failed')); + $swooleResponse->shouldReceive('end')->once()->andThrow(new RuntimeException('end failed')); + + try { + ResponseBridge::send($response, $swooleResponse); + $this->fail('Expected the write failure to propagate'); + } catch (RuntimeException $exception) { + $this->assertSame('write failed', $exception->getMessage()); + } + + $this->assertTrue($closed); + } + + public function testOrdinaryStreamedResponseStopsWritingAfterDisconnect(): void + { + $callbackCompleted = false; + $response = new StreamedResponse(function () use (&$callbackCompleted): void { + echo 'first'; + echo 'second'; + $callbackCompleted = true; + }); + $swooleResponse = $this->mockSwooleResponse(); + $swooleResponse->shouldReceive('status')->once(); + $swooleResponse->shouldReceive('header')->withAnyArgs(); + $swooleResponse->shouldReceive('write')->once()->with('first')->andReturnFalse(); + $swooleResponse->shouldReceive('end')->once()->withNoArgs(); + + ResponseBridge::send($response, $swooleResponse); + + $this->assertTrue($callbackCompleted); + } + + public function testOrdinaryStreamedResponsePreservesWriteFailureAndStillEnds(): void + { + $response = new StreamedResponse(static function (): void { + echo 'first'; + }); + $swooleResponse = $this->mockSwooleResponse(); + $swooleResponse->shouldReceive('status')->once(); + $swooleResponse->shouldReceive('header')->withAnyArgs(); + $swooleResponse->shouldReceive('write')->once()->andThrow(new RuntimeException('write failed')); + $swooleResponse->shouldReceive('end')->once()->andThrow(new RuntimeException('end failed')); + + try { + ResponseBridge::send($response, $swooleResponse); + $this->fail('Expected the write failure to propagate'); + } catch (RuntimeException $exception) { + $this->assertSame('write failed', $exception->getMessage()); + } + } + public function testStreamedResponseRemovesConflictingHeaders() { $response = new StreamedResponse(function () { @@ -204,8 +299,7 @@ public function testStreamedResponseCleansUpOutputBufferOnException() $swooleResponse->shouldReceive('status')->once(); $swooleResponse->shouldReceive('header')->withAnyArgs()->andReturn(true); $swooleResponse->shouldReceive('write')->andReturn(true); - // end() should NOT be called — exception interrupts before end() - $swooleResponse->shouldNotReceive('end'); + $swooleResponse->shouldReceive('end')->once(); $levelBefore = ob_get_level(); diff --git a/tests/Routing/ResponseFactoryTest.php b/tests/Routing/ResponseFactoryTest.php new file mode 100644 index 000000000..1d923642b --- /dev/null +++ b/tests/Routing/ResponseFactoryTest.php @@ -0,0 +1,146 @@ +factory()->stream(function () use (&$iterations) { + ++$iterations; + + yield 'first'; + yield 'second'; + }); + + $this->assertInstanceOf(IterableStreamedResponse::class, $response); + $this->assertSame('no', $response->headers->get('X-Accel-Buffering')); + $this->assertSame(0, $iterations); + $chunks = []; + + $this->assertTrue($response->streamTo(function (string $chunk) use (&$chunks): bool { + $chunks[] = $chunk; + + return true; + })); + $this->assertSame(['first', 'second'], $chunks); + $this->assertSame(1, $iterations); + } + + public function testStreamSupportsArrayGeneratorCallables(): void + { + $source = new ResponseFactoryGeneratorSource; + $response = $this->factory()->stream([$source, 'generate']); + $chunks = []; + + $this->assertInstanceOf(IterableStreamedResponse::class, $response); + $this->assertTrue($response->streamTo(function (string $chunk) use (&$chunks): bool { + $chunks[] = $chunk; + + return true; + })); + $this->assertSame(['array'], $chunks); + } + + public function testStreamSupportsInvokableGeneratorCallables(): void + { + $response = $this->factory()->stream(new ResponseFactoryGeneratorSource); + $chunks = []; + + $this->assertInstanceOf(IterableStreamedResponse::class, $response); + $this->assertTrue($response->streamTo(function (string $chunk) use (&$chunks): bool { + $chunks[] = $chunk; + + return true; + })); + $this->assertSame(['invokable'], $chunks); + } + + public function testStreamKeepsOrdinaryCallbacksOnSymfonyResponse(): void + { + $response = $this->factory()->stream(static function (): void { + echo 'callback'; + }); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertNotInstanceOf(IterableStreamedResponse::class, $response); + } + + public function testEventStreamStopsProducingAfterAFailedWrite(): void + { + $produced = 0; + $response = $this->factory()->eventStream(function () use (&$produced) { + ++$produced; + yield 'first'; + ++$produced; + yield 'second'; + }); + $chunks = []; + + $this->assertInstanceOf(IterableStreamedResponse::class, $response); + $this->assertTrue($response->streamTo(function (string $chunk) use (&$chunks): bool { + $chunks[] = $chunk; + + return false; + })); + $this->assertSame(["event: update\ndata: first\n\n"], $chunks); + $this->assertSame(1, $produced); + } + + public function testEventStreamYieldsCompleteMessageAndTerminalEventChunks(): void + { + $response = $this->factory()->eventStream( + static function () { + yield 'first'; + yield new StreamedEvent('custom', ['value' => true]); + }, + endStreamWith: new StreamedEvent('complete', ['finished' => true]), + ); + $chunks = []; + + $this->assertTrue($response->streamTo(function (string $chunk) use (&$chunks): bool { + $chunks[] = $chunk; + + return true; + })); + $this->assertSame([ + "event: update\ndata: first\n\n", + "event: custom\ndata: {\"value\":true}\n\n", + "event: complete\ndata: {\"finished\":true}\n\n", + ], $chunks); + } + + private function factory(): ResponseFactory + { + return new ResponseFactory( + m::mock(ViewFactory::class), + m::mock(Redirector::class), + ); + } +} + +class ResponseFactoryGeneratorSource +{ + public function generate(): Generator + { + yield 'array'; + } + + public function __invoke(): Generator + { + yield 'invokable'; + } +} From d50d9bfc0ded8e48689cfdc829f9ba4030d3b464 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:14:42 +0000 Subject: [PATCH 21/30] fix(foundation): finalize request lifecycles exhaustively Run request events, response sending, kernel termination, application callbacks, terminable middleware, duration handlers, and context removal through independent cleanup boundaries while preserving the earliest failure. Treat a response already committed for streaming as authoritative when later application code fails, avoiding an invalid second render and ensuring HTTP Server events and termination receive the same response and original throwable. Add regression coverage for every failing finalization phase, continued cleanup after each failure, committed-response preservation, duration-handler isolation, middleware traversal, and unconditional request context removal. --- src/foundation/src/Application.php | 13 +- src/foundation/src/Http/Kernel.php | 100 +++++++++++----- src/http-server/src/Server.php | 69 +++++++---- .../Foundation/FoundationApplicationTest.php | 25 ++++ tests/Foundation/Http/KernelTest.php | 113 ++++++++++++++++++ tests/HttpServer/ServerTest.php | 89 ++++++++++++++ 6 files changed, 356 insertions(+), 53 deletions(-) diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index b749f8bb5..01e455ec7 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -31,6 +31,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Throwable; use function Hypervel\Filesystem\join_paths; @@ -1143,8 +1144,18 @@ public function terminating(callable|string $callback): static */ public function terminate(): void { + $exception = null; + foreach ($this->terminatingCallbacks as $callback) { - $this->call($callback); + try { + $this->call($callback); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; } } diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index 02ec963dc..24f9248b7 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -9,6 +9,7 @@ use DateTimeInterface; use Hypervel\Context\CoroutineContext; use Hypervel\Context\RequestContext; +use Hypervel\Context\ResponseContext; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Http\Kernel as KernelContract; @@ -132,21 +133,25 @@ public function handle(Request $request): Response try { $request->enableHttpMethodParameterOverride(); $response = $this->sendRequestThroughRouter($request); + + $events = $this->app['events']; + + if ($events->hasListeners(RequestHandled::class)) { + $events->dispatch( + new RequestHandled($request, $response) + ); + } + + return $response; } catch (Throwable $e) { $this->reportException($e); - $response = $this->renderException($request, $e); - } - - $events = $this->app['events']; + if (ResponseContext::getOrNull()?->isStreamed()) { + throw $e; + } - if ($events->hasListeners(RequestHandled::class)) { - $events->dispatch( - new RequestHandled($request, $response) - ); + return $this->renderException($request, $e); } - - return $response; } /** @@ -199,33 +204,61 @@ protected function dispatchToRouter(): Closure */ public function terminate(Request $request, Response $response): void { + $exception = null; $events = $this->app['events']; - if ($events->hasListeners(Terminating::class)) { - $events->dispatch(new Terminating); + try { + if ($events->hasListeners(Terminating::class)) { + $events->dispatch(new Terminating); + } + } catch (Throwable $throwable) { + $exception = $throwable; } - $this->terminateMiddleware($request, $response); - $this->app->terminate(); - - $requestStartedAt = CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); - if ($requestStartedAt === null || $this->requestLifecycleDurationHandlers === []) { - CoroutineContext::forget(self::REQUEST_STARTED_AT_CONTEXT_KEY); + try { + $this->terminateMiddleware($request, $response); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } - return; + try { + $this->app->terminate(); + } catch (Throwable $throwable) { + $exception ??= $throwable; } - $requestStartedAt->setTimezone($this->app['config']->get('app.timezone') ?? 'UTC'); + try { + $requestStartedAt = CoroutineContext::get(self::REQUEST_STARTED_AT_CONTEXT_KEY); + + if ($requestStartedAt !== null && $this->requestLifecycleDurationHandlers !== []) { + $requestStartedAt->setTimezone($this->app['config']->get('app.timezone') ?? 'UTC'); + $end = null; - foreach ($this->requestLifecycleDurationHandlers as ['threshold' => $threshold, 'handler' => $handler]) { - $end ??= Carbon::now(); + foreach ($this->requestLifecycleDurationHandlers as ['threshold' => $threshold, 'handler' => $handler]) { + try { + $end ??= Carbon::now(); - if ($requestStartedAt->diffInMilliseconds($end) > $threshold) { - $handler($requestStartedAt, $request, $response); + if ($requestStartedAt->diffInMilliseconds($end) > $threshold) { + $handler($requestStartedAt, $request, $response); + } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } } + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + CoroutineContext::forget(self::REQUEST_STARTED_AT_CONTEXT_KEY); + } catch (Throwable $throwable) { + $exception ??= $throwable; } - CoroutineContext::forget(self::REQUEST_STARTED_AT_CONTEXT_KEY); + if ($exception !== null) { + throw $exception; + } } /** @@ -244,20 +277,29 @@ protected function terminateMiddleware(Request $request, Response $response): vo } $middlewares = [...$routeMiddleware, ...$this->middleware]; + $exception = null; foreach ($middlewares as $middleware) { if (! is_string($middleware)) { continue; } - [$name] = $this->parseMiddleware($middleware); + try { + [$name] = $this->parseMiddleware($middleware); - $instance = $this->app->make($name); + $instance = $this->app->make($name); - if (method_exists($instance, 'terminate')) { - $instance->terminate($request, $response); + if (method_exists($instance, 'terminate')) { + $instance->terminate($request, $response); + } + } catch (Throwable $throwable) { + $exception ??= $throwable; } } + + if ($exception !== null) { + throw $exception; + } } /** diff --git a/src/http-server/src/Server.php b/src/http-server/src/Server.php index cd9feea75..95d9663ea 100644 --- a/src/http-server/src/Server.php +++ b/src/http-server/src/Server.php @@ -108,42 +108,65 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe // Dispatch through the Kernel (global middleware → Router → response) $response = $this->kernel->handle($request); } catch (Throwable $throwable) { - // If Kernel::handle() itself throws (shouldn't normally — it catches internally), - // we still need to send something back to the client. - $response = new SymfonyResponse('Internal Server Error', 500); + $contextResponse = ResponseContext::getOrNull(); + $response = $contextResponse?->isStreamed() + ? $contextResponse + : new SymfonyResponse('Internal Server Error', 500); } finally { + $finalizationException = null; + if (isset($request)) { - if ($this->event?->hasListeners(RequestTerminated::class)) { - Coroutine::defer(fn () => $this->event->dispatch(new RequestTerminated( - request: $request, - response: $response ?? null, - exception: $throwable ?? null, - server: $this->serverName - ))); + try { + if ($this->event?->hasListeners(RequestTerminated::class)) { + Coroutine::defer(fn () => $this->event->dispatch(new RequestTerminated( + request: $request, + response: $response ?? null, + exception: $throwable ?? null, + server: $this->serverName + ))); + } + } catch (Throwable $exception) { + $finalizationException = $exception; } - if ($this->event?->hasListeners(RequestHandled::class)) { - $this->event->dispatch(new RequestHandled( - request: $request, - response: $response ?? null, - exception: $throwable ?? null, - server: $this->serverName - )); + try { + if ($this->event?->hasListeners(RequestHandled::class)) { + $this->event->dispatch(new RequestHandled( + request: $request, + response: $response ?? null, + exception: $throwable ?? null, + server: $this->serverName + )); + } + } catch (Throwable $exception) { + $finalizationException ??= $exception; } } // Send HttpFoundation response back through Swoole if (isset($response)) { - ResponseBridge::send( - $response, - $swooleResponse, - withBody: ! isset($rawMethod) || $rawMethod !== 'HEAD' - ); + try { + ResponseBridge::send( + $response, + $swooleResponse, + withBody: ! isset($rawMethod) || $rawMethod !== 'HEAD' + ); + } catch (Throwable $exception) { + $finalizationException ??= $exception; + } } // Terminable middleware if (isset($request, $response)) { - $this->kernel->terminate($request, $response); + try { + $this->kernel->terminate($request, $response); + } catch (Throwable $exception) { + $finalizationException ??= $exception; + } + } + + if ($finalizationException !== null) { + throw $finalizationException; } } } diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 6a5323b23..bba546ce7 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -325,6 +325,31 @@ public function testTerminationTests() $this->assertEquals([1, 2, 3], $result); } + public function testTerminationCallbacksAreExhaustiveAndPreserveTheFirstFailure(): void + { + $app = new Application; + $called = []; + $firstFailure = new RuntimeException('first failure'); + + $app->terminating(function () use (&$called, $firstFailure): void { + $called[] = 'first'; + + throw $firstFailure; + }); + $app->terminating(function () use (&$called): void { + $called[] = 'second'; + }); + + try { + $app->terminate(); + $this->fail('Expected the first termination failure to propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame($firstFailure, $exception); + } + + $this->assertSame(['first', 'second'], $called); + } + public function testTerminationCallbacksCanAcceptAtNotation() { $app = new Application; diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index d6d107721..7db142547 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -6,6 +6,8 @@ use Carbon\Carbon; use Hypervel\Config\Repository; +use Hypervel\Context\ResponseContext; +use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Events\Dispatcher; use Hypervel\Foundation\Application; use Hypervel\Foundation\Events\Terminating; @@ -15,6 +17,7 @@ use Hypervel\Routing\Router; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; use function Hypervel\Coroutine\parallel; @@ -260,6 +263,116 @@ public function terminate($request, $response): void ], $called); } + public function testHandleRethrowsAfterAResponseIsCommitted(): void + { + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $app->bootstrapWith([]); + $failure = new RuntimeException('stream failed'); + $handler = m::mock(ExceptionHandler::class); + $handler->shouldReceive('report')->once()->with($failure); + $handler->shouldReceive('render')->andReturn(new Response('replacement', 500)); + $app->instance(ExceptionHandler::class, $handler); + $router = m::mock(Router::class); + $router->shouldReceive('dispatch')->once()->andThrow($failure); + $kernel = new Kernel($app, $router); + $committedResponse = new Response; + $committedResponse->markStreamed(); + ResponseContext::set($committedResponse); + + try { + $kernel->handle(Request::create('/')); + $this->fail('Expected the committed stream failure to propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } finally { + ResponseContext::forget(); + } + } + + public function testTerminationIsExhaustiveAndPreservesTheFirstFailure(): void + { + $called = []; + $app = new Application; + $events = new Dispatcher($app); + $app->instance('events', $events); + $app->instance('config', new Repository(['app' => ['timezone' => 'UTC']])); + $app->bootstrapWith([]); + $router = m::mock(Router::class); + $router->shouldReceive('dispatch')->once()->andReturn(new Response); + $kernel = new Kernel($app, $router); + $request = Request::create('/'); + $response = $kernel->handle($request); + $eventFailure = new RuntimeException('event failed'); + $middlewareFailure = new RuntimeException('middleware failed'); + $applicationFailure = new RuntimeException('application failed'); + $durationFailure = new RuntimeException('duration failed'); + + $events->listen(function (Terminating $event) use (&$called, $eventFailure): void { + $called[] = 'event'; + + throw $eventFailure; + }); + $app->instance('first-middleware', new class($called, $middlewareFailure) { + public function __construct(private &$called, private RuntimeException $failure) + { + } + + public function terminate(Request $request, Response $response): void + { + $this->called[] = 'first middleware'; + + throw $this->failure; + } + }); + $app->instance('second-middleware', new class($called) { + public function __construct(private &$called) + { + } + + public function terminate(Request $request, Response $response): void + { + $this->called[] = 'second middleware'; + } + }); + $kernel->setGlobalMiddleware(['first-middleware', 'second-middleware']); + $app->terminating(function () use (&$called, $applicationFailure): void { + $called[] = 'first application'; + + throw $applicationFailure; + }); + $app->terminating(function () use (&$called): void { + $called[] = 'second application'; + }); + $kernel->whenRequestLifecycleIsLongerThan(-1, function () use (&$called, $durationFailure): void { + $called[] = 'first duration'; + + throw $durationFailure; + }); + $kernel->whenRequestLifecycleIsLongerThan(-1, function () use (&$called): void { + $called[] = 'second duration'; + }); + + try { + $kernel->terminate($request, $response); + $this->fail('Expected the first termination failure to propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame($eventFailure, $exception); + } + + $this->assertSame([ + 'event', + 'first middleware', + 'second middleware', + 'first application', + 'second application', + 'first duration', + 'second duration', + ], $called); + $this->assertNull($kernel->requestStartedAt()); + } + public function testRequestStartedAtIsIsolatedBetweenConcurrentCoroutines() { $app = new Application; diff --git a/tests/HttpServer/ServerTest.php b/tests/HttpServer/ServerTest.php index 3c0e03710..1206dd4a5 100644 --- a/tests/HttpServer/ServerTest.php +++ b/tests/HttpServer/ServerTest.php @@ -139,6 +139,95 @@ public function testOnRequestReturns500OnKernelException() $server->onRequest($swooleRequest, $swooleResponse); } + public function testOnRequestPreservesACommittedResponseAfterKernelFailure(): void + { + CoordinatorManager::until(Constants::WORKER_START)->resume(); + $failure = new RuntimeException('stream failed'); + $committedResponse = null; + $kernel = m::mock(KernelContract::class); + $kernel->shouldReceive('handle') + ->once() + ->andReturnUsing(function () use ($failure, &$committedResponse): never { + $committedResponse = ResponseContext::get(); + $committedResponse->markStreamed(); + + throw $failure; + }); + $kernel->shouldReceive('terminate') + ->once() + ->with(m::type(Request::class), m::on( + function (Response $response) use (&$committedResponse): bool { + return $response === $committedResponse; + } + )); + $handledEvent = null; + $eventDispatcher = m::mock(EventDispatcherContract::class); + $eventDispatcher->shouldReceive('hasListeners')->once()->with(RequestReceived::class)->andReturn(false); + $eventDispatcher->shouldReceive('hasListeners')->once()->with(RequestTerminated::class)->andReturn(false); + $eventDispatcher->shouldReceive('hasListeners')->once()->with(RequestHandled::class)->andReturn(true); + $eventDispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(RequestHandled::class)) + ->andReturnUsing(function (RequestHandled $event) use (&$handledEvent): RequestHandled { + $handledEvent = $event; + + return $event; + }); + $container = m::mock(Container::class); + $container->shouldReceive('bound')->with('events')->andReturn(true); + $container->shouldReceive('make')->with('events')->andReturn($eventDispatcher); + $server = new Server($container); + $this->setKernel($server, $kernel); + $this->setServerName($server, 'http'); + $swooleResponse = m::mock(SwooleResponse::class); + $swooleResponse->shouldNotReceive('status'); + $swooleResponse->shouldNotReceive('header'); + $swooleResponse->shouldReceive('end')->once()->withNoArgs(); + + $server->onRequest($this->createSwooleRequest(), $swooleResponse); + + $this->assertInstanceOf(HypervelResponse::class, $committedResponse); + $this->assertInstanceOf(RequestHandled::class, $handledEvent); + $this->assertSame($committedResponse, $handledEvent->response); + $this->assertSame($failure, $handledEvent->exception); + } + + public function testOnRequestFinalizationIsExhaustiveAndPreservesTheFirstFailure(): void + { + CoordinatorManager::until(Constants::WORKER_START)->resume(); + $handledFailure = new RuntimeException('handled failed'); + $sendFailure = new RuntimeException('send failed'); + $terminateFailure = new RuntimeException('terminate failed'); + $kernel = m::mock(KernelContract::class); + $kernel->shouldReceive('handle')->once()->andReturn(new Response('OK')); + $kernel->shouldReceive('terminate')->once()->andThrow($terminateFailure); + $eventDispatcher = m::mock(EventDispatcherContract::class); + $eventDispatcher->shouldReceive('hasListeners')->once()->with(RequestReceived::class)->andReturn(false); + $eventDispatcher->shouldReceive('hasListeners')->once()->with(RequestTerminated::class)->andReturn(false); + $eventDispatcher->shouldReceive('hasListeners')->once()->with(RequestHandled::class)->andReturn(true); + $eventDispatcher->shouldReceive('dispatch') + ->once() + ->with(m::type(RequestHandled::class)) + ->andThrow($handledFailure); + $container = m::mock(Container::class); + $container->shouldReceive('bound')->with('events')->andReturn(true); + $container->shouldReceive('make')->with('events')->andReturn($eventDispatcher); + $server = new Server($container); + $this->setKernel($server, $kernel); + $this->setServerName($server, 'http'); + $swooleResponse = m::mock(SwooleResponse::class); + $swooleResponse->shouldReceive('status')->once()->with(200); + $swooleResponse->shouldReceive('header')->withAnyArgs(); + $swooleResponse->shouldReceive('end')->once()->with('OK')->andThrow($sendFailure); + + try { + $server->onRequest($this->createSwooleRequest(), $swooleResponse); + $this->fail('Expected the first finalization failure to propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame($handledFailure, $exception); + } + } + public function testOnRequestSuppressesBodyForHeadRequests() { CoordinatorManager::until(Constants::WORKER_START)->resume(); From 9783ba00ee2da37547cbb1c82ce7121aa33d10a7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:14:55 +0000 Subject: [PATCH 22/30] fix(filesystem): finalize streamed file resources explicitly Fall back to application/octet-stream when MIME detection fails so file responses never publish an invalid Content-Type value. Remove LeasedStream destructor cleanup and keep native pooled release on the explicit stream_close and construction-rollback boundaries, avoiding unsafe native teardown during PHP object destruction. Prove failed response writes stop further reads and close the stream, missing MIME data uses the binary fallback, and explicit stream resource destruction releases each lease exactly once. --- src/filesystem/src/FileResponseBuilder.php | 2 +- src/filesystem/src/LeasedStream.php | 8 --- tests/Filesystem/FileResponseBuilderTest.php | 72 ++++++++++++++++++++ tests/Filesystem/LeasedStreamTest.php | 2 +- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/filesystem/src/FileResponseBuilder.php b/src/filesystem/src/FileResponseBuilder.php index 51a906126..7057ef73e 100644 --- a/src/filesystem/src/FileResponseBuilder.php +++ b/src/filesystem/src/FileResponseBuilder.php @@ -39,7 +39,7 @@ public function build( Closure $size, Closure $streamResolver, ): Response { - $headers['Content-Type'] ??= $mimeType(); + $headers['Content-Type'] ??= $mimeType() ?: 'application/octet-stream'; if (! array_key_exists('Content-Disposition', $headers)) { $filename = $name ?? basename($path); diff --git a/src/filesystem/src/LeasedStream.php b/src/filesystem/src/LeasedStream.php index baa908313..9f8664cff 100644 --- a/src/filesystem/src/LeasedStream.php +++ b/src/filesystem/src/LeasedStream.php @@ -187,14 +187,6 @@ public function stream_close(): void $this->finalize(); } - /** - * Finalize an abandoned wrapper without throwing. - */ - public function __destruct() - { - $this->finalize(); - } - /** * Close a resource without allowing cleanup failures to escape. */ diff --git a/tests/Filesystem/FileResponseBuilderTest.php b/tests/Filesystem/FileResponseBuilderTest.php index 4ea8e71a1..8de1d7c67 100644 --- a/tests/Filesystem/FileResponseBuilderTest.php +++ b/tests/Filesystem/FileResponseBuilderTest.php @@ -108,6 +108,27 @@ public function testBodyContainingOnlyZeroIsWritten(): void $this->assertSame('0', $writable->written); } + public function testMissingMimeTypeFallsBackToBinaryContent(): void + { + $writable = new FakeWritableConnection; + $response = $this->response($writable); + + (new FileResponseBuilder)->build( + Request::create('/file.unknown', 'GET'), + $response, + 'file.unknown', + null, + [], + 'inline', + static fn (): false => false, + static fn (): int => 4, + fn (?int $start, ?int $end): mixed => $this->stream('body'), + ); + + $this->assertSame('application/octet-stream', $response->headers->get('Content-Type')); + $this->assertSame('application/octet-stream', $writable->getSocket()->headers['Content-Type']); + } + #[DataProvider('validRangeProvider')] public function testValidRangesAreNormalizedAndStreamedExactly( string $header, @@ -427,6 +448,25 @@ public function testOutputFailureStaysPrimaryWhenClosingAlsoFails(): void $this->assertSame(1, $state->closeCount); } + public function testFailedWriteStopsReadingAndClosesTheStream(): void + { + $state = new FileResponseBuilderStreamState(str_repeat('x', 128 * 1024)); + $connection = new FileResponseBuilderDisconnectingConnection; + + $this->build( + Request::create('/file.txt', 'GET'), + $this->response($connection), + fn (?int $start, ?int $end): mixed => $this->wrappedStream($state), + 128 * 1024, + ); + + $this->assertSame(1, $connection->writeCalls); + $this->assertGreaterThan(0, $connection->bytesAttempted); + $this->assertSame($connection->bytesAttempted, $state->position); + $this->assertLessThan(128 * 1024, $state->position); + $this->assertSame(1, $state->closeCount); + } + public function testAStreamBackedByALeaseReleasesAfterEmission(): void { $pool = new SimpleObjectPool( @@ -629,3 +669,35 @@ public function end(): ?bool return true; } } + +class FileResponseBuilderDisconnectingConnection implements Writable +{ + public int $writeCalls = 0; + + public int $bytesAttempted = 0; + + private readonly FakeSwooleSocket $socket; + + public function __construct() + { + $this->socket = new FakeSwooleSocket; + } + + public function getSocket(): FakeSwooleSocket + { + return $this->socket; + } + + public function write(string $data): bool + { + ++$this->writeCalls; + $this->bytesAttempted += strlen($data); + + return false; + } + + public function end(): ?bool + { + return true; + } +} diff --git a/tests/Filesystem/LeasedStreamTest.php b/tests/Filesystem/LeasedStreamTest.php index 88315fbd9..34d53801a 100644 --- a/tests/Filesystem/LeasedStreamTest.php +++ b/tests/Filesystem/LeasedStreamTest.php @@ -38,7 +38,7 @@ public function testReadEofAndRewindDoNotReleaseUntilClose(): void $pool->close(); } - public function testExplicitCloseAndWrapperDestructionReleaseExactlyOnce(): void + public function testExplicitCloseAndStreamResourceDestructionReleaseExactlyOnce(): void { $releaseCount = 0; [$pool, $lease, $inner] = $this->leaseWithStream( From 46705dbd513b8577675f9ff94fd4908de761309a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:15:11 +0000 Subject: [PATCH 23/30] test(filesystem): return truthful native write counts Update environment command and file-session mocks to model Filesystem::put as the native int-or-false boundary instead of returning booleans or null that strict production code cannot produce. Correct the shared environment spy so get returns file contents and put returns the written byte count, keeping the tests faithful to the real caller and callee contracts without adding source-side guards for unreachable mock states. --- .../Console/EnvironmentDecryptCommandTest.php | 2 +- .../Console/EnvironmentEncryptCommandTest.php | 16 ++++++++-------- tests/Session/FileSessionHandlerTest.php | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/Integration/Console/EnvironmentDecryptCommandTest.php b/tests/Integration/Console/EnvironmentDecryptCommandTest.php index b36fb9f2f..6f5a8789a 100644 --- a/tests/Integration/Console/EnvironmentDecryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentDecryptCommandTest.php @@ -19,7 +19,7 @@ protected function setUp(): void $this->filesystem = m::spy(Filesystem::class); $this->filesystem->shouldReceive('put') - ->andReturn(true); + ->andReturn(1); File::swap($this->filesystem); } diff --git a/tests/Integration/Console/EnvironmentEncryptCommandTest.php b/tests/Integration/Console/EnvironmentEncryptCommandTest.php index 77842f19f..76df5fd84 100644 --- a/tests/Integration/Console/EnvironmentEncryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentEncryptCommandTest.php @@ -19,9 +19,9 @@ protected function setUp(): void $this->filesystem = m::spy(Filesystem::class); $this->filesystem->shouldReceive('get') - ->andReturn(true) + ->andReturn('APP_NAME=Laravel') ->shouldReceive('put') - ->andReturn('APP_NAME=Laravel'); + ->andReturn(1); File::swap($this->filesystem); } @@ -187,7 +187,7 @@ public function testItEncryptsInReadableFormat(): void && str_starts_with($lines[0], 'APP_NAME=') && str_starts_with($lines[1], 'APP_ENV='); })) - ->andReturn(true); + ->andReturn(1); File::swap($filesystem); $this->artisan('env:encrypt', ['--readable' => true, '--key' => 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP']) @@ -220,7 +220,7 @@ public function testItSkipsCommentsAndBlankLinesInReadableFormat(): void && str_starts_with($lines[0], 'APP_NAME=') && str_starts_with($lines[1], 'APP_ENV='); })) - ->andReturn(true); + ->andReturn(1); File::swap($filesystem); $this->artisan('env:encrypt', ['--readable' => true, '--key' => 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP']) @@ -264,7 +264,7 @@ public function testItEncryptsMultiLineValuesInReadableFormat(): void return true; })) - ->andReturn(true); + ->andReturn(1); File::swap($filesystem); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) @@ -318,7 +318,7 @@ public function testItEncryptsVariableReferencesInReadableFormat(): void return true; })) - ->andReturn(true); + ->andReturn(1); File::swap($filesystem); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) @@ -373,7 +373,7 @@ public function testItSkipsInvalidEnvLinesInReadableFormat(): void return true; })) - ->andReturn(true); + ->andReturn(1); File::swap($filesystem); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) @@ -427,7 +427,7 @@ public function testItEncryptsSpecialCharactersInReadableFormat(): void return true; })) - ->andReturn(true); + ->andReturn(1); File::swap($filesystem); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) diff --git a/tests/Session/FileSessionHandlerTest.php b/tests/Session/FileSessionHandlerTest.php index 54ba06917..589644187 100644 --- a/tests/Session/FileSessionHandlerTest.php +++ b/tests/Session/FileSessionHandlerTest.php @@ -103,7 +103,7 @@ public function testWriteStoresData() $sessionId = 'session_id'; $data = 'session_data'; - $this->files->shouldReceive('put')->with('/path/to/sessions/' . $sessionId, $data, true)->once()->andReturn(null); + $this->files->shouldReceive('put')->with('/path/to/sessions/' . $sessionId, $data, true)->once()->andReturn(strlen($data)); $result = $this->sessionHandler->write($sessionId, $data); From 941e7487edb1df7d231ced5fde09320fd4103985 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:15:24 +0000 Subject: [PATCH 24/30] test(filesystem): align path helper coverage Run the join_paths tests through Hypervel's framework test base so they receive the same coroutine and state-cleanup behavior as the rest of the suite. Add complete native return types to test methods, generator providers, and Stringable fixtures while preserving the Unix and Windows path matrix unchanged. --- tests/Filesystem/JoinPathsHelperTest.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/Filesystem/JoinPathsHelperTest.php b/tests/Filesystem/JoinPathsHelperTest.php index f0db4d40f..8a1a1cd7c 100644 --- a/tests/Filesystem/JoinPathsHelperTest.php +++ b/tests/Filesystem/JoinPathsHelperTest.php @@ -4,9 +4,10 @@ namespace Hypervel\Tests\Filesystem; +use Generator; +use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; -use PHPUnit\Framework\TestCase; use Stringable; use function Hypervel\Filesystem\join_paths; @@ -15,12 +16,12 @@ class JoinPathsHelperTest extends TestCase { #[RequiresOperatingSystem('Linux|Darwin')] #[DataProvider('unixDataProvider')] - public function testItCanMergePathsForUnix(string $expected, string $given) + public function testItCanMergePathsForUnix(string $expected, string $given): void { $this->assertSame($expected, $given); } - public static function unixDataProvider() + public static function unixDataProvider(): Generator { yield ['very/Basic/Functionality.php', join_paths('very', 'Basic', 'Functionality.php')]; yield ['segments/get/ltrimed/by_directory/separator.php', join_paths('segments', '/get/ltrimed', '/by_directory/separator.php')]; @@ -30,13 +31,13 @@ public static function unixDataProvider() yield ['', join_paths(null, null, '')]; yield ['1/2/3', join_paths(1, 0, 2, 3)]; yield ['app/objecty', join_paths('app', new class implements Stringable { - public function __toString() + public function __toString(): string { return 'objecty'; } })]; yield ['app/0', join_paths('app', new class implements Stringable { - public function __toString() + public function __toString(): string { return '0'; } @@ -45,12 +46,12 @@ public function __toString() #[RequiresOperatingSystem('Windows')] #[DataProvider('windowsDataProvider')] - public function testItCanMergePathsForWindows(string $expected, string $given) + public function testItCanMergePathsForWindows(string $expected, string $given): void { $this->assertSame($expected, $given); } - public static function windowsDataProvider() + public static function windowsDataProvider(): Generator { yield ['app\Basic\Functionality.php', join_paths('app', 'Basic', 'Functionality.php')]; yield ['segments\get\ltrimed\by_directory\separator.php', join_paths('segments', '\get\ltrimed', '\by_directory\separator.php')]; @@ -60,13 +61,13 @@ public static function windowsDataProvider() yield ['', join_paths(null, null, '')]; yield ['1\2\3', join_paths(1, 2, 3)]; yield ['app\objecty', join_paths('app', new class implements Stringable { - public function __toString() + public function __toString(): string { return 'objecty'; } })]; yield ['app\0', join_paths('app', new class implements Stringable { - public function __toString() + public function __toString(): string { return '0'; } From dd2064b4b0fbdf43695ee3344c0caaf77de16aa7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:15:45 +0000 Subject: [PATCH 25/30] docs(filesystem): explain lazy streams and empty assertions Document that S3 and Google Cloud Storage readStream operations are lazy by default, including the bounded-memory benefit, the unchanged behavior of ordinary reads, the connection-reuse tradeoff, and the stream_reads opt-out. Add assertEmpty examples alongside the existing filesystem testing assertions and align the documented Google Cloud Storage configuration with the framework default. --- src/boost/docs/filesystem.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/boost/docs/filesystem.md b/src/boost/docs/filesystem.md index 0342c71b9..e8e4013b6 100644 --- a/src/boost/docs/filesystem.md +++ b/src/boost/docs/filesystem.md @@ -207,7 +207,7 @@ If you need to configure a Google Cloud Storage filesystem manually, you may use 'visibility_handler' => null, 'metadata' => ['cacheControl' => 'public,max-age=86400'], 'throw' => false, - 'stream_reads' => false, + 'stream_reads' => true, 'pool' => [ 'min_retained_objects' => 1, 'max_objects' => 10, @@ -281,6 +281,8 @@ $result = Storage::disk('s3')->withClient(function ($client) { `Storage::forgetDisk()` only removes the manager's cached disk wrapper; an equivalent wrapper can continue using the shared pool. `Storage::purge()` removes the wrapper and closes its current pool, deriving the same pool identity even when the named disk has not been resolved yet or is composed from nested scoped disks. Other disks converging on that pool transparently create a fresh one on their next operation. Streams returned by `readStream()` retain their client lease until the stream is closed or destroyed. +S3 and Google Cloud Storage streams are read lazily by default, which keeps memory usage bounded and makes data available before the entire file has downloaded. This applies to `readStream()` and `readStreamRange()`; methods such as `get()` retain their normal behavior. Streaming requests close their HTTP connection after the read, so applications that open many small streams may prefer connection reuse and set the disk's `stream_reads` option to `false`. + ### Scoped and Read-Only Filesystems @@ -936,6 +938,9 @@ test('albums can be uploaded', function () { // Assert that a given directory is empty... Storage::disk('photos')->assertDirectoryEmpty('/wallpapers'); + + // Assert that the disk contains no files... + Storage::disk('photos')->assertEmpty(); }); ``` @@ -972,6 +977,9 @@ class ExampleTest extends TestCase // Assert that a given directory is empty... Storage::disk('photos')->assertDirectoryEmpty('/wallpapers'); + + // Assert that the disk contains no files... + Storage::disk('photos')->assertEmpty(); } } ``` From a87c3f184cae40bb7ce04fc1c4c56858edc948ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:16:00 +0000 Subject: [PATCH 26/30] docs(audit): record filesystem lifecycle completion Record the verified native I/O, adapter, streaming, response, and finalization findings together with their accepted designs, rejected complexity, implementation result, regression coverage, performance tradeoffs, and signed-off validation. Close the carried Filesystem, Coroutine, DI, Encryption, and Support revalidations, remove stale pending-audit language, and mark Filesystem complete in the authoritative package checklist. Advance the routing index to Pipeline with support-02 as its required prior decision while preserving the remaining Cache, HTTP, Foundation, and HTTP Server revalidation routes. --- ...-coroutine-state-lifecycle-audit-ledger.md | 45 ++++++++++++++++--- ...amework-coroutine-state-lifecycle-audit.md | 24 +++++----- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md index 869311bf3..77ac4475d 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md @@ -122,8 +122,8 @@ Append package entries in checklist order. Keep each entry compact but complete - **Failure:** The public contract accepts fewer valid disk names than the manager, returns `mixed`, and allows a custom creator to publish an arbitrary object as a disk. - **Decision:** Accept `UnitEnum|string|null`, return `Filesystem` throughout the construction boundary, and validate custom creator output once before publication/caching. - **Implementation and cleanup:** Typed the contract and manager construction path as `Filesystem`, aligned enum-capable disk names, and reject an invalid custom-creator result once before caching or publication. Existing creator binding behavior remains intact. -- **Validation:** Valid custom creators, manager binding, and invalid-result regressions pass; the work-unit review is signed off and the later full `filesystem` audit remains pending. -- **Revalidation:** Full `filesystem` audit remains pending. +- **Validation:** Valid custom creators, manager binding, and invalid-result regressions pass; the work-unit review is signed off. +- **Revalidation:** The full `filesystem` audit retained the typed custom-creator boundary and its regression coverage unchanged. ### Shared finding `queue-01`: restorable model-identifier boundary @@ -379,7 +379,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved implementation boundary:** The owner approved replacing the inherited Hyperf closure generator with the wrapper/helper design after reviewing the reproduced failures, upstream-maintenance cost, and overengineering boundary. The owner also approved `filesystem-02`, including the Laravel-facing change from silent directory-creation failure to a deterministic exception. Laravel has no native AOP API; Hypervel's documented aspect registration, target declarations, `ProceedingJoinPoint`, advice pipeline, and configuration remain unchanged. The complete intercepted hot path must be benchmarked, and any measured regression returns to the owner stop gate before implementation continues. - **Generation and runtime design:** Preserve original method signatures, attributes, documentation, modifiers, defaults, return types, lexical magic constants, PHP 8.4 closure descriptors, and top-level argument-introspection behavior. Resolve direct argument-introspection calls through PHP's function-import and runtime namespace-fallback rules; reject literal indirect `call_user_func` forms and unpacked argument-introspection calls that PHP can bind to the caller frame rather than silently reporting the helper frame. Forward reference parameters and variadics by reference so advice and the original body mutate the caller's values. Use expression dispatch for `void` and `never`. Compute the complete aspect list locally and publish it to `AspectManager` once. Inject an intentionally empty `ProxyMarker` trait and detect its exact identity through the existing recursive trait helper, including proxied traits and inherited proxies, without reserving a target method name. Leave unstable anonymous-class runtime identities native rather than rejecting useful source or emulating process-dependent names. - **Artifact design:** Fingerprint the canonical source path and content, complete aspect rules, visitor order and implementation content, the DI AOP generator source, installed `nikic/php-parser` identity, and `PHP_VERSION_ID`. Read only the embedded header on a hit. On a miss, generate to a collision-free encoded path and publish with `Filesystem::replace()`. Reject an invalid target before writing that target's proxy or publishing any loader-map entry; a rejection anywhere in the batch publishes no map. Valid artifacts written earlier in a failed batch remain safe fingerprinted cache entries for a later boot. Document the supported one-named-class-like source shape in the AOP guide. -- **Cleanup and cross-package implications:** Remove dead `Ast::parseClassByStmts()`, `ProxyManager::getAspectClasses()`, the dispatching `ProxyTrait`, and their obsolete fixtures/tests; retain `RewriteCollection::getShouldNotRewriteMethods()` and the test-owned `AspectCollector::forgetAspect()`. Route `ServiceProvider::aspects()` through `ClassMetadataCache::reflectClass()` to complete `reflection-04`. The later full `filesystem`, `foundation`, `support`, `sentry`, and `telescope` audits must retain and revalidate these boundaries. +- **Cleanup and cross-package implications:** Remove dead `Ast::parseClassByStmts()`, `ProxyManager::getAspectClasses()`, the dispatching `ProxyTrait`, and their obsolete fixtures/tests; retain `RewriteCollection::getShouldNotRewriteMethods()` and the test-owned `AspectCollector::forgetAspect()`. Route `ServiceProvider::aspects()` through `ClassMetadataCache::reflectClass()` to complete `reflection-04`. The full `filesystem` audit retained checked directory creation; the later full `foundation`, `support`, `sentry`, and `telescope` audits must retain and revalidate the remaining boundaries. - **Regression strategy:** Compare proxied and unproxied execution for omitted, positional, named-skipped, numeric-variadic, and named-variadic argument shapes. Cover global, imported, namespace-shadowed, and explicit-relative argument-introspection calls; valid named `func_get_arg`; unchanged first-class callables; and deterministic rejection of literal indirect and builtin-resolving unpacked calls while definite custom functions remain untouched. Cover method-local statics, default-object identity, caller-visible mutation by original bodies and aspects, reference variadics, nested magic constants, private/static/trait/aliased/void/never methods, deterministic pre-publication rejection, fingerprint inputs, same-mtime changes, encoded-name collisions, failed native boundaries, atomic publication, exact marker detection, loader restoration, and real Sentry/Telescope interception. - **Implementation:** Generated proxies now retain each original method signature as the advice wrapper and move its body to one collision-checked private helper. One stateless dispatcher owns generated and manual aspect calls, and one empty marker trait identifies proxied classes, enums, traits, aliases, inherited proxies, and classes composing multiple proxied traits without reserving a method name. Direct argument-introspection calls preserve the original frame across positional, named, skipped, and variadic calls; unsafe indirect or unpacked forms fail before publication. Magic constants, nested PHP 8.4 closure descriptors, method-local statics, default-object identity, references, `void`, and `never` retain native behavior. Proxy artifacts use collision-free encoded paths, checked native boundaries, complete content fingerprints, header-only cache hits, atomic replacement, and all-or-nothing loader-map publication. Class-map tests isolate and restore their Composer loader, Support uses the canonical reflection cache, and failed directory creation now throws after allowing a concurrent creator. - **Regression tests:** Native and proxied twins cover every supported argument shape and both native `func_get_arg()` failure messages. Focused coverage also pins function-import and namespace fallback rules, rejected dynamic forms, method-local state, object defaults, caller-visible references, variadics, private/static/trait/aliased/enum/void/never methods, exact and inherited marker detection, multiple proxied traits, nested closure-descriptor format, leading-backslash normalization, generated-name collisions, source-shape rejection, every fingerprint input, same-mtime changes, encoded filename collisions, native boundary failures, batch publication, loader restoration, and real Sentry and Telescope interception. @@ -413,7 +413,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Important rejected concerns:** Do not rebuild or deep-clone complete Monolog graphs per request, serialize logging through a mutex, pool loggers, add a generic handler registry, coordinate loop detection across distinct channels, normalize arbitrary subclasses or custom drivers, reconstruct private vendor state with reflection, adapt handlers without a reproduced race, coordinate custom manager creation speculatively, widen the `logs()` helper to enums, or change scheduled-command propagation. Raw `getLogger()` remains an explicit opt-out. User-owned handler subclasses, processors, and full custom drivers remain user-owned. - **Approved implementation boundary:** The owner approved the complete design and its four stop gates. `log-02` allocates one combined state on the first handled record for a logger family in a coroutine and reuses one lookup for context and recursion depth. `log-05` adds one context lookup only to channels configured with `action_level`. `log-07` adds one context lookup only to channels explicitly configured with the exact standard `UidProcessor`. `log-08` intentionally makes runtime build taps work, stops retaining built loggers as `ondemand`, and confines manager-wide context mutation to cached named channels. Ordinary requests, jobs, and Context operations that do not log pay none of these per-record costs. -- **Construction and native-boundary design:** Built-in single, daily, and emergency channels construct safe framework stream classes directly. The monolog driver swaps only the exact configured vendor `StreamHandler` and `RotatingFileHandler` class strings before container construction, forwarding the existing `with` arguments; subclasses pass through untouched. UID normalization resolves the configured processor first and replaces only an exact standard instance, preserving its length with `strlen(getUid())`. Native failures use deterministic operation/path diagnostics and immediate result checks, never `set_error_handler()`, `error_get_last()`, or `@` around a yielding operation. PHP's `@` suppression changes process-global error reporting across a Swoole coroutine switch, while Hypervel's runtime error handler turns unsuppressed native warnings into `ErrorException`; the local open, write, lock, permission, close, inode, directory, and rotation catches are therefore required ownership boundaries rather than optional warning cleanup. The stream implementations preserve one reopen retry, best-effort locking and permissions, chunk size, inode handling, rotation, and retention behavior. +- **Construction and native-boundary design:** Built-in single, daily, and emergency channels construct safe framework stream classes directly. The monolog driver swaps only the exact configured vendor `StreamHandler` and `RotatingFileHandler` class strings before container construction, forwarding the existing `with` arguments; subclasses pass through untouched. UID normalization resolves the configured processor first and replaces only an exact standard instance, preserving its length with `strlen(getUid())`. Native failures use deterministic operation/path diagnostics and immediate result checks, never `set_error_handler()` or `error_get_last()` around a yielding operation. PHP's `@` suppression is coroutine-local under the current Swoole hooks, but process-global error handlers and `error_get_last()` can observe another coroutine's warnings; the local open, write, lock, permission, close, inode, directory, and rotation catches are therefore required ownership boundaries rather than optional warning cleanup. The stream implementations preserve one reopen retry, best-effort locking and permissions, chunk size, inode handling, rotation, and retention behavior. - **State ownership design:** One logger-family state object contains visible context and recursion depth; replication copies visible context and resets depth. `withoutContext()` mutates only the visible-context field, including for a full clear, because removing the combined slot during a handler-triggered re-entrant log would reset loop detection and orphan the outer frame's depth bookkeeping. Fingers-crossed replication starts with an empty buffer and initial buffering state so a child neither races on nor duplicates its parent's history. UID replication copies the current request identifier. The exception-reporting slot is an array containing coroutine ID plus exception so a copied child cannot mistake an inherited parent report for its own. The logger-family counter is a worker-lifetime identity generator, not resettable state; resetting it can reintroduce context aliasing. - **Queue timing and error contract:** SyncQueue creates a serialized payload at the execution scheduling boundary and delegates to `executePayload()`. Background and Deferred queues schedule only that immutable string; delayed timers capture it immediately, while after-commit callbacks continue to create it only when the commit callback runs. Serialization failures now throw synchronously to the dispatcher or commit callback, matching persistent queues; configured exception callbacks handle only asynchronous execution failures. A payload without log context flushes an already-created repository but does not allocate one solely to hydrate null. - **Cross-package implications:** Log owns logger, handler, processor, manager, context-listener, provenance, and logging-guide changes. Foundation owns exception-reporting state and the `logs()` helper. Support owns callback rebinding, with matching direct manager consumers in Auth, Broadcasting, Cache, Filesystem, and Log. The shared `Manager` correction also restores Laravel's custom-driver callback contract across its eight Reverb, JWT, Session, Socialite, Notifications, Foundation maintenance-mode, and Hashing subclasses; stale Socialite and JWT test creators now use explicit container or local captures rather than relying on an external anonymous-closure receiver. Queue owns payload timing and serialized execution. Sentry removes its workaround and delegates action-level wrapping to Log. Later full Foundation, Support, Queue, and Sentry audits must retain and revalidate these boundaries. @@ -494,12 +494,12 @@ Append package entries in checklist order. Keep each entry compact but complete | `encryption-06` | Defect | Minor | High | `key:generate` omits current Laravel's existing command-prohibition integration | Use the existing `Prohibitable` trait, guard before mutation, and reset the per-command static flag between tests | | `encryption-07` | Defect | Minor | High | A literal null `APP_PREVIOUS_KEYS` crashes strict config loading in `explode()` | Cast the environment value to string at the config boundary | | `encryption-08` | Defect | Major | High | SerializableClosure's process-global signer and Queue callbacks survive application/test lifecycles and retain stale keys or application graphs | Make Encryption boot own set-or-clear of the signer and reset all three vendor globals in authoritative test cleanup | -| `filesystem-03` | Defect | Major | High | Atomic replacement retains its complete content argument in exception traces, exposing `APP_KEY` and other environment secrets published by Encryption and Support | Mark only `Filesystem::replace()`'s content parameter sensitive; keep the broader generic write surface for evidence-based review during the full Filesystem audit | +| `filesystem-03` | Defect | Major | High | Atomic replacement retains its complete content argument in exception traces, exposing `APP_KEY` and other environment secrets published by Encryption and Support | Mark only `Filesystem::replace()`'s content parameter sensitive; the full Filesystem audit found no evidence for broader generic-write redaction | | `sanctum-01` | Defect | Minor | High | Sanctum has the same unguarded `explode(env())` shape, so a literal null stateful-domain value crashes strict config loading | Cast the environment value to string at the Sanctum config boundary and revalidate it during the later full Sanctum audit | - **Owner-approved security tradeoff:** CBC decryption with configured previous keys performs every HMAC check even when the current key matches. The owner approved the measured approximately 1.4 microseconds per additional previous key per CBC decrypt during active rotation. There is no extra work without previous keys and no GCM cost; removing retired keys removes the overhead. - **Important rejected concerns:** Do not constantize GCM decryption across keys, hot-reload or scope the application key per request, add command locking/retries/a general environment parser, add a public Filesystem mode API or shared mode trait for cold CLI duplication, change the cipher set or payload format, remove the protected `validMac()` extension seam, or add metadata/micro-optimization abstractions. Do not annotate the internal regex correction as a Laravel divergence; focused regressions and this ledger are the durable guards. -- **Cross-package implications:** Encryption owns all eight primary findings. Contracts owns secret-parameter metadata and was revalidated; Foundation owns strict application config and the global `encrypt()` helper frame; Support owns Crypt's dynamic-dispatch frame and was revalidated; Testing owns SerializableClosure reset; Queue is revalidated as the callback installer; and Sanctum owns `sanctum-01`. Filesystem owns `filesystem-03`; its concrete atomic replacement boundary also protects Support's existing Env writer, while Encryption's consumer regression guards the complete key-publication chain. Route `filesystem-03` back into the later full Filesystem audit for an owning-package regression and evidence-based review of any other raw-write paths, route the helper boundary into the later full Foundation audit, and route `sanctum-01` back into the later full Sanctum audit. +- **Cross-package implications:** Encryption owns all eight primary findings. Contracts owns secret-parameter metadata and was revalidated; Foundation owns strict application config and the global `encrypt()` helper frame; Support owns Crypt's dynamic-dispatch frame and was revalidated; Testing owns SerializableClosure reset; Queue is revalidated as the callback installer; and Sanctum owns `sanctum-01`. Filesystem owns `filesystem-03`; its concrete atomic replacement boundary also protects Support's existing Env writer, while Encryption's consumer regression guards the complete key-publication chain. The full Filesystem audit retained the focused sensitive replacement boundary and found no evidence for broader generic-write redaction; route the helper boundary into the later full Foundation audit, and route `sanctum-01` back into the later full Sanctum audit. - **Implementation:** CBC decryption now validates every configured key's MAC and decrypts once with the first valid key, while GCM keeps its existing fallback flow. Missing/empty GCM tags fail through `DecryptException`. Secret-bearing parameters are redacted from both direct and indirect helper/facade traces without broadening the base Facade policy. `key:generate` uses the checked Filesystem service and mode-preserving atomic replacement, matches only the exact quoted or unquoted current key, and preserves CRLF through a zero-width line-ending assertion before committing runtime config. The command now supports current Laravel prohibition, Encryption boot set-or-clears the SerializableClosure signer, authoritative test cleanup resets all three vendor globals and the command flag, and strict application/Sanctum config parsing accepts literal-null environment values. No user-facing encryption documentation changes are required because public APIs and documented payload behavior remain unchanged. - **Performance and complexity:** `encryption-01` has the narrow owner-approved CBC rotation cost above. All other changes are metadata, boot, configuration, CLI, failure-path, test-cleanup, or an exact Crypt-local copy of the existing facade dispatch operations. The design adds no request-wide lock, retry, context slot, registry, cache, or new abstraction. - **Validation and review:** Focused Encryption, Foundation-config, Sanctum-config, and Testing-cleanup tests pass. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, the package-list consistency check, runtime trace-redaction probes, a fresh full-diff caller/callee and lifecycle review, and independent code review are complete. The final review corrected CRLF replacement to assert rather than consume the carriage return and strengthened every quoted-key regression to compare the exact resulting file contents; the complete gate remained green afterward. @@ -604,6 +604,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Every changed test file and the affected Context, Coroutine, Concurrency, Filesystem, Foundation testing, Log, and WebSocket groups are green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, package-checklist parity, stale-reference scans, a fresh caller/callee and lifecycle review, and independent code review are complete. - **Laravel-facing result:** Public Laravel APIs, configuration, documented behavior, and conventional extension patterns are unchanged. The only new public method is the Hypervel/Hyperf infrastructure primitive `CoroutineContext::captureFrom()`, which has concrete framework consumers and centralizes an existing operation rather than introducing a new user-facing model. - **Assessment:** The final design fixes demonstrated creation, reporting, ownership, wait-tracking, retained-state, and context-copy defects at their lowest boundaries. It adds no lock, registry, retry, handshake, timeout policy, cancellation framework, or compatibility layer; removes duplicate reporting and stale map entries; and adds no meaningful hot-path cost. +- **Later Filesystem revalidation:** The full Filesystem audit removed its worker-local `Locker` layer and the two acquisition booleans introduced for `coroutine-05`, replacing them with checked native file-lock ownership. Cancellation can no longer release another coroutine's worker-local gate because no such gate remains. Coroutine's own Locker semantics and regressions are unchanged. ### Make process concurrency transport lossless and reconstruct failures safely @@ -797,3 +798,35 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** The final focused `AbstractProcessTest` passes with 13 tests and 42 assertions; the serial and 16-worker Server Process suites each pass with 58 tests and 143 assertions. PHP CS Fixer changed none of 5,571 files; both PHPStan configurations pass; the complete components suite passes with 23,219 tests, 66,136 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; package Composer metadata validates; and `git diff --check` is clean. Fresh lifecycle, API, native-boundary, failure-precedence, performance, stale-code, and overengineering review is complete, and independent code review signed off after the collector regression and socket warning boundary were corrected. - **Laravel-facing result:** Laravel has no corresponding custom-server-process API or configuration. Hypervel's Swoole-only surface now follows Laravel authoring conventions through container-resolved process classes, descriptive option names, service-provider registration, and the canonical `server.processes` configuration. The owner-approved changes remove only Hypervel/Hyperf-derived dead or duplicate surfaces; they do not create a Laravel public or configuration divergence. - **Assessment:** Every change addresses a verified defect, a documented footgun, or an owner-approved simplification. Normal message handling gains no allocation, container lookup, lock, registry, retry, poll, yield, or retained worker state, while exporting the socket once removes repeated native work. Boot-only checks and failure/exit cleanup have no hot-path cost, and the rejected compatibility, discovery, lifecycle, and generic-server machinery remains unnecessary. + +### Harden filesystem I/O, streaming, and response teardown + +- **Architecture and inspected risk surfaces:** Filesystem is a Laravel-derived local-filesystem and Flysystem boundary plus Hypervel's pooled S3 and Google Cloud Storage adapters. The audit covered every package source and test file; contracts, facades, scoped and pooled proxies, manager construction, cache file locking, Foundation and HTTP Server response lifecycles, Swoole response writes, Guzzle and cloud-SDK streaming, current Laravel source/tests/docs and originating pull requests, current Flysystem and Symfony response internals, Swoole 6.2.2 file-lock source, and every repository caller of the changed APIs. + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `filesystem-04` | Defect | Major | High | Worker-local `Locker` wrapping native file locks changes nonblocking semantics, manually polls blocking locks, and complicates cancellation ownership; `LockableFile` also leaves native failures and partial writes unchecked | Remove the wrapper from Filesystem and LockableFile, use checked native locks, preserve immediate `LOCK_NB` failure, and make LockableFile writes and cleanup complete and failure-safe | +| `filesystem-05` | Defect | Major | High | Local Filesystem metadata, MIME, replacement, link, mode, and recursive-delete paths can violate declared contracts, silently report success, or skip remaining work after native failure | Suppress checked metadata warnings so `type()`, `size()`, `lastModified()`, `hash()`, and `glob()` return native `false` instead of `ErrorException`; correct remaining native results, aggregate recursive deletion, support mode `0000`, and make replacement and link failures deterministic without changing inode semantics | +| `filesystem-06` | Defect | Major | High | Adapter append/prepend can overwrite an unreadable existing object, stream opening is unchecked, JSON and file-input metadata are incomplete, and Storage publishes an unnecessarily narrow concrete disk type | Stop on failed reads, close checked upload streams, publish truthful Laravel-compatible unions and contract types, and add no detached-stream ownership machinery after probes disproved a leak | +| `http-02` | Defect | Major | High | Generator-backed streamed responses lose returned chunks under Symfony's callback path, while Swoole disconnects cannot stop a generator through output buffering and event-stream callbacks can continue producing work after the peer is gone | Add one small iterable response subtype, stream generator chunks directly to the Swoole bridge with write-result feedback, and keep ordinary callbacks and streamed JSON on the existing buffered path | +| `filesystem-07` | Defect | Major | High | File responses can publish invalid MIME data and keep reading after native response writes fail, while committed streaming exceptions can be replaced by a second render and lifecycle failures can skip independent termination | Fall back to binary MIME, stop and close on failed writes, preserve committed responses, and make Foundation/HTTP Server finalization exhaustive with earliest-failure precedence | +| `filesystem-08` | Defect | Minor | High | Signed local temporary download and upload routes do not encode individual path segments, so delimiters and nested paths can produce invalid or ambiguous URLs | Port current Laravel segment encoding and its complete current regression surface, using originating pull requests only for discovery | +| `filesystem-09` | Defect | Minor | High | `LeasedStream::__destruct()` can reach native pooled cleanup during unsafe destructor timing even though explicit `stream_close` already owns release | Remove destructor cleanup and retain deterministic wrapper-close and construction-rollback ownership | +| `filesystem-10` | Improvement | Improvement | High | Cloud `readStream()` defaults spool complete remote objects before consumers can read, increasing peak memory and delaying first-byte delivery under concurrent Swoole workloads | With owner approval, default S3 and GCS `stream_reads` to true, document the connection-reuse tradeoff and false opt-out, and prove lazy coroutine-safe consumption and cleanup deterministically | +| `filesystem-11` | Improvement | Improvement | High | Hypervel lacks current Laravel's public `assertEmpty()` storage testing assertion and the scoped/pooled forwarding and metadata needed for consistent use | With owner approval, port the current Laravel API, tests, facade metadata, forwarding, and concise testing documentation without adding it to the Filesystem contract | +| `foundation-04` | Defect | Major | High | Kernel and application termination stop after the first listener, middleware, application, duration, or context failure, allowing independent request cleanup to be skipped | Run each fixed termination phase independently, preserve the earliest failure, and keep context removal as final belt-and-suspenders cleanup | + +- **Native lock design:** Under Swoole 6.2.2, any `LOCK_NB` request goes directly to one native `flock` attempt; only bare `LOCK_EX` and `LOCK_SH` enter Swoole's 1-to-100-millisecond coroutine backoff. Current locked `put()` and `sharedGet()` therefore wait through Hypervel's fixed one-millisecond polling for both same-worker and cross-process contention. Checked blocking native calls move those waits to Swoole's adaptive backoff, while nonblocking `LockableFile` and Cache FileStore paths remain immediate and no longer queue behind a worker-local gate. This reduces wakeups under sustained contention; the owner accepted that a lone waiter behind a long-held lock can observe release up to roughly 100 milliseconds later. No timeout, configurable polling policy, replacement gate, or compatibility path is added. `append(lock: true)` and bare blocking `LockableFile` calls were already native. +- **Local and adapter boundaries:** `sharedGet()` and `LockableFile` preserve the earliest read/write or cleanup failure while still attempting unlock and close. `LockableFile` uses handle-local metadata, concurrent-safe directory creation, complete partial-write handling, checked flush/rewind/truncate, and exact resource ownership. FileStore restores Laravel's native nonblocking add/refresh behavior. Adapter append/prepend never replace data after a failed read; `putFileAs()` validates and closes its opened stream. `json()` exposes the complete decoded scalar/array/null union, and contract/file/facade metadata follows current Laravel where it defines the public surface. Focused `/proc/self/fd` probes proved that detaching a PSR stream does not leak on success or exception, so no wrapper, helper, or resource state machine is justified. +- **Streaming design:** Generator callbacks are invoked lazily once and retained as iterable chunks. The Swoole bridge writes each yielded chunk directly, stops pulling immediately when `Response::write()` returns false, and clears the generator reference on every terminal path. Server-sent events yield one complete event per item plus the optional terminal event. Ordinary echo callbacks retain Symfony's output-buffer flow with non-throwing write capture and safe removable-buffer cleanup; `StreamedJsonResponse` remains unchanged. Remote `readStream()` alone opts into Guzzle's PHP stream handler under Swoole hooks; ordinary `read()` and `Storage::get()` retain cURL/sink behavior. This structurally bounds memory to stream buffers plus the framework's 64 KiB response chunk and remains coroutine-cooperative. The handler forces `Connection: close`, so many small remote streams pay repeated setup; `stream_reads=false` remains the explicit opt-out. No custom cURL producer/channel implementation or timing benchmark is added. +- **Response and termination ownership:** A failed Swoole write ends file or generator consumption and releases ordinary or leased streams. Once a filesystem stream response is committed, Foundation reports and rethrows its original failure rather than attempting a second render; HTTP Server preserves that response and passes the original throwable through lifecycle handling. Request-handled dispatch, response send, kernel termination, request-terminated defer, application termination callbacks, terminable middleware, duration handling, and response-context removal each receive independent cleanup boundaries with earliest-failure precedence. Receive-file storage failure becomes HTTP 500. +- **Upstream updates:** Signed-local-path encoding follows current Laravel after tracing framework pull requests `#60137` and `#60350`. `assertEmpty()` follows current Laravel after tracing framework pull request `#60658`, its facade follow-up, and its documentation change; the current upstream branch supplies the source, tests, metadata, and documentation. No implementation-specific assertion is added to the Filesystem contract. +- **Cross-package implications:** Filesystem owns `filesystem-04` through `filesystem-11`; Cache consumes the corrected nonblocking LockableFile boundary; HTTP owns generator and event-stream response construction; HTTP Server and Foundation own committed-stream and exhaustive-finalization handling. Revalidation of `filesystem-01`, `filesystem-02`, `filesystem-03`, `coroutine-05`, `encryption-03`, and `support-02` is complete. The Image package is an owner-directed future package handoff recorded in `docs/notes/image-package.md` on the integration worktree; it is not part of this work, does not add an orphan Filesystem API, and does not block Filesystem completion. +- **Important rejected concerns:** Do not add a generic resource state machine, lock callback API, lock timeout or retry policy, replacement mutex, read-range stream state machine, detached-stream wrapper, custom cURL streaming bridge, inbound-streaming facade, streamed-JSON reflection path, response registry, response retry, chunk coalescing, arbitrary finalizer abstraction, or `assertEmpty()` contract method. Do not duplicate Flysystem adapters, add speculative MIME machinery, broadly remove error suppression, or port Image work into this transaction. +- **Performance and compatibility:** Native adaptive lock waiting removes fixed polling wakeups under contention, while true nonblocking calls regain immediate failure. Generator and remote streaming bound retained memory and stop production after disconnect; ordinary non-streaming requests and ordinary cloud reads remain on their existing paths. The only accepted tradeoffs are the bounded native lock-detection tail and connection reuse for streamed cloud reads, both explicitly owner-approved. Laravel public API and configuration remain compatible except for the approved safer native-failure behavior and the Swoole-specific remote-streaming default; current Laravel APIs are otherwise restored rather than redesigned. No new registry, lock layer, retry loop, context slot, broad abstraction, or hot-path container resolution is accepted. +- **Implementation:** Removed the worker-local lock wrapper and moved Filesystem, LockableFile, and FileStore onto checked native lock ownership. Hardened local metadata, writes, replacement, links, deletion, modes, MIME handling, adapter reads, upload streams, JSON types, signed paths, facades, contracts, scoped and pooled proxies, and leased-stream teardown. Added direct iterable streaming from generator and SSE producers through the Swoole bridge, stopped production on failed writes, preserved committed responses, and made Foundation and HTTP Server finalization exhaustive. S3 and GCS streamed reads now default to lazy coroutine-cooperative PHP-stream transport with a documented opt-out; ordinary cloud reads remain unchanged. Ported Laravel's current `assertEmpty()` API without expanding the generic Filesystem contract. +- **Regression tests:** Deterministic coverage proves native lock serialization, immediate nonblocking failure, cancellation safety, partial and zero-progress writes, checked native failures, cleanup precedence, cache add and refresh behavior, exhaustive deletion, zero modes, link and replacement failures, metadata false results, adapter read protection, upload-stream ownership, JSON unions, signed nested paths, assertion forwarding, lazy remote reads, scheduler progress, disconnect termination, stream and lease release, generator and SSE chunking, committed failures, exhaustive Foundation and HTTP Server termination, receive-file failure, and exact `stream_close` ownership. +- **Cross-package revalidation:** `filesystem-01` retains its typed custom-creator boundary; `filesystem-02` retains checked directory creation; `filesystem-03` and `encryption-03` retain sensitive atomic replacement; `support-02` retains enum disk normalization. The obsolete Filesystem acquisition booleans from `coroutine-05` were removed with their worker-local lock layer, while the corrected Coroutine Locker primitive remains unchanged. Cache's nonblocking FileStore behavior, HTTP's response construction, and Foundation and HTTP Server response lifecycles pass their affected coverage. +- **Validation and review:** Every changed test file and all affected Filesystem, Cache, HTTP, Routing, Foundation, HTTP Server, contract, facade, environment, session, and streaming groups pass. PHP CS Fixer changed none of 5,574 files; both PHPStan configurations pass; the complete components suite passes with 23,280 tests, 66,328 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; `git diff --check` and package-checklist parity are clean. Fresh full-diff caller/callee, resource-lifecycle, API, performance, stale-code, and overengineering review is complete, and independent code review signed off after the metadata-hash failure contract and ledger wording were corrected. +- **Laravel-facing result:** Current Laravel filesystem APIs, configuration structure, and conventional extension shapes remain compatible. The owner approved the safer native-failure behavior, the Swoole-specific lazy remote-streaming default, and the bounded native lock-detection tail; `stream_reads=false` preserves the documented eager transport option. No public API was removed or renamed, and the added `assertEmpty()` surface restores current Laravel parity. +- **Assessment:** The result fixes verified native-boundary, ownership, streaming, disconnect, and cleanup defects at their lowest owners while bounding remote-read and response-stream memory. Ordinary non-streaming requests and ordinary cloud reads gain no new runtime work. The design adds no registry, replacement lock, retry loop, timeout policy, context state, custom cURL bridge, resource state machine, compatibility shim, or speculative Image surface; every accepted mechanism has a demonstrated consumer and the completed work is free of overengineering. diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md index c56d89b5c..0c5b40e4f 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `filesystem` -- **Ledger entries required for the active work:** `Harden framework contracts and request-scoped state` (`filesystem-01`); `Make coroutine creation and copied context failure-safe` (`coroutine-05`); `Correct AOP proxy generation and publication` (`filesystem-02`); `Harden encryption rotation, key publication, and global lifecycle state` (`filesystem-03`, `encryption-03`); and `Normalize framework enum identifiers at string boundaries` (`support-02`). -- **Pending revalidation carried into the active work:** Revalidate `filesystem-01`, `coroutine-05`, `filesystem-02`, `filesystem-03`, `encryption-03`, and `support-02` during the full Filesystem audit. +- **Active package or work unit:** `pipeline` +- **Ledger entries required for the active work:** `Normalize framework enum identifiers at string boundaries` (`support-02`). +- **Pending revalidation carried into the active work:** Revalidate `support-02` during the full Pipeline audit. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1004,7 +1004,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano |---|---|---|---| | `validation-01` | `validation` | `contracts`; later full `validation` audit | `Harden framework contracts and request-scoped state`; shared finding `validation-01` | | `view-01` | `view` | `contracts`, `foundation`; later full `view` and `foundation` audits | `Harden framework contracts and request-scoped state`; shared finding `view-01` | -| `filesystem-01` | `filesystem` | `contracts`; later full `filesystem` audit | `Harden framework contracts and request-scoped state`; shared finding `filesystem-01` | +| `filesystem-01` | `filesystem` | `contracts` and `filesystem` (revalidation complete) | `Harden framework contracts and request-scoped state`; shared finding `filesystem-01` | | `queue-01` | `queue` | `contracts`; later full `queue` audit | `Harden framework contracts and request-scoped state`; shared finding `queue-01` | | `testbench-01` | `testbench` | `foundation`; later full `testbench` and `foundation` audits | `Restore Conditionable proxy truthiness`; shared finding `testbench-01` | | `http-01` | `http` | `macroable`, `testing`; later full `http` and `testing` audits | `Complete Macroable callable and test-state handling`; shared finding `http-01` | @@ -1021,7 +1021,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `container-10` | `log` | `container` (revalidation complete); later full `log` audit | `Coordinate shared container construction and complete current contextual resolution`; finding `container-10` | | `context-01` | `context` | `container` (revalidation complete), `foundation`; later full `foundation` audit | `Correct explicit coroutine context targeting`; finding `context-01` | | `context-04` | `context` | `foundation`, `database`; later full consumer audits | `Correct explicit coroutine context targeting`; finding `context-04` | -| `coroutine-05` | `coroutine`, `filesystem` | later full `filesystem` audit | `Make coroutine creation and copied context failure-safe`; finding `coroutine-05` | +| `coroutine-05` | `coroutine`, `filesystem` | `filesystem` (revalidation complete) | `Make coroutine creation and copied context failure-safe`; finding `coroutine-05` | | `coroutine-06` | `context`, `coroutine` | `concurrency`, `foundation`; later full consumer audits | `Make coroutine creation and copied context failure-safe`; finding `coroutine-06` | | `foundation-02` | `foundation` | `coroutine`; later full `foundation` audit | `Make coroutine creation and copied context failure-safe`; finding `foundation-02` | | `websocket-server-01` | `websocket-server` | later full `websocket-server` audit | `Make coroutine creation and copied context failure-safe`; finding `websocket-server-01` | @@ -1038,8 +1038,12 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `database-01` | `database` | later full `database` audit | `Release cleared coordinator timers deterministically`; finding `database-01` | | `redis-01` | `redis` | later full `redis` audit | `Release cleared coordinator timers deterministically`; finding `redis-01` | | `di-02` | `di` | `foundation`, `sentry`, `telescope`; later full consumer audits | `Correct AOP proxy generation and publication`; finding `di-02` | -| `filesystem-02` | `filesystem` | `di` (revalidation complete); later full `filesystem` audit | `Correct AOP proxy generation and publication`; finding `filesystem-02` | -| `filesystem-03` | `filesystem` | `encryption` (revalidation complete), `support`; later full `filesystem` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `filesystem-03` | +| `filesystem-02` | `filesystem` | `di` and `filesystem` (revalidation complete) | `Correct AOP proxy generation and publication`; finding `filesystem-02` | +| `filesystem-03` | `filesystem` | `encryption`, `support`, and `filesystem` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `filesystem-03` | +| `filesystem-04` | `filesystem` | `cache`; later full `cache` audit | `Harden filesystem I/O, streaming, and response teardown`; finding `filesystem-04` | +| `http-02` | `http` | `filesystem` (revalidation complete), `http-server`, `foundation`; later full consumer audits | `Harden filesystem I/O, streaming, and response teardown`; finding `http-02` | +| `filesystem-07` | `filesystem`, `foundation`, `http-server` | `filesystem` (revalidation complete), `http`; later full `foundation`, `http-server`, and `http` audits | `Harden filesystem I/O, streaming, and response teardown`; finding `filesystem-07` | +| `foundation-04` | `foundation` | `filesystem` (revalidation complete), `http-server`; later full `foundation` and `http-server` audits | `Harden filesystem I/O, streaming, and response teardown`; finding `foundation-04` | | `events-01` | `foundation` | `events` (revalidation complete); later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-01` | | `events-03` | `events`, `queue` | later full `queue` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-03` | | `events-04` | `events`, `foundation` | later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-04` | @@ -1048,9 +1052,9 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events` (revalidation complete), `broadcasting`; later full `bus`, `queue`, and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support`; later full `foundation` and `support` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem`, `foundation`, `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem` (revalidation complete), `foundation`, `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` | | `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | -| `encryption-03` | `encryption` | `contracts` and `support` (revalidation complete), `filesystem`, `foundation`; later full `filesystem` and `foundation` audits | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | +| `encryption-03` | `encryption` | `contracts`, `support`, and `filesystem` (revalidation complete), `foundation`; later full `foundation` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | | `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | | `process-02` | `process` | `concurrency` (revalidation complete) | `Make Process callbacks and pools failure-safe`; finding `process-02` | | `server-process-10` | `server-process` | `foundation`; later full `foundation` audit | `Make custom server processes failure-safe`; finding `server-process-10` | @@ -1106,7 +1110,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `object-pool` - [x] `process` - [x] `server-process` -- [ ] `filesystem` +- [x] `filesystem` ### Framework dispatch and runtime From 09e0a5cb9495c23dd9c35676baffb2b572633e26 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:16:45 +0000 Subject: [PATCH 27/30] fix(filesystem): preserve native MIME failure semantics Suppress warnings at both fileinfo native boundaries and return the declared false result when the MIME database or file probe fails. Cover the missing-file failure path and keep exhaustive recursive deletion coverage independent of FilesystemIterator ordering without weakening the asserted entries. --- src/filesystem/src/Filesystem.php | 4 ++-- tests/Filesystem/FilesystemTest.php | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php index 472c5657d..94ce495f2 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -447,9 +447,9 @@ public function type(string $path): string|false */ public function mimeType(string $path): string|false { - $fileInfo = finfo_open(FILEINFO_MIME_TYPE); + $fileInfo = @finfo_open(FILEINFO_MIME_TYPE); - return $fileInfo === false ? false : finfo_file($fileInfo, $path); + return $fileInfo === false ? false : @finfo_file($fileInfo, $path); } /** diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index 7eeaca0ae..d7a370199 100755 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -328,7 +328,7 @@ public function testDeleteDirectoryReportsFailureAfterAttemptingEveryEntry(): vo $filesystem = new RecordingDeleteFilesystem; $this->assertFalse($filesystem->deleteDirectory($directory)); - $this->assertSame( + $this->assertEqualsCanonicalizing( [$directory . '/first.txt', $directory . '/second.txt'], $filesystem->deleted ); @@ -613,6 +613,14 @@ public function testMimeTypeOutputsMimeType() $this->assertSame('text/plain', $files->mimeType($this->tempDir . '/foo.txt')); } + #[RequiresPhpExtension('fileinfo')] + public function testMimeTypeReturnsFalseWhenDetectionFails(): void + { + $files = new Filesystem; + + $this->assertFalse($files->mimeType($this->tempDir . '/missing.txt')); + } + public function testGuessExtensionReturnsNullWhenMimeDetectionFails(): void { $this->assertNull((new MissingMimeTypeFilesystem)->guessExtension('/missing.txt')); From 189f8a7ea55af7f4160b47d772b7f715ce57a31e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:16:54 +0000 Subject: [PATCH 28/30] fix(routing): frame multiline server-sent event data Normalize CRLF and carriage-return boundaries before prefixing each logical payload line with an SSE data field. Preserve the single-line fast path with one native scan and no normalization allocation. Add exact-byte regression coverage for CRLF, CR, and LF delimiters so future formatter changes cannot silently emit invalid continuation lines. --- src/routing/src/ResponseFactory.php | 8 ++++++++ tests/Routing/ResponseFactoryTest.php | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/routing/src/ResponseFactory.php b/src/routing/src/ResponseFactory.php index cf95de3c7..1fbfc0b4d 100644 --- a/src/routing/src/ResponseFactory.php +++ b/src/routing/src/ResponseFactory.php @@ -131,6 +131,14 @@ private function formatStreamedEvent(mixed $message): string $message = Js::encode($message); } + $message = (string) $message; + + if (strpbrk($message, "\r\n") !== false) { + // Normalize CRLF first so the carriage-return pass does not create an extra data line. + $message = str_replace(["\r\n", "\r"], "\n", $message); + $message = str_replace("\n", "\ndata: ", $message); + } + return "event: {$event}\ndata: {$message}\n\n"; } diff --git a/tests/Routing/ResponseFactoryTest.php b/tests/Routing/ResponseFactoryTest.php index 1d923642b..d33c6703f 100644 --- a/tests/Routing/ResponseFactoryTest.php +++ b/tests/Routing/ResponseFactoryTest.php @@ -123,6 +123,26 @@ static function () { ], $chunks); } + public function testEventStreamPrefixesEveryMultilineDataField(): void + { + $response = $this->factory()->eventStream( + static function () { + yield "first\r\nsecond\rthird\nfourth"; + }, + endStreamWith: null, + ); + $chunks = []; + + $this->assertTrue($response->streamTo(function (string $chunk) use (&$chunks): bool { + $chunks[] = $chunk; + + return true; + })); + $this->assertSame([ + "event: update\ndata: first\ndata: second\ndata: third\ndata: fourth\n\n", + ], $chunks); + } + private function factory(): ResponseFactory { return new ResponseFactory( From 7fd315a652505aabcecbdc8eb0c1a3a91bef62cf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:17:01 +0000 Subject: [PATCH 29/30] docs(filesystem): make storage assertions coherent Demonstrate disk emptiness before uploads and use a distinct empty directory for directory-level assertions in both Pest and PHPUnit examples. Keep the upstream documentation structure while removing contradictory states that would mislead application and package authors. --- src/boost/docs/filesystem.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/boost/docs/filesystem.md b/src/boost/docs/filesystem.md index e8e4013b6..ea17eaee0 100644 --- a/src/boost/docs/filesystem.md +++ b/src/boost/docs/filesystem.md @@ -920,6 +920,9 @@ use Hypervel\Support\Facades\Storage; test('albums can be uploaded', function () { Storage::fake('photos'); + // Assert that the disk contains no files... + Storage::disk('photos')->assertEmpty(); + $response = $this->json('POST', '/photos', [ UploadedFile::fake()->image('photo1.jpg'), UploadedFile::fake()->image('photo2.jpg') @@ -936,11 +939,10 @@ test('albums can be uploaded', function () { // Assert that the number of files in a given directory matches the expected count... Storage::disk('photos')->assertCount('/wallpapers', 2); - // Assert that a given directory is empty... - Storage::disk('photos')->assertDirectoryEmpty('/wallpapers'); + Storage::disk('photos')->makeDirectory('/empty'); - // Assert that the disk contains no files... - Storage::disk('photos')->assertEmpty(); + // Assert that a given directory is empty... + Storage::disk('photos')->assertDirectoryEmpty('/empty'); }); ``` @@ -959,6 +961,9 @@ class ExampleTest extends TestCase { Storage::fake('photos'); + // Assert that the disk contains no files... + Storage::disk('photos')->assertEmpty(); + $response = $this->json('POST', '/photos', [ UploadedFile::fake()->image('photo1.jpg'), UploadedFile::fake()->image('photo2.jpg') @@ -975,11 +980,10 @@ class ExampleTest extends TestCase // Assert that the number of files in a given directory matches the expected count... Storage::disk('photos')->assertCount('/wallpapers', 2); - // Assert that a given directory is empty... - Storage::disk('photos')->assertDirectoryEmpty('/wallpapers'); + Storage::disk('photos')->makeDirectory('/empty'); - // Assert that the disk contains no files... - Storage::disk('photos')->assertEmpty(); + // Assert that a given directory is empty... + Storage::disk('photos')->assertDirectoryEmpty('/empty'); } } ``` From 1054be69ea11bab4ad4ebaaadd3c848ca38819bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:17:11 +0000 Subject: [PATCH 30/30] docs(audit): record filesystem review corrections Amend the completed Filesystem work unit with the verified MIME boundary, multiline SSE framing, deterministic regression coverage, and coherent documentation examples. Repair the Markdown union table cell, record the exact performance and Laravel-parity effects, and update validation and review evidence to the final green state. --- ...-coroutine-state-lifecycle-audit-ledger.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md index 77ac4475d..da375f9d1 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md @@ -776,7 +776,7 @@ Append package entries in checklist order. Keep each entry compact but complete | ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | |---|---|---|---|---|---| | `server-process-01` | Defect | Major | High | `Swoole\Server::addProcess()` can return `false`, but `AbstractProcess::bind()` ignores it and publishes the rejected process to `ProcessCollector` | Check the exact native result, close the failed process pipe without replacing the registration failure, throw descriptively, and publish only after success; do not build sibling rollback because the propagated failure aborts server boot before accepted siblings start | -| `server-process-02` | Defect | Major | High | `exportSocket()` can return `false`; the declared `Socket` return leaks a `TypeError` into the retry loop, which re-exports and re-reports forever | Make the protected native boundary return `Socket|false`, convert `false` into one permanent socket failure, export once before the receive loop, and reuse the exact socket | +| `server-process-02` | Defect | Major | High | `exportSocket()` can return `false`; the declared `Socket` return leaks a `TypeError` into the retry loop, which re-exports and re-reports forever | Make the protected native boundary return `Socket\|false`, convert `false` into one permanent socket failure, export once before the receive loop, and reuse the exact socket | | `server-process-03` | Defect | Major | High | Truthiness-based deserialization drops valid serialized `false`, `null`, `0`, empty-string, and empty-array payloads despite `PipeMessage::$data` being `mixed` | Filter timeout `false` before deserialization, deserialize the narrowed string once, and dispatch every valid value including serialized `false` | | `server-process-04` | Defect | Minor | High | Coroutine creation failure leaves the untransferred quit channel open, while a throwing listener reporter skips its trailing close | Close the channel on typed creation failure, transfer terminal ownership only after successful spawn, and close from the listener in `finally`; normal push-after-close and repeated-close `false` results are not cleanup failures | | `server-process-05` | Defect | Major | High | A throwing after-event or failure reporter skips independent quit notification, timer clearing, coordinator resume, and restart backoff, allowing incomplete teardown and immediate respawn | Run fixed teardown steps independently, retain the earliest unhandled failure, use fakeable `Sleep`, complete backoff before rethrow, and avoid a closure-list abstraction | @@ -806,9 +806,9 @@ Append package entries in checklist order. Keep each entry compact but complete | ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | |---|---|---|---|---|---| | `filesystem-04` | Defect | Major | High | Worker-local `Locker` wrapping native file locks changes nonblocking semantics, manually polls blocking locks, and complicates cancellation ownership; `LockableFile` also leaves native failures and partial writes unchecked | Remove the wrapper from Filesystem and LockableFile, use checked native locks, preserve immediate `LOCK_NB` failure, and make LockableFile writes and cleanup complete and failure-safe | -| `filesystem-05` | Defect | Major | High | Local Filesystem metadata, MIME, replacement, link, mode, and recursive-delete paths can violate declared contracts, silently report success, or skip remaining work after native failure | Suppress checked metadata warnings so `type()`, `size()`, `lastModified()`, `hash()`, and `glob()` return native `false` instead of `ErrorException`; correct remaining native results, aggregate recursive deletion, support mode `0000`, and make replacement and link failures deterministic without changing inode semantics | +| `filesystem-05` | Defect | Major | High | Local Filesystem metadata, MIME, replacement, link, mode, and recursive-delete paths can violate declared contracts, silently report success, or skip remaining work after native failure | Suppress checked metadata warnings so `type()`, `mimeType()`, `size()`, `lastModified()`, `hash()`, and `glob()` return native `false` instead of `ErrorException`; correct remaining native results, aggregate recursive deletion, support mode `0000`, and make replacement and link failures deterministic without changing inode semantics | | `filesystem-06` | Defect | Major | High | Adapter append/prepend can overwrite an unreadable existing object, stream opening is unchecked, JSON and file-input metadata are incomplete, and Storage publishes an unnecessarily narrow concrete disk type | Stop on failed reads, close checked upload streams, publish truthful Laravel-compatible unions and contract types, and add no detached-stream ownership machinery after probes disproved a leak | -| `http-02` | Defect | Major | High | Generator-backed streamed responses lose returned chunks under Symfony's callback path, while Swoole disconnects cannot stop a generator through output buffering and event-stream callbacks can continue producing work after the peer is gone | Add one small iterable response subtype, stream generator chunks directly to the Swoole bridge with write-result feedback, and keep ordinary callbacks and streamed JSON on the existing buffered path | +| `http-02` | Defect | Major | High | Generator-backed streamed responses lose returned chunks under Symfony's callback path, while Swoole disconnects cannot stop a generator through output buffering and event streams misframe multiline data or continue producing work after the peer is gone | Add one small iterable response subtype, frame every SSE payload line as data, stream generator chunks directly to the Swoole bridge with write-result feedback, and keep ordinary callbacks and streamed JSON on the existing buffered path | | `filesystem-07` | Defect | Major | High | File responses can publish invalid MIME data and keep reading after native response writes fail, while committed streaming exceptions can be replaced by a second render and lifecycle failures can skip independent termination | Fall back to binary MIME, stop and close on failed writes, preserve committed responses, and make Foundation/HTTP Server finalization exhaustive with earliest-failure precedence | | `filesystem-08` | Defect | Minor | High | Signed local temporary download and upload routes do not encode individual path segments, so delimiters and nested paths can produce invalid or ambiguous URLs | Port current Laravel segment encoding and its complete current regression surface, using originating pull requests only for discovery | | `filesystem-09` | Defect | Minor | High | `LeasedStream::__destruct()` can reach native pooled cleanup during unsafe destructor timing even though explicit `stream_close` already owns release | Remove destructor cleanup and retain deterministic wrapper-close and construction-rollback ownership | @@ -817,16 +817,16 @@ Append package entries in checklist order. Keep each entry compact but complete | `foundation-04` | Defect | Major | High | Kernel and application termination stop after the first listener, middleware, application, duration, or context failure, allowing independent request cleanup to be skipped | Run each fixed termination phase independently, preserve the earliest failure, and keep context removal as final belt-and-suspenders cleanup | - **Native lock design:** Under Swoole 6.2.2, any `LOCK_NB` request goes directly to one native `flock` attempt; only bare `LOCK_EX` and `LOCK_SH` enter Swoole's 1-to-100-millisecond coroutine backoff. Current locked `put()` and `sharedGet()` therefore wait through Hypervel's fixed one-millisecond polling for both same-worker and cross-process contention. Checked blocking native calls move those waits to Swoole's adaptive backoff, while nonblocking `LockableFile` and Cache FileStore paths remain immediate and no longer queue behind a worker-local gate. This reduces wakeups under sustained contention; the owner accepted that a lone waiter behind a long-held lock can observe release up to roughly 100 milliseconds later. No timeout, configurable polling policy, replacement gate, or compatibility path is added. `append(lock: true)` and bare blocking `LockableFile` calls were already native. -- **Local and adapter boundaries:** `sharedGet()` and `LockableFile` preserve the earliest read/write or cleanup failure while still attempting unlock and close. `LockableFile` uses handle-local metadata, concurrent-safe directory creation, complete partial-write handling, checked flush/rewind/truncate, and exact resource ownership. FileStore restores Laravel's native nonblocking add/refresh behavior. Adapter append/prepend never replace data after a failed read; `putFileAs()` validates and closes its opened stream. `json()` exposes the complete decoded scalar/array/null union, and contract/file/facade metadata follows current Laravel where it defines the public surface. Focused `/proc/self/fd` probes proved that detaching a PSR stream does not leak on success or exception, so no wrapper, helper, or resource state machine is justified. -- **Streaming design:** Generator callbacks are invoked lazily once and retained as iterable chunks. The Swoole bridge writes each yielded chunk directly, stops pulling immediately when `Response::write()` returns false, and clears the generator reference on every terminal path. Server-sent events yield one complete event per item plus the optional terminal event. Ordinary echo callbacks retain Symfony's output-buffer flow with non-throwing write capture and safe removable-buffer cleanup; `StreamedJsonResponse` remains unchanged. Remote `readStream()` alone opts into Guzzle's PHP stream handler under Swoole hooks; ordinary `read()` and `Storage::get()` retain cURL/sink behavior. This structurally bounds memory to stream buffers plus the framework's 64 KiB response chunk and remains coroutine-cooperative. The handler forces `Connection: close`, so many small remote streams pay repeated setup; `stream_reads=false` remains the explicit opt-out. No custom cURL producer/channel implementation or timing benchmark is added. +- **Local and adapter boundaries:** `sharedGet()` and `LockableFile` preserve the earliest read/write or cleanup failure while still attempting unlock and close. `LockableFile` uses handle-local metadata, concurrent-safe directory creation, complete partial-write handling, checked flush/rewind/truncate, and exact resource ownership. FileStore restores Laravel's native nonblocking add/refresh behavior. MIME detection checks both the database open and file read so either native failure returns `false` without escaping a warning. Adapter append/prepend never replace data after a failed read; `putFileAs()` validates and closes its opened stream. `json()` exposes the complete decoded scalar/array/null union, and contract/file/facade metadata follows current Laravel where it defines the public surface. Focused `/proc/self/fd` probes proved that detaching a PSR stream does not leak on success or exception, so no wrapper, helper, or resource state machine is justified. +- **Streaming design:** Generator callbacks are invoked lazily once and retained as iterable chunks. The Swoole bridge writes each yielded chunk directly, stops pulling immediately when `Response::write()` returns false, and clears the generator reference on every terminal path. Server-sent events yield one complete event per item plus the optional terminal event and frame every CRLF-, CR-, or LF-delimited payload line as data. Ordinary echo callbacks retain Symfony's output-buffer flow with non-throwing write capture and safe removable-buffer cleanup; `StreamedJsonResponse` remains unchanged. Remote `readStream()` alone opts into Guzzle's PHP stream handler under Swoole hooks; ordinary `read()` and `Storage::get()` retain cURL/sink behavior. This structurally bounds memory to stream buffers plus the framework's 64 KiB response chunk and remains coroutine-cooperative. The handler forces `Connection: close`, so many small remote streams pay repeated setup; `stream_reads=false` remains the explicit opt-out. No custom cURL producer/channel implementation or timing benchmark is added. - **Response and termination ownership:** A failed Swoole write ends file or generator consumption and releases ordinary or leased streams. Once a filesystem stream response is committed, Foundation reports and rethrows its original failure rather than attempting a second render; HTTP Server preserves that response and passes the original throwable through lifecycle handling. Request-handled dispatch, response send, kernel termination, request-terminated defer, application termination callbacks, terminable middleware, duration handling, and response-context removal each receive independent cleanup boundaries with earliest-failure precedence. Receive-file storage failure becomes HTTP 500. -- **Upstream updates:** Signed-local-path encoding follows current Laravel after tracing framework pull requests `#60137` and `#60350`. `assertEmpty()` follows current Laravel after tracing framework pull request `#60658`, its facade follow-up, and its documentation change; the current upstream branch supplies the source, tests, metadata, and documentation. No implementation-specific assertion is added to the Filesystem contract. +- **Upstream updates:** Signed-local-path encoding follows current Laravel after tracing framework pull requests `#60137` and `#60350`. `assertEmpty()` follows current Laravel after tracing framework pull request `#60658`, its facade follow-up, and its documentation change; the current upstream branch supplies the source, tests, metadata, and documentation, while Hypervel's examples place empty assertions before writes and on a distinct empty directory so every assertion remains coherent. No implementation-specific assertion is added to the Filesystem contract. - **Cross-package implications:** Filesystem owns `filesystem-04` through `filesystem-11`; Cache consumes the corrected nonblocking LockableFile boundary; HTTP owns generator and event-stream response construction; HTTP Server and Foundation own committed-stream and exhaustive-finalization handling. Revalidation of `filesystem-01`, `filesystem-02`, `filesystem-03`, `coroutine-05`, `encryption-03`, and `support-02` is complete. The Image package is an owner-directed future package handoff recorded in `docs/notes/image-package.md` on the integration worktree; it is not part of this work, does not add an orphan Filesystem API, and does not block Filesystem completion. - **Important rejected concerns:** Do not add a generic resource state machine, lock callback API, lock timeout or retry policy, replacement mutex, read-range stream state machine, detached-stream wrapper, custom cURL streaming bridge, inbound-streaming facade, streamed-JSON reflection path, response registry, response retry, chunk coalescing, arbitrary finalizer abstraction, or `assertEmpty()` contract method. Do not duplicate Flysystem adapters, add speculative MIME machinery, broadly remove error suppression, or port Image work into this transaction. -- **Performance and compatibility:** Native adaptive lock waiting removes fixed polling wakeups under contention, while true nonblocking calls regain immediate failure. Generator and remote streaming bound retained memory and stop production after disconnect; ordinary non-streaming requests and ordinary cloud reads remain on their existing paths. The only accepted tradeoffs are the bounded native lock-detection tail and connection reuse for streamed cloud reads, both explicitly owner-approved. Laravel public API and configuration remain compatible except for the approved safer native-failure behavior and the Swoole-specific remote-streaming default; current Laravel APIs are otherwise restored rather than redesigned. No new registry, lock layer, retry loop, context slot, broad abstraction, or hot-path container resolution is accepted. -- **Implementation:** Removed the worker-local lock wrapper and moved Filesystem, LockableFile, and FileStore onto checked native lock ownership. Hardened local metadata, writes, replacement, links, deletion, modes, MIME handling, adapter reads, upload streams, JSON types, signed paths, facades, contracts, scoped and pooled proxies, and leased-stream teardown. Added direct iterable streaming from generator and SSE producers through the Swoole bridge, stopped production on failed writes, preserved committed responses, and made Foundation and HTTP Server finalization exhaustive. S3 and GCS streamed reads now default to lazy coroutine-cooperative PHP-stream transport with a documented opt-out; ordinary cloud reads remain unchanged. Ported Laravel's current `assertEmpty()` API without expanding the generic Filesystem contract. -- **Regression tests:** Deterministic coverage proves native lock serialization, immediate nonblocking failure, cancellation safety, partial and zero-progress writes, checked native failures, cleanup precedence, cache add and refresh behavior, exhaustive deletion, zero modes, link and replacement failures, metadata false results, adapter read protection, upload-stream ownership, JSON unions, signed nested paths, assertion forwarding, lazy remote reads, scheduler progress, disconnect termination, stream and lease release, generator and SSE chunking, committed failures, exhaustive Foundation and HTTP Server termination, receive-file failure, and exact `stream_close` ownership. +- **Performance and compatibility:** Native adaptive lock waiting removes fixed polling wakeups under contention, while true nonblocking calls regain immediate failure. Generator and remote streaming bound retained memory and stop production after disconnect; ordinary non-streaming requests and ordinary cloud reads remain on their existing paths. The single-line SSE path adds one native newline scan without normalized-data allocation; multiline payloads perform bounded native string normalization. The only accepted tradeoffs are the bounded native lock-detection tail and connection reuse for streamed cloud reads, both explicitly owner-approved. Laravel public API and configuration remain compatible except for the approved safer native-failure behavior and the Swoole-specific remote-streaming default; current Laravel APIs are otherwise restored rather than redesigned. No new registry, lock layer, retry loop, context slot, broad abstraction, or hot-path container resolution is accepted. +- **Implementation:** Removed the worker-local lock wrapper and moved Filesystem, LockableFile, and FileStore onto checked native lock ownership. Hardened local metadata, writes, replacement, links, deletion, modes, MIME database/file failure handling, adapter reads, upload streams, JSON types, signed paths, facades, contracts, scoped and pooled proxies, and leased-stream teardown. Added direct iterable streaming from generator and SSE producers through the Swoole bridge, framed every multiline SSE data field, stopped production on failed writes, preserved committed responses, and made Foundation and HTTP Server finalization exhaustive. S3 and GCS streamed reads now default to lazy coroutine-cooperative PHP-stream transport with a documented opt-out; ordinary cloud reads remain unchanged. Ported Laravel's current `assertEmpty()` API without expanding the generic Filesystem contract and kept its testing examples internally coherent. +- **Regression tests:** Deterministic coverage proves native lock serialization, immediate nonblocking failure, cancellation safety, partial and zero-progress writes, checked native and MIME failures, cleanup precedence, cache add and refresh behavior, exhaustive deletion without iterator-order assumptions, zero modes, link and replacement failures, metadata false results, adapter read protection, upload-stream ownership, JSON unions, signed nested paths, assertion forwarding, lazy remote reads, scheduler progress, disconnect termination, stream and lease release, exact CRLF/CR/LF SSE framing, committed failures, exhaustive Foundation and HTTP Server termination, receive-file failure, and exact `stream_close` ownership. - **Cross-package revalidation:** `filesystem-01` retains its typed custom-creator boundary; `filesystem-02` retains checked directory creation; `filesystem-03` and `encryption-03` retain sensitive atomic replacement; `support-02` retains enum disk normalization. The obsolete Filesystem acquisition booleans from `coroutine-05` were removed with their worker-local lock layer, while the corrected Coroutine Locker primitive remains unchanged. Cache's nonblocking FileStore behavior, HTTP's response construction, and Foundation and HTTP Server response lifecycles pass their affected coverage. -- **Validation and review:** Every changed test file and all affected Filesystem, Cache, HTTP, Routing, Foundation, HTTP Server, contract, facade, environment, session, and streaming groups pass. PHP CS Fixer changed none of 5,574 files; both PHPStan configurations pass; the complete components suite passes with 23,280 tests, 66,328 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; `git diff --check` and package-checklist parity are clean. Fresh full-diff caller/callee, resource-lifecycle, API, performance, stale-code, and overengineering review is complete, and independent code review signed off after the metadata-hash failure contract and ledger wording were corrected. -- **Laravel-facing result:** Current Laravel filesystem APIs, configuration structure, and conventional extension shapes remain compatible. The owner approved the safer native-failure behavior, the Swoole-specific lazy remote-streaming default, and the bounded native lock-detection tail; `stream_reads=false` preserves the documented eager transport option. No public API was removed or renamed, and the added `assertEmpty()` surface restores current Laravel parity. -- **Assessment:** The result fixes verified native-boundary, ownership, streaming, disconnect, and cleanup defects at their lowest owners while bounding remote-read and response-stream memory. Ordinary non-streaming requests and ordinary cloud reads gain no new runtime work. The design adds no registry, replacement lock, retry loop, timeout policy, context state, custom cURL bridge, resource state machine, compatibility shim, or speculative Image surface; every accepted mechanism has a demonstrated consumer and the completed work is free of overengineering. +- **Validation and review:** Every changed test file and all affected Filesystem, Cache, HTTP, Routing, Foundation, HTTP Server, contract, facade, environment, session, and streaming groups pass. PHP CS Fixer changed none of 5,574 files; both PHPStan configurations pass; the complete components suite passes with 23,282 tests, 66,332 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; `git diff --check` and package-checklist parity are clean. Fresh full-diff caller/callee, resource-lifecycle, API, performance, stale-code, and overengineering review is complete, and independent code review signed off on the final MIME, SSE, documentation, test, and ledger corrections. +- **Laravel-facing result:** Current Laravel filesystem APIs, configuration structure, and conventional extension shapes remain compatible. The owner approved the safer native-failure behavior, the Swoole-specific lazy remote-streaming default, the bounded native lock-detection tail, and protocol-correct multiline SSE framing where current Laravel emits invalid subsequent data lines; `stream_reads=false` preserves the documented eager transport option. No public API was removed or renamed, and the added `assertEmpty()` surface restores current Laravel parity. +- **Assessment:** The result fixes verified native-boundary, ownership, streaming, disconnect, and cleanup defects at their lowest owners while bounding remote-read and response-stream memory. Ordinary non-streaming requests and ordinary cloud reads gain no new runtime work; single-line SSE events add one native scan without normalized-data allocation, while multiline events incur only the bounded native string work required for correct framing. The design adds no registry, replacement lock, retry loop, timeout policy, context state, custom cURL bridge, resource state machine, compatibility shim, or speculative Image surface; every accepted mechanism has a demonstrated consumer and the completed work is free of overengineering.