Skip to content

Commit 260882c

Browse files
committed
Version 1.0.0
1 parent 7db2690 commit 260882c

File tree

10 files changed

+267
-2
lines changed

10 files changed

+267
-2
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/vendor
2+
/composer.lock
3+
/.phpunit.result.cache

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
BSD 3-Clause License
22

3-
Copyright (c) 2023, Eugene
3+
Copyright (c) 2023, Eugene Greendrake
44

55
Redistribution and use in source and binary forms, with or without
66
modification, are permitted provided that the following conditions are met:

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,20 @@
11
# php-asyncprocess
2-
ReactPHP Promise implementation for truly asyncronous background processes
2+
[ReactPHP Promise](https://reactphp.org/promise/) implementation for truly asynchronous background processes.
3+
4+
This library allows to run commands in background shaped as ReactPHP Promises. Non-blocking.
5+
Tested on Linux only.
6+
7+
Under the hood, it works this way:
8+
9+
1. A child process is forked using [pcntl_fork](https://www.php.net/manual/en/function.pcntl-fork.php). This runs the specified command and reports the result back to the parent process via a local HTTP call (using a one-off [reactphp/http](https://github.com/reactphp/http) server/client).
10+
11+
2. Once the parent process gets the result, it fulfils (or rejects, depending on the exit code) the Promise. Profit.
12+
13+
Example:
14+
15+
```php
16+
use function React\Async\await;
17+
$p = new \Greendrake\AsyncProcess\Promise('a=$( expr 10 - 3 ); echo $a'); // Kick off the process in background.
18+
$result = await($p->get()); // Get the instance of React\Promise\Promise, wait for it to resolve.
19+
echo $result; // outputs "7"
20+
```

composer.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "greendrake/php-asyncprocess",
3+
"type": "library",
4+
"description": "ReactPHP Promise implementation for truly asynchronous background processes",
5+
"version": "1.0.0",
6+
"authors": [
7+
{
8+
"name": "Eugene Greendrake",
9+
"email": "[email protected]",
10+
"role": "Developer"
11+
}
12+
],
13+
"license": "BSD-3-Clause",
14+
"keywords": [
15+
"async",
16+
"asynchronous",
17+
"promise",
18+
"background",
19+
"process"
20+
],
21+
"minimum-stability": "dev",
22+
"require": {
23+
"php": ">=8.2.0",
24+
"react/promise": "3.x-dev",
25+
"react/http": "1.x-dev",
26+
"react/async": "4.x-dev"
27+
},
28+
"require-dev": {
29+
"phpunit/phpunit": "10"
30+
},
31+
"autoload": {
32+
"psr-4": {
33+
"Greendrake\\AsyncProcess\\": ["src/", "test/"]
34+
}
35+
}
36+
}

phpunit.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<phpunit colors="true" bootstrap="src/bootstrap.php">
2+
<testsuites>
3+
<testsuite name="default">
4+
<directory>test/</directory>
5+
</testsuite>
6+
</testsuites>
7+
</phpunit>

src/NonZeroExitException.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
declare (strict_types = 1);
3+
namespace Greendrake\AsyncProcess;
4+
class NonZeroExitException extends \RuntimeException
5+
{
6+
}

src/Promise.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
declare (strict_types = 1);
3+
namespace Greendrake\AsyncProcess;
4+
use Ds\Set;
5+
use function React\Async\await;
6+
use Psr\Http\Message\RequestInterface as Request;
7+
use React\Http\Browser;
8+
use React\Http\Server;
9+
use React\Promise as BasePromise;
10+
use React\Socket\ConnectionInterface;
11+
use React\Socket\Server as SocketServer;
12+
13+
class Promise {
14+
15+
protected BasePromise\Deferred $deferred;
16+
protected BasePromise\Promise $promise;
17+
protected ?int $pid = null;
18+
19+
private static $sigHoldHandlerSetup = false;
20+
private static $sigHoldDefaultHandler;
21+
22+
public function __construct(protected string $command) {
23+
$this->deferred = new BasePromise\Deferred;
24+
$this->promise = $this->deferred->promise();
25+
if (!self::$sigHoldHandlerSetup) {
26+
self::$sigHoldDefaultHandler = pcntl_signal_get_handler(SIGCHLD);
27+
// When the forked process exits, it sends the parent process SIGCHLD signal.
28+
// Handling it properly is required to avoid the exited children becoming zombies.
29+
// (For whatever reason it doesn't actually work for me, so there is a posix_kill call underneath, but trying it anyway).
30+
pcntl_signal(SIGCHLD, SIG_IGN);
31+
self::$sigHoldHandlerSetup = true;
32+
}
33+
$httpAddress = '127.0.0.1:' . self::findUnusedPort();
34+
$pid = pcntl_fork();
35+
if ($pid == -1) {
36+
throw new \RuntimeException('Could not fork process');
37+
} else if ($pid) {
38+
// The original, now parent process. Setup an HTTP server and wait for the forked process to post the result to it:
39+
$this->pid = $pid; // The PID of the forked process, not the parent.
40+
$result = null;
41+
$socket = new SocketServer($httpAddress);
42+
// This is a one-off request/response HTTP server.
43+
// After the connection is closed, close the socket and pass the response over to the deferred promise.
44+
$socket->on('connection', function (ConnectionInterface $conn) use ($socket, &$result) {
45+
$conn->on('close', function () use ($socket, &$result) {
46+
$socket->close();
47+
// We've made the forked process session leader, we've set SIGCHLD handler to SIG_IGN above,
48+
// and yet it may remain a zombie process even after calling exit in itself. Fuck knows why.
49+
// Kill the zombie in case it is still walking:
50+
posix_kill($this->pid, SIGKILL);
51+
// Now that the job is done, cleanup our mess — bring the original handler back:
52+
pcntl_signal(SIGCHLD, self::$sigHoldDefaultHandler);
53+
// Report the results (whatever they are) as per our promise:
54+
if ($result['success']) {
55+
$m = $result['code'] === 0 ? 'resolve' : 'reject';
56+
$output = implode(PHP_EOL, $result['result']);
57+
if ($m === 'reject') {
58+
$output = new NonZeroExitException(sprintf('Exit code %s: %s', $result['code'], $output));
59+
}
60+
$this->deferred->$m($output);
61+
} else {
62+
$this->deferred->reject($result['error']);
63+
}
64+
});
65+
});
66+
// Actually run the one-off HTTP server to wait for what the forked process has to say:
67+
$server = new Server(function (Request $request) use (&$result) {
68+
$result = unserialize((string) $request->getBody());
69+
});
70+
$server->listen($socket);
71+
} else {
72+
// The forked process.
73+
// Re-instate the default handler (otherwise the reported exit code will be -1, see https://stackoverflow.com/questions/77288724):
74+
pcntl_signal(SIGCHLD, self::$sigHoldDefaultHandler);
75+
$browser = new Browser;
76+
// Define the function that will report results back to the parent:
77+
$reportBack = function (int $forkExitCode = 0, ?int $jobExitCode = null, ?array $result = null, ?\Throwable $error = null) use ($browser, $httpAddress) {
78+
await($browser->post('http://' . $httpAddress, [], serialize([
79+
'success' => $error === null,
80+
'result' => $result,
81+
'code' => $jobExitCode,
82+
'error' => $error,
83+
]))->catch(function () {
84+
// Don't give a fuck. This is the forked background process, and if anything is wrong, no one is gonna hear anyway.
85+
}));
86+
// This forked process will probably be killed by the parent before it reaches this point,
87+
// but, just in case, we put an explicit exit here to make sure it does not do anything else:
88+
exit($forkExitCode);
89+
};
90+
if (false === ($pid = getmypid())) {
91+
// This is very unlikely, but let's handle it.
92+
$reportBack(
93+
forkExitCode : 1,
94+
error: new \RuntimeException('Could not get child process PID within itself')
95+
);
96+
} else {
97+
try {
98+
$this->pid = $pid;
99+
// Make this process the session leader so that it does not depend on the parent anymore:
100+
if (posix_setsid() < 0) {
101+
$reportBack(
102+
forkExitCode: 1,
103+
error: new \RuntimeException('Could not make background process ' . $pid . ' session leader')
104+
);
105+
} else {
106+
// Do the actual job however long it takes, suppress STDERR (otherwise it'll make its way to the parent's shell):
107+
exec($this->command . ' 2> /dev/null', $output, $resultCode);
108+
if ($output === false) {
109+
$reportBack(
110+
forkExitCode: 1,
111+
error: new \RuntimeException(sprintf('Could not run the command "%s" (PID %s)', $this->command, $this->pid))
112+
);
113+
} else {
114+
$reportBack(
115+
jobExitCode: $resultCode,
116+
result: $output
117+
);
118+
}
119+
}
120+
} catch (\Throwable $e) {
121+
$reportBack(
122+
forkExitCode: 1,
123+
error: $e
124+
);
125+
}
126+
}
127+
}
128+
}
129+
130+
public function getPid(): int {
131+
return $this->pid;
132+
}
133+
134+
public function get(): BasePromise\PromiseInterface
135+
{
136+
return $this->promise;
137+
}
138+
139+
private static function findUnusedPort(): int {
140+
$tried = new Set;
141+
$add = function (int $port) use ($tried) {
142+
$tried->add($port);
143+
return true;
144+
};
145+
do {
146+
$port = mt_rand(1024, 65535);
147+
} while ($tried->contains($port) || (self::isPortOpen($port) && $add($port)));
148+
return $port;
149+
}
150+
151+
private static function isPortOpen(int $port): bool {
152+
$result = false;
153+
try {
154+
if ($pf = fsockopen('127.0.0.1', $port)) {
155+
$result = true;
156+
fclose($pf);
157+
}
158+
} catch (\ErrorException $e) {
159+
if (!str_contains($e->getMessage(), 'Connection refused')) {
160+
throw $e; // Unexpected exception
161+
}
162+
}
163+
return $result;
164+
}
165+
166+
}

src/bootstrap.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
declare (strict_types = 1);
3+
require_once __DIR__ . '/../vendor/autoload.php';
4+
set_error_handler(function ($severity, $message, $file, $line) {
5+
throw new \ErrorException($message, 0, $severity, $file, $line);
6+
});

test/Test.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
declare (strict_types = 1);
3+
namespace Greendrake\AsyncProcess;
4+
use function React\Async\await;
5+
use PHPUnit\Framework\TestCase;
6+
7+
class Test extends TestCase {
8+
9+
public function testFailure() {
10+
$this->expectException(NonZeroExitException::class);
11+
$p = new Promise('no-bananas');
12+
await($p->get());
13+
}
14+
15+
public function testSuccess() {
16+
$p = new Promise('a=$( expr 10 - 3 ); echo $a');
17+
$result = await($p->get());
18+
$this->assertSame('7', $result);
19+
}
20+
21+
}

unittest

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/bash
2+
./vendor/bin/phpunit --testdox

0 commit comments

Comments
 (0)