Skip to content

Commit

Permalink
Child processes (#389)
Browse files Browse the repository at this point in the history
* wip

* Fix styling

* cleanup

* phpstan

* Fix event

* Fix event watcher

* Fix facade

* add events

* Remove useless stubs

* Fix styling

* add some sanity tests

* wip

* add artisan shorthand

* allow passing either a string or array

* correct return type

* flip arguments for consistency

* tidy - remove unused class properties

* remove unnecessary space escape

* add optional arg to make the process persistent

* improvements

- ChildProcess instances can be used to interact with a process
- get, all and restart are piped up

* Fix styling

* Update src/ChildProcess.php

Co-authored-by: Simon Hamp <[email protected]>

* feedback - tidy cwd default path

* stub out php command tests

* fix - tests after upstream merge

* add php convenience method

* wip - refactor

* add phpdoc for facade methods

* Update src/Facades/ChildProcess.php

Co-authored-by: Simon Hamp <[email protected]>

* remove exploding string commands

* fix - return a fresh instance from the facade each time

---------

Co-authored-by: simonhamp <[email protected]>
Co-authored-by: gwleuverink <[email protected]>
Co-authored-by: Willem Leuverink <[email protected]>
  • Loading branch information
4 people authored Oct 31, 2024
1 parent 2c89fba commit fcdcc06
Show file tree
Hide file tree
Showing 10 changed files with 372 additions and 3 deletions.
1 change: 0 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,4 @@ parameters:
tmpDir: build/phpstan
checkOctaneCompatibility: true
checkModelProperties: true
checkMissingIterableValueType: false

129 changes: 129 additions & 0 deletions src/ChildProcess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

namespace Native\Laravel;

use Native\Laravel\Client\Client;

class ChildProcess
{
public readonly int $pid;

public readonly string $alias;

public readonly array $cmd;

public readonly ?string $cwd;

public readonly ?array $env;

public readonly bool $persistent;

public function __construct(protected Client $client) {}

public function get(?string $alias = null): ?static
{
$alias = $alias ?? $this->alias;

$process = $this->client->get("child-process/get/{$alias}")->json();

if (! $process) {
return null;
}

return $this->fromRuntimeProcess($process);
}

public function all(): array
{
$processes = $this->client->get('child-process/')->json();

if (empty($processes)) {
return [];
}

$hydrated = [];

foreach ($processes as $alias => $process) {
$hydrated[$alias] = (new static($this->client))
->fromRuntimeProcess($process);
}

return $hydrated;
}

public function start(
string|array $cmd,
string $alias,
?string $cwd = null,
?array $env = null,
bool $persistent = false
): static {

$process = $this->client->post('child-process/start', [
'alias' => $alias,
'cmd' => (array) $cmd,
'cwd' => $cwd ?? base_path(),
'env' => $env,
'persistent' => $persistent,
])->json();

return $this->fromRuntimeProcess($process);
}

public function php(string|array $cmd, string $alias, ?array $env = null, ?bool $persistent = false): self
{
$cmd = [PHP_BINARY, ...(array) $cmd];

return $this->start($cmd, $alias, env: $env, persistent: $persistent);
}

public function artisan(string|array $cmd, string $alias, ?array $env = null, ?bool $persistent = false): self
{
$cmd = ['artisan', ...(array) $cmd];

return $this->php($cmd, $alias, env: $env, persistent: $persistent);
}

public function stop(?string $alias = null): void
{
$this->client->post('child-process/stop', [
'alias' => $alias ?? $this->alias,
])->json();
}

public function restart(?string $alias = null): ?static
{
$process = $this->client->post('child-process/restart', [
'alias' => $alias ?? $this->alias,
])->json();

if (! $process) {
return null;
}

return $this->fromRuntimeProcess($process);
}

public function message(string $message, ?string $alias = null): static
{
$this->client->post('child-process/message', [
'alias' => $alias ?? $this->alias,
'message' => $message,
])->json();

return $this;
}

protected function fromRuntimeProcess($process): static
{
if (isset($process['pid'])) {
$this->pid = $process['pid'];
}

foreach ($process['settings'] as $key => $value) {
$this->{$key} = $value;
}

return $this;
}
}
2 changes: 1 addition & 1 deletion src/Commands/MigrateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ public function handle()
{
(new NativeServiceProvider($this->laravel))->rewriteDatabase();

parent::handle();
return parent::handle();
}
}
22 changes: 22 additions & 0 deletions src/Events/ChildProcess/ErrorReceived.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Native\Laravel\Events\ChildProcess;

use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ErrorReceived implements ShouldBroadcast
{
use Dispatchable, SerializesModels;

public function __construct(public string $alias, public mixed $data) {}

public function broadcastOn()
{
return [
new Channel('nativephp'),
];
}
}
22 changes: 22 additions & 0 deletions src/Events/ChildProcess/MessageReceived.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Native\Laravel\Events\ChildProcess;

use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MessageReceived implements ShouldBroadcast
{
use Dispatchable, SerializesModels;

public function __construct(public string $alias, public mixed $data) {}

public function broadcastOn()
{
return [
new Channel('nativephp'),
];
}
}
22 changes: 22 additions & 0 deletions src/Events/ChildProcess/ProcessExited.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Native\Laravel\Events\ChildProcess;

use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProcessExited implements ShouldBroadcast
{
use Dispatchable, SerializesModels;

public function __construct(public string $alias, public int $code) {}

public function broadcastOn()
{
return [
new Channel('nativephp'),
];
}
}
22 changes: 22 additions & 0 deletions src/Events/ChildProcess/ProcessSpawned.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Native\Laravel\Events\ChildProcess;

use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProcessSpawned implements ShouldBroadcast
{
use Dispatchable, SerializesModels;

public function __construct(public string $alias) {}

public function broadcastOn()
{
return [
new Channel('nativephp'),
];
}
}
2 changes: 1 addition & 1 deletion src/Events/EventWatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public function register(): void
{
Event::listen('*', function (string $eventName, array $data) {

$event = $data[0] ?? null;
$event = $data[0] ?? (object) null;

if (! method_exists($event, 'broadcastOn')) {
return;
Expand Down
26 changes: 26 additions & 0 deletions src/Facades/ChildProcess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Native\Laravel\Facades;

use Illuminate\Support\Facades\Facade;
use Native\Laravel\ChildProcess as Implement;

/**
* @method static \Native\Laravel\ChildProcess[] all()
* @method static \Native\Laravel\ChildProcess get(string $alias = null)
* @method static \Native\Laravel\ChildProcess message(string $message, string $alias = null)
* @method static \Native\Laravel\ChildProcess restart(string $alias = null)
* @method static \Native\Laravel\ChildProcess start(string|array $cmd, string $alias, string $cwd = null, array $env = null, bool $persistent = false)
* @method static \Native\Laravel\ChildProcess php(string|array $cmd, string $alias, array $env = null, bool $persistent = false)
* @method static \Native\Laravel\ChildProcess artisan(string|array $cmd, string $alias, array $env = null, bool $persistent = false)
* @method static void stop(string $alias = null)
*/
class ChildProcess extends Facade
{
protected static function getFacadeAccessor()
{
self::clearResolvedInstance(Implement::class);

return Implement::class;
}
}
127 changes: 127 additions & 0 deletions tests/ChildProcess/ChildProcessTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Mockery;

Check warning on line 5 in tests/ChildProcess/ChildProcessTest.php

View workflow job for this annotation

GitHub Actions / P8.3 - L11.* - prefer-lowest - ubuntu-latest

The use statement with non-compound name 'Mockery' has no effect

Check warning on line 5 in tests/ChildProcess/ChildProcessTest.php

View workflow job for this annotation

GitHub Actions / P8.3 - L10.* - prefer-lowest - ubuntu-latest

The use statement with non-compound name 'Mockery' has no effect

Check warning on line 5 in tests/ChildProcess/ChildProcessTest.php

View workflow job for this annotation

GitHub Actions / P8.3 - L10.* - prefer-stable - ubuntu-latest

The use statement with non-compound name 'Mockery' has no effect

Check warning on line 5 in tests/ChildProcess/ChildProcessTest.php

View workflow job for this annotation

GitHub Actions / P8.2 - L11.* - prefer-stable - ubuntu-latest

The use statement with non-compound name 'Mockery' has no effect

Check warning on line 5 in tests/ChildProcess/ChildProcessTest.php

View workflow job for this annotation

GitHub Actions / P8.2 - L10.* - prefer-lowest - ubuntu-latest

The use statement with non-compound name 'Mockery' has no effect

Check warning on line 5 in tests/ChildProcess/ChildProcessTest.php

View workflow job for this annotation

GitHub Actions / P8.1 - L10.* - prefer-stable - ubuntu-latest

The use statement with non-compound name 'Mockery' has no effect
use Native\Laravel\ChildProcess as ChildProcessImplement;
use Native\Laravel\Client\Client;
use Native\Laravel\Facades\ChildProcess;

beforeEach(function () {
Http::fake();

$mock = Mockery::mock(ChildProcessImplement::class, [resolve(Client::class)])
->makePartial()
->shouldAllowMockingProtectedMethods();

$this->instance(ChildProcessImplement::class, $mock->allows([
'fromRuntimeProcess' => $mock,
]));
});

it('can start a child process', function () {
ChildProcess::start('foo bar', 'some-alias', 'path/to/dir', ['baz' => 'zah']);

Http::assertSent(function (Request $request) {
return $request->url() === 'http://localhost:4000/api/child-process/start' &&
$request['alias'] === 'some-alias' &&
$request['cmd'] === ['foo bar'] &&
$request['cwd'] === 'path/to/dir' &&
$request['env'] === ['baz' => 'zah'];
});
});

it('can start a php command', function () {
ChildProcess::php("-r 'sleep(5);'", 'some-alias', ['baz' => 'zah']);

Http::assertSent(function (Request $request) {
return $request->url() === 'http://localhost:4000/api/child-process/start' &&
$request['alias'] === 'some-alias' &&
$request['cmd'] === [PHP_BINARY, "-r 'sleep(5);'"] &&
$request['cwd'] === base_path() &&
$request['env'] === ['baz' => 'zah'];
});
});

it('can start a artisan command', function () {
ChildProcess::artisan('foo:bar --verbose', 'some-alias', ['baz' => 'zah']);

Http::assertSent(function (Request $request) {
return $request->url() === 'http://localhost:4000/api/child-process/start' &&
$request['alias'] === 'some-alias' &&
$request['cmd'] === [PHP_BINARY, 'artisan', 'foo:bar --verbose'] &&
$request['cwd'] === base_path() &&
$request['env'] === ['baz' => 'zah'];
});
});

it('accepts either a string or a array as start command argument', function () {
ChildProcess::start('foo bar', 'some-alias');
Http::assertSent(fn (Request $request) => $request['cmd'] === ['foo bar']);

ChildProcess::start(['foo', 'baz'], 'some-alias');
Http::assertSent(fn (Request $request) => $request['cmd'] === ['foo', 'baz']);
});

it('accepts either a string or a array as php command argument', function () {
ChildProcess::php("-r 'sleep(5);'", 'some-alias');
Http::assertSent(fn (Request $request) => $request['cmd'] === [PHP_BINARY, "-r 'sleep(5);'"]);

ChildProcess::php(['-r', "'sleep(5);'"], 'some-alias');
Http::assertSent(fn (Request $request) => $request['cmd'] === [PHP_BINARY, '-r', "'sleep(5);'"]);
});

it('accepts either a string or a array as artisan command argument', function () {
ChildProcess::artisan('foo:bar', 'some-alias');
Http::assertSent(fn (Request $request) => $request['cmd'] === [PHP_BINARY, 'artisan', 'foo:bar']);

ChildProcess::artisan(['foo:baz'], 'some-alias');
Http::assertSent(fn (Request $request) => $request['cmd'] === [PHP_BINARY, 'artisan', 'foo:baz']);
});

it('sets the cwd to the base path if none was given', function () {
ChildProcess::start(['foo', 'bar'], 'some-alias', cwd: 'path/to/dir');
Http::assertSent(fn (Request $request) => $request['cwd'] === 'path/to/dir');

ChildProcess::start(['foo', 'bar'], 'some-alias');
Http::assertSent(fn (Request $request) => $request['cwd'] === base_path());
});

it('can stop a child process', function () {
ChildProcess::stop('some-alias');

Http::assertSent(function (Request $request) {
return $request->url() === 'http://localhost:4000/api/child-process/stop' &&
$request['alias'] === 'some-alias';
});
});

it('can send messages to a child process', function () {
ChildProcess::message('some-message', 'some-alias');

Http::assertSent(function (Request $request) {
return $request->url() === 'http://localhost:4000/api/child-process/message' &&
$request['alias'] === 'some-alias' &&
$request['message'] === 'some-message';
});
});

it('can mark a process as persistent', function () {
ChildProcess::start('foo bar', 'some-alias', persistent: true);
Http::assertSent(fn (Request $request) => $request['persistent'] === true);
});

it('can mark a php command as persistent', function () {
ChildProcess::php("-r 'sleep(5);'", 'some-alias', persistent: true);
Http::assertSent(fn (Request $request) => $request['persistent'] === true);
});

it('can mark a artisan command as persistent', function () {
ChildProcess::artisan('foo:bar', 'some-alias', persistent: true);
Http::assertSent(fn (Request $request) => $request['persistent'] === true);
});

it('marks the process as non-persistent by default', function () {
ChildProcess::start('foo bar', 'some-alias');
Http::assertSent(fn (Request $request) => $request['persistent'] === false);
});

0 comments on commit fcdcc06

Please sign in to comment.