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