You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
586 lines
12 KiB
586 lines
12 KiB
4 years ago
|
<?php
|
||
|
|
||
|
declare(strict_types=1);
|
||
|
|
||
|
namespace App\System;
|
||
|
|
||
|
class Process
|
||
|
{
|
||
|
/**
|
||
|
* The full command path to execute.
|
||
|
*
|
||
|
* This may include raw strings as well as instances of CommandArgument. When
|
||
|
* executing, the instances will be resolved with the CommandArgument::get()
|
||
|
* method. When printing for errors/logs/etc, it will instead be cast to string.
|
||
|
*
|
||
|
* @var array
|
||
|
*/
|
||
|
protected $command;
|
||
|
|
||
|
/**
|
||
|
* Whether to raise an exception if the process exits with a non-zero (failure)
|
||
|
* exit code.
|
||
|
*
|
||
|
* @var bool
|
||
|
*/
|
||
|
protected $raiseExceptionOnFailure = true;
|
||
|
|
||
|
/**
|
||
|
* An array of callbacks to call with each line of output.
|
||
|
*
|
||
|
* Each element should be an array with two elements. The first is a callable.
|
||
|
* The second is optional and may contain a preg-compatible regular expression.
|
||
|
* If this is set, then only lines matching this regular expression will trigger
|
||
|
* the callback and each invocation will include the matches array.
|
||
|
*
|
||
|
* @var array[][callable,string|null]
|
||
|
*/
|
||
|
protected $outputCallbacks = [];
|
||
|
|
||
|
/**
|
||
|
* An array of callbacks to call when the process is stopping.
|
||
|
*
|
||
|
* @var array[callable]
|
||
|
*/
|
||
|
protected $exitCallback = [];
|
||
|
|
||
|
/**
|
||
|
* How to start the process -- one of the START_MODE constants.
|
||
|
*
|
||
|
* @var int
|
||
|
*/
|
||
|
protected $startMode = self::START_MODE_ASYNC;
|
||
|
|
||
|
/**
|
||
|
* Start the process asynchronously. start() will return immediately.
|
||
|
*
|
||
|
* This uses PHP's tick functions to continue pumping the stdout/stderr
|
||
|
* streams and monitoring the process status.
|
||
|
*/
|
||
|
protected const START_MODE_ASYNC = 0x01;
|
||
|
|
||
|
/**
|
||
|
* Start the process sychronously. start() will return once the process
|
||
|
* exits.
|
||
|
*
|
||
|
* This uses stream_select to monitor for output on stdout/stderr and calls
|
||
|
* tick() to pump the streams and check the process status whenever that
|
||
|
* changes.
|
||
|
*/
|
||
|
protected const START_MODE_SYNC = 0x02;
|
||
|
|
||
|
/**
|
||
|
* Start the process bound to the parent process's streams.
|
||
|
*/
|
||
|
protected const START_MODE_REPLACE = 0x04;
|
||
|
|
||
|
/**
|
||
|
* Override the working directory of the spawned process.
|
||
|
*
|
||
|
* @var string|null
|
||
|
*/
|
||
|
protected $cwd;
|
||
|
|
||
|
/**
|
||
|
* Associative array of additional environment variables to set for the
|
||
|
* child process.
|
||
|
*
|
||
|
* @var array[string]
|
||
|
*/
|
||
|
protected $env = [];
|
||
|
|
||
|
/**
|
||
|
* The process's current status represented by one of the STATUS_ constants.
|
||
|
*
|
||
|
* @var int
|
||
|
*/
|
||
|
protected $status = self::STATUS_CREATED;
|
||
|
|
||
|
/**
|
||
|
* A new Process object has been created. The handle has not been opened.
|
||
|
*/
|
||
|
protected const STATUS_CREATED = 0x01;
|
||
|
|
||
|
/**
|
||
|
* The process is starting.
|
||
|
*/
|
||
|
protected const STATUS_STARTING = 0x02;
|
||
|
|
||
|
/**
|
||
|
* The process has been started and is now running.
|
||
|
*/
|
||
|
protected const STATUS_RUNNING = 0x04;
|
||
|
|
||
|
/**
|
||
|
* It looks like the process has exited. We'll call the exit callbacks.
|
||
|
*/
|
||
|
protected const STATUS_STOPPING = 0x05;
|
||
|
|
||
|
/**
|
||
|
* All exit callbacks have been completed.
|
||
|
*/
|
||
|
protected const STATUS_STOPPED = 0x06;
|
||
|
|
||
|
/**
|
||
|
* The process has exited, we've completed all our callbacks and
|
||
|
* unregistered the tick callback if applicable.
|
||
|
*/
|
||
|
protected const STATUS_EXITED = 0x07;
|
||
|
|
||
|
/**
|
||
|
* The process handle returned from proc_open.
|
||
|
* @var resource
|
||
|
*/
|
||
|
protected $handle;
|
||
|
|
||
|
/**
|
||
|
* The process's stdin stream.
|
||
|
*/
|
||
|
protected $stream_stdin;
|
||
|
|
||
|
/**
|
||
|
* The process's stdout stream.
|
||
|
*/
|
||
|
protected $stream_stdout;
|
||
|
|
||
|
/**
|
||
|
* The process's stderr stream.
|
||
|
*/
|
||
|
protected $stream_stderr;
|
||
|
|
||
|
/**
|
||
|
* All of the process's stdout output when not running in replace mode.
|
||
|
* @var string
|
||
|
*/
|
||
|
protected $stdout = null;
|
||
|
|
||
|
/**
|
||
|
* All of the process's stderr output when not running in replace mode.
|
||
|
* @var string
|
||
|
*/
|
||
|
protected $stderr = null;
|
||
|
|
||
|
/**
|
||
|
* When registering an output callback, bitwise constant to specify to
|
||
|
* listen to stdout output.
|
||
|
*
|
||
|
* @var int
|
||
|
*/
|
||
|
public const STREAM_STDOUT = 1;
|
||
|
|
||
|
/**
|
||
|
* When registering an output callback, bitwise constant to specify to
|
||
|
* listen to stderr output.
|
||
|
*
|
||
|
* @var int
|
||
|
*/
|
||
|
public const STREAM_STDERR = 2;
|
||
|
|
||
|
/**
|
||
|
* The process's exit status once it has exited.
|
||
|
* @var int|null
|
||
|
*/
|
||
|
protected $exitStatus = null;
|
||
|
|
||
|
public function __construct(array $command)
|
||
|
{
|
||
|
$this->command = $command;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set an environment variable to pass to the child process.
|
||
|
*
|
||
|
* @param string $key
|
||
|
* @param string|null $value
|
||
|
* @return Process
|
||
|
*/
|
||
|
public function setEnv($key, $value)
|
||
|
{
|
||
|
if (isset($value))
|
||
|
{
|
||
|
$this->env[$key] = $value;
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
unset($this->env[$key]);
|
||
|
}
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set multiple environment variables from an associative array.
|
||
|
*
|
||
|
* @param array $values
|
||
|
* @return Process
|
||
|
*/
|
||
|
public function setEnvRange(array $values)
|
||
|
{
|
||
|
foreach ($values as $k => $v)
|
||
|
{
|
||
|
$this->setEnv($k, $v);
|
||
|
}
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Whether to raise an exception if the process returns a non-zero exit
|
||
|
* code.
|
||
|
*
|
||
|
* @param bool $raiseException true to raise an exception
|
||
|
* @return void
|
||
|
*/
|
||
|
public function setRaiseExceptionOnFailure($raiseException)
|
||
|
{
|
||
|
$this->raiseExceptionOnFailure = $raiseException;
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add an output callback to receive process stdout and stderr output.
|
||
|
*
|
||
|
* Signature is function(Process $process, string $line, array $matches = null)
|
||
|
*
|
||
|
* @param callable $callback the callback to call
|
||
|
* @param string $filter regex to use to filter lines before calling callback
|
||
|
* @param mixed $streams
|
||
|
* @return void
|
||
|
*/
|
||
|
public function addOutputCallback(callable $callback, $filter = null, $streams = Process::STREAM_STDOUT)
|
||
|
{
|
||
|
$this->outputCallbacks[] = [$callback, $filter, $streams];
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add a callback to be called when the process transitions into stopping.
|
||
|
*
|
||
|
* @param callable $callback
|
||
|
* @return void
|
||
|
*/
|
||
|
public function addExitCallback(callable $callback)
|
||
|
{
|
||
|
$this->exitCallback[] = $callback;
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
public function setStartModeSync()
|
||
|
{
|
||
|
$this->startMode = static::START_MODE_SYNC;
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
public function setStartModeAsync()
|
||
|
{
|
||
|
$this->startMode = static::START_MODE_ASYNC;
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
public function setStartModeReplace()
|
||
|
{
|
||
|
$this->startMode = static::START_MODE_SYNC | static::START_MODE_REPLACE;
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
public function getStdout()
|
||
|
{
|
||
|
return $this->stdout;
|
||
|
}
|
||
|
|
||
|
public function getStdoutString()
|
||
|
{
|
||
|
return isset($this->stdout) ? implode(PHP_EOL, $this->stdout) : null;
|
||
|
}
|
||
|
|
||
|
public function getStderr()
|
||
|
{
|
||
|
return $this->stderr;
|
||
|
}
|
||
|
|
||
|
public function getStderrString()
|
||
|
{
|
||
|
return isset($this->stderr) ? implode(PHP_EOL, $this->stderr) : null;
|
||
|
}
|
||
|
|
||
|
public function getExitStatus()
|
||
|
{
|
||
|
return $this->exitStatus;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check whether the process is still running. This will return false
|
||
|
* during the times it is transitioning to starting or into exiting.
|
||
|
*
|
||
|
* @return bool true if running
|
||
|
*/
|
||
|
public function isRunning()
|
||
|
{
|
||
|
return $this->status == static::STATUS_RUNNING;
|
||
|
}
|
||
|
|
||
|
public function fork()
|
||
|
{
|
||
|
if (PHP_OS == 'WINNT')
|
||
|
{
|
||
|
$command = sprintf(
|
||
|
'start /B %s',
|
||
|
$this->getCommand()
|
||
|
);
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
$command = sprintf(
|
||
|
'nohup %s > /dev/null 2>&1 &',
|
||
|
$this->getCommand()
|
||
|
);
|
||
|
}
|
||
|
shell_exec($command);
|
||
|
}
|
||
|
|
||
|
public function start()
|
||
|
{
|
||
|
$this->status = static::STATUS_STARTING;
|
||
|
if ($this->startMode & static::START_MODE_REPLACE)
|
||
|
{
|
||
|
$descriptors = [
|
||
|
0 => STDIN,
|
||
|
1 => STDOUT,
|
||
|
2 => STDERR
|
||
|
];
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
$descriptors = [
|
||
|
0 => ['pipe', 'r'],
|
||
|
1 => ['pipe', 'w'],
|
||
|
2 => ['pipe', 'w']
|
||
|
];
|
||
|
}
|
||
|
$pipes = null;
|
||
|
|
||
|
if (PHP_OS == 'WINNT')
|
||
|
{
|
||
|
$commandWrapper = '"';
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
$commandWrapper = '';
|
||
|
}
|
||
|
|
||
|
$this->handle = $handle = proc_open(
|
||
|
$commandWrapper.$this->getCommand().$commandWrapper,
|
||
|
$descriptors,
|
||
|
$pipes,
|
||
|
$this->cwd,
|
||
|
array_merge(getenv(), $this->env)
|
||
|
);
|
||
|
|
||
|
if (!is_resource($handle))
|
||
|
{
|
||
|
throw new \Exception('Failed to start process');
|
||
|
}
|
||
|
|
||
|
if (sizeof($pipes))
|
||
|
{
|
||
|
$this->stream_stdin = $pipes[0];
|
||
|
$this->stream_stdout = $pipes[1];
|
||
|
$this->stream_stderr = $pipes[2];
|
||
|
|
||
|
stream_set_blocking($pipes[0], false);
|
||
|
stream_set_blocking($pipes[1], false);
|
||
|
stream_set_blocking($pipes[2], false);
|
||
|
}
|
||
|
|
||
|
$this->status = static::STATUS_RUNNING;
|
||
|
|
||
|
if ($this->startMode & static::START_MODE_ASYNC)
|
||
|
{
|
||
|
declare(ticks=1);
|
||
|
register_tick_function([&$this, 'tick']);
|
||
|
}
|
||
|
elseif ($this->startMode & static::START_MODE_SYNC)
|
||
|
{
|
||
|
while ($this->status != static::STATUS_EXITED)
|
||
|
{
|
||
|
if ($this->startMode & static::START_MODE_REPLACE)
|
||
|
{
|
||
|
sleep(1);
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
$read_streams = [$pipes[1], $pipes[2]];
|
||
|
$write_streams = null;
|
||
|
$except_streams = null;
|
||
|
stream_select($read_streams, $write_streams, $except_streams, 1);
|
||
|
}
|
||
|
$this->tick();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
public function stop()
|
||
|
{
|
||
|
proc_terminate($this->handle);
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
protected function tick()
|
||
|
{
|
||
|
if (!($this->startMode & static::START_MODE_REPLACE))
|
||
|
{
|
||
|
$this->tickStreams();
|
||
|
}
|
||
|
$this->tickProcessStatus();
|
||
|
}
|
||
|
|
||
|
protected function tickStreams()
|
||
|
{
|
||
|
// Pump streams if necessary
|
||
|
if (!isset($this->stdout) || !is_array($this->stdout))
|
||
|
{
|
||
|
$this->stdout = [];
|
||
|
}
|
||
|
while ($line = fgets($this->stream_stdout))
|
||
|
{
|
||
|
// This array is initialized a few lines above and not set anywhere else.
|
||
|
// @phan-suppress-next-line PhanTypeMismatchDimEmpty
|
||
|
$this->stdout[] = trim($line);
|
||
|
|
||
|
foreach ($this->outputCallbacks as $callback)
|
||
|
{
|
||
|
if (($callback[2] & static::STREAM_STDOUT) == 0)
|
||
|
{
|
||
|
continue;
|
||
|
}
|
||
|
if (isset($callback[1]))
|
||
|
{
|
||
|
if (preg_match($callback[1], $line, $matches))
|
||
|
{
|
||
|
call_user_func($callback[0], $this, $line, $matches);
|
||
|
}
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
call_user_func($callback[0], $this, $line);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!isset($this->stderr) || !is_array($this->stderr))
|
||
|
{
|
||
|
$this->stderr = [];
|
||
|
}
|
||
|
while ($line = fgets($this->stream_stderr))
|
||
|
{
|
||
|
// This array is initialized a few lines above and not set anywhere else.
|
||
|
// @phan-suppress-next-line PhanTypeMismatchDimEmpty
|
||
|
$this->stderr[] = trim($line);
|
||
|
|
||
|
foreach ($this->outputCallbacks as $callback)
|
||
|
{
|
||
|
if (($callback[2] & static::STREAM_STDERR) == 0)
|
||
|
{
|
||
|
continue;
|
||
|
}
|
||
|
if (isset($callback[1]))
|
||
|
{
|
||
|
if (preg_match($callback[1], $line, $matches))
|
||
|
{
|
||
|
call_user_func($callback[0], $this, $line, $matches);
|
||
|
}
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
call_user_func($callback[0], $this, $line);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
protected function tickProcessStatus()
|
||
|
{
|
||
|
// Check current status
|
||
|
$status = proc_get_status($this->handle);
|
||
|
if (isset($status['exitcode']) && $status['exitcode'] !== -1)
|
||
|
{
|
||
|
$this->exitStatus = $status['exitcode'];
|
||
|
}
|
||
|
if ($status['running'] === false && $this->status == static::STATUS_RUNNING)
|
||
|
{
|
||
|
$this->stopping();
|
||
|
}
|
||
|
elseif ($this->status == static::STATUS_STOPPED)
|
||
|
{
|
||
|
$this->status = static::STATUS_EXITED;
|
||
|
if ($this->startMode & static::START_MODE_ASYNC)
|
||
|
{
|
||
|
unregister_tick_function([&$this, 'tick']);
|
||
|
}
|
||
|
$this->exited();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
protected function stopping()
|
||
|
{
|
||
|
$this->status = static::STATUS_STOPPING;
|
||
|
foreach ($this->exitCallback as $exitCallback)
|
||
|
{
|
||
|
call_user_func($exitCallback, $this);
|
||
|
}
|
||
|
$this->status = static::STATUS_STOPPED;
|
||
|
}
|
||
|
|
||
|
protected function exited()
|
||
|
{
|
||
|
if (!($this->startMode & static::START_MODE_REPLACE))
|
||
|
{
|
||
|
fclose($this->stream_stdin);
|
||
|
fclose($this->stream_stdout);
|
||
|
fclose($this->stream_stderr);
|
||
|
}
|
||
|
proc_close($this->handle);
|
||
|
|
||
|
if ($this->raiseExceptionOnFailure && $this->exitStatus != 0)
|
||
|
{
|
||
|
throw new \Exception('Command execution failed ('.$this->exitStatus.'): '.implode(' ', $this->command).PHP_EOL.
|
||
|
'Stdout:'.PHP_EOL.
|
||
|
$this->getStdoutString().PHP_EOL.
|
||
|
'Stderr:'.PHP_EOL.
|
||
|
$this->getStderrString().PHP_EOL);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public function write($data)
|
||
|
{
|
||
|
fwrite($this->stream_stdin, $data);
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
public function close()
|
||
|
{
|
||
|
fclose($this->stream_stdin);
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
protected function getCommand()
|
||
|
{
|
||
|
$processed = [];
|
||
|
$command = $this->command;
|
||
|
$commandName = array_shift($command);
|
||
|
$processed[] = strpos($commandName, ' ') === false ? $commandName : '"'.$commandName.'"';
|
||
|
foreach ($command as $part)
|
||
|
{
|
||
|
if (is_object($part) && $part instanceof CommandArgument)
|
||
|
{
|
||
|
$processed[] = $part->get();
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
$processed[] = $part;
|
||
|
}
|
||
|
}
|
||
|
return implode(' ', $processed);
|
||
|
}
|
||
|
}
|