GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Push — master ( cc39b3...2b121f )
by Anton
04:25 queued 01:05
created

Process   F

Complexity

Total Complexity 248

Size/Duplication

Total Lines 1626
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 541
c 2
b 0
f 0
dl 0
loc 1626
rs 2
wmc 248

77 Methods

Rating   Name   Duplication   Size   Complexity  
C getIterator() 0 44 15
A readPipes() 0 10 5
A __destruct() 0 6 2
A getErrorOutput() 0 9 2
A replacePlaceholders() 0 9 3
A getCommandLine() 0 3 1
A __wakeup() 0 3 1
A getEnv() 0 3 1
A requireProcessIsTerminated() 0 4 2
A setIgnoredSignals() 0 7 2
A isSuccessful() 0 3 1
A getStopSignal() 0 5 1
A getExitCode() 0 5 1
A isStarted() 0 3 1
A restart() 0 10 2
A getTimeout() 0 3 1
A setTimeout() 0 5 1
A mustRun() 0 7 2
A addOutput() 0 7 1
A getIdleTimeout() 0 3 1
A fromShellCommandline() 0 6 1
A buildShellCommandline() 0 7 2
A hasBeenSignaled() 0 5 1
A setOptions() 0 15 4
A setPty() 0 5 1
A isRunning() 0 9 2
A clearErrorOutput() 0 7 1
A setIdleTimeout() 0 9 3
A validateTimeout() 0 11 3
A enableOutput() 0 9 2
A getIncrementalOutput() 0 12 2
A setWorkingDirectory() 0 5 1
A isPty() 0 3 1
A close() 0 26 6
A isOutputDisabled() 0 3 1
A isTerminated() 0 5 1
F start() 0 97 22
A isSigchildEnabled() 0 14 3
A isTty() 0 3 1
A signal() 0 5 1
A buildCallback() 0 16 5
A readPipesForOutput() 0 9 2
A isPtySupported() 0 13 3
A setEnv() 0 5 1
A disableOutput() 0 12 3
B wait() 0 30 10
A requireProcessIsStarted() 0 4 2
A __construct() 0 23 6
C waitUntil() 0 32 12
A addErrorOutput() 0 7 1
A escapeArgument() 0 17 6
A getIncrementalErrorOutput() 0 12 2
C updateStatus() 0 30 14
A __sleep() 0 3 1
A run() 0 5 1
A getOutput() 0 9 2
C doSignal() 0 47 14
A checkTimeout() 0 16 6
A getPid() 0 3 2
A __clone() 0 3 1
A clearOutput() 0 7 1
B stop() 0 27 8
A setTty() 0 13 5
B prepareWindowsCommandLine() 0 45 6
A getLastOutputTime() 0 3 1
A isTtySupported() 0 5 3
A getDescriptors() 0 12 5
A getInput() 0 3 1
A setInput() 0 9 2
A getTermSignal() 0 9 3
A getStartTime() 0 7 2
A getStatus() 0 5 1
A resetProcessData() 0 14 1
A getExitCodeText() 0 7 2
A hasBeenStopped() 0 5 1
A getDefaultEnv() 0 6 4
A getWorkingDirectory() 0 9 3

How to fix   Complexity   

Complex Class

Complex classes like Process often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Process, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/*
4
 * This file is part of the Symfony package.
5
 *
6
 * (c) Fabien Potencier <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Symfony\Component\Process;
13
14
use Symfony\Component\Process\Exception\InvalidArgumentException;
15
use Symfony\Component\Process\Exception\LogicException;
16
use Symfony\Component\Process\Exception\ProcessFailedException;
17
use Symfony\Component\Process\Exception\ProcessSignaledException;
18
use Symfony\Component\Process\Exception\ProcessStartFailedException;
19
use Symfony\Component\Process\Exception\ProcessTimedOutException;
20
use Symfony\Component\Process\Exception\RuntimeException;
21
use Symfony\Component\Process\Pipes\UnixPipes;
22
use 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 <[email protected]>
29
 * @author Romain Neutron <[email protected]>
30
 *
31
 * @implements \IteratorAggregate<string, string>
32
 */
33
class 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 ?\Closure $callback = null;
55
    private array|string $commandline;
56
    private ?string $cwd;
57
    private array $env = [];
58
    /** @var resource|string|\Iterator|null */
59
    private $input;
60
    private ?float $starttime = null;
61
    private ?float $lastOutputTime = null;
62
    private ?float $timeout = null;
63
    private ?float $idleTimeout = null;
64
    private ?int $exitcode = null;
65
    private array $fallbackStatus = [];
66
    private array $processInformation;
67
    private bool $outputDisabled = false;
68
    /** @var resource */
69
    private $stdout;
70
    /** @var resource */
71
    private $stderr;
72
    /** @var resource|null */
73
    private $process;
74
    private string $status = self::STATUS_READY;
75
    private int $incrementalOutputOffset = 0;
76
    private int $incrementalErrorOutputOffset = 0;
77
    private bool $tty = false;
78
    private bool $pty;
79
    private array $options = ['suppress_errors' => true, 'bypass_shell' => true];
80
    private array $ignoredSignals = [];
81
82
    private WindowsPipes|UnixPipes $processPipes;
83
84
    private ?int $latestSignal = null;
85
    private ?int $cachedExitCode = null;
86
87
    private static ?bool $sigchild = null;
88
89
    /**
90
     * Exit codes translation table.
91
     *
92
     * User-defined errors must use exit codes in the 64-113 range.
93
     */
94
    public static array $exitCodes = [
95
        0 => 'OK',
96
        1 => 'General error',
97
        2 => 'Misuse of shell builtins',
98
99
        126 => 'Invoked command cannot execute',
100
        127 => 'Command not found',
101
        128 => 'Invalid exit argument',
102
103
        // signals
104
        129 => 'Hangup',
105
        130 => 'Interrupt',
106
        131 => 'Quit and dump core',
107
        132 => 'Illegal instruction',
108
        133 => 'Trace/breakpoint trap',
109
        134 => 'Process aborted',
110
        135 => 'Bus error: "access to undefined portion of memory object"',
111
        136 => 'Floating point exception: "erroneous arithmetic operation"',
112
        137 => 'Kill (terminate immediately)',
113
        138 => 'User-defined 1',
114
        139 => 'Segmentation violation',
115
        140 => 'User-defined 2',
116
        141 => 'Write to pipe with no one reading',
117
        142 => 'Signal raised by alarm',
118
        143 => 'Termination (request to terminate)',
119
        // 144 - not defined
120
        145 => 'Child process terminated, stopped (or continued*)',
121
        146 => 'Continue if stopped',
122
        147 => 'Stop executing temporarily',
123
        148 => 'Terminal stop signal',
124
        149 => 'Background process attempting to read from tty ("in")',
125
        150 => 'Background process attempting to write to tty ("out")',
126
        151 => 'Urgent data available on socket',
127
        152 => 'CPU time limit exceeded',
128
        153 => 'File size limit exceeded',
129
        154 => 'Signal raised by timer counting virtual time: "virtual timer expired"',
130
        155 => 'Profiling timer expired',
131
        // 156 - not defined
132
        157 => 'Pollable event',
133
        // 158 - not defined
134
        159 => 'Bad syscall',
135
    ];
136
137
    /**
138
     * @param array          $command The command to run and its arguments listed as separate entries
139
     * @param string|null    $cwd     The working directory or null to use the working dir of the current PHP process
140
     * @param array|null     $env     The environment variables or null to use the same environment as the current PHP process
141
     * @param mixed          $input   The input as stream resource, scalar or \Traversable, or null for no input
142
     * @param int|float|null $timeout The timeout in seconds or null to disable
143
     *
144
     * @throws LogicException When proc_open is not installed
145
     */
146
    public function __construct(array $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60)
147
    {
148
        if (!\function_exists('proc_open')) {
149
            throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.');
150
        }
151
152
        $this->commandline = $command;
153
        $this->cwd = $cwd;
154
155
        // on Windows, if the cwd changed via chdir(), proc_open defaults to the dir where PHP was started
156
        // on Gnu/Linux, PHP builds with --enable-maintainer-zts are also affected
157
        // @see : https://bugs.php.net/51800
158
        // @see : https://bugs.php.net/50524
159
        if (null === $this->cwd && (\defined('ZEND_THREAD_SAFE') || '\\' === \DIRECTORY_SEPARATOR)) {
160
            $this->cwd = getcwd();
161
        }
162
        if (null !== $env) {
163
            $this->setEnv($env);
164
        }
165
166
        $this->setInput($input);
167
        $this->setTimeout($timeout);
168
        $this->pty = false;
169
    }
170
171
    /**
172
     * Creates a Process instance as a command-line to be run in a shell wrapper.
173
     *
174
     * Command-lines are parsed by the shell of your OS (/bin/sh on Unix-like, cmd.exe on Windows.)
175
     * This allows using e.g. pipes or conditional execution. In this mode, signals are sent to the
176
     * shell wrapper and not to your commands.
177
     *
178
     * In order to inject dynamic values into command-lines, we strongly recommend using placeholders.
179
     * This will save escaping values, which is not portable nor secure anyway:
180
     *
181
     *   $process = Process::fromShellCommandline('my_command "${:MY_VAR}"');
182
     *   $process->run(null, ['MY_VAR' => $theValue]);
183
     *
184
     * @param string         $command The command line to pass to the shell of the OS
185
     * @param string|null    $cwd     The working directory or null to use the working dir of the current PHP process
186
     * @param array|null     $env     The environment variables or null to use the same environment as the current PHP process
187
     * @param mixed          $input   The input as stream resource, scalar or \Traversable, or null for no input
188
     * @param int|float|null $timeout The timeout in seconds or null to disable
189
     *
190
     * @throws LogicException When proc_open is not installed
191
     */
192
    public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static
193
    {
194
        $process = new static([], $cwd, $env, $input, $timeout);
195
        $process->commandline = $command;
196
197
        return $process;
198
    }
199
200
    public function __sleep(): array
201
    {
202
        throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
203
    }
204
205
    public function __wakeup(): void
206
    {
207
        throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
208
    }
209
210
    public function __destruct()
211
    {
212
        if ($this->options['create_new_console'] ?? false) {
213
            $this->processPipes->close();
214
        } else {
215
            $this->stop(0);
216
        }
217
    }
218
219
    public function __clone()
220
    {
221
        $this->resetProcessData();
222
    }
223
224
    /**
225
     * Runs the process.
226
     *
227
     * The callback receives the type of output (out or err) and
228
     * some bytes from the output in real-time. It allows to have feedback
229
     * from the independent process during execution.
230
     *
231
     * The STDOUT and STDERR are also available after the process is finished
232
     * via the getOutput() and getErrorOutput() methods.
233
     *
234
     * @param callable|null $callback A PHP callback to run whenever there is some
235
     *                                output available on STDOUT or STDERR
236
     *
237
     * @return int The exit status code
238
     *
239
     * @throws ProcessStartFailedException When process can't be launched
240
     * @throws RuntimeException            When process is already running
241
     * @throws ProcessTimedOutException    When process timed out
242
     * @throws ProcessSignaledException    When process stopped after receiving signal
243
     * @throws LogicException              In case a callback is provided and output has been disabled
244
     *
245
     * @final
246
     */
247
    public function run(?callable $callback = null, array $env = []): int
248
    {
249
        $this->start($callback, $env);
250
251
        return $this->wait();
252
    }
253
254
    /**
255
     * Runs the process.
256
     *
257
     * This is identical to run() except that an exception is thrown if the process
258
     * exits with a non-zero exit code.
259
     *
260
     * @return $this
261
     *
262
     * @throws ProcessFailedException if the process didn't terminate successfully
263
     *
264
     * @final
265
     */
266
    public function mustRun(?callable $callback = null, array $env = []): static
267
    {
268
        if (0 !== $this->run($callback, $env)) {
269
            throw new ProcessFailedException($this);
270
        }
271
272
        return $this;
273
    }
274
275
    /**
276
     * Starts the process and returns after writing the input to STDIN.
277
     *
278
     * This method blocks until all STDIN data is sent to the process then it
279
     * returns while the process runs in the background.
280
     *
281
     * The termination of the process can be awaited with wait().
282
     *
283
     * The callback receives the type of output (out or err) and some bytes from
284
     * the output in real-time while writing the standard input to the process.
285
     * It allows to have feedback from the independent process during execution.
286
     *
287
     * @param callable|null $callback A PHP callback to run whenever there is some
288
     *                                output available on STDOUT or STDERR
289
     *
290
     * @throws ProcessStartFailedException When process can't be launched
291
     * @throws RuntimeException            When process is already running
292
     * @throws LogicException              In case a callback is provided and output has been disabled
293
     */
294
    public function start(?callable $callback = null, array $env = []): void
295
    {
296
        if ($this->isRunning()) {
297
            throw new RuntimeException('Process is already running.');
298
        }
299
300
        $this->resetProcessData();
301
        $this->starttime = $this->lastOutputTime = microtime(true);
302
        $this->callback = $this->buildCallback($callback);
303
        $descriptors = $this->getDescriptors(null !== $callback);
304
305
        if ($this->env) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->env of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
306
            $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->env, $env, 'strcasecmp') : $this->env;
307
        }
308
309
        $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv();
310
311
        if (\is_array($commandline = $this->commandline)) {
312
            $commandline = array_values(array_map(strval(...), $commandline));
0 ignored issues
show
Bug introduced by
The type Symfony\Component\Process\strval was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
313
        } else {
314
            $commandline = $this->replacePlaceholders($commandline, $env);
315
        }
316
317
        if ('\\' === \DIRECTORY_SEPARATOR) {
318
            $commandline = $this->prepareWindowsCommandLine($commandline, $env);
319
        } elseif ($this->isSigchildEnabled()) {
320
            // last exit code is output on the fourth pipe and caught to work around --enable-sigchild
321
            $descriptors[3] = ['pipe', 'w'];
322
323
            if (\is_array($commandline)) {
324
                // exec is mandatory to deal with sending a signal to the process
325
                $commandline = 'exec '.$this->buildShellCommandline($commandline);
326
            }
327
328
            // See https://unix.stackexchange.com/questions/71205/background-process-pipe-input
329
            $commandline = '{ ('.$commandline.') <&3 3<&- 3>/dev/null & } 3<&0;';
330
            $commandline .= 'pid=$!; echo $pid >&3; wait $pid 2>/dev/null; code=$?; echo $code >&3; exit $code';
331
        }
332
333
        $envPairs = [];
334
        foreach ($env as $k => $v) {
335
            if (false !== $v && false === \in_array($k, ['argc', 'argv', 'ARGC', 'ARGV'], true)) {
336
                $envPairs[] = $k.'='.$v;
337
            }
338
        }
339
340
        if (!is_dir($this->cwd)) {
0 ignored issues
show
Bug introduced by
It seems like $this->cwd can also be of type null; however, parameter $filename of is_dir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

340
        if (!is_dir(/** @scrutinizer ignore-type */ $this->cwd)) {
Loading history...
341
            throw new RuntimeException(sprintf('The provided cwd "%s" does not exist.', $this->cwd));
342
        }
343
344
        $lastError = null;
345
        set_error_handler(function ($type, $msg) use (&$lastError) {
346
            $lastError = $msg;
347
348
            return true;
349
        });
350
351
        $oldMask = [];
352
353
        if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->ignoredSignals of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
354
            // we block signals we want to ignore, as proc_open will use fork / posix_spawn which will copy the signal mask this allow to block
355
            // signals in the child process
356
            pcntl_sigprocmask(\SIG_BLOCK, $this->ignoredSignals, $oldMask);
357
        }
358
359
        try {
360
            $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options);
361
362
            // Ensure array vs string commands behave the same
363
            if (!$process && \is_array($commandline)) {
0 ignored issues
show
introduced by
$process is of type false|resource, thus it always evaluated to false.
Loading history...
364
                $process = @proc_open('exec '.$this->buildShellCommandline($commandline), $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options);
365
            }
366
        } finally {
367
            if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->ignoredSignals of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
368
                // we restore the signal mask here to avoid any side effects
369
                pcntl_sigprocmask(\SIG_SETMASK, $oldMask);
370
            }
371
372
            restore_error_handler();
373
        }
374
375
        if (!$process) {
0 ignored issues
show
introduced by
$process is of type false|resource, thus it always evaluated to false.
Loading history...
376
            throw new ProcessStartFailedException($this, $lastError);
377
        }
378
        $this->process = $process;
379
        $this->status = self::STATUS_STARTED;
380
381
        if (isset($descriptors[3])) {
382
            $this->fallbackStatus['pid'] = (int) fgets($this->processPipes->pipes[3]);
383
        }
384
385
        if ($this->tty) {
386
            return;
387
        }
388
389
        $this->updateStatus(false);
390
        $this->checkTimeout();
391
    }
392
393
    /**
394
     * Restarts the process.
395
     *
396
     * Be warned that the process is cloned before being started.
397
     *
398
     * @param callable|null $callback A PHP callback to run whenever there is some
399
     *                                output available on STDOUT or STDERR
400
     *
401
     * @throws ProcessStartFailedException When process can't be launched
402
     * @throws RuntimeException            When process is already running
403
     *
404
     * @see start()
405
     *
406
     * @final
407
     */
408
    public function restart(?callable $callback = null, array $env = []): static
409
    {
410
        if ($this->isRunning()) {
411
            throw new RuntimeException('Process is already running.');
412
        }
413
414
        $process = clone $this;
415
        $process->start($callback, $env);
416
417
        return $process;
418
    }
419
420
    /**
421
     * Waits for the process to terminate.
422
     *
423
     * The callback receives the type of output (out or err) and some bytes
424
     * from the output in real-time while writing the standard input to the process.
425
     * It allows to have feedback from the independent process during execution.
426
     *
427
     * @param callable|null $callback A valid PHP callback
428
     *
429
     * @return int The exitcode of the process
430
     *
431
     * @throws ProcessTimedOutException When process timed out
432
     * @throws ProcessSignaledException When process stopped after receiving signal
433
     * @throws LogicException           When process is not yet started
434
     */
435
    public function wait(?callable $callback = null): int
436
    {
437
        $this->requireProcessIsStarted(__FUNCTION__);
438
439
        $this->updateStatus(false);
440
441
        if (null !== $callback) {
442
            if (!$this->processPipes->haveReadSupport()) {
443
                $this->stop(0);
444
                throw new LogicException('Pass the callback to the "Process::start" method or call enableOutput to use a callback with "Process::wait".');
445
            }
446
            $this->callback = $this->buildCallback($callback);
447
        }
448
449
        do {
450
            $this->checkTimeout();
451
            $running = $this->isRunning() && ('\\' === \DIRECTORY_SEPARATOR || $this->processPipes->areOpen());
452
            $this->readPipes($running, '\\' !== \DIRECTORY_SEPARATOR || !$running);
453
        } while ($running);
454
455
        while ($this->isRunning()) {
456
            $this->checkTimeout();
457
            usleep(1000);
458
        }
459
460
        if ($this->processInformation['signaled'] && $this->processInformation['termsig'] !== $this->latestSignal) {
461
            throw new ProcessSignaledException($this);
462
        }
463
464
        return $this->exitcode;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->exitcode could return the type null which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
465
    }
466
467
    /**
468
     * Waits until the callback returns true.
469
     *
470
     * The callback receives the type of output (out or err) and some bytes
471
     * from the output in real-time while writing the standard input to the process.
472
     * It allows to have feedback from the independent process during execution.
473
     *
474
     * @throws RuntimeException         When process timed out
475
     * @throws LogicException           When process is not yet started
476
     * @throws ProcessTimedOutException In case the timeout was reached
477
     */
478
    public function waitUntil(callable $callback): bool
479
    {
480
        $this->requireProcessIsStarted(__FUNCTION__);
481
        $this->updateStatus(false);
482
483
        if (!$this->processPipes->haveReadSupport()) {
484
            $this->stop(0);
485
            throw new LogicException('Pass the callback to the "Process::start" method or call enableOutput to use a callback with "Process::waitUntil".');
486
        }
487
        $callback = $this->buildCallback($callback);
488
489
        $ready = false;
490
        while (true) {
491
            $this->checkTimeout();
492
            $running = '\\' === \DIRECTORY_SEPARATOR ? $this->isRunning() : $this->processPipes->areOpen();
493
            $output = $this->processPipes->readAndWrite($running, '\\' !== \DIRECTORY_SEPARATOR || !$running);
494
495
            foreach ($output as $type => $data) {
496
                if (3 !== $type) {
497
                    $ready = $callback(self::STDOUT === $type ? self::OUT : self::ERR, $data) || $ready;
498
                } elseif (!isset($this->fallbackStatus['signaled'])) {
499
                    $this->fallbackStatus['exitcode'] = (int) $data;
500
                }
501
            }
502
            if ($ready) {
503
                return true;
504
            }
505
            if (!$running) {
506
                return false;
507
            }
508
509
            usleep(1000);
510
        }
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return boolean. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
511
    }
512
513
    /**
514
     * Returns the Pid (process identifier), if applicable.
515
     *
516
     * @return int|null The process id if running, null otherwise
517
     */
518
    public function getPid(): ?int
519
    {
520
        return $this->isRunning() ? $this->processInformation['pid'] : null;
521
    }
522
523
    /**
524
     * Sends a POSIX signal to the process.
525
     *
526
     * @param int $signal A valid POSIX signal (see https://php.net/pcntl.constants)
527
     *
528
     * @return $this
529
     *
530
     * @throws LogicException   In case the process is not running
531
     * @throws RuntimeException In case --enable-sigchild is activated and the process can't be killed
532
     * @throws RuntimeException In case of failure
533
     */
534
    public function signal(int $signal): static
535
    {
536
        $this->doSignal($signal, true);
537
538
        return $this;
539
    }
540
541
    /**
542
     * Disables fetching output and error output from the underlying process.
543
     *
544
     * @return $this
545
     *
546
     * @throws RuntimeException In case the process is already running
547
     * @throws LogicException   if an idle timeout is set
548
     */
549
    public function disableOutput(): static
550
    {
551
        if ($this->isRunning()) {
552
            throw new RuntimeException('Disabling output while the process is running is not possible.');
553
        }
554
        if (null !== $this->idleTimeout) {
555
            throw new LogicException('Output cannot be disabled while an idle timeout is set.');
556
        }
557
558
        $this->outputDisabled = true;
559
560
        return $this;
561
    }
562
563
    /**
564
     * Enables fetching output and error output from the underlying process.
565
     *
566
     * @return $this
567
     *
568
     * @throws RuntimeException In case the process is already running
569
     */
570
    public function enableOutput(): static
571
    {
572
        if ($this->isRunning()) {
573
            throw new RuntimeException('Enabling output while the process is running is not possible.');
574
        }
575
576
        $this->outputDisabled = false;
577
578
        return $this;
579
    }
580
581
    /**
582
     * Returns true in case the output is disabled, false otherwise.
583
     */
584
    public function isOutputDisabled(): bool
585
    {
586
        return $this->outputDisabled;
587
    }
588
589
    /**
590
     * Returns the current output of the process (STDOUT).
591
     *
592
     * @throws LogicException in case the output has been disabled
593
     * @throws LogicException In case the process is not started
594
     */
595
    public function getOutput(): string
596
    {
597
        $this->readPipesForOutput(__FUNCTION__);
598
599
        if (false === $ret = stream_get_contents($this->stdout, -1, 0)) {
600
            return '';
601
        }
602
603
        return $ret;
604
    }
605
606
    /**
607
     * Returns the output incrementally.
608
     *
609
     * In comparison with the getOutput method which always return the whole
610
     * output, this one returns the new output since the last call.
611
     *
612
     * @throws LogicException in case the output has been disabled
613
     * @throws LogicException In case the process is not started
614
     */
615
    public function getIncrementalOutput(): string
616
    {
617
        $this->readPipesForOutput(__FUNCTION__);
618
619
        $latest = stream_get_contents($this->stdout, -1, $this->incrementalOutputOffset);
620
        $this->incrementalOutputOffset = ftell($this->stdout);
621
622
        if (false === $latest) {
623
            return '';
624
        }
625
626
        return $latest;
627
    }
628
629
    /**
630
     * Returns an iterator to the output of the process, with the output type as keys (Process::OUT/ERR).
631
     *
632
     * @param int $flags A bit field of Process::ITER_* flags
633
     *
634
     * @return \Generator<string, string>
635
     *
636
     * @throws LogicException in case the output has been disabled
637
     * @throws LogicException In case the process is not started
638
     */
639
    public function getIterator(int $flags = 0): \Generator
640
    {
641
        $this->readPipesForOutput(__FUNCTION__, false);
642
643
        $clearOutput = !(self::ITER_KEEP_OUTPUT & $flags);
644
        $blocking = !(self::ITER_NON_BLOCKING & $flags);
645
        $yieldOut = !(self::ITER_SKIP_OUT & $flags);
646
        $yieldErr = !(self::ITER_SKIP_ERR & $flags);
647
648
        while (null !== $this->callback || ($yieldOut && !feof($this->stdout)) || ($yieldErr && !feof($this->stderr))) {
649
            if ($yieldOut) {
650
                $out = stream_get_contents($this->stdout, -1, $this->incrementalOutputOffset);
651
652
                if (isset($out[0])) {
653
                    if ($clearOutput) {
654
                        $this->clearOutput();
655
                    } else {
656
                        $this->incrementalOutputOffset = ftell($this->stdout);
657
                    }
658
659
                    yield self::OUT => $out;
660
                }
661
            }
662
663
            if ($yieldErr) {
664
                $err = stream_get_contents($this->stderr, -1, $this->incrementalErrorOutputOffset);
665
666
                if (isset($err[0])) {
667
                    if ($clearOutput) {
668
                        $this->clearErrorOutput();
669
                    } else {
670
                        $this->incrementalErrorOutputOffset = ftell($this->stderr);
671
                    }
672
673
                    yield self::ERR => $err;
674
                }
675
            }
676
677
            if (!$blocking && !isset($out[0]) && !isset($err[0])) {
678
                yield self::OUT => '';
679
            }
680
681
            $this->checkTimeout();
682
            $this->readPipesForOutput(__FUNCTION__, $blocking);
683
        }
684
    }
685
686
    /**
687
     * Clears the process output.
688
     *
689
     * @return $this
690
     */
691
    public function clearOutput(): static
692
    {
693
        ftruncate($this->stdout, 0);
694
        fseek($this->stdout, 0);
695
        $this->incrementalOutputOffset = 0;
696
697
        return $this;
698
    }
699
700
    /**
701
     * Returns the current error output of the process (STDERR).
702
     *
703
     * @throws LogicException in case the output has been disabled
704
     * @throws LogicException In case the process is not started
705
     */
706
    public function getErrorOutput(): string
707
    {
708
        $this->readPipesForOutput(__FUNCTION__);
709
710
        if (false === $ret = stream_get_contents($this->stderr, -1, 0)) {
711
            return '';
712
        }
713
714
        return $ret;
715
    }
716
717
    /**
718
     * Returns the errorOutput incrementally.
719
     *
720
     * In comparison with the getErrorOutput method which always return the
721
     * whole error output, this one returns the new error output since the last
722
     * call.
723
     *
724
     * @throws LogicException in case the output has been disabled
725
     * @throws LogicException In case the process is not started
726
     */
727
    public function getIncrementalErrorOutput(): string
728
    {
729
        $this->readPipesForOutput(__FUNCTION__);
730
731
        $latest = stream_get_contents($this->stderr, -1, $this->incrementalErrorOutputOffset);
732
        $this->incrementalErrorOutputOffset = ftell($this->stderr);
733
734
        if (false === $latest) {
735
            return '';
736
        }
737
738
        return $latest;
739
    }
740
741
    /**
742
     * Clears the process output.
743
     *
744
     * @return $this
745
     */
746
    public function clearErrorOutput(): static
747
    {
748
        ftruncate($this->stderr, 0);
749
        fseek($this->stderr, 0);
750
        $this->incrementalErrorOutputOffset = 0;
751
752
        return $this;
753
    }
754
755
    /**
756
     * Returns the exit code returned by the process.
757
     *
758
     * @return int|null The exit status code, null if the Process is not terminated
759
     */
760
    public function getExitCode(): ?int
761
    {
762
        $this->updateStatus(false);
763
764
        return $this->exitcode;
765
    }
766
767
    /**
768
     * Returns a string representation for the exit code returned by the process.
769
     *
770
     * This method relies on the Unix exit code status standardization
771
     * and might not be relevant for other operating systems.
772
     *
773
     * @return string|null A string representation for the exit status code, null if the Process is not terminated
774
     *
775
     * @see http://tldp.org/LDP/abs/html/exitcodes.html
776
     * @see http://en.wikipedia.org/wiki/Unix_signal
777
     */
778
    public function getExitCodeText(): ?string
779
    {
780
        if (null === $exitcode = $this->getExitCode()) {
781
            return null;
782
        }
783
784
        return self::$exitCodes[$exitcode] ?? 'Unknown error';
785
    }
786
787
    /**
788
     * Checks if the process ended successfully.
789
     */
790
    public function isSuccessful(): bool
791
    {
792
        return 0 === $this->getExitCode();
793
    }
794
795
    /**
796
     * Returns true if the child process has been terminated by an uncaught signal.
797
     *
798
     * It always returns false on Windows.
799
     *
800
     * @throws LogicException In case the process is not terminated
801
     */
802
    public function hasBeenSignaled(): bool
803
    {
804
        $this->requireProcessIsTerminated(__FUNCTION__);
805
806
        return $this->processInformation['signaled'];
807
    }
808
809
    /**
810
     * Returns the number of the signal that caused the child process to terminate its execution.
811
     *
812
     * It is only meaningful if hasBeenSignaled() returns true.
813
     *
814
     * @throws RuntimeException In case --enable-sigchild is activated
815
     * @throws LogicException   In case the process is not terminated
816
     */
817
    public function getTermSignal(): int
818
    {
819
        $this->requireProcessIsTerminated(__FUNCTION__);
820
821
        if ($this->isSigchildEnabled() && -1 === $this->processInformation['termsig']) {
822
            throw new RuntimeException('This PHP has been compiled with --enable-sigchild. Term signal cannot be retrieved.');
823
        }
824
825
        return $this->processInformation['termsig'];
826
    }
827
828
    /**
829
     * Returns true if the child process has been stopped by a signal.
830
     *
831
     * It always returns false on Windows.
832
     *
833
     * @throws LogicException In case the process is not terminated
834
     */
835
    public function hasBeenStopped(): bool
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
     * @throws LogicException In case the process is not terminated
848
     */
849
    public function getStopSignal(): int
850
    {
851
        $this->requireProcessIsTerminated(__FUNCTION__);
852
853
        return $this->processInformation['stopsig'];
854
    }
855
856
    /**
857
     * Checks if the process is currently running.
858
     */
859
    public function isRunning(): bool
860
    {
861
        if (self::STATUS_STARTED !== $this->status) {
862
            return false;
863
        }
864
865
        $this->updateStatus(false);
866
867
        return $this->processInformation['running'];
868
    }
869
870
    /**
871
     * Checks if the process has been started with no regard to the current state.
872
     */
873
    public function isStarted(): bool
874
    {
875
        return self::STATUS_READY != $this->status;
876
    }
877
878
    /**
879
     * Checks if the process is terminated.
880
     */
881
    public function isTerminated(): bool
882
    {
883
        $this->updateStatus(false);
884
885
        return self::STATUS_TERMINATED == $this->status;
886
    }
887
888
    /**
889
     * Gets the process status.
890
     *
891
     * The status is one of: ready, started, terminated.
892
     */
893
    public function getStatus(): string
894
    {
895
        $this->updateStatus(false);
896
897
        return $this->status;
898
    }
899
900
    /**
901
     * Stops the process.
902
     *
903
     * @param int|float $timeout The timeout in seconds
904
     * @param int|null  $signal  A POSIX signal to send in case the process has not stop at timeout, default is SIGKILL (9)
905
     *
906
     * @return int|null The exit-code of the process or null if it's not running
907
     */
908
    public function stop(float $timeout = 10, ?int $signal = null): ?int
909
    {
910
        $timeoutMicro = microtime(true) + $timeout;
911
        if ($this->isRunning()) {
912
            // given SIGTERM may not be defined and that "proc_terminate" uses the constant value and not the constant itself, we use the same here
913
            $this->doSignal(15, false);
914
            do {
915
                usleep(1000);
916
            } while ($this->isRunning() && microtime(true) < $timeoutMicro);
917
918
            if ($this->isRunning()) {
919
                // Avoid exception here: process is supposed to be running, but it might have stopped just
920
                // after this line. In any case, let's silently discard the error, we cannot do anything.
921
                $this->doSignal($signal ?: 9, false);
922
            }
923
        }
924
925
        if ($this->isRunning()) {
926
            if (isset($this->fallbackStatus['pid'])) {
927
                unset($this->fallbackStatus['pid']);
928
929
                return $this->stop(0, $signal);
930
            }
931
            $this->close();
932
        }
933
934
        return $this->exitcode;
935
    }
936
937
    /**
938
     * Adds a line to the STDOUT stream.
939
     *
940
     * @internal
941
     */
942
    public function addOutput(string $line): void
943
    {
944
        $this->lastOutputTime = microtime(true);
945
946
        fseek($this->stdout, 0, \SEEK_END);
947
        fwrite($this->stdout, $line);
948
        fseek($this->stdout, $this->incrementalOutputOffset);
949
    }
950
951
    /**
952
     * Adds a line to the STDERR stream.
953
     *
954
     * @internal
955
     */
956
    public function addErrorOutput(string $line): void
957
    {
958
        $this->lastOutputTime = microtime(true);
959
960
        fseek($this->stderr, 0, \SEEK_END);
961
        fwrite($this->stderr, $line);
962
        fseek($this->stderr, $this->incrementalErrorOutputOffset);
963
    }
964
965
    /**
966
     * Gets the last output time in seconds.
967
     */
968
    public function getLastOutputTime(): ?float
969
    {
970
        return $this->lastOutputTime;
971
    }
972
973
    /**
974
     * Gets the command line to be executed.
975
     */
976
    public function getCommandLine(): string
977
    {
978
        return $this->buildShellCommandline($this->commandline);
979
    }
980
981
    /**
982
     * Gets the process timeout in seconds (max. runtime).
983
     */
984
    public function getTimeout(): ?float
985
    {
986
        return $this->timeout;
987
    }
988
989
    /**
990
     * Gets the process idle timeout in seconds (max. time since last output).
991
     */
992
    public function getIdleTimeout(): ?float
993
    {
994
        return $this->idleTimeout;
995
    }
996
997
    /**
998
     * Sets the process timeout (max. runtime) in seconds.
999
     *
1000
     * To disable the timeout, set this value to null.
1001
     *
1002
     * @return $this
1003
     *
1004
     * @throws InvalidArgumentException if the timeout is negative
1005
     */
1006
    public function setTimeout(?float $timeout): static
1007
    {
1008
        $this->timeout = $this->validateTimeout($timeout);
1009
1010
        return $this;
1011
    }
1012
1013
    /**
1014
     * Sets the process idle timeout (max. time since last output) in seconds.
1015
     *
1016
     * To disable the timeout, set this value to null.
1017
     *
1018
     * @return $this
1019
     *
1020
     * @throws LogicException           if the output is disabled
1021
     * @throws InvalidArgumentException if the timeout is negative
1022
     */
1023
    public function setIdleTimeout(?float $timeout): static
1024
    {
1025
        if (null !== $timeout && $this->outputDisabled) {
1026
            throw new LogicException('Idle timeout cannot be set while the output is disabled.');
1027
        }
1028
1029
        $this->idleTimeout = $this->validateTimeout($timeout);
1030
1031
        return $this;
1032
    }
1033
1034
    /**
1035
     * Enables or disables the TTY mode.
1036
     *
1037
     * @return $this
1038
     *
1039
     * @throws RuntimeException In case the TTY mode is not supported
1040
     */
1041
    public function setTty(bool $tty): static
1042
    {
1043
        if ('\\' === \DIRECTORY_SEPARATOR && $tty) {
1044
            throw new RuntimeException('TTY mode is not supported on Windows platform.');
1045
        }
1046
1047
        if ($tty && !self::isTtySupported()) {
1048
            throw new RuntimeException('TTY mode requires /dev/tty to be read/writable.');
1049
        }
1050
1051
        $this->tty = $tty;
1052
1053
        return $this;
1054
    }
1055
1056
    /**
1057
     * Checks if the TTY mode is enabled.
1058
     */
1059
    public function isTty(): bool
1060
    {
1061
        return $this->tty;
1062
    }
1063
1064
    /**
1065
     * Sets PTY mode.
1066
     *
1067
     * @return $this
1068
     */
1069
    public function setPty(bool $bool): static
1070
    {
1071
        $this->pty = $bool;
1072
1073
        return $this;
1074
    }
1075
1076
    /**
1077
     * Returns PTY state.
1078
     */
1079
    public function isPty(): bool
1080
    {
1081
        return $this->pty;
1082
    }
1083
1084
    /**
1085
     * Gets the working directory.
1086
     */
1087
    public function getWorkingDirectory(): ?string
1088
    {
1089
        if (null === $this->cwd) {
1090
            // getcwd() will return false if any one of the parent directories does not have
1091
            // the readable or search mode set, even if the current directory does
1092
            return getcwd() ?: null;
1093
        }
1094
1095
        return $this->cwd;
1096
    }
1097
1098
    /**
1099
     * Sets the current working directory.
1100
     *
1101
     * @return $this
1102
     */
1103
    public function setWorkingDirectory(string $cwd): static
1104
    {
1105
        $this->cwd = $cwd;
1106
1107
        return $this;
1108
    }
1109
1110
    /**
1111
     * Gets the environment variables.
1112
     */
1113
    public function getEnv(): array
1114
    {
1115
        return $this->env;
1116
    }
1117
1118
    /**
1119
     * Sets the environment variables.
1120
     *
1121
     * @param array<string|\Stringable> $env The new environment variables
1122
     *
1123
     * @return $this
1124
     */
1125
    public function setEnv(array $env): static
1126
    {
1127
        $this->env = $env;
1128
1129
        return $this;
1130
    }
1131
1132
    /**
1133
     * Gets the Process input.
1134
     *
1135
     * @return resource|string|\Iterator|null
1136
     */
1137
    public function getInput()
1138
    {
1139
        return $this->input;
1140
    }
1141
1142
    /**
1143
     * Sets the input.
1144
     *
1145
     * This content will be passed to the underlying process standard input.
1146
     *
1147
     * @param string|resource|\Traversable|self|null $input The content
1148
     *
1149
     * @return $this
1150
     *
1151
     * @throws LogicException In case the process is running
1152
     */
1153
    public function setInput(mixed $input): static
1154
    {
1155
        if ($this->isRunning()) {
1156
            throw new LogicException('Input cannot be set while the process is running.');
1157
        }
1158
1159
        $this->input = ProcessUtils::validateInput(__METHOD__, $input);
1160
1161
        return $this;
1162
    }
1163
1164
    /**
1165
     * Performs a check between the timeout definition and the time the process started.
1166
     *
1167
     * In case you run a background process (with the start method), you should
1168
     * trigger this method regularly to ensure the process timeout
1169
     *
1170
     * @throws ProcessTimedOutException In case the timeout was reached
1171
     */
1172
    public function checkTimeout(): void
1173
    {
1174
        if (self::STATUS_STARTED !== $this->status) {
1175
            return;
1176
        }
1177
1178
        if (null !== $this->timeout && $this->timeout < microtime(true) - $this->starttime) {
1179
            $this->stop(0);
1180
1181
            throw new ProcessTimedOutException($this, ProcessTimedOutException::TYPE_GENERAL);
1182
        }
1183
1184
        if (null !== $this->idleTimeout && $this->idleTimeout < microtime(true) - $this->lastOutputTime) {
1185
            $this->stop(0);
1186
1187
            throw new ProcessTimedOutException($this, ProcessTimedOutException::TYPE_IDLE);
1188
        }
1189
    }
1190
1191
    /**
1192
     * @throws LogicException in case process is not started
1193
     */
1194
    public function getStartTime(): float
1195
    {
1196
        if (!$this->isStarted()) {
1197
            throw new LogicException('Start time is only available after process start.');
1198
        }
1199
1200
        return $this->starttime;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->starttime could return the type null which is incompatible with the type-hinted return double. Consider adding an additional type-check to rule them out.
Loading history...
1201
    }
1202
1203
    /**
1204
     * Defines options to pass to the underlying proc_open().
1205
     *
1206
     * @see https://php.net/proc_open for the options supported by PHP.
1207
     *
1208
     * Enabling the "create_new_console" option allows a subprocess to continue
1209
     * to run after the main process exited, on both Windows and *nix
1210
     */
1211
    public function setOptions(array $options): void
1212
    {
1213
        if ($this->isRunning()) {
1214
            throw new RuntimeException('Setting options while the process is running is not possible.');
1215
        }
1216
1217
        $defaultOptions = $this->options;
1218
        $existingOptions = ['blocking_pipes', 'create_process_group', 'create_new_console'];
1219
1220
        foreach ($options as $key => $value) {
1221
            if (!\in_array($key, $existingOptions)) {
1222
                $this->options = $defaultOptions;
1223
                throw new LogicException(sprintf('Invalid option "%s" passed to "%s()". Supported options are "%s".', $key, __METHOD__, implode('", "', $existingOptions)));
1224
            }
1225
            $this->options[$key] = $value;
1226
        }
1227
    }
1228
1229
    /**
1230
     * Defines a list of posix signals that will not be propagated to the process.
1231
     *
1232
     * @param list<\SIG*> $signals
0 ignored issues
show
Bug introduced by
The type Symfony\Component\Process\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1233
     */
1234
    public function setIgnoredSignals(array $signals): void
1235
    {
1236
        if ($this->isRunning()) {
1237
            throw new RuntimeException('Setting ignored signals while the process is running is not possible.');
1238
        }
1239
1240
        $this->ignoredSignals = $signals;
1241
    }
1242
1243
    /**
1244
     * Returns whether TTY is supported on the current operating system.
1245
     */
1246
    public static function isTtySupported(): bool
1247
    {
1248
        static $isTtySupported;
1249
1250
        return $isTtySupported ??= ('/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT) && @is_writable('/dev/tty'));
1251
    }
1252
1253
    /**
1254
     * Returns whether PTY is supported on the current operating system.
1255
     */
1256
    public static function isPtySupported(): bool
1257
    {
1258
        static $result;
1259
1260
        if (null !== $result) {
1261
            return $result;
1262
        }
1263
1264
        if ('\\' === \DIRECTORY_SEPARATOR) {
1265
            return $result = false;
1266
        }
1267
1268
        return $result = (bool) @proc_open('echo 1 >/dev/null', [['pty'], ['pty'], ['pty']], $pipes);
1269
    }
1270
1271
    /**
1272
     * Creates the descriptors needed by the proc_open.
1273
     */
1274
    private function getDescriptors(bool $hasCallback): array
1275
    {
1276
        if ($this->input instanceof \Iterator) {
1277
            $this->input->rewind();
1278
        }
1279
        if ('\\' === \DIRECTORY_SEPARATOR) {
1280
            $this->processPipes = new WindowsPipes($this->input, !$this->outputDisabled || $hasCallback);
1281
        } else {
1282
            $this->processPipes = new UnixPipes($this->isTty(), $this->isPty(), $this->input, !$this->outputDisabled || $hasCallback);
1283
        }
1284
1285
        return $this->processPipes->getDescriptors();
1286
    }
1287
1288
    /**
1289
     * Builds up the callback used by wait().
1290
     *
1291
     * The callbacks adds all occurred output to the specific buffer and calls
1292
     * the user callback (if present) with the received output.
1293
     *
1294
     * @param callable|null $callback The user defined PHP callback
1295
     */
1296
    protected function buildCallback(?callable $callback = null): \Closure
1297
    {
1298
        if ($this->outputDisabled) {
1299
            return fn ($type, $data): bool => null !== $callback && $callback($type, $data);
1300
        }
1301
1302
        $out = self::OUT;
1303
1304
        return function ($type, $data) use ($callback, $out): bool {
1305
            if ($out == $type) {
1306
                $this->addOutput($data);
1307
            } else {
1308
                $this->addErrorOutput($data);
1309
            }
1310
1311
            return null !== $callback && $callback($type, $data);
1312
        };
1313
    }
1314
1315
    /**
1316
     * Updates the status of the process, reads pipes.
1317
     *
1318
     * @param bool $blocking Whether to use a blocking read call
1319
     */
1320
    protected function updateStatus(bool $blocking): void
1321
    {
1322
        if (self::STATUS_STARTED !== $this->status) {
1323
            return;
1324
        }
1325
1326
        $this->processInformation = proc_get_status($this->process);
1327
        $running = $this->processInformation['running'];
1328
1329
        // In PHP < 8.3, "proc_get_status" only returns the correct exit status on the first call.
1330
        // Subsequent calls return -1 as the process is discarded. This workaround caches the first
1331
        // retrieved exit status for consistent results in later calls, mimicking PHP 8.3 behavior.
1332
        if (\PHP_VERSION_ID < 80300) {
1333
            if (!isset($this->cachedExitCode) && !$running && -1 !== $this->processInformation['exitcode']) {
1334
                $this->cachedExitCode = $this->processInformation['exitcode'];
1335
            }
1336
1337
            if (isset($this->cachedExitCode) && !$running && -1 === $this->processInformation['exitcode']) {
1338
                $this->processInformation['exitcode'] = $this->cachedExitCode;
1339
            }
1340
        }
1341
1342
        $this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running);
1343
1344
        if ($this->fallbackStatus && $this->isSigchildEnabled()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->fallbackStatus of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1345
            $this->processInformation = $this->fallbackStatus + $this->processInformation;
1346
        }
1347
1348
        if (!$running) {
1349
            $this->close();
1350
        }
1351
    }
1352
1353
    /**
1354
     * Returns whether PHP has been compiled with the '--enable-sigchild' option or not.
1355
     */
1356
    protected function isSigchildEnabled(): bool
1357
    {
1358
        if (null !== self::$sigchild) {
1359
            return self::$sigchild;
1360
        }
1361
1362
        if (!\function_exists('phpinfo')) {
1363
            return self::$sigchild = false;
1364
        }
1365
1366
        ob_start();
1367
        phpinfo(\INFO_GENERAL);
1368
1369
        return self::$sigchild = str_contains(ob_get_clean(), '--enable-sigchild');
1370
    }
1371
1372
    /**
1373
     * Reads pipes for the freshest output.
1374
     *
1375
     * @param string $caller   The name of the method that needs fresh outputs
1376
     * @param bool   $blocking Whether to use blocking calls or not
1377
     *
1378
     * @throws LogicException in case output has been disabled or process is not started
1379
     */
1380
    private function readPipesForOutput(string $caller, bool $blocking = false): void
1381
    {
1382
        if ($this->outputDisabled) {
1383
            throw new LogicException('Output has been disabled.');
1384
        }
1385
1386
        $this->requireProcessIsStarted($caller);
1387
1388
        $this->updateStatus($blocking);
1389
    }
1390
1391
    /**
1392
     * Validates and returns the filtered timeout.
1393
     *
1394
     * @throws InvalidArgumentException if the given timeout is a negative number
1395
     */
1396
    private function validateTimeout(?float $timeout): ?float
1397
    {
1398
        $timeout = (float) $timeout;
1399
1400
        if (0.0 === $timeout) {
0 ignored issues
show
introduced by
The condition 0.0 === $timeout is always false.
Loading history...
1401
            $timeout = null;
1402
        } elseif ($timeout < 0) {
1403
            throw new InvalidArgumentException('The timeout value must be a valid positive integer or float number.');
1404
        }
1405
1406
        return $timeout;
1407
    }
1408
1409
    /**
1410
     * Reads pipes, executes callback.
1411
     *
1412
     * @param bool $blocking Whether to use blocking calls or not
1413
     * @param bool $close    Whether to close file handles or not
1414
     */
1415
    private function readPipes(bool $blocking, bool $close): void
1416
    {
1417
        $result = $this->processPipes->readAndWrite($blocking, $close);
1418
1419
        $callback = $this->callback;
1420
        foreach ($result as $type => $data) {
1421
            if (3 !== $type) {
1422
                $callback(self::STDOUT === $type ? self::OUT : self::ERR, $data);
1423
            } elseif (!isset($this->fallbackStatus['signaled'])) {
1424
                $this->fallbackStatus['exitcode'] = (int) $data;
1425
            }
1426
        }
1427
    }
1428
1429
    /**
1430
     * Closes process resource, closes file handles, sets the exitcode.
1431
     *
1432
     * @return int The exitcode
1433
     */
1434
    private function close(): int
1435
    {
1436
        $this->processPipes->close();
1437
        if ($this->process) {
1438
            proc_close($this->process);
1439
            $this->process = null;
1440
        }
1441
        $this->exitcode = $this->processInformation['exitcode'];
1442
        $this->status = self::STATUS_TERMINATED;
1443
1444
        if (-1 === $this->exitcode) {
1445
            if ($this->processInformation['signaled'] && 0 < $this->processInformation['termsig']) {
1446
                // if process has been signaled, no exitcode but a valid termsig, apply Unix convention
1447
                $this->exitcode = 128 + $this->processInformation['termsig'];
1448
            } elseif ($this->isSigchildEnabled()) {
1449
                $this->processInformation['signaled'] = true;
1450
                $this->processInformation['termsig'] = -1;
1451
            }
1452
        }
1453
1454
        // Free memory from self-reference callback created by buildCallback
1455
        // Doing so in other contexts like __destruct or by garbage collector is ineffective
1456
        // Now pipes are closed, so the callback is no longer necessary
1457
        $this->callback = null;
1458
1459
        return $this->exitcode;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->exitcode could return the type null which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
1460
    }
1461
1462
    /**
1463
     * Resets data related to the latest run of the process.
1464
     */
1465
    private function resetProcessData(): void
1466
    {
1467
        $this->starttime = null;
1468
        $this->callback = null;
1469
        $this->exitcode = null;
1470
        $this->fallbackStatus = [];
1471
        $this->processInformation = [];
1472
        $this->stdout = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+');
1473
        $this->stderr = fopen('php://temp/maxmemory:'.(1024 * 1024), 'w+');
1474
        $this->process = null;
1475
        $this->latestSignal = null;
1476
        $this->status = self::STATUS_READY;
1477
        $this->incrementalOutputOffset = 0;
1478
        $this->incrementalErrorOutputOffset = 0;
1479
    }
1480
1481
    /**
1482
     * Sends a POSIX signal to the process.
1483
     *
1484
     * @param int  $signal         A valid POSIX signal (see https://php.net/pcntl.constants)
1485
     * @param bool $throwException Whether to throw exception in case signal failed
1486
     *
1487
     * @throws LogicException   In case the process is not running
1488
     * @throws RuntimeException In case --enable-sigchild is activated and the process can't be killed
1489
     * @throws RuntimeException In case of failure
1490
     */
1491
    private function doSignal(int $signal, bool $throwException): bool
1492
    {
1493
        // Signal seems to be send when sigchild is enable, this allow blocking the signal correctly in this case
1494
        if ($this->isSigchildEnabled() && \in_array($signal, $this->ignoredSignals)) {
1495
            return false;
1496
        }
1497
1498
        if (null === $pid = $this->getPid()) {
1499
            if ($throwException) {
1500
                throw new LogicException('Cannot send signal on a non running process.');
1501
            }
1502
1503
            return false;
1504
        }
1505
1506
        if ('\\' === \DIRECTORY_SEPARATOR) {
1507
            exec(sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode);
1508
            if ($exitCode && $this->isRunning()) {
1509
                if ($throwException) {
1510
                    throw new RuntimeException(sprintf('Unable to kill the process (%s).', implode(' ', $output)));
1511
                }
1512
1513
                return false;
1514
            }
1515
        } else {
1516
            if (!$this->isSigchildEnabled()) {
1517
                $ok = @proc_terminate($this->process, $signal);
1518
            } elseif (\function_exists('posix_kill')) {
1519
                $ok = @posix_kill($pid, $signal);
1520
            } elseif ($ok = proc_open(sprintf('kill -%d %d', $signal, $pid), [2 => ['pipe', 'w']], $pipes)) {
1521
                $ok = false === fgets($pipes[2]);
1522
            }
1523
            if (!$ok) {
1524
                if ($throwException) {
1525
                    throw new RuntimeException(sprintf('Error while sending signal "%s".', $signal));
1526
                }
1527
1528
                return false;
1529
            }
1530
        }
1531
1532
        $this->latestSignal = $signal;
1533
        $this->fallbackStatus['signaled'] = true;
1534
        $this->fallbackStatus['exitcode'] = -1;
1535
        $this->fallbackStatus['termsig'] = $this->latestSignal;
1536
1537
        return true;
1538
    }
1539
1540
    private function buildShellCommandline(string|array $commandline): string
1541
    {
1542
        if (\is_string($commandline)) {
0 ignored issues
show
introduced by
The condition is_string($commandline) is always false.
Loading history...
1543
            return $commandline;
1544
        }
1545
1546
        return implode(' ', array_map($this->escapeArgument(...), $commandline));
1547
    }
1548
1549
    private function prepareWindowsCommandLine(string|array $cmd, array &$env): string
1550
    {
1551
        $cmd = $this->buildShellCommandline($cmd);
1552
        $uid = uniqid('', true);
1553
        $cmd = preg_replace_callback(
1554
            '/"(?:(
1555
                [^"%!^]*+
1556
                (?:
1557
                    (?: !LF! | "(?:\^[%!^])?+" )
1558
                    [^"%!^]*+
1559
                )++
1560
            ) | [^"]*+ )"/x',
1561
            function ($m) use (&$env, $uid) {
1562
                static $varCount = 0;
1563
                static $varCache = [];
1564
                if (!isset($m[1])) {
1565
                    return $m[0];
1566
                }
1567
                if (isset($varCache[$m[0]])) {
1568
                    return $varCache[$m[0]];
1569
                }
1570
                if (str_contains($value = $m[1], "\0")) {
1571
                    $value = str_replace("\0", '?', $value);
1572
                }
1573
                if (false === strpbrk($value, "\"%!\n")) {
1574
                    return '"'.$value.'"';
1575
                }
1576
1577
                $value = str_replace(['!LF!', '"^!"', '"^%"', '"^^"', '""'], ["\n", '!', '%', '^', '"'], $value);
1578
                $value = '"'.preg_replace('/(\\\\*)"/', '$1$1\\"', $value).'"';
1579
                $var = $uid.++$varCount;
1580
1581
                $env[$var] = $value;
1582
1583
                return $varCache[$m[0]] = '!'.$var.'!';
1584
            },
1585
            $cmd
1586
        );
1587
1588
        $cmd = 'cmd /V:ON /E:ON /D /C ('.str_replace("\n", ' ', $cmd).')';
1589
        foreach ($this->processPipes->getFiles() as $offset => $filename) {
1590
            $cmd .= ' '.$offset.'>"'.$filename.'"';
1591
        }
1592
1593
        return $cmd;
1594
    }
1595
1596
    /**
1597
     * Ensures the process is running or terminated, throws a LogicException if the process has a not started.
1598
     *
1599
     * @throws LogicException if the process has not run
1600
     */
1601
    private function requireProcessIsStarted(string $functionName): void
1602
    {
1603
        if (!$this->isStarted()) {
1604
            throw new LogicException(sprintf('Process must be started before calling "%s()".', $functionName));
1605
        }
1606
    }
1607
1608
    /**
1609
     * Ensures the process is terminated, throws a LogicException if the process has a status different than "terminated".
1610
     *
1611
     * @throws LogicException if the process is not yet terminated
1612
     */
1613
    private function requireProcessIsTerminated(string $functionName): void
1614
    {
1615
        if (!$this->isTerminated()) {
1616
            throw new LogicException(sprintf('Process must be terminated before calling "%s()".', $functionName));
1617
        }
1618
    }
1619
1620
    /**
1621
     * Escapes a string to be used as a shell argument.
1622
     */
1623
    private function escapeArgument(?string $argument): string
1624
    {
1625
        if ('' === $argument || null === $argument) {
1626
            return '""';
1627
        }
1628
        if ('\\' !== \DIRECTORY_SEPARATOR) {
1629
            return "'".str_replace("'", "'\\''", $argument)."'";
1630
        }
1631
        if (str_contains($argument, "\0")) {
1632
            $argument = str_replace("\0", '?', $argument);
1633
        }
1634
        if (!preg_match('/[\/()%!^"<>&|\s]/', $argument)) {
1635
            return $argument;
1636
        }
1637
        $argument = preg_replace('/(\\\\+)$/', '$1$1', $argument);
1638
1639
        return '"'.str_replace(['"', '^', '%', '!', "\n"], ['""', '"^^"', '"^%"', '"^!"', '!LF!'], $argument).'"';
1640
    }
1641
1642
    private function replacePlaceholders(string $commandline, array $env): string
1643
    {
1644
        return preg_replace_callback('/"\$\{:([_a-zA-Z]++[_a-zA-Z0-9]*+)\}"/', function ($matches) use ($commandline, $env) {
1645
            if (!isset($env[$matches[1]]) || false === $env[$matches[1]]) {
1646
                throw new InvalidArgumentException(sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline);
1647
            }
1648
1649
            return $this->escapeArgument($env[$matches[1]]);
1650
        }, $commandline);
1651
    }
1652
1653
    private function getDefaultEnv(): array
1654
    {
1655
        $env = getenv();
1656
        $env = ('\\' === \DIRECTORY_SEPARATOR ? array_intersect_ukey($env, $_SERVER, 'strcasecmp') : array_intersect_key($env, $_SERVER)) ?: $env;
1657
1658
        return $_ENV + ('\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($env, $_ENV, 'strcasecmp') : $env);
1659
    }
1660
}
1661