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/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/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", 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 diff --git a/src/process/composer.json b/src/process/composer.json index e720410441..6017781333 100644 --- a/src/process/composer.json +++ b/src/process/composer.json @@ -26,6 +26,7 @@ "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", diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php index ec9f5131c8..3c7550a494 100644 --- a/src/process/src/InvokedProcess.php +++ b/src/process/src/InvokedProcess.php @@ -8,8 +8,8 @@ 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\Traits\GuardsProcessOutput; use Hypervel\Support\Traits\Macroable; use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyTimeoutException; use Symfony\Component\Process\Process; diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php index d435f57c1c..e28e031813 100644 --- a/src/process/src/PendingProcess.php +++ b/src/process/src/PendingProcess.php @@ -10,11 +10,11 @@ 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 +241,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 +262,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 ab5cfd7d9b..ba6afcb46a 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/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/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/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/src/process/src/Concerns/GuardsProcessOutput.php b/src/support/src/Traits/GuardsProcessOutput.php similarity index 56% rename from src/process/src/Concerns/GuardsProcessOutput.php rename to src/support/src/Traits/GuardsProcessOutput.php index fb63e366bd..9d18c2c209 100644 --- a/src/process/src/Concerns/GuardsProcessOutput.php +++ b/src/support/src/Traits/GuardsProcessOutput.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Hypervel\Process\Concerns; +namespace Hypervel\Support\Traits; use Closure; -use Hypervel\Process\InvokedProcess; +use Hypervel\Coroutine\Coroutine; use Swoole\Coroutine\CanceledException; use Symfony\Component\Process\Process; use Throwable; @@ -34,16 +34,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) { + throw Coroutine::preferCancellation($exception, $cancellation); + } + throw $exception; } }; 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); 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)); 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 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); } } 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(); } } 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 ed657c91e5..2b232dd0a6 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])] @@ -671,13 +753,18 @@ 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, 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; @@ -690,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 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(); 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); } } 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. */ 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]; 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]; } /**