Skip to content

Instantly share code, notes, and snippets.

@WyriHaximus
Created December 1, 2018 18:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save WyriHaximus/7a790289a41fc4617153c1e169f053bb to your computer and use it in GitHub Desktop.
Save WyriHaximus/7a790289a41fc4617153c1e169f053bb to your computer and use it in GitHub Desktop.
<?php
namespace React\ChildProcess;
use Evenement\EventEmitter;
use React\EventLoop\LoopInterface;
use React\Stream\ReadableResourceStream;
use React\Stream\WritableResourceStream;
/**
* Process component.
*
* This class borrows logic from Symfony's Process component for ensuring
* compatibility when PHP is compiled with the --enable-sigchild option.
*
* @event exit
*/
class Process extends EventEmitter
{
public $stdin;
public $stdout;
public $stderr;
private $cmd;
private $cwd;
private $env;
private $options;
private $enhanceSigchildCompatibility;
private $pipes;
private $process;
private $status;
private $exitCode;
private $fallbackExitCode;
private $stopSignal;
private $termSignal;
private static $sigchild;
/**
* Constructor.
*
* @param string $cmd Command line to run
* @param string $cwd Current working directory or null to inherit
* @param array $env Environment variables or null to inherit
* @param array $options Options for proc_open()
* @throws LogicException On windows or when proc_open() is not installed
*/
public function __construct($cmd, $cwd = null, array $env = null, array $options = array())
{
if (substr(strtolower(PHP_OS), 0, 3) === 'win') {
throw new \LogicException('Windows isn\'t supported due to the blocking nature of STDIN/STDOUT/STDERR pipes.');
}
if (!function_exists('proc_open')) {
throw new \LogicException('The Process class relies on proc_open(), which is not available on your PHP installation.');
}
$this->cmd = $cmd;
$this->cwd = $cwd;
if (null !== $env) {
$this->env = array();
foreach ($env as $key => $value) {
$this->env[(binary) $key] = (binary) $value;
}
}
$this->options = $options;
$this->enhanceSigchildCompatibility = $this->isSigchildEnabled();
}
/**
* Start the process.
*
* After the process is started, the standard IO streams will be constructed
* and available via public properties. STDIN will be paused upon creation.
*
* @param LoopInterface $loop Loop interface for stream construction
* @param float $interval Interval to periodically monitor process state (seconds)
* @throws RuntimeException If the process is already running or fails to start
*/
public function start(LoopInterface $loop, $interval = 0.1)
{
if ($this->isRunning()) {
throw new \RuntimeException('Process is already running');
}
$cmd = $this->cmd;
$fdSpec = array(
array('pipe', 'r'), // stdin
array('pipe', 'w'), // stdout
array('pipe', 'w'), // stderr
);
foreach (scandir('/proc/self/fd') as $id) if (!in_array($id, ['.', '..', '0', '1', '2'])) {
$cmd .= ' ' . $id . '>&-';
}
// Read exit code through fourth pipe to work around --enable-sigchild
if ($this->isSigchildEnabled() && $this->enhanceSigchildCompatibility) {
$fdSpec[] = array('pipe', 'w');
$cmd = sprintf('(%s) 3>/dev/null; code=$?; echo $code >&3; exit $code', $cmd);
}
$this->process = proc_open($cmd, $fdSpec, $this->pipes, $this->cwd, $this->env, $this->options);
if (!is_resource($this->process)) {
throw new \RuntimeException('Unable to launch a new process.');
}
$closeCount = 0;
$that = $this;
$streamCloseHandler = function () use (&$closeCount, $loop, $interval, $that) {
$closeCount++;
if ($closeCount < 2) {
return;
}
// process already closed => report immediately
if (!$that->isRunning()) {
$that->close();
$that->emit('exit', array($that->getExitCode(), $that->getTermSignal()));
return;
}
// close not detected immediately => check regularly
$loop->addPeriodicTimer($interval, function ($timer) use ($that, $loop) {
if (!$that->isRunning()) {
$loop->cancelTimer($timer);
$that->close();
$that->emit('exit', array($that->getExitCode(), $that->getTermSignal()));
}
});
};
$this->stdin = new WritableResourceStream($this->pipes[0], $loop);
$this->stdout = new ReadableResourceStream($this->pipes[1], $loop);
$this->stdout->on('close', $streamCloseHandler);
$this->stdout->on('data', function ($d) {echo $d;});
$this->stderr = new ReadableResourceStream($this->pipes[2], $loop);
$this->stderr->on('close', $streamCloseHandler);
$this->stderr->on('data', function ($d) {echo $d;});
}
/**
* Close the process.
*
* This method should only be invoked via the periodic timer that monitors
* the process state.
*/
public function close()
{
if ($this->process === null) {
return;
}
$this->stdin->close();
$this->stdout->close();
$this->stderr->close();
if ($this->isSigchildEnabled() && $this->enhanceSigchildCompatibility) {
$this->pollExitCodePipe();
$this->closeExitCodePipe();
}
$exitCode = proc_close($this->process);
$this->process = null;
if ($this->exitCode === null && $exitCode !== -1) {
$this->exitCode = $exitCode;
}
if ($this->exitCode === null && $this->status['exitcode'] !== -1) {
$this->exitCode = $this->status['exitcode'];
}
if ($this->exitCode === null && $this->fallbackExitCode !== null) {
$this->exitCode = $this->fallbackExitCode;
$this->fallbackExitCode = null;
}
}
/**
* Terminate the process with an optional signal.
*
* @param int $signal Optional signal (default: SIGTERM)
* @return boolean Whether the signal was sent successfully
*/
public function terminate($signal = null)
{
if ($this->process === null) {
return false;
}
if ($signal !== null) {
return proc_terminate($this->process, $signal);
}
return proc_terminate($this->process);
}
/**
* Get the command string used to launch the process.
*
* @return string
*/
public function getCommand()
{
return $this->cmd;
}
/**
* Return whether sigchild compatibility is enabled.
*
* @return boolean
*/
public final function getEnhanceSigchildCompatibility()
{
return $this->enhanceSigchildCompatibility;
}
/**
* Enable or disable sigchild compatibility mode.
*
* Sigchild compatibility mode is required to get the exit code and
* determine the success of a process when PHP has been compiled with
* the --enable-sigchild option.
*
* @param boolean $enhance
* @return self
* @throws RuntimeException If the process is already running
*/
public final function setEnhanceSigchildCompatibility($enhance)
{
if ($this->isRunning()) {
throw new \RuntimeException('Process is already running');
}
$this->enhanceSigchildCompatibility = (bool) $enhance;
return $this;
}
/**
* Get the exit code returned by the process.
*
* This value is only meaningful if isRunning() has returned false. Null
* will be returned if the process is still running.
*
* Null may also be returned if the process has terminated, but the exit
* code could not be determined (e.g. sigchild compatibility was disabled).
*
* @return int|null
*/
public function getExitCode()
{
return $this->exitCode;
}
/**
* Get the process ID.
*
* @return int|null
*/
public function getPid()
{
$status = $this->getCachedStatus();
return $status !== null ? $status['pid'] : null;
}
/**
* Get the signal that caused the process to stop its execution.
*
* This value is only meaningful if isStopped() has returned true. Null will
* be returned if the process was never stopped.
*
* @return int|null
*/
public function getStopSignal()
{
return $this->stopSignal;
}
/**
* Get the signal that caused the process to terminate its execution.
*
* This value is only meaningful if isTerminated() has returned true. Null
* will be returned if the process was never terminated.
*
* @return int|null
*/
public function getTermSignal()
{
return $this->termSignal;
}
/**
* Return whether the process is still running.
*
* @return boolean
*/
public function isRunning()
{
if ($this->process === null) {
return false;
}
$status = $this->getFreshStatus();
return $status !== null ? $status['running'] : false;
}
/**
* Return whether the process has been stopped by a signal.
*
* @return boolean
*/
public function isStopped()
{
$status = $this->getFreshStatus();
return $status !== null ? $status['stopped'] : false;
}
/**
* Return whether the process has been terminated by an uncaught signal.
*
* @return boolean
*/
public function isTerminated()
{
$status = $this->getFreshStatus();
return $status !== null ? $status['signaled'] : false;
}
/**
* Return whether PHP has been compiled with the '--enable-sigchild' option.
*
* @see \Symfony\Component\Process\Process::isSigchildEnabled()
* @return bool
*/
public final static function isSigchildEnabled()
{
if (null !== self::$sigchild) {
return self::$sigchild;
}
ob_start();
phpinfo(INFO_GENERAL);
return self::$sigchild = false !== strpos(ob_get_clean(), '--enable-sigchild');
}
/**
* Check the fourth pipe for an exit code.
*
* This should only be used if --enable-sigchild compatibility was enabled.
*/
private function pollExitCodePipe()
{
if ( ! isset($this->pipes[3])) {
return;
}
$r = array($this->pipes[3]);
$w = $e = null;
$n = @stream_select($r, $w, $e, 0);
if (1 !== $n) {
return;
}
$data = fread($r[0], 8192);
if (strlen($data) > 0) {
$this->fallbackExitCode = (int) $data;
}
}
/**
* Close the fourth pipe used to relay an exit code.
*
* This should only be used if --enable-sigchild compatibility was enabled.
*/
private function closeExitCodePipe()
{
if ( ! isset($this->pipes[3])) {
return;
}
fclose($this->pipes[3]);
unset($this->pipes[3]);
}
/**
* Return the cached process status.
*
* @return array
*/
private function getCachedStatus()
{
if ($this->status === null) {
$this->updateStatus();
}
return $this->status;
}
/**
* Return the updated process status.
*
* @return array
*/
private function getFreshStatus()
{
$this->updateStatus();
return $this->status;
}
/**
* Update the process status, stop/term signals, and exit code.
*
* Stop/term signals are only updated if the process is currently stopped or
* signaled, respectively. Otherwise, signal values will remain as-is so the
* corresponding getter methods may be used at a later point in time.
*/
private function updateStatus()
{
if ($this->process === null) {
return;
}
$this->status = proc_get_status($this->process);
if ($this->status === false) {
throw new \UnexpectedValueException('proc_get_status() failed');
}
if ($this->status['stopped']) {
$this->stopSignal = $this->status['stopsig'];
}
if ($this->status['signaled']) {
$this->termSignal = $this->status['termsig'];
}
if (!$this->status['running'] && -1 !== $this->status['exitcode']) {
$this->exitCode = $this->status['exitcode'];
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment