diff --git a/composer.json b/composer.json index f7bf817..6b4804d 100755 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "amphp/sync": "^1.0.1" }, "require-dev": { + "jelix/fakeserverconf": "^1.0", "phpunit/phpunit": "^8 || ^7", "amphp/phpunit-util": "^1.1", "amphp/php-cs-fixer-config": "dev-master" diff --git a/lib/Context/Internal/Runner/ProcessRunner.php b/lib/Context/Internal/Runner/ProcessRunner.php index 1052235..0fc236f 100644 --- a/lib/Context/Internal/Runner/ProcessRunner.php +++ b/lib/Context/Internal/Runner/ProcessRunner.php @@ -16,7 +16,7 @@ final class ProcessRunner extends RunnerAbstract /** @var \Amp\Process\Process */ private $process; /** - * Constructor + * Constructor. * * @param string|array $script Path to PHP script or array with first element as path and following elements options * to the PHP script (e.g.: ['bin/worker', 'Option1Value', 'Option2Value']. @@ -25,7 +25,7 @@ final class ProcessRunner extends RunnerAbstract * @param array $env Environment variables * @param string $binary PHP binary path */ - public function __construct($script, string $runPath, ProcessHub $hub, string $cwd = null, array $env = [], string $binary = null) + public function __construct($script, ProcessHub $hub, string $cwd = null, array $env = [], string $binary = null) { if ($binary === null) { if (\PHP_SAPI === "cli") { @@ -37,22 +37,30 @@ final class ProcessRunner extends RunnerAbstract throw new \Error(\sprintf("The PHP binary path '%s' was not found or is not executable", $binary)); } - if (\is_array($script)) { - $script = \implode(" ", \array_map("escapeshellarg", $script)); - } else { - $script = \escapeshellarg($script); - } - $options = [ "html_errors" => "0", "display_errors" => "0", "log_errors" => "1", ]; + $runner = self::getScriptPath(); + + // Monkey-patch the script path in the same way, only supported if the command is given as array. + if (isset(self::$pharCopy) && \is_array($script) && isset($script[0])) { + $script[0] = "phar://".self::$pharCopy.\substr($script[0], \strlen(\Phar::running(true))); + } + + if (\is_array($script)) { + $script = \implode(" ", \array_map("escapeshellarg", $script)); + } else { + $script = \escapeshellarg($script); + } + + $command = \implode(" ", [ \escapeshellarg($binary), self::formatOptions($options), - \escapeshellarg($runPath), + \escapeshellarg($runner), $hub->getUri(), $script, ]); @@ -90,10 +98,10 @@ final class ProcessRunner extends RunnerAbstract /** - * Set process key + * Set process key. * * @param string $key Process key - * + * * @return Promise */ public function setProcessKey(string $key): Promise @@ -205,5 +213,4 @@ final class ProcessRunner extends RunnerAbstract { return $this->process->getStderr(); } - } diff --git a/lib/Context/Internal/Runner/RunnerAbstract.php b/lib/Context/Internal/Runner/RunnerAbstract.php index 80b1ef9..70dc4ab 100644 --- a/lib/Context/Internal/Runner/RunnerAbstract.php +++ b/lib/Context/Internal/Runner/RunnerAbstract.php @@ -10,6 +10,52 @@ use Amp\Promise; abstract class RunnerAbstract { + const SCRIPT_PATH = __DIR__ . "/process-runner.php"; + + /** @var string|null External version of SCRIPT_PATH if inside a PHAR. */ + protected static $pharScriptPath; + + /** @var string|null PHAR path with a '.phar' extension. */ + protected static $pharCopy; + + protected static function getScriptPath(string $alternateTmpDir = '') + { + // Write process runner to external file if inside a PHAR, + // because PHP can't open files inside a PHAR directly except for the stub. + if (\strpos(self::SCRIPT_PATH, "phar://") === 0) { + $alternateTmpDir = $alternateTmpDir ?: \sys_get_temp_dir(); + + if (self::$pharScriptPath) { + $scriptPath = self::$pharScriptPath; + } else { + $path = \dirname(self::SCRIPT_PATH); + + if (\substr(\Phar::running(false), -5) !== ".phar") { + self::$pharCopy = $alternateTmpDir . "/phar-" . \bin2hex(\random_bytes(10)) . ".phar"; + \copy(\Phar::running(false), self::$pharCopy); + + \register_shutdown_function(static function (): void { + @\unlink(self::$pharCopy); + }); + + $path = "phar://" . self::$pharCopy . "/" . \substr($path, \strlen(\Phar::running(true))); + } + + $contents = \file_get_contents(self::SCRIPT_PATH); + $contents = \str_replace("__DIR__", \var_export($path, true), $contents); + $suffix = \bin2hex(\random_bytes(10)); + self::$pharScriptPath = $scriptPath = $alternateTmpDir . "/amp-process-runner-" . $suffix . ".php"; + \file_put_contents($scriptPath, $contents); + + \register_shutdown_function(static function (): void { + @\unlink(self::$pharScriptPath); + }); + } + } else { + $scriptPath = self::SCRIPT_PATH; + } + return $scriptPath; + } /** * Constructor. * @@ -20,7 +66,7 @@ abstract class RunnerAbstract * @param array $env Environment variables * @param string $binary PHP binary path */ - abstract public function __construct($script, string $runPath, ProcessHub $hub, string $cwd = null, array $env = [], string $binary = null); + abstract public function __construct($script, ProcessHub $hub, string $cwd = null, array $env = [], string $binary = null); /** * Set process key. diff --git a/lib/Context/Internal/Runner/WebRunner.php b/lib/Context/Internal/Runner/WebRunner.php index 195f17a..9676844 100644 --- a/lib/Context/Internal/Runner/WebRunner.php +++ b/lib/Context/Internal/Runner/WebRunner.php @@ -1,7 +1,9 @@ params = [ - 'options' => [ - "html_errors" => "0", - "display_errors" => "0", - "log_errors" => "1", - ], + 'cwd' => $cwd, + 'env' => $env, 'argv' => [ $hub->getUri(), ...$script @@ -85,23 +142,20 @@ final class WebRunner extends RunnerAbstract public function setProcessKey(string $key): Promise { $this->params['key'] = $key; - $params = \http_build_query($params); + $params = \http_build_query($this->params); $address = ($_SERVER['HTTPS'] ?? false ? 'tls' : 'tcp').'://'.$_SERVER['SERVER_NAME']; $port = $_SERVER['SERVER_PORT']; - $uri = $_SERVER['REQUEST_URI']; - $params = $_GET; - $url = \explode('?', $uri, 2)[0] ?? ''; - $query = \http_build_query($params); - $uri = \implode('?', [$url, $query]); + $uri = self::$runPath.'?'.$params; - $this->payload = "GET $uri HTTP/1.1\r\nHost: ${_SERVER['SERVER_NAME']}\r\n\r\n"; + $payload = "GET $uri HTTP/1.1\r\nHost: ${_SERVER['SERVER_NAME']}\r\n\r\n"; - $a = \fsockopen($address, $port); - \fwrite($a, $payload); - - $this->running =true; + // We don't care for results or timeouts here, PHP doesn't count IOwait time as execution time anyway + // Technically should use amphp/socket, but I guess it's OK to not introduce another dependency just for a socket that will be used once. + $this->res = new ResourceOutputStream(\fsockopen($address, $port)); + $this->running = true; + return $this->res->write($payload); } /** @@ -119,7 +173,7 @@ final class WebRunner extends RunnerAbstract */ public function start(): Promise { - return new Success(); + return new Success($this->pid); } /** @@ -127,7 +181,10 @@ final class WebRunner extends RunnerAbstract */ public function kill(): void { - $this->process->kill(); + if (isset($this->res)) { + unset($this->res); + } + $this->isRunning = false; } /** @@ -135,6 +192,6 @@ final class WebRunner extends RunnerAbstract */ public function join(): Promise { - return new Success(); + return new Success(0); } } diff --git a/lib/Context/Internal/process-runner.php b/lib/Context/Internal/Runner/process-runner.php similarity index 85% rename from lib/Context/Internal/process-runner.php rename to lib/Context/Internal/Runner/process-runner.php index 376dd1d..809c479 100644 --- a/lib/Context/Internal/process-runner.php +++ b/lib/Context/Internal/Runner/process-runner.php @@ -17,8 +17,8 @@ if (\function_exists("cli_set_process_title")) { (function (): void { $paths = [ - \dirname(__DIR__, 5)."/autoload.php", - \dirname(__DIR__, 3)."/vendor/autoload.php", + \dirname(__DIR__, 6)."/autoload.php", + \dirname(__DIR__, 4)."/vendor/autoload.php", ]; foreach ($paths as $path) { @@ -36,7 +36,19 @@ if (\function_exists("cli_set_process_title")) { require $autoloadPath; })(); -(function () use ($argc, $argv): void { +$fromWeb = false; +if (!isset($argv)) { // Running from web + $argv = $_REQUEST['argv'] ?? []; + array_unshift($argv, __DIR__); + $argc = count($argv); + $fromWeb = true; + + @\ini_set('html_errors', 0); + @\ini_set('display_errors', 0); + @\ini_set('log_errors', 1); +} + +(function () use ($argc, $argv, $fromWeb): void { // Remove this scripts path from process arguments. --$argc; \array_shift($argv); @@ -50,16 +62,16 @@ if (\function_exists("cli_set_process_title")) { --$argc; $uri = \array_shift($argv); - $key = ""; + $key = $fromWeb ? $_REQUEST['key'] : ""; // Read random key from STDIN and send back to parent over IPC socket to authenticate. - do { + while (\strlen($key) < Process::KEY_LENGTH) { if (($chunk = \fread(\STDIN, Process::KEY_LENGTH)) === false || \feof(\STDIN)) { \trigger_error("Could not read key from parent", E_USER_ERROR); exit(1); } $key .= $chunk; - } while (\strlen($key) < Process::KEY_LENGTH); + } if (\strpos($uri, 'tcp://') === false && \strpos($uri, 'unix://') === false) { $suffix = \bin2hex(\random_bytes(10)); @@ -121,6 +133,17 @@ if (\function_exists("cli_set_process_title")) { exit(1); } + if ($fromWeb) { // Set environment variables only after auth + if (isset($_REQUEST['cwd'])) { + chdir($_REQUEST['cwd']); + } + if (isset($_REQUEST['env']) && is_array($_REQUEST['env'])) { + foreach ($_REQUEST['env'] as $key => $value) { + @\putenv("$key=$value"); + } + } + } + try { if (!isset($argv[0])) { throw new \Error("No script path given"); diff --git a/lib/Context/Process.php b/lib/Context/Process.php index c3ad981..4475d77 100644 --- a/lib/Context/Process.php +++ b/lib/Context/Process.php @@ -4,6 +4,7 @@ namespace Amp\Parallel\Context; use Amp\Loop; use Amp\Parallel\Context\Internal\Runner\ProcessRunner; +use Amp\Parallel\Context\Internal\Runner\WebRunner; use Amp\Parallel\Sync\ChannelException; use Amp\Parallel\Sync\ExitResult; use Amp\Parallel\Sync\SynchronizationError; @@ -15,15 +16,8 @@ use function Amp\call; final class Process implements Context { - const SCRIPT_PATH = __DIR__ . "/Internal/process-runner.php"; const KEY_LENGTH = 32; - /** @var string|null External version of SCRIPT_PATH if inside a PHAR. */ - private static $pharScriptPath; - - /** @var string|null PHAR path with a '.phar' extension. */ - private static $pharCopy; - /** @var Internal\ProcessHub */ private $hub; @@ -59,10 +53,11 @@ final class Process implements Context * @param string|null $cwd Working directory. * @param mixed[] $env Array of environment variables. * @param string $binary Path to PHP binary. Null will attempt to automatically locate the binary. + * @param bool $useWeb Whether to use the WebRunner by default * * @throws \Error If the PHP binary path given cannot be found or is not executable. */ - public function __construct($script, string $cwd = null, array $env = [], string $binary = null) + public function __construct($script, string $cwd = null, array $env = [], string $binary = null, bool $useWeb = false) { $this->hub = Loop::getState(self::class); if (!$this->hub instanceof Internal\ProcessHub) { @@ -70,45 +65,15 @@ final class Process implements Context Loop::setState(self::class, $this->hub); } - // Write process runner to external file if inside a PHAR, - // because PHP can't open files inside a PHAR directly except for the stub. - if (\strpos(self::SCRIPT_PATH, "phar://") === 0) { - if (self::$pharScriptPath) { - $scriptPath = self::$pharScriptPath; - } else { - $path = \dirname(self::SCRIPT_PATH); - - if (\substr(\Phar::running(false), -5) !== ".phar") { - self::$pharCopy = \sys_get_temp_dir() . "/phar-" . \bin2hex(\random_bytes(10)) . ".phar"; - \copy(\Phar::running(false), self::$pharCopy); - - \register_shutdown_function(static function (): void { - @\unlink(self::$pharCopy); - }); - - $path = "phar://" . self::$pharCopy . "/" . \substr($path, \strlen(\Phar::running(true))); - } - - $contents = \file_get_contents(self::SCRIPT_PATH); - $contents = \str_replace("__DIR__", \var_export($path, true), $contents); - $suffix = \bin2hex(\random_bytes(10)); - self::$pharScriptPath = $scriptPath = \sys_get_temp_dir() . "/amp-process-runner-" . $suffix . ".php"; - \file_put_contents($scriptPath, $contents); - - \register_shutdown_function(static function (): void { - @\unlink(self::$pharScriptPath); - }); + try { + if (!$useWeb) { + $this->process = new ProcessRunner($script, $this->hub, $cwd, $env, $binary); } - - // Monkey-patch the script path in the same way, only supported if the command is given as array. - if (isset(self::$pharCopy) && \is_array($script) && isset($script[0])) { - $script[0] = "phar://" . self::$pharCopy . \substr($script[0], \strlen(\Phar::running(true))); - } - } else { - $scriptPath = self::SCRIPT_PATH; + } catch (\Throwable $e) { + } + if (!$this->process) { + $this->process = new WebRunner($script, $this->hub, $cwd, $env); } - - $this->process = new ProcessRunner($script, $scriptPath, $this->hub, $cwd, $env, $binary); } diff --git a/test/Context/ProcessWebTest.php b/test/Context/ProcessWebTest.php new file mode 100644 index 0000000..ea77820 --- /dev/null +++ b/test/Context/ProcessWebTest.php @@ -0,0 +1,143 @@ + ["pipe", "w"]], $pipes, $root = realpath(__DIR__.'/../../')); + fgets($pipes[2]); + + $server = new ApacheCGI($root); + + $file = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $file = end($file)['file']; + $file = substr($file, strlen($root)); + + // now simulate an HTTP request + $server->setHttpRequest("http://localhost:8080/$file?baz=2"); + } + public static function tearDownAfterClass(): void + { + proc_terminate(self::$proc); + } + + private static function locateBinary(): string + { + $executable = \strncasecmp(\PHP_OS, "WIN", 3) === 0 ? "php.exe" : "php"; + + $paths = \array_filter(\explode(\PATH_SEPARATOR, \getenv("PATH"))); + $paths[] = \PHP_BINDIR; + $paths = \array_unique($paths); + + foreach ($paths as $path) { + $path .= \DIRECTORY_SEPARATOR.$executable; + if (\is_executable($path)) { + return $path; + } + } + + throw new \Error("Could not locate PHP executable binary"); + } + + + public function createContext($script): Context + { + Loop::setState(Process::class, new ProcessHub(false)); // Manually set ProcessHub using socket server. + return new Process($script, null, [], null, true); + } + + + public function testBasicProcess() + { + $context = $this->createContext([ + __DIR__ . "/Fixtures/test-process.php", + "Test" + ]); + yield $context->start(); + $this->assertSame("Test", yield $context->join()); + } + + public function testFailingProcess() + { + $this->expectException(PanicError::class); + $this->expectExceptionMessage('No string provided'); + + $context = $this->createContext(__DIR__ . "/Fixtures/test-process.php"); + yield $context->start(); + yield $context->join(); + } + + public function testThrowingProcessOnReceive() + { + $this->expectException(PanicError::class); + $this->expectExceptionMessage('Test message'); + + $context = $this->createContext(__DIR__ . "/Fixtures/throwing-process.php"); + yield $context->start(); + yield $context->receive(); + } + + public function testThrowingProcessOnSend() + { + $this->expectException(PanicError::class); + $this->expectExceptionMessage('Test message'); + + $context = $this->createContext(__DIR__ . "/Fixtures/throwing-process.php"); + yield $context->start(); + yield new Delayed(100); + yield $context->send(1); + } + + public function testInvalidScriptPath() + { + $this->expectException(PanicError::class); + $this->expectExceptionMessage("No script found at '../test-process.php'"); + + $context = $this->createContext("../test-process.php"); + yield $context->start(); + yield $context->join(); + } + + public function testInvalidResult() + { + $this->expectException(PanicError::class); + $this->expectExceptionMessage('The given data cannot be sent because it is not serializable'); + + $context = $this->createContext(__DIR__ . "/Fixtures/invalid-result-process.php"); + yield $context->start(); + \var_dump(yield $context->join()); + } + + public function testNoCallbackReturned() + { + $this->expectException(PanicError::class); + $this->expectExceptionMessage('did not return a callable function'); + + $context = $this->createContext(__DIR__ . "/Fixtures/no-callback-process.php"); + yield $context->start(); + \var_dump(yield $context->join()); + } + + public function testParseError() + { + $this->expectException(PanicError::class); + $this->expectExceptionMessage('contains a parse error'); + + $context = $this->createContext(__DIR__ . "/Fixtures/parse-error-process.inc"); + yield $context->start(); + yield $context->join(); + } +}