Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/database/src/Schema/MySqlSchemaState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion src/database/src/Schema/PostgresSchemaState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]));
});
Expand Down
3 changes: 3 additions & 0 deletions src/database/src/Schema/SchemaState.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/docs/porting-from-laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<a name="configuration"></a>
Expand Down
6 changes: 4 additions & 2 deletions src/docs/processes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<a name="process-ids-and-signals"></a>
### Process IDs and Signals
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions src/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/foundation/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/process/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/process/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/process/src/InvokedProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 10 additions & 3 deletions src/process/src/PendingProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand All @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/process/src/Pool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
61 changes: 61 additions & 0 deletions src/process/src/ProcessState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Hypervel\Process;

use Hypervel\Context\CoroutineContext;
use Hypervel\Context\NonCopyableContext;
use Hypervel\Coroutine\Coroutine;
use Symfony\Component\Process\Process;
use WeakMap;

/**
* Keep asynchronous processes with their starting coroutine without retaining completed processes.
*
* @internal
*/
class ProcessState implements NonCopyableContext
{
public const string CONTEXT_KEY = '__process.state';

/** @var WeakMap<Process, true> */
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);
}
}
7 changes: 5 additions & 2 deletions src/queue/src/Listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

use Closure;
use Hypervel\Support\Sleep;
use Hypervel\Support\Traits\GuardsProcessOutput;
use Symfony\Component\Process\Process;

use function Hypervel\Support\artisan_binary;
use function Hypervel\Support\php_binary;

class Listener
{
use GuardsProcessOutput;

/**
* The environment the workers should run under.
*/
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/support/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 21 additions & 14 deletions src/support/src/Composer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +16,8 @@

class Composer
{
use GuardsProcessOutput;

/**
* The filesystem instance.
*/
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}

if ($cancellation !== null) {
throw Coroutine::preferCancellation($exception, $cancellation);
}

throw $exception;
}
};
Expand Down
Loading
Loading