From 68eaac2332b1e3b67f27f99ba092191e40f64e60 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 30 Jan 2026 18:35:25 +0100 Subject: [PATCH] Parallelize RunCommand in issue-bot --- issue-bot/composer.json | 4 + issue-bot/composer.lock | 408 +++++++++++++++++- issue-bot/src/Console/RunCommand.php | 160 +++++-- .../src/Process/ProcessCanceledException.php | 10 + .../src/Process/ProcessCrashedException.php | 10 + issue-bot/src/Process/ProcessPromise.php | 92 ++++ 6 files changed, 655 insertions(+), 29 deletions(-) create mode 100644 issue-bot/src/Process/ProcessCanceledException.php create mode 100644 issue-bot/src/Process/ProcessCrashedException.php create mode 100644 issue-bot/src/Process/ProcessPromise.php diff --git a/issue-bot/composer.json b/issue-bot/composer.json index 6b704b867a..193072af2a 100644 --- a/issue-bot/composer.json +++ b/issue-bot/composer.json @@ -2,6 +2,7 @@ "name": "phpstan/issue-bot", "require": { "php": "^8.3", + "fidry/cpu-core-counter": "^1.3", "guzzlehttp/guzzle": "^7.5", "knplabs/github-api": "^3.9", "league/commonmark": "^2.3", @@ -9,6 +10,9 @@ "nette/utils": "^3.2", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", + "react/child-process": "^0.6.7", + "react/event-loop": "^1.6", + "react/promise": "^3.3", "symfony/console": "^6.1", "symfony/finder": "^6.1" }, diff --git a/issue-bot/composer.lock b/issue-bot/composer.lock index ff4869f5fa..7d64891a6f 100644 --- a/issue-bot/composer.lock +++ b/issue-bot/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bb62f565dfbfe471988a5d5490ae3d5", + "content-hash": "ba403059630af346d54dbfe76869234a", "packages": [ { "name": "clue/stream-filter", @@ -147,6 +147,114 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.10.0", @@ -1914,6 +2022,304 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, { "name": "symfony/console", "version": "v6.4.27", diff --git a/issue-bot/src/Console/RunCommand.php b/issue-bot/src/Console/RunCommand.php index 8027255fe5..fb8ad112c0 100644 --- a/issue-bot/src/Console/RunCommand.php +++ b/issue-bot/src/Console/RunCommand.php @@ -3,28 +3,41 @@ namespace PHPStan\IssueBot\Console; use Exception; +use Fidry\CpuCoreCounter\CpuCoreCounter as FidryCpuCoreCounter; +use Fidry\CpuCoreCounter\NumberOfCpuCoreNotFound; use Nette\Neon\Neon; use Nette\Utils\Json; use PHPStan\IssueBot\Playground\PlaygroundCache; use PHPStan\IssueBot\Playground\PlaygroundError; use PHPStan\IssueBot\Playground\PlaygroundResult; +use PHPStan\IssueBot\Process\ProcessPromise; +use React\EventLoop\LoopInterface; +use React\EventLoop\StreamSelectLoop; +use React\Promise\Deferred; +use React\Promise\PromiseInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; use function array_key_exists; -use function exec; +use function count; +use function escapeshellarg; use function explode; use function file_get_contents; use function file_put_contents; use function implode; use function is_file; +use function ksort; use function microtime; +use function mkdir; +use function React\Promise\set_rejection_handler; use function serialize; use function sha1; use function sprintf; use function str_replace; use function strpos; +use function sys_get_temp_dir; use function unserialize; class RunCommand extends Command @@ -48,17 +61,47 @@ protected function execute(InputInterface $input, OutputInterface $output): int $commaSeparatedPlaygroundHashes = $input->getArgument('playgroundHashes'); $playgroundHashes = explode(',', $commaSeparatedPlaygroundHashes); $playgroundCache = $this->loadPlaygroundCache(); - $errors = []; + + try { + $cpuCount = (new FidryCpuCoreCounter())->getCount(); + } catch (NumberOfCpuCoreNotFound) { + $cpuCount = 1; + } + + $loop = new StreamSelectLoop(); + $jobs = []; foreach ($playgroundHashes as $hash) { if (!array_key_exists($hash, $playgroundCache->getResults())) { throw new Exception(sprintf('Hash %s must exist', $hash)); } - $errors[$hash] = $this->analyseHash($output, $phpVersion, $playgroundCache->getResults()[$hash]); + $jobs[] = [$phpVersion, $hash, $playgroundCache->getResults()[$hash]]; } - $data = ['phpVersion' => $phpVersion, 'errors' => $errors]; + $allErrors = []; + + set_rejection_handler(static function (Throwable $t): void { + throw $t; + }); + + $this->runPool($jobs, $cpuCount, function (array $job) use ($output, $loop): PromiseInterface { + [$phpVersion, $hash, $result] = $job; + return $this->analyseHash($loop, $output, $phpVersion, $result)->then( + static fn (array $errors) => [$hash, $errors], + ); + })->then(static function (array $results) use (&$allErrors): void { + foreach ($results as [$hash, $errors]) { + $allErrors[$hash] = $errors; + } + }); + + $loop->run(); - $writeSuccess = file_put_contents(sprintf($this->tmpDir . '/results-%d-%s.tmp', $phpVersion, sha1($commaSeparatedPlaygroundHashes)), serialize($data)); + $data = ['phpVersion' => $phpVersion, 'errors' => $allErrors]; + + $writeSuccess = file_put_contents( + sprintf($this->tmpDir . '/results-%d-%s.tmp', $phpVersion, sha1($commaSeparatedPlaygroundHashes)), + serialize($data), + ); if ($writeSuccess === false) { throw new Exception('Result write unsuccessful'); } @@ -67,9 +110,64 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * @return list + * @param array $jobs + * @param callable(array{int, string, PlaygroundResult}): PromiseInterface}> $jobRunner + * @return PromiseInterface}>> */ - private function analyseHash(OutputInterface $output, int $phpVersion, PlaygroundResult $result): array + private function runPool(array $jobs, int $concurrency, callable $jobRunner): PromiseInterface + { + $deferred = new Deferred(); + $results = []; + $pending = 0; + $index = 0; + $total = count($jobs); + $rejected = false; + + if ($total === 0) { + $deferred->resolve([]); + return $deferred->promise(); + } + + $runNext = static function () use (&$runNext, &$jobs, &$results, &$pending, &$index, &$rejected, $total, $concurrency, $jobRunner, $deferred): void { + if ($rejected) { + return; + } + while ($pending < $concurrency && $index < $total) { + $currentIndex = $index++; + $pending++; + + $jobRunner($jobs[$currentIndex])->then( + static function ($result) use (&$results, &$pending, $currentIndex, $total, $runNext, $deferred): void { + $results[$currentIndex] = $result; + $pending--; + + if (count($results) === $total) { + ksort($results); + $deferred->resolve($results); + } else { + $runNext(); + } + }, + static function ($error) use (&$rejected, $deferred): void { + if ($rejected) { + return; + } + $rejected = true; + $deferred->reject($error); + }, + ); + } + }; + + $runNext(); + + return $deferred->promise(); + } + + /** + * @return PromiseInterface> + */ + private function analyseHash(LoopInterface $loop, OutputInterface $output, int $phpVersion, PlaygroundResult $result): PromiseInterface { $configFiles = [ __DIR__ . '/../../playground.neon', @@ -81,6 +179,8 @@ private function analyseHash(OutputInterface $output, int $phpVersion, Playgroun if ($result->isStrictRules()) { $configFiles[] = __DIR__ . '/../../vendor/phpstan/phpstan-strict-rules/rules.neon'; } + $tmpDir = sys_get_temp_dir() . '/phpstan-issue-bot-' . $result->getHash(); + @mkdir($tmpDir, 0777, true); $neon = Neon::encode([ 'includes' => $configFiles, 'parameters' => [ @@ -88,6 +188,7 @@ private function analyseHash(OutputInterface $output, int $phpVersion, Playgroun 'inferPrivatePropertyTypeFromConstructor' => true, 'treatPhpDocTypesAsCertain' => $result->isTreatPhpDocTypesAsCertain(), 'phpVersion' => $phpVersion, + 'tmpDir' => $tmpDir, ], ]); @@ -98,40 +199,43 @@ private function analyseHash(OutputInterface $output, int $phpVersion, Playgroun file_put_contents($codePath, $result->getCode()); $commandArray = [ - __DIR__ . '/../../../bin/phpstan', + escapeshellarg(__DIR__ . '/../../../bin/phpstan'), 'analyse', '--error-format', 'json', '--no-progress', '-c', - $neonPath, - $codePath, + escapeshellarg($neonPath), + escapeshellarg($codePath), ]; $output->writeln(sprintf('Starting analysis of %s', $hash)); - + $process = new ProcessPromise($loop, implode(' ', $commandArray)); $startTime = microtime(true); - exec(implode(' ', $commandArray), $outputLines, $exitCode); - $elapsedTime = microtime(true) - $startTime; - $output->writeln(sprintf('Analysis of %s took %.2f s', $hash, $elapsedTime)); - - if ($exitCode !== 0 && $exitCode !== 1) { - throw new Exception(sprintf('PHPStan exited with code %d during analysis of %s', $exitCode, $hash)); - } + return $process->run()->then(static function (string $stdout) use ($hash, $output, $startTime) { + try { + $json = Json::decode($stdout, Json::FORCE_ARRAY); + } catch (Throwable $e) { + echo $stdout . "\n"; + throw new Exception(sprintf('Failed to decode JSON for %s: %s', $hash, $e->getMessage())); + } - $json = Json::decode(implode("\n", $outputLines), Json::FORCE_ARRAY); - $errors = []; - foreach ($json['files'] as ['messages' => $messages]) { - foreach ($messages as $message) { - $messageText = str_replace(sprintf('/%s.php', $hash), '/tmp.php', $message['message']); - if (strpos($messageText, 'Internal error') !== false) { - throw new Exception(sprintf('While analysing %s: %s', $hash, $messageText)); + $errors = []; + foreach ($json['files'] as ['messages' => $messages]) { + foreach ($messages as $message) { + $messageText = str_replace(sprintf('/%s.php', $hash), '/tmp.php', $message['message']); + if (strpos($messageText, 'Internal error') !== false) { + throw new Exception(sprintf('While analysing %s: %s', $hash, $messageText)); + } + $errors[] = new PlaygroundError($message['line'] ?? -1, $messageText, $message['identifier'] ?? null); } - $errors[] = new PlaygroundError($message['line'] ?? -1, $messageText, $message['identifier'] ?? null); } - } - return $errors; + $elapsedTime = microtime(true) - $startTime; + $output->writeln(sprintf('Analysis of %s took %.2f s', $hash, $elapsedTime)); + + return $errors; + }); } private function loadPlaygroundCache(): PlaygroundCache diff --git a/issue-bot/src/Process/ProcessCanceledException.php b/issue-bot/src/Process/ProcessCanceledException.php new file mode 100644 index 0000000000..56bc2d5e87 --- /dev/null +++ b/issue-bot/src/Process/ProcessCanceledException.php @@ -0,0 +1,10 @@ + */ + private Deferred $deferred; + + private ?Process $process = null; + + private bool $canceled = false; + + public function __construct(private LoopInterface $loop, private string $command) + { + $this->deferred = new Deferred(function (): void { + $this->cancel(); + }); + } + + /** + * @return PromiseInterface + */ + public function run(): PromiseInterface + { + $tmpStdOutResource = tmpfile(); + if ($tmpStdOutResource === false) { + throw new ProcessCrashedException('Failed creating temp file for stdout.'); + } + $tmpStdErrResource = tmpfile(); + if ($tmpStdErrResource === false) { + throw new ProcessCrashedException('Failed creating temp file for stderr.'); + } + + $this->process = new Process($this->command, fds: [ + 1 => $tmpStdOutResource, + 2 => $tmpStdErrResource, + ]); + $this->process->start($this->loop); + + $this->process->on('exit', function ($exitCode) use ($tmpStdOutResource, $tmpStdErrResource): void { + if ($this->canceled) { + fclose($tmpStdOutResource); + fclose($tmpStdErrResource); + return; + } + rewind($tmpStdOutResource); + $stdOut = stream_get_contents($tmpStdOutResource); + fclose($tmpStdOutResource); + + rewind($tmpStdErrResource); + $stdErr = stream_get_contents($tmpStdErrResource); + fclose($tmpStdErrResource); + + if ($exitCode === null) { + $this->deferred->reject(new ProcessCrashedException($stdOut . $stdErr)); + return; + } + + if ($exitCode === 0 || $exitCode === 1) { + echo $stdErr . "\n"; + $this->deferred->resolve($stdOut); + return; + } + + $this->deferred->reject(new ProcessCrashedException($stdOut . $stdErr)); + }); + + return $this->deferred->promise(); + } + + private function cancel(): void + { + if ($this->process === null) { + throw new ProcessCanceledException('Cancelling process before running'); + } + $this->canceled = true; + $this->process->terminate(); + $this->deferred->reject(new ProcessCanceledException()); + } + +}