1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Process; 13 14use Symfony\Component\Process\Exception\InvalidArgumentException; 15use Symfony\Component\Process\Exception\LogicException; 16use Symfony\Component\Process\Exception\ProcessFailedException; 17use Symfony\Component\Process\Exception\ProcessSignaledException; 18use Symfony\Component\Process\Exception\ProcessTimedOutException; 19use Symfony\Component\Process\Exception\RuntimeException; 20use Symfony\Component\Process\Pipes\PipesInterface; 21use Symfony\Component\Process\Pipes\UnixPipes; 22use Symfony\Component\Process\Pipes\WindowsPipes; 23 24/** 25 * Process is a thin wrapper around proc_* functions to easily 26 * start independent PHP processes. 27 * 28 * @author Fabien Potencier <fabien@symfony.com> 29 * @author Romain Neutron <imprec@gmail.com> 30 * 31 * @implements \IteratorAggregate<string, string> 32 */ 33class Process implements \IteratorAggregate 34{ 35 public const ERR = 'err'; 36 public const OUT = 'out'; 37 38 public const STATUS_READY = 'ready'; 39 public const STATUS_STARTED = 'started'; 40 public const STATUS_TERMINATED = 'terminated'; 41 42 public const STDIN = 0; 43 public const STDOUT = 1; 44 public const STDERR = 2; 45 46 // Timeout Precision in seconds. 47 public const TIMEOUT_PRECISION = 0.2; 48 49 public const ITER_NON_BLOCKING = 1; // By default, iterating over outputs is a blocking call, use this flag to make it non-blocking 50 public const ITER_KEEP_OUTPUT = 2; // By default, outputs are cleared while iterating, use this flag to keep them in memory 51 public const ITER_SKIP_OUT = 4; // Use this flag to skip STDOUT while iterating 52 public const ITER_SKIP_ERR = 8; // Use this flag to skip STDERR while iterating 53 54 private $callback; 55 private $hasCallback = false; 56 private $commandline; 57 private $cwd; 58 private $env = []; 59 private $input; 60 private $starttime; 61 private $lastOutputTime; 62 private $timeout; 63 private $idleTimeout; 64 private $exitcode; 65 private $fallbackStatus = []; 66 private $processInformation; 67 private $outputDisabled = false; 68 private $stdout; 69 private $stderr; 70 private $process; 71 private $status = self::STATUS_READY; 72 private $incrementalOutputOffset = 0; 73 private $incrementalErrorOutputOffset = 0; 74 private $tty = false; 75 private $pty; 76 private $options = ['suppress_errors' => true, 'bypass_shell' => true]; 77 78 private $useFileHandles = false; 79 /** @var PipesInterface */ 80 private $processPipes; 81 82 private $latestSignal; 83 84 private static $sigchild; 85 86 /** 87 * Exit codes translation table. 88 * 89 * User-defined errors must use exit codes in the 64-113 range. 90 */ 91 public static $exitCodes = [ 92 0 => 'OK', 93 1 => 'General error', 94 2 => 'Misuse of shell builtins', 95 96 126 => 'Invoked command cannot execute', 97 127 => 'Command not found', 98 128 => 'Invalid exit argument', 99 100 // signals 101 129 => 'Hangup', 102 130 => 'Interrupt', 103 131 => 'Quit and dump core', 104 132 => 'Illegal instruction', 105 133 => 'Trace/breakpoint trap', 106 134 => 'Process aborted', 107 135 => 'Bus error: "access to undefined portion of memory object"', 108 136 => 'Floating point exception: "erroneous arithmetic operation"', 109 137 => 'Kill (terminate immediately)', 110 138 => 'User-defined 1', 111 139 => 'Segmentation violation', 112 140 => 'User-defined 2', 113 141 => 'Write to pipe with no one reading', 114 142 => 'Signal raised by alarm', 115 143 => 'Termination (request to terminate)', 116 // 144 - not defined 117 145 => 'Child process terminated, stopped (or continued*)', 118 146 => 'Continue if stopped', 119 147 => 'Stop executing temporarily', 120 148 => 'Terminal stop signal', 121 149 => 'Background process attempting to read from tty ("in")', 122 150 => 'Background process attempting to write to tty ("out")', 123 151 => 'Urgent data available on socket', 124 152 => 'CPU time limit exceeded', 125 153 => 'File size limit exceeded', 126 154 => 'Signal raised by timer counting virtual time: "virtual timer expired"', 127 155 => 'Profiling timer expired', 128 // 156 - not defined 129 157 => 'Pollable event', 130 // 158 - not defined 131 159 => 'Bad syscall', 132 ]; 133 134 /** 135 * @param array $command The command to run and its arguments listed as separate entries 136 * @param string|null $cwd The working directory or null to use the working dir of the current PHP process 137 * @param array|null $env The environment variables or null to use the same environment as the current PHP process 138 * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input 139 * @param int|float|null $timeout The timeout in seconds or null to disable 140 * 141 * @throws LogicException When proc_open is not installed 142 */ 143 public function __construct(array $command, string $cwd = null, array $env = null, $input = null, ?float $timeout = 60) 144 { 145 if (!\function_exists('proc_open')) { 146 throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.'); 147 } 148 149 $this->commandline = $command; 150 $this->cwd = $cwd; 151 152 // on Windows, if the cwd changed via chdir(), proc_open defaults to the dir where PHP was started 153 // on Gnu/Linux, PHP builds with --enable-maintainer-zts are also affected 154 // @see : https://bugs.php.net/51800 155 // @see : https://bugs.php.net/50524 156 if (null === $this->cwd && (\defined('ZEND_THREAD_SAFE') || '\\' === \DIRECTORY_SEPARATOR)) { 157 $this->cwd = getcwd(); 158 } 159 if (null !== $env) { 160 $this->setEnv($env); 161 } 162 163 $this->setInput($input); 164 $this->setTimeout($timeout); 165 $this->useFileHandles = '\\' === \DIRECTORY_SEPARATOR; 166 $this->pty = false; 167 } 168 169 /** 170 * Creates a Process instance as a command-line to be run in a shell wrapper. 171 * 172 * Command-lines are parsed by the shell of your OS (/bin/sh on Unix-like, cmd.exe on Windows.) 173 * This allows using e.g. pipes or conditional execution. In this mode, signals are sent to the 174 * shell wrapper and not to your commands. 175 * 176 * In order to inject dynamic values into command-lines, we strongly recommend using placeholders. 177 * This will save escaping values, which is not portable nor secure anyway: 178 * 179 * $process = Process::fromShellCommandline('my_command "${:MY_VAR}"'); 180 * $process->run(null, ['MY_VAR' => $theValue]); 181 * 182 * @param string $command The command line to pass to the shell of the OS 183 * @param string|null $cwd The working directory or null to use the working dir of the current PHP process 184 * @param array|null $env The environment variables or null to use the same environment as the current PHP process 185 * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input 186 * @param int|float|null $timeout The timeout in seconds or null to disable 187 * 188 * @return static 189 * 190 * @throws LogicException When proc_open is not installed 191 */ 192 public static function fromShellCommandline(string $command, string $cwd = null, array $env = null, $input = null, ?float $timeout = 60) 193 { 194 $process = new static([], $cwd, $env, $input, $timeout); 195 $process->commandline = $command; 196 197 return $process; 198 } 199 200 /** 201 * @return array 202 */ 203 public function __sleep() 204 { 205 throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 206 } 207 208 public function __wakeup() 209 { 210 throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 211 } 212 213 public function __destruct() 214 { 215 if ($this->options['create_new_console'] ?? false) { 216 $this->processPipes->close(); 217 } else { 218 $this->stop(0); 219 } 220 } 221 222 public function __clone() 223 { 224 $this->resetProcessData(); 225 } 226 227 /** 228 * Runs the process. 229 * 230 * The callback receives the type of output (out or err) and 231 * some bytes from the output in real-time. It allows to have feedback 232 * from the independent process during execution. 233 * 234 * The STDOUT and STDERR are also available after the process is finished 235 * via the getOutput() and getErrorOutput() methods. 236 * 237 * @param callable|null $callback A PHP callback to run whenever there is some 238 * output available on STDOUT or STDERR 239 * 240 * @return int The exit status code 241 * 242 * @throws RuntimeException When process can't be launched 243 * @throws RuntimeException When process is already running 244 * @throws ProcessTimedOutException When process timed out 245 * @throws ProcessSignaledException When process stopped after receiving signal 246 * @throws LogicException In case a callback is provided and output has been disabled 247 * 248 * @final 249 */ 250 public function run(callable $callback = null, array $env = []): int 251 { 252 $this->start($callback, $env); 253 254 return $this->wait(); 255 } 256 257 /** 258 * Runs the process. 259 * 260 * This is identical to run() except that an exception is thrown if the process 261 * exits with a non-zero exit code. 262 * 263 * @return $this 264 * 265 * @throws ProcessFailedException if the process didn't terminate successfully 266 * 267 * @final 268 */ 269 public function mustRun(callable $callback = null, array $env = []): self 270 { 271 if (0 !== $this->run($callback, $env)) { 272 throw new ProcessFailedException($this); 273 } 274 275 return $this; 276 } 277 278 /** 279 * Starts the process and returns after writing the input to STDIN. 280 * 281 * This method blocks until all STDIN data is sent to the process then it 282 * returns while the process runs in the background. 283 * 284 * The termination of the process can be awaited with wait(). 285 * 286 * The callback receives the type of output (out or err) and some bytes from 287 * the output in real-time while writing the standard input to the process. 288 * It allows to have feedback from the independent process during execution. 289 * 290 * @param callable|null $callback A PHP callback to run whenever there is some 291 * output available on STDOUT or STDERR 292 * 293 * @throws RuntimeException When process can't be launched 294 * @throws RuntimeException When process is already running 295 * @throws LogicException In case a callback is provided and output has been disabled 296 */ 297 public function start(callable $callback = null, array $env = []) 298 { 299 if ($this->isRunning()) { 300 throw new RuntimeException('Process is already running.'); 301 } 302 303 $this->resetProcessData(); 304 $this->starttime = $this->lastOutputTime = microtime(true); 305 $this->callback = $this->buildCallback($callback); 306 $this->hasCallback = null !== $callback; 307 $descriptors = $this->getDescriptors(); 308 309 if ($this->env) { 310 $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->env, $env, 'strcasecmp') : $this->env; 311 } 312 313 $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv(); 314 315 if (\is_array($commandline = $this->commandline)) { 316 $commandline = implode(' ', array_map([$this, 'escapeArgument'], $commandline)); 317 318 if ('\\' !== \DIRECTORY_SEPARATOR) { 319 // exec is mandatory to deal with sending a signal to the process 320 $commandline = 'exec '.$commandline; 321 } 322 } else { 323 $commandline = $this->replacePlaceholders($commandline, $env); 324 } 325 326 if ('\\' === \DIRECTORY_SEPARATOR) { 327 $commandline = $this->prepareWindowsCommandLine($commandline, $env); 328 } elseif (!$this->useFileHandles && $this->isSigchildEnabled()) { 329 // last exit code is output on the fourth pipe and caught to work around --enable-sigchild 330 $descriptors[3] = ['pipe', 'w']; 331 332 // See https://unix.stackexchange.com/questions/71205/background-process-pipe-input 333 $commandline = '{ ('.$commandline.') <&3 3<&- 3>/dev/null & } 3<&0;'; 334 $commandline .= 'pid=$!; echo $pid >&3; wait $pid; code=$?; echo $code >&3; exit $code'; 335 336 // Workaround for the bug, when PTS functionality is enabled. 337 // @see : https://bugs.php.net/69442 338 $ptsWorkaround = fopen(__FILE__, 'r'); 339 } 340 341 $envPairs = []; 342 foreach ($env as $k => $v) { 343 if (false !== $v && false === \in_array($k, ['argc', 'argv', 'ARGC', 'ARGV'], true)) { 344 $envPairs[] = $k.'='.$v; 345 } 346 } 347 348 if (!is_dir($this->cwd)) { 349 throw new RuntimeException(sprintf('The provided cwd "%s" does not exist.', $this->cwd)); 350 } 351 352 $this->process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); 353 354 if (!\is_resource($this->process)) { 355 throw new RuntimeException('Unable to launch a new process.'); 356 } 357 $this->status = self::STATUS_STARTED; 358 359 if (isset($descriptors[3])) { 360 $this->fallbackStatus['pid'] = (int) fgets($this->processPipes->pipes[3]); 361 } 362 363 if ($this->tty) { 364 return; 365 } 366 367 $this->updateStatus(false); 368 $this->checkTimeout(); 369 } 370 371 /** 372 * Restarts the process. 373 * 374 * Be warned that the process is cloned before being started. 375 * 376 * @param callable|null $callback A PHP callback to run whenever there is some 377 * output available on STDOUT or STDERR 378 * 379 * @return static 380 * 381 * @throws RuntimeException When process can't be launched 382 * @throws RuntimeException When process is already running 383 * 384 * @see start() 385 * 386 * @final 387 */ 388 public function restart(callable $callback = null, array $env = []): self 389 { 390 if ($this->isRunning()) { 391 throw new RuntimeException('Process is already running.'); 392 } 393 394 $process = clone $this; 395 $process->start($callback, $env); 396 397 return $process; 398 } 399 400 /** 401 * Waits for the process to terminate. 402 * 403 * The callback receives the type of output (out or err) and some bytes 404 * from the output in real-time while writing the standard input to the process. 405 * It allows to have feedback from the independent process during execution. 406 * 407 * @param callable|null $callback A valid PHP callback 408 * 409 * @return int The exitcode of the process 410 * 411 * @throws ProcessTimedOutException When process timed out 412 * @throws ProcessSignaledException When process stopped after receiving signal 413 * @throws LogicException When process is not yet started 414 */ 415 public function wait(callable $callback = null) 416 { 417 $this->requireProcessIsStarted(__FUNCTION__); 418 419 $this->updateStatus(false); 420 421 if (null !== $callback) { 422 if (!$this->processPipes->haveReadSupport()) { 423 $this->stop(0); 424 throw new LogicException('Pass the callback to the "Process::start" method or call enableOutput to use a callback with "Process::wait".'); 425 } 426 $this->callback = $this->buildCallback($callback); 427 } 428 429 do { 430 $this->checkTimeout(); 431 $running = '\\' === \DIRECTORY_SEPARATOR ? $this->isRunning() : $this->processPipes->areOpen(); 432 $this->readPipes($running, '\\' !== \DIRECTORY_SEPARATOR || !$running); 433 } while ($running); 434 435 while ($this->isRunning()) { 436 $this->checkTimeout(); 437 usleep(1000); 438 } 439 440 if ($this->processInformation['signaled'] && $this->processInformation['termsig'] !== $this->latestSignal) { 441 throw new ProcessSignaledException($this); 442 } 443 444 return $this->exitcode; 445 } 446 447 /** 448 * Waits until the callback returns true. 449 * 450 * The callback receives the type of output (out or err) and some bytes 451 * from the output in real-time while writing the standard input to the process. 452 * It allows to have feedback from the independent process during execution. 453 * 454 * @throws RuntimeException When process timed out 455 * @throws LogicException When process is not yet started 456 * @throws ProcessTimedOutException In case the timeout was reached 457 */ 458 public function waitUntil(callable $callback): bool 459 { 460 $this->requireProcessIsStarted(__FUNCTION__); 461 $this->updateStatus(false); 462 463 if (!$this->processPipes->haveReadSupport()) { 464 $this->stop(0); 465 throw new LogicException('Pass the callback to the "Process::start" method or call enableOutput to use a callback with "Process::waitUntil".'); 466 } 467 $callback = $this->buildCallback($callback); 468 469 $ready = false; 470 while (true) { 471 $this->checkTimeout(); 472 $running = '\\' === \DIRECTORY_SEPARATOR ? $this->isRunning() : $this->processPipes->areOpen(); 473 $output = $this->processPipes->readAndWrite($running, '\\' !== \DIRECTORY_SEPARATOR || !$running); 474 475 foreach ($output as $type => $data) { 476 if (3 !== $type) { 477 $ready = $callback(self::STDOUT === $type ? self::OUT : self::ERR, $data) || $ready; 478 } elseif (!isset($this->fallbackStatus['signaled'])) { 479 $this->fallbackStatus['exitcode'] = (int) $data; 480 } 481 } 482 if ($ready) { 483 return true; 484 } 485 if (!$running) { 486 return false; 487 } 488 489 usleep(1000); 490 } 491 } 492 493 /** 494 * Returns the Pid (process identifier), if applicable. 495 * 496 * @return int|null The process id if running, null otherwise 497 */ 498 public function getPid() 499 { 500 return $this->isRunning() ? $this->processInformation['pid'] : null; 501 } 502 503 /** 504 * Sends a POSIX signal to the process. 505 * 506 * @param int $signal A valid POSIX signal (see https://php.net/pcntl.constants) 507 * 508 * @return $this 509 * 510 * @throws LogicException In case the process is not running 511 * @throws RuntimeException In case --enable-sigchild is activated and the process can't be killed 512 * @throws RuntimeException In case of failure 513 */ 514 public function signal(int $signal) 515 { 516 $this->doSignal($signal, true); 517 518 return $this; 519 } 520 521 /** 522 * Disables fetching output and error output from the underlying process. 523 * 524 * @return $this 525 * 526 * @throws RuntimeException In case the process is already running 527 * @throws LogicException if an idle timeout is set 528 */ 529 public function disableOutput() 530 { 531 if ($this->isRunning()) { 532 throw new RuntimeException('Disabling output while the process is running is not possible.'); 533 } 534 if (null !== $this->idleTimeout) { 535 throw new LogicException('Output cannot be disabled while an idle timeout is set.'); 536 } 537 538 $this->outputDisabled = true; 539 540 return $this; 541 } 542 543 /** 544 * Enables fetching output and error output from the underlying process. 545 * 546 * @return $this 547 * 548 * @throws RuntimeException In case the process is already running 549 */ 550 public function enableOutput() 551 { 552 if ($this->isRunning()) { 553 throw new RuntimeException('Enabling output while the process is running is not possible.'); 554 } 555 556 $this->outputDisabled = false; 557 558 return $this; 559 } 560 561 /** 562 * Returns true in case the output is disabled, false otherwise. 563 * 564 * @return bool 565 */ 566 public function isOutputDisabled() 567 { 568 return $this->outputDisabled; 569 } 570 571 /** 572 * Returns the current output of the process (STDOUT). 573 * 574 * @return string 575 * 576 * @throws LogicException in case the output has been disabled 577 * @throws LogicException In case the process is not started 578 */ 579 public function getOutput() 580 { 581 $this->readPipesForOutput(__FUNCTION__); 582 583 if (false === $ret = stream_get_contents($this->stdout, -1, 0)) { 584 return ''; 585 } 586 587 return $ret; 588 } 589 590 /** 591 * Returns the output incrementally. 592 * 593 * In comparison with the getOutput method which always return the whole 594 * output, this one returns the new output since the last call. 595 * 596 * @return string 597 * 598 * @throws LogicException in case the output has been disabled 599 * @throws LogicException In case the process is not started 600 */ 601 public function getIncrementalOutput() 602 { 603 $this->readPipesForOutput(__FUNCTION__); 604 605 $latest = stream_get_contents($this->stdout, -1, $this->incrementalOutputOffset); 606 $this->incrementalOutputOffset = ftell($this->stdout); 607 608 if (false === $latest) { 609 return ''; 610 } 611 612 return $latest; 613 } 614 615 /** 616 * Returns an iterator to the output of the process, with the output type as keys (Process::OUT/ERR). 617 * 618 * @param int $flags A bit field of Process::ITER_* flags 619 * 620 * @throws LogicException in case the output has been disabled 621 * @throws LogicException In case the process is not started 622 * 623 * @return \Generator<string, string> 624 */ 625 #[\ReturnTypeWillChange] 626 public function getIterator(int $flags = 0) 627 { 628 $this->readPipesForOutput(__FUNCTION__, false); 629 630 $clearOutput = !(self::ITER_KEEP_OUTPUT & $flags); 631 $blocking = !(self::ITER_NON_BLOCKING & $flags); 632 $yieldOut = !(self::ITER_SKIP_OUT & $flags); 633 $yieldErr = !(self::ITER_SKIP_ERR & $flags); 634 635 while (null !== $this->callback || ($yieldOut && !feof($this->stdout)) || ($yieldErr && !feof($this->stderr))) { 636 if ($yieldOut) { 637 $out = stream_get_contents($this->stdout, -1, $this->incrementalOutputOffset); 638 639 if (isset($out[0])) { 640 if ($clearOutput) { 641 $this->clearOutput(); 642 } else { 643 $this->incrementalOutputOffset = ftell($this->stdout); 644 } 645 646 yield self::OUT => $out; 647 } 648 } 649 650 if ($yieldErr) { 651 $err = stream_get_contents($this->stderr, -1, $this->incrementalErrorOutputOffset); 652 653 if (isset($err[0])) { 654 if ($clearOutput) { 655 $this->clearErrorOutput(); 656 } else { 657 $this->incrementalErrorOutputOffset = ftell($this->stderr); 658 } 659 660 yield self::ERR => $err; 661 } 662 } 663 664 if (!$blocking && !isset($out[0]) && !isset($err[0])) { 665 yield self::OUT => ''; 666 } 667 668 $this->checkTimeout(); 669 $this->readPipesForOutput(__FUNCTION__, $blocking); 670 } 671 } 672 673 /** 674 * Clears the process output. 675 * 676 * @return $this 677 */ 678 public function clearOutput() 679 { 680 ftruncate($this->stdout, 0); 681 fseek($this->stdout, 0); 682 $this->incrementalOutputOffset = 0; 683 684 return $this; 685 } 686 687 /** 688 * Returns the current error output of the process (STDERR). 689 * 690 * @return string 691 * 692 * @throws LogicException in case the output has been disabled 693 * @throws LogicException In case the process is not started 694 */ 695 public function getErrorOutput() 696 { 697 $this->readPipesForOutput(__FUNCTION__); 698 699 if (false === $ret = stream_get_contents($this->stderr, -1, 0)) { 700 return ''; 701 } 702 703 return $ret; 704 } 705 706 /** 707 * Returns the errorOutput incrementally. 708 * 709 * In comparison with the getErrorOutput method which always return the 710 * whole error output, this one returns the new error output since the last 711 * call. 712 * 713 * @return string 714 * 715 * @throws LogicException in case the output has been disabled 716 * @throws LogicException In case the process is not started 717 */ 718 public function getIncrementalErrorOutput() 719 { 720 $this->readPipesForOutput(__FUNCTION__); 721 722 $latest = stream_get_contents($this->stderr, -1, $this->incrementalErrorOutputOffset); 723 $this->incrementalErrorOutputOffset = ftell($this->stderr); 724 725 if (false === $latest) { 726 return ''; 727 } 728 729 return $latest; 730 } 731 732 /** 733 * Clears the process output. 734 * 735 * @return $this 736 */ 737 public function clearErrorOutput() 738 { 739 ftruncate($this->stderr, 0); 740 fseek($this->stderr, 0); 741 $this->incrementalErrorOutputOffset = 0; 742 743 return $this; 744 } 745 746 /** 747 * Returns the exit code returned by the process. 748 * 749 * @return int|null The exit status code, null if the Process is not terminated 750 */ 751 public function getExitCode() 752 { 753 $this->updateStatus(false); 754 755 return $this->exitcode; 756 } 757 758 /** 759 * Returns a string representation for the exit code returned by the process. 760 * 761 * This method relies on the Unix exit code status standardization 762 * and might not be relevant for other operating systems. 763 * 764 * @return string|null A string representation for the exit status code, null if the Process is not terminated 765 * 766 * @see http://tldp.org/LDP/abs/html/exitcodes.html 767 * @see http://en.wikipedia.org/wiki/Unix_signal 768 */ 769 public function getExitCodeText() 770 { 771 if (null === $exitcode = $this->getExitCode()) { 772 return null; 773 } 774 775 return self::$exitCodes[$exitcode] ?? 'Unknown error'; 776 } 777 778 /** 779 * Checks if the process ended successfully. 780 * 781 * @return bool 782 */ 783 public function isSuccessful() 784 { 785 return 0 === $this->getExitCode(); 786 } 787 788 /** 789 * Returns true if the child process has been terminated by an uncaught signal. 790 * 791 * It always returns false on Windows. 792 * 793 * @return bool 794 * 795 * @throws LogicException In case the process is not terminated 796 */ 797 public function hasBeenSignaled() 798 { 799 $this->requireProcessIsTerminated(__FUNCTION__); 800 801 return $this->processInformation['signaled']; 802 } 803 804 /** 805 * Returns the number of the signal that caused the child process to terminate its execution. 806 * 807 * It is only meaningful if hasBeenSignaled() returns true. 808 * 809 * @return int 810 * 811 * @throws RuntimeException In case --enable-sigchild is activated 812 * @throws LogicException In case the process is not terminated 813 */ 814 public function getTermSignal() 815 { 816 $this->requireProcessIsTerminated(__FUNCTION__); 817 818 if ($this->isSigchildEnabled() && -1 === $this->processInformation['termsig']) { 819 throw new RuntimeException('This PHP has been compiled with --enable-sigchild. Term signal cannot be retrieved.'); 820 } 821 822 return $this->processInformation['termsig']; 823 } 824 825 /** 826 * Returns true if the child process has been stopped by a signal. 827 * 828 * It always returns false on Windows. 829 * 830 * @return bool 831 * 832 * @throws LogicException In case the process is not terminated 833 */ 834 public function hasBeenStopped() 835 { 836 $this->requireProcessIsTerminated(__FUNCTION__); 837 838 return $this->processInformation['stopped']; 839 } 840 841 /** 842 * Returns the number of the signal that caused the child process to stop its execution. 843 * 844 * It is only meaningful if hasBeenStopped() returns true. 845 * 846 * @return int 847 * 848 * @throws LogicException In case the process is not terminated 849 */ 850 public function getStopSignal() 851 { 852 $this->requireProcessIsTerminated(__FUNCTION__); 853 854 return $this->processInformation['stopsig']; 855 } 856 857 /** 858 * Checks if the process is currently running. 859 * 860 * @return bool 861 */ 862 public function isRunning() 863 { 864 if (self::STATUS_STARTED !== $this->status) { 865 return false; 866 } 867 868 $this->updateStatus(false); 869 870 return $this->processInformation['running']; 871 } 872 873 /** 874 * Checks if the process has been started with no regard to the current state. 875 * 876 * @return bool 877 */ 878 public function isStarted() 879 { 880 return self::STATUS_READY != $this->status; 881 } 882 883 /** 884 * Checks if the process is terminated. 885 * 886 * @return bool 887 */ 888 public function isTerminated() 889 { 890 $this->updateStatus(false); 891 892 return self::STATUS_TERMINATED == $this->status; 893 } 894 895 /** 896 * Gets the process status. 897 * 898 * The status is one of: ready, started, terminated. 899 * 900 * @return string 901 */ 902 public function getStatus() 903 { 904 $this->updateStatus(false); 905 906 return $this->status; 907 } 908 909 /** 910 * Stops the process. 911 * 912 * @param int|float $timeout The timeout in seconds 913 * @param int $signal A POSIX signal to send in case the process has not stop at timeout, default is SIGKILL (9) 914 * 915 * @return int|null The exit-code of the process or null if it's not running 916 */ 917 public function stop(float $timeout = 10, int $signal = null) 918 { 919 $timeoutMicro = microtime(true) + $timeout; 920 if ($this->isRunning()) { 921 // given SIGTERM may not be defined and that "proc_terminate" uses the constant value and not the constant itself, we use the same here 922 $this->doSignal(15, false); 923 do { 924 usleep(1000); 925 } while ($this->isRunning() && microtime(true) < $timeoutMicro); 926 927 if ($this->isRunning()) { 928 // Avoid exception here: process is supposed to be running, but it might have stopped just 929 // after this line. In any case, let's silently discard the error, we cannot do anything. 930 $this->doSignal($signal ?: 9, false); 931 } 932 } 933 934 if ($this->isRunning()) { 935 if (isset($this->fallbackStatus['pid'])) { 936 unset($this->fallbackStatus['pid']); 937 938 return $this->stop(0, $signal); 939 } 940 $this->close(); 941 } 942 943 return $this->exitcode; 944 } 945 946 /** 947 * Adds a line to the STDOUT stream. 948 * 949 * @internal 950 */ 951 public function addOutput(string $line) 952 { 953 $this->lastOutputTime = microtime(true); 954 955 fseek($this->stdout, 0, \SEEK_END); 956 fwrite($this->stdout, $line); 957 fseek($this->stdout, $this->incrementalOutputOffset); 958 } 959 960 /** 961 * Adds a line to the STDERR stream. 962 * 963 * @internal 964 */ 965 public function addErrorOutput(string $line) 966 { 967 $this->lastOutputTime = microtime(true); 968 969 fseek($this->stderr, 0, \SEEK_END); 970 fwrite($this->stderr, $line); 971 fseek($this->stderr, $this->incrementalErrorOutputOffset); 972 } 973 974 /** 975 * Gets the last output time in seconds. 976 */ 977 public function getLastOutputTime(): ?float 978 { 979 return $this->lastOutputTime; 980 } 981 982 /** 983 * Gets the command line to be executed. 984 * 985 * @return string 986 */ 987 public function getCommandLine() 988 { 989 return \is_array($this->commandline) ? implode(' ', array_map([$this, 'escapeArgument'], $this->commandline)) : $this->commandline; 990 } 991 992 /** 993 * Gets the process timeout in seconds (max. runtime). 994 * 995 * @return float|null 996 */ 997 public function getTimeout() 998 { 999 return $this->timeout; 1000 } 1001 1002 /** 1003 * Gets the process idle timeout in seconds (max. time since last output). 1004 * 1005 * @return float|null 1006 */ 1007 public function getIdleTimeout() 1008 { 1009 return $this->idleTimeout; 1010 } 1011 1012 /** 1013 * Sets the process timeout (max. runtime) in seconds. 1014 * 1015 * To disable the timeout, set this value to null. 1016 * 1017 * @return $this 1018 * 1019 * @throws InvalidArgumentException if the timeout is negative 1020 */ 1021 public function setTimeout(?float $timeout) 1022 { 1023 $this->timeout = $this->validateTimeout($timeout); 1024 1025 return $this; 1026 } 1027 1028 /** 1029 * Sets the process idle timeout (max. time since last output) in seconds. 1030 * 1031 * To disable the timeout, set this value to null. 1032 * 1033 * @return $this 1034 * 1035 * @throws LogicException if the output is disabled 1036 * @throws InvalidArgumentException if the timeout is negative 1037 */ 1038 public function setIdleTimeout(?float $timeout) 1039 { 1040 if (null !== $timeout && $this->outputDisabled) { 1041 throw new LogicException('Idle timeout cannot be set while the output is disabled.'); 1042 } 1043 1044 $this->idleTimeout = $this->validateTimeout($timeout); 1045 1046 return $this; 1047 } 1048 1049 /** 1050 * Enables or disables the TTY mode. 1051 * 1052 * @return $this 1053 * 1054 * @throws RuntimeException In case the TTY mode is not supported 1055 */ 1056 public function setTty(bool $tty) 1057 { 1058 if ('\\' === \DIRECTORY_SEPARATOR && $tty) { 1059 throw new RuntimeException('TTY mode is not supported on Windows platform.'); 1060 } 1061 1062 if ($tty && !self::isTtySupported()) { 1063 throw new RuntimeException('TTY mode requires /dev/tty to be read/writable.'); 1064 } 1065 1066 $this->tty = $tty; 1067 1068 return $this; 1069 } 1070 1071 /** 1072 * Checks if the TTY mode is enabled. 1073 * 1074 * @return bool 1075 */ 1076 public function isTty() 1077 { 1078 return $this->tty; 1079 } 1080 1081 /** 1082 * Sets PTY mode. 1083 * 1084 * @return $this 1085 */ 1086 public function setPty(bool $bool) 1087 { 1088 $this->pty = $bool; 1089 1090 return $this; 1091 } 1092 1093 /** 1094 * Returns PTY state. 1095 * 1096 * @return bool 1097 */ 1098 public function isPty() 1099 { 1100 return $this->pty; 1101 } 1102 1103 /** 1104 * Gets the working directory. 1105 * 1106 * @return string|null 1107 */ 1108 public function getWorkingDirectory() 1109 { 1110 if (null === $this->cwd) { 1111 // getcwd() will return false if any one of the parent directories does not have 1112 // the readable or search mode set, even if the current directory does 1113 return getcwd() ?: null; 1114 } 1115 1116 return $this->cwd; 1117 } 1118 1119 /** 1120 * Sets the current working directory. 1121 * 1122 * @return $this 1123 */ 1124 public function setWorkingDirectory(string $cwd) 1125 { 1126 $this->cwd = $cwd; 1127 1128 return $this; 1129 } 1130 1131 /** 1132 * Gets the environment variables. 1133 * 1134 * @return array 1135 */ 1136 public function getEnv() 1137 { 1138 return $this->env; 1139 } 1140 1141 /** 1142 * Sets the environment variables. 1143 * 1144 * @param array<string|\Stringable> $env The new environment variables 1145 * 1146 * @return $this 1147 */ 1148 public function setEnv(array $env) 1149 { 1150 $this->env = $env; 1151 1152 return $this; 1153 } 1154 1155 /** 1156 * Gets the Process input. 1157 * 1158 * @return resource|string|\Iterator|null 1159 */ 1160 public function getInput() 1161 { 1162 return $this->input; 1163 } 1164 1165 /** 1166 * Sets the input. 1167 * 1168 * This content will be passed to the underlying process standard input. 1169 * 1170 * @param string|int|float|bool|resource|\Traversable|null $input The content 1171 * 1172 * @return $this 1173 * 1174 * @throws LogicException In case the process is running 1175 */ 1176 public function setInput($input) 1177 { 1178 if ($this->isRunning()) { 1179 throw new LogicException('Input cannot be set while the process is running.'); 1180 } 1181 1182 $this->input = ProcessUtils::validateInput(__METHOD__, $input); 1183 1184 return $this; 1185 } 1186 1187 /** 1188 * Performs a check between the timeout definition and the time the process started. 1189 * 1190 * In case you run a background process (with the start method), you should 1191 * trigger this method regularly to ensure the process timeout 1192 * 1193 * @throws ProcessTimedOutException In case the timeout was reached 1194 */ 1195 public function checkTimeout() 1196 { 1197 if (self::STATUS_STARTED !== $this->status) { 1198 return; 1199 } 1200 1201 if (null !== $this->timeout && $this->timeout < microtime(true) - $this->starttime) { 1202 $this->stop(0); 1203 1204 throw new ProcessTimedOutException($this, ProcessTimedOutException::TYPE_GENERAL); 1205 } 1206 1207 if (null !== $this->idleTimeout && $this->idleTimeout < microtime(true) - $this->lastOutputTime) { 1208 $this->stop(0); 1209 1210 throw new ProcessTimedOutException($this, ProcessTimedOutException::TYPE_IDLE); 1211 } 1212 } 1213 1214 /** 1215 * @throws LogicException in case process is not started 1216 */ 1217 public function getStartTime(): float 1218 { 1219 if (!$this->isStarted()) { 1220 throw new LogicException('Start time is only available after process start.'); 1221 } 1222 1223 return $this->starttime; 1224 } 1225 1226 /** 1227 * Defines options to pass to the underlying proc_open(). 1228 * 1229 * @see https://php.net/proc_open for the options supported by PHP. 1230 * 1231 * Enabling the "create_new_console" option allows a subprocess to continue 1232 * to run after the main process exited, on both Windows and *nix 1233 */ 1234 public function setOptions(array $options) 1235 { 1236 if ($this->isRunning()) { 1237 throw new RuntimeException('Setting options while the process is running is not possible.'); 1238 } 1239 1240 $defaultOptions = $this->options; 1241 $existingOptions = ['blocking_pipes', 'create_process_group', 'create_new_console']; 1242 1243 foreach ($options as $key => $value) { 1244 if (!\in_array($key, $existingOptions)) { 1245 $this->options = $defaultOptions; 1246 throw new LogicException(sprintf('Invalid option "%s" passed to "%s()". Supported options are "%s".', $key, __METHOD__, implode('", "', $existingOptions))); 1247 } 1248 $this->options[$key] = $value; 1249 } 1250 } 1251 1252 /** 1253 * Returns whether TTY is supported on the current operating system. 1254 */ 1255 public static function isTtySupported(): bool 1256 { 1257 static $isTtySupported; 1258 1259 if (null === $isTtySupported) { 1260 $isTtySupported = (bool) @proc_open('echo 1 >/dev/null', [['file', '/dev/tty', 'r'], ['file', '/dev/tty', 'w'], ['file', '/dev/tty', 'w']], $pipes); 1261 } 1262 1263 return $isTtySupported; 1264 } 1265 1266 /** 1267 * Returns whether PTY is supported on the current operating system. 1268 * 1269 * @return bool 1270 */ 1271 public static function isPtySupported() 1272 { 1273 static $result; 1274 1275 if (null !== $result) { 1276 return $result; 1277 } 1278 1279 if ('\\' === \DIRECTORY_SEPARATOR) { 1280 return $result = false; 1281 } 1282 1283 return $result = (bool) @proc_open('echo 1 >/dev/null', [['pty'], ['pty'], ['pty']], $pipes); 1284 } 1285 1286 /** 1287 * Creates the descriptors needed by the proc_open. 1288 */ 1289 private function getDescriptors(): array 1290 { 1291 if ($this->input instanceof \Iterator) { 1292 $this->input->rewind(); 1293 } 1294 if ('\\' === \DIRECTORY_SEPARATOR) { 1295 $this->processPipes = new WindowsPipes($this->input, !$this->outputDisabled || $this->hasCallback); 1296 } else { 1297 $this->processPipes = new UnixPipes($this->isTty(), $this->isPty(), $this->input, !$this->outputDisabled || $this->hasCallback); 1298 } 1299 1300 return $this->processPipes->getDescriptors(); 1301 } 1302 1303 /** 1304 * Builds up the callback used by wait(). 1305 * 1306 * The callbacks adds all occurred output to the specific buffer and calls 1307 * the user callback (if present) with the received output. 1308 * 1309 * @param callable|null $callback The user defined PHP callback 1310 * 1311 * @return \Closure 1312 */ 1313 protected function buildCallback(callable $callback = null) 1314 { 1315 if ($this->outputDisabled) { 1316 return function ($type, $data) use ($callback): bool { 1317 return null !== $callback && $callback($type, $data); 1318 }; 1319 } 1320 1321 $out = self::OUT; 1322 1323 return function ($type, $data) use ($callback, $out): bool { 1324 if ($out == $type) { 1325 $this->addOutput($data); 1326 } else { 1327 $this->addErrorOutput($data); 1328 } 1329 1330 return null !== $callback && $callback($type, $data); 1331 }; 1332 } 1333 1334 /** 1335 * Updates the status of the process, reads pipes. 1336 * 1337 * @param bool $blocking Whether to use a blocking read call 1338 */ 1339 protected function updateStatus(bool $blocking) 1340 { 1341 if (self::STATUS_STARTED !== $this->status) { 1342 return; 1343 } 1344 1345 $this->processInformation = proc_get_status($this->process); 1346 $running = $this->processInformation['running']; 1347 1348 $this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running); 1349 1350 if ($this->fallbackStatus && $this->isSigchildEnabled()) { 1351 $this->processInformation = $this->fallbackStatus + $this->processInformation; 1352 } 1353 1354 if (!$running) { 1355 $this->close(); 1356 } 1357 } 1358 1359 /** 1360 * Returns whether PHP has been compiled with the '--enable-sigchild' option or not. 1361 * 1362 * @return bool 1363 */ 1364 protected function isSigchildEnabled() 1365 { 1366 if (null !== self::$sigchild) { 1367 return self::$sigchild; 1368 } 1369 1370 if (!\function_exists('phpinfo')) { 1371 return self::$sigchild = false; 1372 } 1373 1374 ob_start(); 1375 phpinfo(\INFO_GENERAL); 1376 1377 return self::$sigchild = str_contains(ob_get_clean(), '--enable-sigchild'); 1378 } 1379 1380 /** 1381 * Reads pipes for the freshest output. 1382 * 1383 * @param string $caller The name of the method that needs fresh outputs 1384 * @param bool $blocking Whether to use blocking calls or not 1385 * 1386 * @throws LogicException in case output has been disabled or process is not started 1387 */ 1388 private function readPipesForOutput(string $caller, bool $blocking = false) 1389 { 1390 if ($this->outputDisabled) { 1391 throw new LogicException('Output has been disabled.'); 1392 } 1393 1394 $this->requireProcessIsStarted($caller); 1395 1396 $this->updateStatus($blocking); 1397 } 1398 1399 /** 1400 * Validates and returns the filtered timeout. 1401 * 1402 * @throws InvalidArgumentException if the given timeout is a negative number 1403 */ 1404 private function validateTimeout(?float $timeout): ?float 1405 { 1406 $timeout = (float) $timeout; 1407 1408 if (0.0 === $timeout) { 1409 $timeout = null; 1410 } elseif ($timeout < 0) { 1411 throw new InvalidArgumentException('The timeout value must be a valid positive integer or float number.'); 1412 } 1413 1414 return $timeout; 1415 } 1416 1417 /** 1418 * Reads pipes, executes callback. 1419 * 1420 * @param bool $blocking Whether to use blocking calls or not 1421 * @param bool $close Whether to close file handles or not 1422 */ 1423 private function readPipes(bool $blocking, bool $close) 1424 { 1425 $result = $this->processPipes->readAndWrite($blocking, $close); 1426 1427 $callback = $this->callback; 1428 foreach ($result as $type => $data) { 1429 if (3 !== $type) { 1430 $callback(self::STDOUT === $type ? self::OUT : self::ERR, $data); 1431 } elseif (!isset($this->fallbackStatus['signaled'])) { 1432 $this->fallbackStatus['exitcode'] = (int) $data; 1433 } 1434 } 1435 } 1436 1437 /** 1438 * Closes process resource, closes file handles, sets the exitcode. 1439 * 1440 * @return int The exitcode 1441 */ 1442 private function close(): int 1443 { 1444 $this->processPipes->close(); 1445 if (\is_resource($this->process)) { 1446 proc_close($this->process); 1447 } 1448 $this->exitcode = $this->processInformation['exitcode']; 1449 $this->status = self::STATUS_TERMINATED; 1450 1451 if (-1 === $this->exitcode) { 1452 if ($this->processInformation['signaled'] && 0 < $this->processInformation['termsig']) { 1453 // if process has been signaled, no exitcode but a valid termsig, apply Unix convention 1454 $this->exitcode = 128 + $this->processInformation['termsig']; 1455 } elseif ($this->isSigchildEnabled()) { 1456 $this->processInformation['signaled'] = true; 1457 $this->processInformation['termsig'] = -1; 1458 } 1459 } 1460 1461 // Free memory from self-reference callback created by buildCallback 1462 // Doing so in other contexts like __destruct or by garbage collector is ineffective 1463 // Now pipes are closed, so the callback is no longer necessary 1464 $this->callback = null; 1465 1466 return $this->exitcode; 1467 } 1468 1469 /** 1470 * Resets data related to the latest run of the process. 1471 */ 1472 private function resetProcessData() 1473 { 1474 $this->starttime = null; 1475 $this->callback = null; 1476 $this->exitcode = null; 1477 $this->fallbackStatus = []; 1478 $this->processInformation = null; 1479 $this->stdout = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); 1480 $this->stderr = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+'); 1481 $this->process = null; 1482 $this->latestSignal = null; 1483 $this->status = self::STATUS_READY; 1484 $this->incrementalOutputOffset = 0; 1485 $this->incrementalErrorOutputOffset = 0; 1486 } 1487 1488 /** 1489 * Sends a POSIX signal to the process. 1490 * 1491 * @param int $signal A valid POSIX signal (see https://php.net/pcntl.constants) 1492 * @param bool $throwException Whether to throw exception in case signal failed 1493 * 1494 * @throws LogicException In case the process is not running 1495 * @throws RuntimeException In case --enable-sigchild is activated and the process can't be killed 1496 * @throws RuntimeException In case of failure 1497 */ 1498 private function doSignal(int $signal, bool $throwException): bool 1499 { 1500 if (null === $pid = $this->getPid()) { 1501 if ($throwException) { 1502 throw new LogicException('Cannot send signal on a non running process.'); 1503 } 1504 1505 return false; 1506 } 1507 1508 if ('\\' === \DIRECTORY_SEPARATOR) { 1509 exec(sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode); 1510 if ($exitCode && $this->isRunning()) { 1511 if ($throwException) { 1512 throw new RuntimeException(sprintf('Unable to kill the process (%s).', implode(' ', $output))); 1513 } 1514 1515 return false; 1516 } 1517 } else { 1518 if (!$this->isSigchildEnabled()) { 1519 $ok = @proc_terminate($this->process, $signal); 1520 } elseif (\function_exists('posix_kill')) { 1521 $ok = @posix_kill($pid, $signal); 1522 } elseif ($ok = proc_open(sprintf('kill -%d %d', $signal, $pid), [2 => ['pipe', 'w']], $pipes)) { 1523 $ok = false === fgets($pipes[2]); 1524 } 1525 if (!$ok) { 1526 if ($throwException) { 1527 throw new RuntimeException(sprintf('Error while sending signal "%s".', $signal)); 1528 } 1529 1530 return false; 1531 } 1532 } 1533 1534 $this->latestSignal = $signal; 1535 $this->fallbackStatus['signaled'] = true; 1536 $this->fallbackStatus['exitcode'] = -1; 1537 $this->fallbackStatus['termsig'] = $this->latestSignal; 1538 1539 return true; 1540 } 1541 1542 private function prepareWindowsCommandLine(string $cmd, array &$env): string 1543 { 1544 $uid = uniqid('', true); 1545 $varCount = 0; 1546 $varCache = []; 1547 $cmd = preg_replace_callback( 1548 '/"(?:( 1549 [^"%!^]*+ 1550 (?: 1551 (?: !LF! | "(?:\^[%!^])?+" ) 1552 [^"%!^]*+ 1553 )++ 1554 ) | [^"]*+ )"/x', 1555 function ($m) use (&$env, &$varCache, &$varCount, $uid) { 1556 if (!isset($m[1])) { 1557 return $m[0]; 1558 } 1559 if (isset($varCache[$m[0]])) { 1560 return $varCache[$m[0]]; 1561 } 1562 if (str_contains($value = $m[1], "\0")) { 1563 $value = str_replace("\0", '?', $value); 1564 } 1565 if (false === strpbrk($value, "\"%!\n")) { 1566 return '"'.$value.'"'; 1567 } 1568 1569 $value = str_replace(['!LF!', '"^!"', '"^%"', '"^^"', '""'], ["\n", '!', '%', '^', '"'], $value); 1570 $value = '"'.preg_replace('/(\\\\*)"/', '$1$1\\"', $value).'"'; 1571 $var = $uid.++$varCount; 1572 1573 $env[$var] = $value; 1574 1575 return $varCache[$m[0]] = '!'.$var.'!'; 1576 }, 1577 $cmd 1578 ); 1579 1580 $cmd = 'cmd /V:ON /E:ON /D /C ('.str_replace("\n", ' ', $cmd).')'; 1581 foreach ($this->processPipes->getFiles() as $offset => $filename) { 1582 $cmd .= ' '.$offset.'>"'.$filename.'"'; 1583 } 1584 1585 return $cmd; 1586 } 1587 1588 /** 1589 * Ensures the process is running or terminated, throws a LogicException if the process has a not started. 1590 * 1591 * @throws LogicException if the process has not run 1592 */ 1593 private function requireProcessIsStarted(string $functionName) 1594 { 1595 if (!$this->isStarted()) { 1596 throw new LogicException(sprintf('Process must be started before calling "%s()".', $functionName)); 1597 } 1598 } 1599 1600 /** 1601 * Ensures the process is terminated, throws a LogicException if the process has a status different than "terminated". 1602 * 1603 * @throws LogicException if the process is not yet terminated 1604 */ 1605 private function requireProcessIsTerminated(string $functionName) 1606 { 1607 if (!$this->isTerminated()) { 1608 throw new LogicException(sprintf('Process must be terminated before calling "%s()".', $functionName)); 1609 } 1610 } 1611 1612 /** 1613 * Escapes a string to be used as a shell argument. 1614 */ 1615 private function escapeArgument(?string $argument): string 1616 { 1617 if ('' === $argument || null === $argument) { 1618 return '""'; 1619 } 1620 if ('\\' !== \DIRECTORY_SEPARATOR) { 1621 return "'".str_replace("'", "'\\''", $argument)."'"; 1622 } 1623 if (str_contains($argument, "\0")) { 1624 $argument = str_replace("\0", '?', $argument); 1625 } 1626 if (!preg_match('/[\/()%!^"<>&|\s]/', $argument)) { 1627 return $argument; 1628 } 1629 $argument = preg_replace('/(\\\\+)$/', '$1$1', $argument); 1630 1631 return '"'.str_replace(['"', '^', '%', '!', "\n"], ['""', '"^^"', '"^%"', '"^!"', '!LF!'], $argument).'"'; 1632 } 1633 1634 private function replacePlaceholders(string $commandline, array $env) 1635 { 1636 return preg_replace_callback('/"\$\{:([_a-zA-Z]++[_a-zA-Z0-9]*+)\}"/', function ($matches) use ($commandline, $env) { 1637 if (!isset($env[$matches[1]]) || false === $env[$matches[1]]) { 1638 throw new InvalidArgumentException(sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline); 1639 } 1640 1641 return $this->escapeArgument($env[$matches[1]]); 1642 }, $commandline); 1643 } 1644 1645 private function getDefaultEnv(): array 1646 { 1647 $env = getenv(); 1648 $env = ('\\' === \DIRECTORY_SEPARATOR ? array_intersect_ukey($env, $_SERVER, 'strcasecmp') : array_intersect_key($env, $_SERVER)) ?: $env; 1649 1650 return $_ENV + ('\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($env, $_ENV, 'strcasecmp') : $env); 1651 } 1652} 1653