Issues (10)

src/Command/Shell.php (3 issues)

1
<?php
2
3
/**
4
 * Platine Console
5
 *
6
 * Platine Console is a powerful library with support of custom
7
 * style to build command line interface applications
8
 *
9
 * This content is released under the MIT License (MIT)
10
 *
11
 * Copyright (c) 2020 Platine Console
12
 * Copyright (c) 2017-2020 Jitendra Adhikari
13
 *
14
 * Permission is hereby granted, free of charge, to any person obtaining a copy
15
 * of this software and associated documentation files (the "Software"), to deal
16
 * in the Software without restriction, including without limitation the rights
17
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
 * copies of the Software, and to permit persons to whom the Software is
19
 * furnished to do so, subject to the following conditions:
20
 *
21
 * The above copyright notice and this permission notice shall be included in all
22
 * copies or substantial portions of the Software.
23
 *
24
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
 * SOFTWARE.
31
 */
32
33
/**
34
 *  @file Shell.php
35
 *
36
 *  The shell class
37
 *
38
 *  @package    Platine\Console\Command
39
 *  @author Platine Developers Team
40
 *  @copyright  Copyright (c) 2020
41
 *  @license    http://opensource.org/licenses/MIT  MIT License
42
 *  @link   https://www.platine-php.com
43
 *  @version 1.0.0
44
 *  @filesource
45
 */
46
47
declare(strict_types=1);
48
49
namespace Platine\Console\Command;
50
51
use Platine\Console\Exception\RuntimeException;
52
53
/**
54
 * @class Shell
55
 * @package Platine\Console\Command
56
 */
57
class Shell
58
{
59
    public const STDIN_DESCRIPTOR = 0;
60
    public const STDOUT_DESCRIPTOR = 1;
61
    public const STDERR_DESCRIPTOR = 2;
62
63
    public const STATE_READY = 0;
64
    public const STATE_STARTED = 1;
65
    public const STATE_CLOSED = 2;
66
    public const STATE_TERMINATED = 3;
67
68
    /**
69
     * Whether to wait for the process to finish or return instantly
70
     * @var bool
71
     */
72
    protected bool $async = false;
73
74
    /**
75
     * The command to execute
76
     * @var string
77
     */
78
    protected string $command = '';
79
80
    /**
81
     * Current working directory
82
     * @var string|null
83
     */
84
    protected ?string $cwd = null;
85
86
    /**
87
     * The list of descriptors to pass to process
88
     * @var array<int, array<int, string>>
89
     */
90
    protected array $descriptors = [];
91
92
    /**
93
     * List of environment variables
94
     * @var array<string, mixed>|null
95
     */
96
    protected ?array $env = null;
97
98
    /**
99
     * The process exit code
100
     * @var int|null
101
     */
102
    protected ?int $exitCode = null;
103
104
    /**
105
     * The path for input stream
106
     * @var string|null
107
     */
108
    protected ?string $input = null;
109
110
    /**
111
     * Others options to pass to process
112
     * @var array<string, mixed>
113
     */
114
    protected array $options = [];
115
116
    /**
117
     * Pointers to standard input/output/error
118
     * @var array<int, resource>
119
     */
120
    protected array $pipes = [];
121
122
    /**
123
     * The actual process resource returned
124
     * @var resource|false
125
     */
126
    protected $process;
127
128
    /**
129
     * The process start time in Unix timestamp
130
     * @var float
131
     */
132
    protected float $startTime = 0;
133
134
    /**
135
     * Default timeout for the process in seconds with microseconds
136
     * @var float|null
137
     */
138
    protected ?float $timeout = null;
139
140
    /**
141
     * The status list of the process
142
     * @var array<string, mixed>
143
     */
144
    protected array $status = [];
145
146
    /**
147
     * The current process status
148
     * @var int
149
     */
150
    protected int $state = self::STATE_READY;
151
152
    /**
153
     * Create new instance
154
     */
155
    public function __construct()
156
    {
157
        if (!function_exists('proc_open')) {
158
            throw new RuntimeException(
159
                'The "proc_open" could not be found in your PHP setup'
160
            );
161
        }
162
    }
163
164
    /**
165
     * Set the command to be executed
166
     * @param string $command
167
     * @return $this
168
     */
169
    public function setCommand(string $command): self
170
    {
171
        $this->command = $command;
172
173
        return $this;
174
    }
175
176
    /**
177
     * Set the input stream information
178
     * @param string|null $input
179
     * @return $this
180
     */
181
    public function setInput(?string $input): self
182
    {
183
        $this->input = $input;
184
        return $this;
185
    }
186
187
    /**
188
     * Whether the process is running
189
     * @return bool
190
     */
191
    public function isRunning(): bool
192
    {
193
        if ($this->state !== self::STATE_STARTED) {
194
            return false;
195
        }
196
197
        $this->updateStatus();
198
199
        return $this->status['running'];
200
    }
201
202
    /**
203
     * Kill the process
204
     * @return void
205
     */
206
    public function kill(): void
207
    {
208
        if (is_resource($this->process)) {
209
            proc_terminate($this->process);
210
        }
211
212
        $this->state = self::STATE_TERMINATED;
213
    }
214
215
    /**
216
     * Stop the process
217
     * @return int|null
218
     */
219
    public function stop(): ?int
220
    {
221
        $this->closePipes();
222
223
        if (is_resource($this->process)) {
224
            proc_close($this->process);
225
        }
226
227
        $this->state = self::STATE_CLOSED;
228
        $this->exitCode = $this->status['exitcode'];
229
230
        return $this->exitCode;
231
    }
232
233
    /**
234
     * Set options used for process execution
235
     * @param string|null $cwd
236
     * @param array<string, mixed>|null $env
237
     * @param float|null $timeout
238
     * @param array<string, mixed> $options
239
     * @return $this
240
     */
241
    public function setOptions(
242
        ?string $cwd = null,
243
        ?array $env = [],
244
        ?float $timeout = null,
245
        array $options = []
246
    ): self {
247
        $this->cwd = $cwd;
248
        $this->env = $env;
249
        $this->timeout = $timeout;
250
        $this->options = $options;
251
252
        return $this;
253
    }
254
255
    /**
256
     * Execute the process
257
     * @param bool $async
258
     * @return $this
259
     */
260
    public function execute(bool $async = false): self
261
    {
262
        if ($this->isRunning()) {
263
            throw new RuntimeException(sprintf(
264
                'Process [%s] already running',
265
                $this->command
266
            ));
267
        }
268
269
        $this->descriptors = $this->getDescriptors();
270
        $this->startTime = microtime(true);
0 ignored issues
show
Documentation Bug introduced by
It seems like microtime(true) can also be of type string. However, the property $startTime is declared as type double. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
271
272
        $this->process = proc_open(
273
            $this->command,
274
            $this->descriptors,
275
            $this->pipes,
276
            $this->cwd,
277
            $this->env,
278
            $this->options
279
        );
280
281
        $this->writeInput();
282
283
        if (is_resource($this->process) === false) {
284
            throw new RuntimeException(sprintf(
285
                'Bad program [%s] could not be started',
286
                $this->command
287
            ));
288
        }
289
290
        $this->state = self::STATE_STARTED;
291
292
        $this->updateStatus();
293
294
        $this->async = $async;
295
296
        if ($this->async) {
297
            $this->setOutputStreamNonBlocking();
298
        } else {
299
            $this->wait();
300
        }
301
302
        return $this;
303
    }
304
305
    /**
306
     * Return the process current state
307
     * @return int
308
     */
309
    public function getState(): int
310
    {
311
        return $this->state;
312
    }
313
314
    /**
315
     * Return the process command error output
316
     * @param int|null $length
317
     * @param int $offset
318
     * @return string
319
     * @throws RuntimeException
320
     */
321
    public function getErrorOutput(?int $length = null, int $offset = -1): string
322
    {
323
        $output = stream_get_contents(
324
            $this->pipes[self::STDERR_DESCRIPTOR],
325
            $length,
0 ignored issues
show
The call to Platine\Console\Command\stream_get_contents() has too many arguments starting with $length. ( Ignorable by Annotation )

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

325
        $output = /** @scrutinizer ignore-call */ stream_get_contents(

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
326
            $offset
327
        );
328
329
        if ($output === false) {
330
            throw new RuntimeException(sprintf(
331
                'Can not get process [%s] error output',
332
                $this->command
333
            ));
334
        }
335
336
        return $output;
337
    }
338
339
    /**
340
     * Return the process command output
341
     * @param int|null $length
342
     * @param int $offset
343
     * @return string
344
     * @throws RuntimeException
345
     */
346
    public function getOutput(?int $length = null, int $offset = -1): string
347
    {
348
        $output = stream_get_contents(
349
            $this->pipes[self::STDOUT_DESCRIPTOR],
350
            $length,
0 ignored issues
show
The call to Platine\Console\Command\stream_get_contents() has too many arguments starting with $length. ( Ignorable by Annotation )

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

350
        $output = /** @scrutinizer ignore-call */ stream_get_contents(

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
351
            $offset
352
        );
353
354
        if ($output === false) {
355
            throw new RuntimeException(sprintf(
356
                'Can not get process [%s] error output',
357
                $this->command
358
            ));
359
        }
360
361
        return $output;
362
    }
363
364
    /**
365
     * Return the process exit code
366
     * @return int|null
367
     */
368
    public function getExitCode(): ?int
369
    {
370
        $this->updateStatus();
371
372
        return $this->exitCode;
373
    }
374
375
    /**
376
     * Return the process ID
377
     * @return int|null
378
     */
379
    public function getProcessId(): ?int
380
    {
381
        return $this->isRunning() ? $this->status['pid'] : null;
382
    }
383
384
    /**
385
     * Return the descriptors to be used later
386
     * @return array<int, array<int, string>>
387
     */
388
    protected function getDescriptors(): array
389
    {
390
        $out = $this->isWindows()
391
               ? ['pipe', 'w'] // ['file', 'NUL', 'w']
392
               : ['pipe', 'w'];
393
394
        return [
395
         self::STDIN_DESCRIPTOR  => ['pipe', 'r'],
396
         self::STDOUT_DESCRIPTOR => $out,
397
         self::STDERR_DESCRIPTOR => $out,
398
        ];
399
    }
400
401
    /**
402
     * Whether the current Os is Windows
403
     * @return bool
404
     */
405
    protected function isWindows(): bool
406
    {
407
        return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
408
    }
409
410
    /**
411
     * Write to input stream
412
     * @return void
413
     */
414
    protected function writeInput(): void
415
    {
416
        if ($this->input !== null) {
417
            fwrite($this->pipes[self::STDIN_DESCRIPTOR], $this->input);
418
        }
419
    }
420
421
    /**
422
     * Update the process status
423
     * @return void
424
     */
425
    protected function updateStatus(): void
426
    {
427
        if ($this->state !== self::STATE_STARTED) {
428
            return;
429
        }
430
431
        if (is_resource($this->process)) {
432
            $status = proc_get_status($this->process);
433
            if ($status === false) {
434
                throw new RuntimeException(sprintf(
435
                    'Can not get process [%s] status information',
436
                    $this->command
437
                ));
438
            }
439
440
            $this->status = $status;
441
442
            if ($this->status['running'] === false && $this->exitCode === null) {
443
                $this->exitCode = $this->status['exitcode'];
444
            }
445
        }
446
    }
447
448
    /**
449
     * Close the process pipes
450
     * @return void
451
     */
452
    protected function closePipes(): void
453
    {
454
        fclose($this->pipes[self::STDIN_DESCRIPTOR]);
455
        fclose($this->pipes[self::STDOUT_DESCRIPTOR]);
456
        fclose($this->pipes[self::STDERR_DESCRIPTOR]);
457
    }
458
459
    /**
460
     * Waiting the process to finish
461
     * @return int|null
462
     */
463
    protected function wait(): ?int
464
    {
465
        while ($this->isRunning()) {
466
            usleep(5000);
467
            $this->checkTimeout();
468
        }
469
470
        return $this->exitCode;
471
    }
472
473
    /**
474
     * Check for process execution timeout
475
     * @return void
476
     */
477
    protected function checkTimeout(): void
478
    {
479
        if ($this->timeout === null) {
480
            return;
481
        }
482
483
        $duration = microtime(true) - $this->startTime;
484
485
        if ($duration > $this->timeout) {
486
            throw new RuntimeException(sprintf(
487
                'Process [%s] execution timeout, time [%d sec] expected [%d sec]',
488
                $this->command,
489
                $duration,
490
                $this->timeout
491
            ));
492
        }
493
    }
494
495
    /**
496
     * Set the running process to asynchronous
497
     * @return bool
498
     */
499
    protected function setOutputStreamNonBlocking(): bool
500
    {
501
        return stream_set_blocking($this->pipes[self::STDOUT_DESCRIPTOR], false);
502
    }
503
}
504