123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576 |
- <?php
- namespace React\ChildProcess;
- use Evenement\EventEmitter;
- use React\EventLoop\Loop;
- use React\EventLoop\LoopInterface;
- use React\Stream\ReadableResourceStream;
- use React\Stream\ReadableStreamInterface;
- use React\Stream\WritableResourceStream;
- use React\Stream\WritableStreamInterface;
- use React\Stream\DuplexResourceStream;
- use React\Stream\DuplexStreamInterface;
- /**
- * Process component.
- *
- * This class borrows logic from Symfony's Process component for ensuring
- * compatibility when PHP is compiled with the --enable-sigchild option.
- *
- * This class also implements the `EventEmitterInterface`
- * which allows you to react to certain events:
- *
- * exit event:
- * The `exit` event will be emitted whenever the process is no longer running.
- * Event listeners will receive the exit code and termination signal as two
- * arguments:
- *
- * ```php
- * $process = new Process('sleep 10');
- * $process->start();
- *
- * $process->on('exit', function ($code, $term) {
- * if ($term === null) {
- * echo 'exit with code ' . $code . PHP_EOL;
- * } else {
- * echo 'terminated with signal ' . $term . PHP_EOL;
- * }
- * });
- * ```
- *
- * Note that `$code` is `null` if the process has terminated, but the exit
- * code could not be determined (for example
- * [sigchild compatibility](#sigchild-compatibility) was disabled).
- * Similarly, `$term` is `null` unless the process has terminated in response to
- * an uncaught signal sent to it.
- * This is not a limitation of this project, but actual how exit codes and signals
- * are exposed on POSIX systems, for more details see also
- * [here](https://unix.stackexchange.com/questions/99112/default-exit-code-when-process-is-terminated).
- *
- * It's also worth noting that process termination depends on all file descriptors
- * being closed beforehand.
- * This means that all [process pipes](#stream-properties) will emit a `close`
- * event before the `exit` event and that no more `data` events will arrive after
- * the `exit` event.
- * Accordingly, if either of these pipes is in a paused state (`pause()` method
- * or internally due to a `pipe()` call), this detection may not trigger.
- */
- class Process extends EventEmitter
- {
- /**
- * @var WritableStreamInterface|null|DuplexStreamInterface|ReadableStreamInterface
- */
- public $stdin;
- /**
- * @var ReadableStreamInterface|null|DuplexStreamInterface|WritableStreamInterface
- */
- public $stdout;
- /**
- * @var ReadableStreamInterface|null|DuplexStreamInterface|WritableStreamInterface
- */
- public $stderr;
- /**
- * Array with all process pipes (once started)
- *
- * Unless explicitly configured otherwise during construction, the following
- * standard I/O pipes will be assigned by default:
- * - 0: STDIN (`WritableStreamInterface`)
- * - 1: STDOUT (`ReadableStreamInterface`)
- * - 2: STDERR (`ReadableStreamInterface`)
- *
- * @var array<ReadableStreamInterface|WritableStreamInterface|DuplexStreamInterface>
- */
- public $pipes = array();
- private $cmd;
- private $cwd;
- private $env;
- private $fds;
- private $enhanceSigchildCompatibility;
- private $sigchildPipe;
- private $process;
- private $status;
- private $exitCode;
- private $fallbackExitCode;
- private $stopSignal;
- private $termSignal;
- private static $sigchild;
- /**
- * Constructor.
- *
- * @param string $cmd Command line to run
- * @param null|string $cwd Current working directory or null to inherit
- * @param null|array $env Environment variables or null to inherit
- * @param null|array $fds File descriptors to allocate for this process (or null = default STDIO streams)
- * @throws \LogicException On windows or when proc_open() is not installed
- */
- public function __construct($cmd, $cwd = null, array $env = null, array $fds = null)
- {
- 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;
- }
- }
- if ($fds === null) {
- $fds = array(
- array('pipe', 'r'), // stdin
- array('pipe', 'w'), // stdout
- array('pipe', 'w'), // stderr
- );
- }
- if (\DIRECTORY_SEPARATOR === '\\') {
- foreach ($fds as $fd) {
- if (isset($fd[0]) && $fd[0] === 'pipe') {
- throw new \LogicException('Process pipes are not supported on Windows due to their blocking nature on Windows');
- }
- }
- }
- $this->fds = $fds;
- $this->enhanceSigchildCompatibility = self::isSigchildEnabled();
- }
- /**
- * Start the process.
- *
- * After the process is started, the standard I/O streams will be constructed
- * and available via public properties.
- *
- * This method takes an optional `LoopInterface|null $loop` parameter that can be used to
- * pass the event loop instance to use for this process. You can use a `null` value
- * here in order to use the [default loop](https://github.com/reactphp/event-loop#loop).
- * This value SHOULD NOT be given unless you're sure you want to explicitly use a
- * given event loop instance.
- *
- * @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 = null, $interval = 0.1)
- {
- if ($this->isRunning()) {
- throw new \RuntimeException('Process is already running');
- }
- $loop = $loop ?: Loop::get();
- $cmd = $this->cmd;
- $fdSpec = $this->fds;
- $sigchild = null;
- // Read exit code through fourth pipe to work around --enable-sigchild
- if ($this->enhanceSigchildCompatibility) {
- $fdSpec[] = array('pipe', 'w');
- \end($fdSpec);
- $sigchild = \key($fdSpec);
- // make sure this is fourth or higher (do not mess with STDIO)
- if ($sigchild < 3) {
- $fdSpec[3] = $fdSpec[$sigchild];
- unset($fdSpec[$sigchild]);
- $sigchild = 3;
- }
- $cmd = \sprintf('(%s) ' . $sigchild . '>/dev/null; code=$?; echo $code >&' . $sigchild . '; exit $code', $cmd);
- }
- // on Windows, we do not launch the given command line in a shell (cmd.exe) by default and omit any error dialogs
- // the cmd.exe shell can explicitly be given as part of the command as detailed in both documentation and tests
- $options = array();
- if (\DIRECTORY_SEPARATOR === '\\') {
- $options['bypass_shell'] = true;
- $options['suppress_errors'] = true;
- }
- $errstr = '';
- \set_error_handler(function ($_, $error) use (&$errstr) {
- // Match errstr from PHP's warning message.
- // proc_open(/dev/does-not-exist): Failed to open stream: No such file or directory
- $errstr = $error;
- });
- $pipes = array();
- $this->process = @\proc_open($cmd, $fdSpec, $pipes, $this->cwd, $this->env, $options);
- \restore_error_handler();
- if (!\is_resource($this->process)) {
- throw new \RuntimeException('Unable to launch a new process: ' . $errstr);
- }
- // count open process pipes and await close event for each to drain buffers before detecting exit
- $that = $this;
- $closeCount = 0;
- $streamCloseHandler = function () use (&$closeCount, $loop, $interval, $that) {
- $closeCount--;
- if ($closeCount > 0) {
- 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()));
- }
- });
- };
- if ($sigchild !== null) {
- $this->sigchildPipe = $pipes[$sigchild];
- unset($pipes[$sigchild]);
- }
- foreach ($pipes as $n => $fd) {
- // use open mode from stream meta data or fall back to pipe open mode for legacy HHVM
- $meta = \stream_get_meta_data($fd);
- $mode = $meta['mode'] === '' ? ($this->fds[$n][1] === 'r' ? 'w' : 'r') : $meta['mode'];
- if ($mode === 'r+') {
- $stream = new DuplexResourceStream($fd, $loop);
- $stream->on('close', $streamCloseHandler);
- $closeCount++;
- } elseif ($mode === 'w') {
- $stream = new WritableResourceStream($fd, $loop);
- } else {
- $stream = new ReadableResourceStream($fd, $loop);
- $stream->on('close', $streamCloseHandler);
- $closeCount++;
- }
- $this->pipes[$n] = $stream;
- }
- $this->stdin = isset($this->pipes[0]) ? $this->pipes[0] : null;
- $this->stdout = isset($this->pipes[1]) ? $this->pipes[1] : null;
- $this->stderr = isset($this->pipes[2]) ? $this->pipes[2] : null;
- // immediately start checking for process exit when started without any I/O pipes
- if (!$closeCount) {
- $streamCloseHandler();
- }
- }
- /**
- * 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;
- }
- foreach ($this->pipes as $pipe) {
- $pipe->close();
- }
- if ($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 bool 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;
- }
- /**
- * 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 bool
- */
- 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 bool
- */
- public function isStopped()
- {
- $status = $this->getFreshStatus();
- return $status !== null ? $status['stopped'] : false;
- }
- /**
- * Return whether the process has been terminated by an uncaught signal.
- *
- * @return bool
- */
- 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;
- }
- if (!\function_exists('phpinfo')) {
- return self::$sigchild = false; // @codeCoverageIgnore
- }
- \ob_start();
- \phpinfo(INFO_GENERAL);
- return self::$sigchild = false !== \strpos(\ob_get_clean(), '--enable-sigchild');
- }
- /**
- * 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 bool $sigchild
- * @return void
- */
- public final static function setSigchildEnabled($sigchild)
- {
- self::$sigchild = (bool) $sigchild;
- }
- /**
- * Check the fourth pipe for an exit code.
- *
- * This should only be used if --enable-sigchild compatibility was enabled.
- */
- private function pollExitCodePipe()
- {
- if ($this->sigchildPipe === null) {
- return;
- }
- $r = array($this->sigchildPipe);
- $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 ($this->sigchildPipe === null) {
- return;
- }
- \fclose($this->sigchildPipe);
- $this->sigchildPipe = null;
- }
- /**
- * 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'];
- }
- }
- }
|