framework icon indicating copy to clipboard operation
framework copied to clipboard

[9.x] Process DX Layer

Open taylorotwell opened this issue 2 years ago • 3 comments

This PR continues and expands upon @nunomaduro's work on providing a convenient process layer in Laravel. Some features have been rebuilt and others have been added.

Notably, the ability to describe fake process lifecycles for async process handling / testing as well as process result sequences. In addition, the ability to interleave incoming output from a pool of processes and key them by name. Functionality for preventing stray processes during testing has also been added.

In addition, the object model has been rebuilt.

Usage

Basic API

The most basic usage of this feature looks like the following:

use Illuminate\Support\Facades\Process;

$result = Process::run('ls -la');

$result->successful();
$result->failed();
$result->exitCode();
$result->output();
$result->errorOutput();
$result->throw();
$result->throwIf(condition);

Building Processes

A variety of options can be set on the process before actually invoking it, including setting the timeout, idle timeout, TTY support, and environment variables:

$result = Process::timeout(60)->path(base_path())->env([...])->run('ls -la');

$result = Process::forever()->run('ls -la');

Process Output

A callback may be passed to the run function to gather the process output as it is received. The signature on this callback is the same as the underlying Symfony Process callbacks you may be used to:

$result = Process::run('ls -la', function ($type, $buffer) {
    echo $buffer;
});

Asynchronous Processes

The start method may be used to start an asynchronous process. Calling this method returns an implementation of InvokedProcess, allowing you to perform other tasks while the external process is running. The wait method may be used to resolve the invoked process into a process result, waiting on the process to finish executing if necessary:

$process = Process::forever()->start('ls -la', function ($type, $buffer) {
     ...
});

while ($process->running()) {
    echo $process->pid();
    echo $process->signal(...);
    echo $process->output();
    echo $process->errorOutput();
    echo $process->latestOutput();
    echo $process->latestErrorOutput();
}

$result = $process->wait();

echo $result->output();

Process Pools

Process pools allow you to manage and interact with a pool of processes at once, then access their results by key. A callback may be passed to the start method of the pool to capture output from all processes by key (the third argument given to the closure). The start method returns an instance of InvokedProcessPool, allowing you to interact with the pool via various methods. Calling the wait method on the invoked pool will wait until all processes in the pool have finished running and run an instance of ProcessPoolResults, which may be accessed like an array:

$pool = Process::pool(function (Pool $pool) {
    $pool->path(base_path())->command('ls -la');
    $pool->path(base_path())->command('ls -la');
    $pool->path(base_path())->command('ls -la');
})->start(function ($type, $buffer, $key) {
    dump($type, $buffer, $key);
});

// Get a collection of all of the running processes...
echo $pool->running()->count();

// Send a signal to every running process in the pool...
$pool->signal(SIGUSR2);

$results = $pool->wait();

echo $results[0]->output();

For convenience, process pool processes may also be assigned string keys, just like HTTP request pools:

$pool = Process::pool(function (Pool $pool) {
    $pool->as('first')->path(base_path())->command('ls -la');
    $pool->as('second')->path(base_path())->command('ls -la');
})->start(function ($type, $buffer, $key) {
    dump($type, $buffer, $key);
});

$results = $pool->wait();

echo $results['first']->output();

In addition, for convenience, a concurrently method is provided which is the equivalent to calling start and wait on the process pool to resolve the results synchronously. In this example, we are using array destructuring to assign the pool process results to individual variables:

[$first, $second, $third] = Process::concurrently(function (Pool $pool) {
    $pool->path(base_path())->command('ls -la');
    $pool->path(base_path())->command('ls -la');
    $pool->path(base_path())->command('ls -la');
});

echo $first->output();

Testing

Testing is similar to @nunomaduro's API, with the following basic API:

Process::fake([
    'ls *' => Process::result('Hello World'),
]);

$result = Process::run('ls -la');

Process::assertRan(function ($process, $result) {
    return $process->command == 'ls -la';
});

Process::assertRanTimes(function ($process, $result) {
    return $process->command == 'ls -la';
}, times: 1);

Process::assertNotRan(function ($process, $result) {
    return $process->command == 'cat foo';
});

Fake process results can be defined in a variety of ways, from calling Process::result to just passing simple strings and arrays:

Process::fake(['ls -la' => 'Hello World']);

Process::fake(['ls -la' => ['Line 1', 'Line 2']]);

Process Sequences

Like the HTTP fake's functionality, you may also define sequences of results for commands that are invoked multiple times in a request:

Process::fake([
    'ls *' => Process::sequence()
                       ->push(Process::result('first'))
                       ->push(Process::result('second')),
]);

$first = Process::run('ls -la');
$second = Process::run('ls -la');

Async Process Lifecycles

Finally, you may also describe async process lifecycles in order to test code that starts processes asynchronously and then interacts with them while running. In this example, running will return true three times as we specified by calling iterations(3) when describing our process. In addition, we define several lines of standard output as well as some error output:

Process::fake([
    '*' => Process::describe()
                   ->output('First line of output')
                   ->output('Next line of output')
                   ->errorOutput('Next line of error output')
                   ->exitCode(0)
                   ->iterations(3),
]);

$process = Process::start('test-async-process');

while ($process->running()) {
    echo $process->latestOutput();
}

echo $process->wait()->output();

Preventing Stray Processes

By default, if Process::fake has been called and Laravel isn't able to find a matching fake definition for a given process, the process will actually execute. This behavior is consistent with the HTTP facade's fake functionality. You can prevent this behavior by invoking the preventStrayProcesses method. This will cause Laravel to throw an exception when attempting to invoke a process that does not have a corresponding fake definition:

Process::preventStrayProcesses();

Process::fake(['cat *' => Process::result('Hello World')]);

$result = Process::run('ls -la');

taylorotwell avatar Dec 14 '22 20:12 taylorotwell

Outstanding work! What do you think about Process::remote($ip, $port)->run(…)? I know this can be achieved with the code presented above, but it can be pretty useful helper function to run commands via ssh.

geowrgetudor avatar Dec 17 '22 00:12 geowrgetudor

@geowrgetudor not sure I want to go down that road - would require supporting all sorts of SSH options, etc.

taylorotwell avatar Dec 17 '22 15:12 taylorotwell

Leaving thought to myself... if I have the same process but want to turn it into a pool without repeating myself, it could be nice to do something like:

$pool = Process::timeout(60)->pool(10)->run('some-command');

$pool->start();

taylorotwell avatar Dec 17 '22 15:12 taylorotwell

What's the reason for removing the functions like beforeCallback($closure) and afterCallback($closure) present in the previous iteration of the Process module in Laravel? I'm actively using the previous branch / this branch in a upcoming project and was a fan of the before and after callback hooks.

gofish543 avatar Jan 11 '23 18:01 gofish543