Tool for laying out displays on Linux
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.
 
 

585 lines
12 KiB

<?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);
}
}