From 5c55fcfaba5cff3323772391816a68f4a76c470b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:55 +0000 Subject: [PATCH 01/14] Clean up asynchronous processes when their coroutine exits Track native process handles weakly in coroutine-local state and reuse pool cleanup at owner exit. Preserve detached, fake and non-coroutine starts. Share the output guard through Support while retaining interrupted-stop behavior. Verified ProcessTest, ProcessNonCoroutineTest, real concurrency consumers, source/type analysis and facade consistency. --- src/process/composer.json | 2 + src/process/src/InvokedProcess.php | 2 +- src/process/src/PendingProcess.php | 14 ++- src/process/src/Pool.php | 3 +- src/process/src/ProcessState.php | 61 ++++++++++++ src/support/composer.json | 1 + .../src/Traits}/GuardsProcessOutput.php | 26 +++-- tests/Process/ProcessNonCoroutineTest.php | 24 +++++ tests/Process/ProcessTest.php | 96 +++++++++++++++++-- 9 files changed, 208 insertions(+), 21 deletions(-) create mode 100644 src/process/src/ProcessState.php rename src/{process/src/Concerns => support/src/Traits}/GuardsProcessOutput.php (57%) create mode 100644 tests/Process/ProcessNonCoroutineTest.php diff --git a/src/process/composer.json b/src/process/composer.json index 922d5f6f75..6017781333 100644 --- a/src/process/composer.json +++ b/src/process/composer.json @@ -26,7 +26,9 @@ "php": "^8.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", + "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/macroable": "^0.4", "hypervel/support": "^0.4", diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php index b9245e2246..df3a86024f 100644 --- a/src/process/src/InvokedProcess.php +++ b/src/process/src/InvokedProcess.php @@ -7,8 +7,8 @@ use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; use Hypervel\Database\ConnectionResolver; -use Hypervel\Process\Concerns\GuardsProcessOutput; use Hypervel\Process\Exceptions\ProcessTimedOutException; +use Hypervel\Support\Traits\GuardsProcessOutput; use Hypervel\Support\Traits\Macroable; use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index d65668e515..e0438cae08 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -8,12 +8,13 @@ use Closure; use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionResolver; -use Hypervel\Process\Concerns\GuardsProcessOutput; use Hypervel\Process\Exceptions\ProcessTimedOutException; use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Support\Traits\Conditionable; +use Hypervel\Support\Traits\GuardsProcessOutput; use LogicException; use RuntimeException; use Stringable; @@ -241,7 +242,8 @@ 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. + * Start the process in the coroutine that will wait for it. It is stopped when that + * coroutine exits, unless the create_new_console option explicitly detaches it. * * @throws RuntimeException */ @@ -261,7 +263,13 @@ public function start(array|string|null $command = null, ?callable $output = nul } try { - return new InvokedProcess(tap($process)->start($this->guardProcessOutput($process, $output))); // @phpstan-ignore method.notFound (tap proxy __call) + $process->start($this->guardProcessOutput($process, $output)); + + if (Coroutine::inCoroutine() && ! ($this->options['create_new_console'] ?? false)) { + ProcessState::current()->processes[$process] = true; + } + + return new InvokedProcess($process); } catch (Throwable $exception) { $this->stopAfterFailure($process, $exception); } diff --git a/src/process/src/Pool.php b/src/process/src/Pool.php index 252231dd59..ed1b9caf30 100644 --- a/src/process/src/Pool.php +++ b/src/process/src/Pool.php @@ -47,7 +47,8 @@ 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. + * Start the pool in the coroutine that will wait for it. Its processes are + * stopped when that coroutine exits unless explicitly detached by their options. * * @throws InvalidArgumentException */ diff --git a/src/process/src/ProcessState.php b/src/process/src/ProcessState.php new file mode 100644 index 0000000000..49c675351b --- /dev/null +++ b/src/process/src/ProcessState.php @@ -0,0 +1,61 @@ + */ + public WeakMap $processes; + + /** + * Create process state owned by one coroutine. + */ + public function __construct() + { + $this->processes = new WeakMap; + } + + /** + * Get the current coroutine's state and register its exit cleanup once. + */ + public static function current(): self + { + if ($state = CoroutineContext::get(self::CONTEXT_KEY)) { + return $state; + } + + $state = CoroutineContext::set(self::CONTEXT_KEY, new self); + Coroutine::defer($state->stop(...)); + + return $state; + } + + /** + * Stop the processes left behind when their owning coroutine exits. + */ + public function stop(): void + { + $processes = []; + + foreach ($this->processes as $process => $tracked) { + $processes[] = new InvokedProcess($process); + } + + (new InvokedProcessPool($processes))->stop(0); + } +} diff --git a/src/support/composer.json b/src/support/composer.json index f1051ffe28..3bdcaccf02 100644 --- a/src/support/composer.json +++ b/src/support/composer.json @@ -56,6 +56,7 @@ "phpoption/phpoption": "^1.9", "symfony/console": "^8.1.10", "symfony/http-foundation": "^8.1", + "symfony/process": "^8.1", "symfony/uid": "^8.1", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" diff --git a/src/process/src/Concerns/GuardsProcessOutput.php b/src/support/src/Traits/GuardsProcessOutput.php similarity index 57% rename from src/process/src/Concerns/GuardsProcessOutput.php rename to src/support/src/Traits/GuardsProcessOutput.php index fb63e366bd..ebdbf8dc6d 100644 --- a/src/process/src/Concerns/GuardsProcessOutput.php +++ b/src/support/src/Traits/GuardsProcessOutput.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Process\Concerns; +namespace Hypervel\Support\Traits; use Closure; -use Hypervel\Process\InvokedProcess; use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Process; use Throwable; @@ -34,16 +33,25 @@ protected function guardProcessOutput(Process $process, ?callable $output): ?Clo return $output($type, $buffer); } catch (Throwable $exception) { $failed = true; - - try { - // SWOOLE_HOOK_PROC can reach an invalid native resource during coroutine teardown. - (new InvokedProcess($process))->stop(0); - } catch (Throwable $cleanupException) { - if ($cleanupException instanceof CanceledException && ! $exception instanceof CanceledException) { - throw $cleanupException; + $cancellation = null; + + // Coroutine teardown can expose an invalid native resource through SWOOLE_HOOK_PROC. + // Retry failed cleanup while preserving the callback failure or cancellation. + for ($attempt = 0; $attempt < 2; ++$attempt) { + try { + $process->stop(0); + + break; + } catch (CanceledException $cleanupException) { + $cancellation ??= $cleanupException; + } catch (Throwable) { } } + if ($cancellation !== null && ! $exception instanceof CanceledException) { + throw $cancellation; + } + throw $exception; } }; diff --git a/tests/Process/ProcessNonCoroutineTest.php b/tests/Process/ProcessNonCoroutineTest.php new file mode 100644 index 0000000000..7406e325f3 --- /dev/null +++ b/tests/Process/ProcessNonCoroutineTest.php @@ -0,0 +1,24 @@ +start([PHP_BINARY, '-r', 'echo "done";']); + + try { + $this->assertSame('done', $process->wait()->output()); + } finally { + $process->stop(0); + } + } +} diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 9bcf124731..e1cd01660e 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -8,8 +8,8 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract; use Hypervel\Contracts\Process\ProcessResult; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Pool\ConnectionLease; -use Hypervel\Process\Concerns\GuardsProcessOutput; use Hypervel\Process\Exceptions\ProcessFailedException; use Hypervel\Process\Exceptions\ProcessIdleTimedOutException; use Hypervel\Process\Exceptions\ProcessTimedOutException; @@ -20,7 +20,9 @@ use Hypervel\Process\InvokedProcessPool; use Hypervel\Process\PendingProcess; use Hypervel\Process\Pool; +use Hypervel\Process\ProcessState; use Hypervel\Support\Collection; +use Hypervel\Support\Traits\GuardsProcessOutput; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; @@ -28,8 +30,11 @@ use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\TestWith; +use ReflectionProperty; use RuntimeException; +use Swoole\Coroutine as SwooleCoroutine; use Swoole\Coroutine\CanceledException; +use Swoole\Coroutine\Channel; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; use Symfony\Component\Process\InputStream; use Symfony\Component\Process\Process as SymfonyProcess; @@ -84,6 +89,87 @@ public function testEmptyProcessPoolReturnsEmptyResults(): void $this->assertCount(0, $pool->wait()->collect()); } + #[TestWith([false, false], 'owner returns')] + #[TestWith([true, false], 'owner is canceled')] + #[TestWith([false, true], 'explicitly detached')] + public function testAsynchronousProcessFollowsItsOwningCoroutine(bool $cancel, bool $detached): void + { + $ready = new Channel(1); + $process = null; + $owner = Coroutine::create(static function () use ($ready, $cancel, $detached): void { + $process = (new Factory)->options(['create_new_console' => $detached]) + ->start([PHP_BINARY, '-r', 'usleep(5000000);']); + // Observe the child without retaining the discarded facade handle. + $ready->push((new ReflectionProperty(InvokedProcess::class, 'process'))->getValue($process)); + unset($process); + + if ($cancel) { + usleep(5000000); + } + }); + + try { + $process = $ready->pop(2); + $this->assertInstanceOf(SymfonyProcess::class, $process); + + if ($cancel) { + SwooleCoroutine::cancel($owner); + } + + Coroutine::join([$owner], 2); + $this->assertFalse(Coroutine::exists($owner)); + $this->assertSame($detached, $process->isRunning()); + } finally { + if (Coroutine::exists($owner)) { + SwooleCoroutine::cancel($owner); + Coroutine::join([$owner], 2); + } + + if ($process instanceof SymfonyProcess) { + $process->stop(0); + } + + $ready->close(); + } + } + + public function testCoroutineCleanupDoesNotRetainCompletedProcesses(): void + { + for ($index = 0; $index < 3; ++$index) { + $process = (new Factory)->start([PHP_BINARY, '-r', 'echo "done";']); + $this->assertSame('done', $process->wait()->output()); + unset($process); + } + + $this->assertCount(0, ProcessState::current()->processes); + } + + public function testForkedCoroutineDoesNotInheritProcessOwnership(): void + { + $process = (new Factory)->start([PHP_BINARY, '-r', 'usleep(5000000);']); + $ready = new Channel(1); + + try { + $child = Coroutine::fork(static function () use ($ready): void { + $inherited = CoroutineContext::get(ProcessState::CONTEXT_KEY); + (new Factory)->start([PHP_BINARY, '-r', 'echo "child";'])->wait(); + $ready->push($inherited); + }); + + $this->assertNull($ready->pop(2)); + Coroutine::join([$child], 2); + $this->assertTrue($process->running()); + } finally { + if (isset($child) && Coroutine::exists($child)) { + SwooleCoroutine::cancel($child); + Coroutine::join([$child], 2); + } + + $process->stop(0); + $ready->close(); + } + } + public function testProcessPoolFailed(): void { $factory = new Factory; @@ -119,10 +205,6 @@ public function testInvokedProcessPoolCount(): void })->start(); $this->assertCount(2, $pool); - - // Laravel omits this, but Swoole's coroutine environment causes Symfony Process - // destructors to fatal when processes are still running at shutdown. - $pool->wait(); } #[TestWith([false])] @@ -647,8 +729,8 @@ public function testOutputGuardPreservesCancellationDuringEmergencyStop(): void $failure = new RuntimeException('Output callback failed.'); $cancellation = new CanceledException('Emergency stop canceled.'); $process = m::mock(SymfonyProcess::class); - $process->shouldReceive('stop')->once()->with(0, null)->ordered()->andThrow($cancellation); - $process->shouldReceive('stop')->once()->with(0, null)->ordered()->andReturn(0); + $process->shouldReceive('stop')->once()->with(0)->ordered()->andThrow($cancellation); + $process->shouldReceive('stop')->once()->with(0)->ordered()->andReturn(0); $guard = new class { use GuardsProcessOutput { guardProcessOutput as public; From 8ed9ced837d09dd75f7c9afc3158c7fdbe3796bd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:55 +0000 Subject: [PATCH 02/14] Stop Composer children when output callbacks fail Guard require and remove output callbacks so their exceptions cannot leave Composer changing the project in the background. Both real-child regressions fail before correction; SupportComposerTest passes. --- src/support/src/Composer.php | 35 ++++++++++++++++----------- tests/Support/SupportComposerTest.php | 30 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index 644dd61d2c..273030ea2e 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -7,6 +7,7 @@ use Closure; use Composer\Autoload\ClassLoader; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Traits\GuardsProcessOutput; use JsonException; use RuntimeException; use Stringable; @@ -15,6 +16,8 @@ class Composer { + use GuardsProcessOutput; + /** * The filesystem instance. */ @@ -73,13 +76,15 @@ public function requirePackages(array $packages, bool $dev = false, Closure|Outp $command->push('--dev'); })->all(); - return $this->getProcess($command, ['COMPOSER_MEMORY_LIMIT' => '-1']) - ->run( - $output instanceof OutputInterface - ? function ($type, $line) use ($output) { - $output->write(' ' . $line); - } : $output - ) === 0; + $process = $this->getProcess($command, ['COMPOSER_MEMORY_LIMIT' => '-1']); + + return $process->run($this->guardProcessOutput( + $process, + $output instanceof OutputInterface + ? function ($type, $line) use ($output) { + $output->write(' ' . $line); + } : $output + )) === 0; } /** @@ -98,13 +103,15 @@ public function removePackages(array $packages, bool $dev = false, Closure|Outpu $command->push('--dev'); })->all(); - return $this->getProcess($command, ['COMPOSER_MEMORY_LIMIT' => '-1']) - ->run( - $output instanceof OutputInterface - ? function ($type, $line) use ($output) { - $output->write(' ' . $line); - } : $output - ) === 0; + $process = $this->getProcess($command, ['COMPOSER_MEMORY_LIMIT' => '-1']); + + return $process->run($this->guardProcessOutput( + $process, + $output instanceof OutputInterface + ? function ($type, $line) use ($output) { + $output->write(' ' . $line); + } : $output + )) === 0; } /** diff --git a/tests/Support/SupportComposerTest.php b/tests/Support/SupportComposerTest.php index 5082f07ebc..dad4119dbe 100644 --- a/tests/Support/SupportComposerTest.php +++ b/tests/Support/SupportComposerTest.php @@ -9,7 +9,10 @@ use Hypervel\Support\Composer; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use Symfony\Component\Process\Process; +use Throwable; use function Hypervel\Support\php_binary; @@ -101,6 +104,33 @@ public function testRemovePackagesRunsTheCorrectCommand(): void $composer->removePackages(['phpunit/phpunit'], true); } + #[TestWith(['requirePackages'])] + #[TestWith(['removePackages'])] + public function testOutputFailureStopsTheComposerProcess(string $method): void + { + $process = new Process([PHP_BINARY, '-r', 'echo "progress"; usleep(5000000);']); + $composer = $this->getMockBuilder(Composer::class) + ->onlyMethods(['getProcess']) + ->setConstructorArgs([new Filesystem, sys_get_temp_dir()]) + ->getMock(); + $composer->expects($this->once())->method('getProcess')->willReturn($process); + $failure = new RuntimeException('Unable to write process output.'); + $caught = null; + + try { + try { + $composer->{$method}(['example/package'], output: static fn () => throw $failure); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse($process->isRunning()); + } finally { + $process->stop(0); + } + } + /** * Create a Composer manager expecting the given process arguments. */ From d0c2865f5e0e935c523ab6130929a718633bb0f3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 03/14] Stop schema dump processes when output handling fails Apply the shared output guard to MySQL, MariaDB and PostgreSQL dump callbacks without changing schema extension points. Real-child regressions fail before correction; MySQL and PostgreSQL schema-state test files pass. --- src/database/src/Schema/MySqlSchemaState.php | 2 +- .../src/Schema/PostgresSchemaState.php | 4 ++- src/database/src/Schema/SchemaState.php | 3 ++ .../Database/DatabaseMySqlSchemaStateTest.php | 28 +++++++++++++++++++ .../DatabasePostgresSchemaStateTest.php | 28 +++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/database/src/Schema/MySqlSchemaState.php b/src/database/src/Schema/MySqlSchemaState.php index 79a8e9b97c..4b571c1972 100644 --- a/src/database/src/Schema/MySqlSchemaState.php +++ b/src/database/src/Schema/MySqlSchemaState.php @@ -168,7 +168,7 @@ protected function executeDumpProcess(Process $process, ?callable $output, array } try { - $process->setTimeout(null)->mustRun($output, $variables); + $process->setTimeout(null)->mustRun($this->guardProcessOutput($process, $output), $variables); } catch (Exception $e) { if (Str::contains($e->getMessage(), ['column-statistics', 'column_statistics'])) { return $this->executeDumpProcess(Process::fromShellCommandLine( diff --git a/src/database/src/Schema/PostgresSchemaState.php b/src/database/src/Schema/PostgresSchemaState.php index f3ff4f163d..56597200a4 100644 --- a/src/database/src/Schema/PostgresSchemaState.php +++ b/src/database/src/Schema/PostgresSchemaState.php @@ -25,7 +25,9 @@ public function dump(Connection $connection, string $path): void } $commands->map(function ($command) use ($path) { - $this->makeProcess($command)->mustRun($this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + $process = $this->makeProcess($command); + + $process->mustRun($this->guardProcessOutput($process, $this->output), array_merge($this->baseVariables($this->connection->getConfig()), [ 'HYPERVEL_LOAD_PATH' => $path, ])); }); diff --git a/src/database/src/Schema/SchemaState.php b/src/database/src/Schema/SchemaState.php index 484a0d5843..8f3f018969 100644 --- a/src/database/src/Schema/SchemaState.php +++ b/src/database/src/Schema/SchemaState.php @@ -7,10 +7,13 @@ use Closure; use Hypervel\Database\Connection; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Traits\GuardsProcessOutput; use Symfony\Component\Process\Process; abstract class SchemaState { + use GuardsProcessOutput; + /** * The connection instance. */ diff --git a/tests/Database/DatabaseMySqlSchemaStateTest.php b/tests/Database/DatabaseMySqlSchemaStateTest.php index fad2098df5..627c8f9b9a 100644 --- a/tests/Database/DatabaseMySqlSchemaStateTest.php +++ b/tests/Database/DatabaseMySqlSchemaStateTest.php @@ -12,10 +12,38 @@ use Pdo\Mysql; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; +use RuntimeException; use Symfony\Component\Process\Process; +use Throwable; class DatabaseMySqlSchemaStateTest extends TestCase { + public function testDumpOutputFailureStopsTheProcess(): void + { + $process = new Process([PHP_BINARY, '-r', 'echo "progress"; usleep(5000000);']); + $state = new MySqlSchemaState($this->createStub(MySqlConnection::class)); + $failure = new RuntimeException('Unable to write dump output.'); + $caught = null; + + try { + try { + (new ReflectionMethod($state, 'executeDumpProcess'))->invoke( + $state, + $process, + static fn () => throw $failure, + [], + ); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse($process->isRunning()); + } finally { + $process->stop(0); + } + } + #[DataProvider('provider')] public function testConnectionString(string $expectedConnectionString, array $expectedVariables, array $dbConfig): void { diff --git a/tests/Database/DatabasePostgresSchemaStateTest.php b/tests/Database/DatabasePostgresSchemaStateTest.php index 19e3edf56c..170dc3665d 100644 --- a/tests/Database/DatabasePostgresSchemaStateTest.php +++ b/tests/Database/DatabasePostgresSchemaStateTest.php @@ -11,10 +11,38 @@ use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionMethod; +use RuntimeException; use Symfony\Component\Process\Process; +use Throwable; class DatabasePostgresSchemaStateTest extends TestCase { + public function testDumpOutputFailureStopsTheProcess(): void + { + $process = new Process([PHP_BINARY, '-r', 'echo "progress"; usleep(5000000);']); + $connection = $this->createStub(PostgresConnection::class); + $connection->method('getConfig')->willReturn([ + 'host' => 'localhost', 'port' => 5432, 'username' => 'root', 'password' => '', 'database' => 'hypervel', + ]); + $state = new PostgresSchemaState($connection, processFactory: static fn () => $process); + $failure = new RuntimeException('Unable to write dump output.'); + $state->withMigrationTable(null)->handleOutputUsing(static fn () => throw $failure); + $caught = null; + + try { + try { + $state->dump($connection, '/unused-schema.sql'); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse($process->isRunning()); + } finally { + $process->stop(0); + } + } + public function testBaseVariablesUseConfiguredConnectionByDefault(): void { $schemaState = new PostgresSchemaState(m::mock(PostgresConnection::class)); From 2d777c98d9826df7e089aae19f34f66706bc6f8b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 04/14] Stop queue listener children when output handlers fail Prevent a caller-supplied output handler from abandoning the current worker process after an exception. The regression fails before correction; QueueListenerTest passes. --- src/queue/src/Listener.php | 7 +++++-- tests/Queue/QueueListenerTest.php | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/queue/src/Listener.php b/src/queue/src/Listener.php index 5483ad5bfd..047c4a6b63 100644 --- a/src/queue/src/Listener.php +++ b/src/queue/src/Listener.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Support\Sleep; +use Hypervel\Support\Traits\GuardsProcessOutput; use Symfony\Component\Process\Process; use function Hypervel\Support\artisan_binary; @@ -13,6 +14,8 @@ class Listener { + use GuardsProcessOutput; + /** * The environment the workers should run under. */ @@ -136,9 +139,9 @@ protected function createCommand(?string $connection, string $queue, ListenerOpt */ public function runProcess(Process $process, float|string $memory): void { - $process->run(function ($type, $line) { + $process->run($this->guardProcessOutput($process, function ($type, $line) { $this->handleWorkerOutput($type, $line); - }); + })); // Once we have run the job we'll go check if the memory limit has been exceeded // for the script. If it has, we will kill this script so the process manager diff --git a/tests/Queue/QueueListenerTest.php b/tests/Queue/QueueListenerTest.php index ef5374f480..53d022e566 100644 --- a/tests/Queue/QueueListenerTest.php +++ b/tests/Queue/QueueListenerTest.php @@ -12,12 +12,35 @@ use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Symfony\Component\Process\Process; +use Throwable; use function Hypervel\Support\artisan_binary; use function Hypervel\Support\php_binary; class QueueListenerTest extends TestCase { + public function testOutputFailureStopsTheWorkerProcess(): void + { + $process = new Process([PHP_BINARY, '-r', 'echo "progress"; usleep(5000000);']); + $listener = new Listener(sys_get_temp_dir()); + $failure = new RuntimeException('Unable to write worker output.'); + $listener->setOutputHandler(static fn () => throw $failure); + $caught = null; + + try { + try { + $listener->runProcess($process, 128); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + $this->assertFalse($process->isRunning()); + } finally { + $process->stop(0); + } + } + public function testRunProcessCallsProcess(): void { $process = m::mock(Process::class)->makePartial(); From 4308241c319c44e7336f05048c4ae81fe27cdc40 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 05/14] Declare Foundation's direct Symfony Process dependency Declare the Process component used directly by Foundation console commands and Composer integration, using the framework's existing version constraint. Package metadata parses successfully; no root dependency versions change. --- src/foundation/composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/foundation/composer.json b/src/foundation/composer.json index a51dd45417..ad92e0d11c 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -40,6 +40,7 @@ "symfony/finder": "^8.1", "symfony/http-foundation": "^8.1", "symfony/http-kernel": "^8.1", + "symfony/process": "^8.1", "symfony/var-dumper": "^8.1", "vlucas/phpdotenv": "^5.6.1", "hypervel/auth": "^0.4", From 9874d36415ec6789f3eb00929490a3ff3c31fcd0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 06/14] Use owned process fixtures for cache worker recycling Start the fixture through the Process API while retaining graceful shutdown before log inspection. SwooleMaintenanceTimerWorkerRecycleTest passes. --- .../SwooleMaintenanceTimerWorkerRecycleTest.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/Cache/SwooleMaintenanceTimerWorkerRecycleTest.php b/tests/Cache/SwooleMaintenanceTimerWorkerRecycleTest.php index f755028eb6..84ba84907c 100644 --- a/tests/Cache/SwooleMaintenanceTimerWorkerRecycleTest.php +++ b/tests/Cache/SwooleMaintenanceTimerWorkerRecycleTest.php @@ -4,12 +4,13 @@ namespace Hypervel\Tests\Cache; +use Hypervel\Contracts\Process\InvokedProcess; use Hypervel\Filesystem\Filesystem; +use Hypervel\Process\Factory as ProcessFactory; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Swoole\Coroutine\Http\Client; -use Symfony\Component\Process\Process; #[RequiresOperatingSystem('Linux|Darwin')] class SwooleMaintenanceTimerWorkerRecycleTest extends TestCase @@ -36,7 +37,7 @@ public function testOwnedCacheTimersDoNotDelayWorkerRecycle(): void $port = $this->reservePort(); $statePath = $this->tempDir . '/state.log'; $logPath = $this->tempDir . '/swoole.log'; - $process = new Process([ + $process = (new ProcessFactory)->start([ PHP_BINARY, __DIR__ . '/Fixtures/SwooleMaintenanceTimerRecycleServer.php', dirname(__DIR__, 2) . '/vendor/autoload.php', @@ -44,7 +45,6 @@ public function testOwnedCacheTimersDoNotDelayWorkerRecycle(): void $statePath, $logPath, ]); - $process->start(); try { $this->waitForState( @@ -65,12 +65,13 @@ public function testOwnedCacheTimersDoNotDelayWorkerRecycle(): void && substr_count($state, "start\n") >= 2, ); } finally { + // Give the server time to shut down its workers before inspecting its logs. $process->stop(2); } $output = strtolower( - $process->getOutput() - . $process->getErrorOutput() + $process->output() + . $process->errorOutput() . (is_file($logPath) ? file_get_contents($logPath) : '') ); @@ -78,7 +79,7 @@ public function testOwnedCacheTimersDoNotDelayWorkerRecycle(): void $this->assertStringNotContainsString('forced to terminate', $output); } - private function waitForState(Process $process, string $statePath, callable $condition): void + private function waitForState(InvokedProcess $process, string $statePath, callable $condition): void { $deadline = microtime(true) + 5; @@ -89,8 +90,8 @@ private function waitForState(Process $process, string $statePath, callable $con return; } - if (! $process->isRunning()) { - $this->fail('The Swoole timer recycle server exited early: ' . $process->getErrorOutput()); + if (! $process->running()) { + $this->fail('The Swoole timer recycle server exited early: ' . $process->errorOutput()); } usleep(10_000); From b18b0884b82d42bc2c23f3065e553a17cf7c1c93 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 07/14] Use the Process API for profiler fixtures Use coroutine-owned subprocesses and return ProcessResult from the profiler helper, preserving output, exit-code and report-cleanup assertions. ProfileScriptTest passes. --- tests/Testing/Profile/ProfileScriptTest.php | 59 ++++++++++----------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/tests/Testing/Profile/ProfileScriptTest.php b/tests/Testing/Profile/ProfileScriptTest.php index f7afbcf26b..6f3b6e0c0e 100644 --- a/tests/Testing/Profile/ProfileScriptTest.php +++ b/tests/Testing/Profile/ProfileScriptTest.php @@ -4,12 +4,13 @@ namespace Hypervel\Tests\Testing\Profile; +use Hypervel\Contracts\Process\ProcessResult; use Hypervel\Filesystem\Filesystem; +use Hypervel\Process\Factory as ProcessFactory; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; use RuntimeException; -use Symfony\Component\Process\Process; class ProfileScriptTest extends TestCase { @@ -37,9 +38,9 @@ public function itProfilesRawParallelTestsIncludingSetupTime(): void $this->writeProfileFixture($filesystem, $fixturePath, 'Fast', 10000, true); [$process, $processId] = $this->runProfiler($fixturePath); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertSame(0, $process->getExitCode(), $output); + $this->assertSame(0, $process->exitCode(), $output); $this->assertStringContainsString('Tests taking at least 0.300 seconds (11)', $output); $this->assertStringContainsString('ProfileScriptSlow10FixtureTest', $output); $this->assertStringNotContainsString('ProfileScriptFastFixtureTest', $output); @@ -71,9 +72,9 @@ public function itPreservesTheParallelTestExitCodeAndCleansTheReport(): void $this->writeProfileFixture($filesystem, $fixturePath, 'Failure', 310000, false); [$process, $processId] = $this->runProfiler($fixturePath); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertSame(1, $process->getExitCode(), $output); + $this->assertSame(1, $process->exitCode(), $output); $this->assertStringContainsString('ProfileScriptFailureFixtureTest', $output); $this->assertStringContainsString('Tests taking at least 0.300 seconds (1)', $output); $this->assertSame([], $this->profilePaths($processId)); @@ -92,9 +93,9 @@ public function itPreservesRunnerFailuresThatProduceNoReport(): void $filesystem->deleteDirectory($fixturePath); [$process, $processId] = $this->runProfiler($fixturePath); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertSame(1, $process->getExitCode(), $output); + $this->assertSame(1, $process->exitCode(), $output); $this->assertStringNotContainsString('Unable to find PHPUnit profile', $output); $this->assertSame([], $this->profilePaths($processId)); } finally { @@ -116,9 +117,9 @@ public function itReportsWhenParaTestIsNotInstalled(): void $fixturePath, scriptPath: $this->writeProfilerWrapper($filesystem, $fixturePath), ); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertSame(1, $process->getExitCode(), $output); + $this->assertSame(1, $process->exitCode(), $output); $this->assertStringContainsString( 'Install the brianium/paratest package to profile tests.', $output, @@ -143,9 +144,9 @@ public function itPreservesRunnerFailuresThatProduceMalformedReports(): void $fixturePath, scriptPath: $this->writeProfilerWrapper($filesystem, $fixturePath), ); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertSame(1, $process->getExitCode(), $output); + $this->assertSame(1, $process->exitCode(), $output); $this->assertStringContainsString('Synthetic runner output.', $output); $this->assertStringNotContainsString('Unable to read PHPUnit profile', $output); $this->assertSame([], $this->profilePaths($processId)); @@ -168,9 +169,9 @@ public function itRejectsMalformedReportsFromSuccessfulRuns(): void $fixturePath, scriptPath: $this->writeProfilerWrapper($filesystem, $fixturePath), ); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertNotSame(0, $process->getExitCode(), $output); + $this->assertNotSame(0, $process->exitCode(), $output); $this->assertStringContainsString('Unable to read PHPUnit profile', $output); $this->assertSame([], $this->profilePaths($processId)); } finally { @@ -190,9 +191,9 @@ public function itReportsWhenNoTestsCrossTheThreshold(): void $this->writeProfileFixture($filesystem, $fixturePath, 'Fast', 10000, true); [$process, $processId] = $this->runProfiler($fixturePath); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertSame(0, $process->getExitCode(), $output); + $this->assertSame(0, $process->exitCode(), $output); $this->assertStringContainsString('No tests took at least 0.300 seconds.', $output); $this->assertStringNotContainsString('ProfileScriptFastFixtureTest::', $output); $this->assertSame([], $this->profilePaths($processId)); @@ -216,9 +217,9 @@ public function itPreservesEmptyTestSuiteOutcomes(): void '--filter=ProfileScriptMissingFixtureTest', '--do-not-fail-on-empty-test-suite', ]); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertSame(0, $process->getExitCode(), $output); + $this->assertSame(0, $process->exitCode(), $output); $this->assertStringContainsString('No tests executed!', $output); $this->assertStringNotContainsString('Unable to find PHPUnit profile', $output); $this->assertSame([], $this->profilePaths($processId)); @@ -227,9 +228,9 @@ public function itPreservesEmptyTestSuiteOutcomes(): void $fixturePath, ['--filter=ProfileScriptMissingFixtureTest'], ); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); - $this->assertNotSame(0, $process->getExitCode(), $output); + $this->assertNotSame(0, $process->exitCode(), $output); $this->assertStringContainsString('No tests executed!', $output); $this->assertStringNotContainsString('Unable to find PHPUnit profile', $output); $this->assertSame([], $this->profilePaths($processId)); @@ -254,10 +255,10 @@ public function itPreservesInformationalCommandsThatProduceNoReport(): void [['-hV'], 'Usage:'], ] as [$arguments, $expectedOutput]) { [$process, $processId] = $this->runProfiler($fixturePath, $arguments); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); $message = sprintf('Failed for arguments [%s].%s%s', implode(' ', $arguments), PHP_EOL, $output); - $this->assertSame(0, $process->getExitCode(), $message); + $this->assertSame(0, $process->exitCode(), $message); $this->assertStringContainsString($expectedOutput, $output, $message); $this->assertStringNotContainsString('Unable to find PHPUnit profile', $output, $message); $this->assertSame([], $this->profilePaths($processId), $message); @@ -285,10 +286,10 @@ public function itRejectsCallerSuppliedProfileReportsBeforeRunningTests(): void : ['--log-junit', $customReportPath]; [$process, $processId] = $this->runProfiler($fixturePath, $arguments); - $output = $process->getOutput() . $process->getErrorOutput(); + $output = $process->output() . $process->errorOutput(); $message = sprintf('Failed for syntax [%s].%s%s', $syntax, PHP_EOL, $output); - $this->assertSame(1, $process->getExitCode(), $message); + $this->assertSame(1, $process->exitCode(), $message); $this->assertStringContainsString( 'The profiler manages the --log-junit option.', $output, @@ -402,31 +403,27 @@ private function writeProfilerWrapper(Filesystem $filesystem, string $fixturePat * Run the Components profile script. * * @param array $arguments - * @return array{Process, int} + * @return array{ProcessResult, int} */ private function runProfiler( string $fixturePath, array $arguments = [], ?string $scriptPath = null, ): array { - $process = new Process([ + $process = (new ProcessFactory)->timeout(30)->start([ PHP_BINARY, $scriptPath ?? dirname(__DIR__, 3) . '/src/testing/bin/hypervel-test-profile', '--processes=4', ...$arguments, $fixturePath, ]); - $process->setTimeout(30); - $process->start(); - $processId = $process->getPid(); + $processId = $process->id(); if ($processId === null) { throw new RuntimeException('Unable to start the Components profile script.'); } - $process->wait(); - - return [$process, $processId]; + return [$process->wait(), $processId]; } /** From 1f9afc6d7a529f9f84a1c738a285d76c619e85bd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 08/14] Clean up filesystem reader processes on test failure Retain each child before starting it and stop all started readers if startup, waiting or an assertion fails. FilesystemNonCoroutineTest passes. --- .../Filesystem/FilesystemNonCoroutineTest.php | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/Filesystem/FilesystemNonCoroutineTest.php b/tests/Filesystem/FilesystemNonCoroutineTest.php index 3973fa1221..4559d5ee3f 100644 --- a/tests/Filesystem/FilesystemNonCoroutineTest.php +++ b/tests/Filesystem/FilesystemNonCoroutineTest.php @@ -68,21 +68,27 @@ public function testSharedGet(): void $autoloader = dirname(__DIR__, 2) . '/vendor/autoload.php'; $processes = []; - for ($i = 0; $i < 20; ++$i) { - $process = new Process([PHP_BINARY, $scriptPath, $autoloader, $filePath, (string) strlen($content)]); - $process->start(); - $processes[] = $process; - } + try { + for ($i = 0; $i < 20; ++$i) { + $process = new Process([PHP_BINARY, $scriptPath, $autoloader, $filePath, (string) strlen($content)]); + $processes[] = $process; + $process->start(); + } - $allSucceeded = true; - foreach ($processes as $index => $process) { - $process->wait(); - if ($process->getExitCode() !== 0) { - $allSucceeded = false; + $allSucceeded = true; + foreach ($processes as $process) { + $process->wait(); + if ($process->getExitCode() !== 0) { + $allSucceeded = false; + } } - } - $this->assertTrue($allSucceeded, 'At least one subprocess got a partial or corrupt read'); + $this->assertTrue($allSucceeded, 'At least one subprocess got a partial or corrupt read'); + } finally { + foreach ($processes as $process) { + $process->stop(0); + } + } } public function testLockedPutWithStreamResource(): void From 976b06cd7ffd69da6fc7bbc8690084089bf0997e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 09/14] Guard missing process IDs in HTTP fixture cleanup Avoid signaling the test runner's process group when the fixture has no process ID. HttpClientStreamingTest passes with its established platform skip. --- tests/Http/HttpClientStreamingTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Http/HttpClientStreamingTest.php b/tests/Http/HttpClientStreamingTest.php index c4c1917638..975064fdaa 100644 --- a/tests/Http/HttpClientStreamingTest.php +++ b/tests/Http/HttpClientStreamingTest.php @@ -629,7 +629,9 @@ protected function withIdentityServer(Closure $callback): void throw $failure; } } finally { - posix_kill(-$processId, SIGKILL); + if ($processId !== null) { + posix_kill(-$processId, SIGKILL); + } $process->stop(0); } } From bff889c2c27f9aa2a2be417e3e877cccdbf07b88 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 10/14] Stop Horizon test processes on failure Keep real workers and supervisor processes inside exception-safe cleanup while preserving mocked behavior and assertions. Both changed Horizon files pass against an isolated temporary Redis instance. --- .../Horizon/Feature/AddSupervisorTest.php | 24 ++++++++++--------- .../Horizon/Feature/WorkerProcessTest.php | 22 +++++++++++------ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/tests/Integration/Horizon/Feature/AddSupervisorTest.php b/tests/Integration/Horizon/Feature/AddSupervisorTest.php index 3e8ae21928..918348c351 100644 --- a/tests/Integration/Horizon/Feature/AddSupervisorTest.php +++ b/tests/Integration/Horizon/Feature/AddSupervisorTest.php @@ -18,21 +18,23 @@ public function testAddSupervisorCommandCreatesNewSupervisorOnMasterProcess(): v $master = new MasterSupervisor; $phpBinary = PhpBinary::path(); - $master->loop(); + try { + $master->loop(); - resolve(HorizonCommandQueue::class)->push($master->commandQueue(), AddSupervisor::class, (new SupervisorOptions('my-supervisor', 'redis'))->toArray()); + resolve(HorizonCommandQueue::class)->push($master->commandQueue(), AddSupervisor::class, (new SupervisorOptions('my-supervisor', 'redis'))->toArray()); - $this->assertCount(0, $master->supervisors); + $this->assertCount(0, $master->supervisors); - $master->loop(); + $master->loop(); - $this->assertCount(1, $master->supervisors); + $this->assertCount(1, $master->supervisors); - $this->assertSame( - 'exec ' . $phpBinary . ' artisan horizon:supervisor my-supervisor redis --workers-name=default --balance=off --max-processes=1 --min-processes=1 --nice=0 --balance-cooldown=3 --balance-max-shift=1 --parent-id=0 --auto-scaling-strategy=time --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue="default" --sleep=3 --timeout=60 --tries=0 --rest=0 --concurrency=1', - $master->supervisors->first()->process->getCommandLine() - ); - - $master->supervisors->each->stop(); + $this->assertSame( + 'exec ' . $phpBinary . ' artisan horizon:supervisor my-supervisor redis --workers-name=default --balance=off --max-processes=1 --min-processes=1 --nice=0 --balance-cooldown=3 --balance-max-shift=1 --parent-id=0 --auto-scaling-strategy=time --backoff=0 --max-time=0 --max-jobs=0 --memory=128 --queue="default" --sleep=3 --timeout=60 --tries=0 --rest=0 --concurrency=1', + $master->supervisors->first()->process->getCommandLine() + ); + } finally { + $master->supervisors->each->stop(); + } } } diff --git a/tests/Integration/Horizon/Feature/WorkerProcessTest.php b/tests/Integration/Horizon/Feature/WorkerProcessTest.php index da10780187..2d746c15c6 100644 --- a/tests/Integration/Horizon/Feature/WorkerProcessTest.php +++ b/tests/Integration/Horizon/Feature/WorkerProcessTest.php @@ -67,15 +67,20 @@ public function testControlSignalSentDuringBootstrapIsDeliveredAfterHandlerInsta PHP; $output = ''; $process = new WorkerProcess(new Process([PHP_BINARY, '-r', $script])); - $process->start(static function (string $type, string $buffer) use (&$output): void { - $output .= $buffer; - }); - $process->pause(); - $exitCode = $process->wait(); + try { + $process->start(static function (string $type, string $buffer) use (&$output): void { + $output .= $buffer; + }); + + $process->pause(); + $exitCode = $process->wait(); - $this->assertSame(0, $exitCode); - $this->assertSame('received', $output); + $this->assertSame(0, $exitCode); + $this->assertSame('received', $output); + } finally { + $process->kill(); + } } public function testStartRestoresTheParentSignalMaskAfterSuccess(): void @@ -166,6 +171,7 @@ public function testWorkerProcessFiresEventIfStoppedProcessCantBeRestarted(): vo Event::assertDispatched(WorkerProcessRestarting::class); Event::assertDispatched(UnableToLaunchProcess::class); } finally { + $workerProcess->kill(); CarbonImmutable::setTestNow(); } } @@ -193,6 +199,7 @@ public function testProcessIsNotRestartedDuringCooldownPeriod(): void Event::assertDispatched(WorkerProcessRestarting::class); $this->assertCount(1, Event::dispatched(WorkerProcessRestarting::class)); } finally { + $workerProcess->kill(); CarbonImmutable::setTestNow(); } } @@ -224,6 +231,7 @@ public function testProcessIsRestartedAfterCooldownPeriod(): void Event::assertDispatched(WorkerProcessRestarting::class); $this->assertCount(2, Event::dispatched(WorkerProcessRestarting::class)); } finally { + $workerProcess->kill(); CarbonImmutable::setTestNow(); } } From 63f3eb0b2c4dde1351f31484b8368b3be31a1e6d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 11/14] Guard missing process IDs in server shutdown tests Keep process-group cleanup limited to a fixture with a known process ID. ShutdownOnInterruptListenerTest passes with its established Swoole skips. --- .../Listeners/ShutdownOnInterruptListenerTest.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/Server/Listeners/ShutdownOnInterruptListenerTest.php b/tests/Server/Listeners/ShutdownOnInterruptListenerTest.php index 52fc653607..9ac7c75a94 100644 --- a/tests/Server/Listeners/ShutdownOnInterruptListenerTest.php +++ b/tests/Server/Listeners/ShutdownOnInterruptListenerTest.php @@ -45,7 +45,9 @@ public function testShutsDownGracefullyWhenTheTerminalProcessGroupIsInterrupted( $this->assertFalse(posix_kill(-$pid, 0), 'Server processes were left running after shutdown.'); } finally { // Only the fixture can lead a group with its PID, so this kills anything a failed test left in it. - posix_kill(-$pid, SIGKILL); + if ($pid !== null) { + posix_kill(-$pid, SIGKILL); + } $process->stop(0); } } @@ -82,7 +84,9 @@ public function testReplacementWorkersKeepTheirSignalHandlers(string $mode, int $this->assertStringContainsString('server shutdown', $process->getOutput()); $this->assertFalse(posix_kill(-$pid, 0), 'Server processes were left running after shutdown.'); } finally { - posix_kill(-$pid, SIGKILL); + if ($pid !== null) { + posix_kill(-$pid, SIGKILL); + } $process->stop(0); } } @@ -120,7 +124,9 @@ public function testSingleProcessShutdownSharesTheWorkerSignalRegistry(string $c $this->assertStringContainsString('application interrupt received', $process->getOutput()); $this->assertStringContainsString('server shutdown', $process->getOutput()); } finally { - posix_kill(-$pid, SIGKILL); + if ($pid !== null) { + posix_kill(-$pid, SIGKILL); + } $process->stop(0); } } From d6d0e4a187b6fd0d84ea90821ebba3d8aa42283d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 12/14] Keep Testbench serve cleanup scoped to known process groups Guard nullable process IDs and remove the startup sleep because Symfony obtains the PID during start. CommanderServeTest passes directly and through package:test with one worker. --- tests/Testbench/CommanderServeTest.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Testbench/CommanderServeTest.php b/tests/Testbench/CommanderServeTest.php index 4c298dc047..e108727f9f 100644 --- a/tests/Testbench/CommanderServeTest.php +++ b/tests/Testbench/CommanderServeTest.php @@ -120,7 +120,9 @@ public function itRestoresACustomSkeletonWhenTheTerminalInterruptsServe(): void } finally { // Only the serve process can lead a group with its PID, and leftover workers stay in that // group after the master exits, so this reaps every remaining process. - posix_kill(-$pid, SIGKILL); + if ($pid !== null) { + posix_kill(-$pid, SIGKILL); + } $process->stop(0); static::$activeServePid = null; $filesystem->deleteDirectory($workingPath); @@ -191,7 +193,9 @@ public function itServesTheWorkbenchAuthenticationPagesWithTheirAssetsAndSyncLin $this->assertFalse(is_link($reverseLink)); } finally { - posix_kill(-$pid, SIGKILL); + if ($pid !== null) { + posix_kill(-$pid, SIGKILL); + } $process->stop(0); static::$activeServePid = null; $filesystem->deleteDirectory($workingPath); @@ -280,9 +284,6 @@ private function startServeProcess(): array $process->setTimeout(20); $process->start(); - - // Give Symfony a moment to spawn the process and obtain its PID. - usleep(200_000); static::$activeServePid = $process->getPid(); return [$process, $serverPort]; From cd57cb9d1b864030563fd641745325801632bf7e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:35:56 +0000 Subject: [PATCH 13/14] Document subprocess ownership and test fixture cleanup Explain coroutine-exit cleanup, explicit detachment and raw Symfony ownership. Recommend the framework Process API for fixtures while retaining explicit shutdown where timing or process trees require it. Documentation matches the reviewed source and behavior tests. --- src/docs/porting-from-laravel.md | 2 ++ src/docs/processes.md | 6 ++++-- src/docs/testing.md | 2 ++ src/process/README.md | 6 ++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index adf060e297..89ebcd5fe3 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -447,6 +447,8 @@ Hypervel's framework clients are designed to cooperate with Swoole coroutines, b Before porting an integration that performs network, filesystem, subprocess, or other external I/O, verify that its client is safe to use with Swoole's coroutine hooks. Prefer a coroutine-aware client. For unusual work that requires an unhookable extension or full process isolation, use the `process` concurrency driver described in the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver). +Hypervel's [asynchronous Process API](/docs/{{version}}/processes#asynchronous-processes) stops a process when its starting coroutine exits. Start it in the coroutine that will wait for it. When using Symfony Process directly, wait for or stop it before that coroutine exits, using `finally` when intervening work can throw. + Pooled database and Redis resources belong to the coroutine or callback that borrowed them. Do not retain a checked-out low-level connection on a singleton or static property, and do not use it after its owning callback or coroutine ends. Use the manager or facade for each operation and the documented callback APIs, such as `Redis::withConnection()`, when several operations must share one borrowed connection. See [database connection pooling](/docs/{{version}}/database#connection-pooling) and [holding a pooled Redis connection](/docs/{{version}}/redis#holding-a-pooled-connection). diff --git a/src/docs/processes.md b/src/docs/processes.md index dff991ad75..a8ed67a23b 100644 --- a/src/docs/processes.md +++ b/src/docs/processes.md @@ -318,7 +318,9 @@ $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. +An asynchronously started process belongs to the coroutine that started it. Hypervel stops the process if that coroutine exits first, including when it is canceled. Start the process in the coroutine that will wait for its result; handing it to another coroutine does not transfer ownership. Returning from `waitUntil` only stops waiting; it does not stop the process. + +You may call `stop` to terminate a process earlier. A process started outside a coroutine, or detached with the `create_new_console` option, is not stopped automatically when a coroutine exits, so you are responsible for waiting for it or stopping it. ### Process IDs and Signals @@ -467,7 +469,7 @@ 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. +An asynchronously started pool belongs to the coroutine that started it. Its processes are stopped if that coroutine exits first, unless explicitly detached. Start the pool in the coroutine that will wait for its results. If the pool fails while starting or waiting, Hypervel stops any processes it has already started or still owns before rethrowing the original exception. diff --git a/src/docs/testing.md b/src/docs/testing.md index a6b2cb9f11..8e6b60efab 100644 --- a/src/docs/testing.md +++ b/src/docs/testing.md @@ -234,6 +234,8 @@ The request is stored in the current coroutine's context, so it is discarded whe Tests that create child coroutines, subscribers, processes, servers, or other asynchronous resources must close or join those resources in a `finally` block. This ensures that assertion failures and other exceptions cannot leave work running after the test has finished. +For subprocess fixtures, prefer Hypervel's `Process` facade, or `Hypervel\Process\Factory` in tests without an application. It stops asynchronous processes when the test coroutine ends, so fixtures with that lifetime do not need their own cleanup block. Stop them explicitly when shutdown timing matters or they finish serving their purpose earlier. Use Symfony Process directly when testing its behavior or an interface that requires it, and keep explicit cleanup for those processes and for processes started outside a coroutine. + When a test needs results or exceptions from child coroutines, prefer the `parallel` helper instead of coordinating them with unbounded channel reads. Use channels directly when channel behavior is what the test is exercising. Swoole tables are also resources your test owns. Swoole only releases a table's memory when `destroy` is called, so a table created in a test stays in memory until the test process exits. When a test creates a Swoole table, use the `InteractsWithSwooleTables` trait and pass the table to `trackSwooleTable`. The trait destroys tracked tables after each test, so you should not call `destroy` on them yourself. diff --git a/src/process/README.md b/src/process/README.md index 4dade543a8..334eac2790 100644 --- a/src/process/README.md +++ b/src/process/README.md @@ -3,4 +3,10 @@ Process for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/process) +Documentation: https://hypervel.org/docs/processes + +## Differences From Laravel + +An asynchronous process is stopped when its starting coroutine exits. Start it in the coroutine that will wait for it; see [asynchronous processes](https://hypervel.org/docs/processes#asynchronous-processes) for ownership and explicit detachment. + Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Process From 57e74a4a2c892db15d1fda695832d7fc15134948 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:11:36 +0000 Subject: [PATCH 14/14] Preserve wrapped callback cancellation during emergency process cleanup A callback failure can already carry cancellation in its exception chain. If emergency stop also throws cancellation, checking only the callback exception class replaces that original failure and loses its cleanup context. Use the shared cancellation-precedence helper when selecting the callback or stop failure. Keep the native-only stop catch, bounded retry and callback re-entry guard unchanged. Extend the existing emergency-stop regression with a wrapped callback failure and assert the retained exception identity. The added row fails before the correction; the complete Process test file, targeted analysis of the trait and its consumers, and formatting pass. --- src/support/src/Traits/GuardsProcessOutput.php | 5 +++-- tests/Process/ProcessTest.php | 13 +++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/support/src/Traits/GuardsProcessOutput.php b/src/support/src/Traits/GuardsProcessOutput.php index ebdbf8dc6d..9d18c2c209 100644 --- a/src/support/src/Traits/GuardsProcessOutput.php +++ b/src/support/src/Traits/GuardsProcessOutput.php @@ -5,6 +5,7 @@ namespace Hypervel\Support\Traits; use Closure; +use Hypervel\Coroutine\Coroutine; use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Process; use Throwable; @@ -48,8 +49,8 @@ protected function guardProcessOutput(Process $process, ?callable $output): ?Clo } } - if ($cancellation !== null && ! $exception instanceof CanceledException) { - throw $cancellation; + if ($cancellation !== null) { + throw Coroutine::preferCancellation($exception, $cancellation); } throw $exception; diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index f502ff9f20..2b232dd0a6 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -753,9 +753,14 @@ protected function toSymfonyProcess(array|string|null $command): SymfonyProcess $this->assertSame($cleanupFailure ?? $failure, $caught); } - public function testOutputGuardPreservesCancellationDuringEmergencyStop(): void + #[TestWith([false], 'ordinary callback failure')] + #[TestWith([true], 'callback failure wraps cancellation')] + public function testOutputGuardPreservesCancellationDuringEmergencyStop(bool $wrapped): void { - $failure = new RuntimeException('Output callback failed.'); + $failure = new RuntimeException( + 'Output callback failed.', + previous: $wrapped ? new CanceledException('Output canceled.') : null, + ); $cancellation = new CanceledException('Emergency stop canceled.'); $process = m::mock(SymfonyProcess::class); $process->shouldReceive('stop')->once()->with(0)->ordered()->andThrow($cancellation); @@ -772,11 +777,11 @@ public function testOutputGuardPreservesCancellationDuringEmergencyStop(): void try { $callback(SymfonyProcess::OUT, 'output'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $caught = $exception; } - $this->assertSame($cancellation, $caught); + $this->assertSame($wrapped ? $failure : $cancellation, $caught); } public function testBasicProcessFake(): void