Skip to content

Commit 5bc9282

Browse files
authored
Merge pull request #40 from adhocore/develop
Shell exec wrapper
2 parents 8b46f8a + f62c483 commit 5bc9282

File tree

3 files changed

+420
-5
lines changed

3 files changed

+420
-5
lines changed

README.md

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Framework agnostic Command Line Interface utilities and helpers for PHP. Build C
1717

1818
#### What's included
1919

20-
**Core:** [Argv parser](#as-argv-parser) · [Cli application](#as-console-app)
20+
**Core:** [Argv parser](#argv-parser) · [Cli application](#console-app) · [Shell](#shell)
2121

2222
**IO:** [Colorizer](#color) · [Cursor manipulator](#cursor) · [Stream writer](#writer) · [Stream reader](#reader)
2323

@@ -28,7 +28,7 @@ composer require adhocore/cli
2828

2929
## Usage
3030

31-
### As argv parser
31+
### Argv parser
3232

3333
```php
3434
$command = new Ahc\Cli\Input\Command('rmdir', 'Remove dirs');
@@ -93,7 +93,7 @@ For above example, the output would be:
9393
0.0.1-dev
9494
```
9595

96-
### As console app
96+
### Console app
9797

9898
Definitely check [adhocore/phint](https://github.com/adhocore/phint) - a real world console application made using `adhocore/cli`.
9999

@@ -225,6 +225,52 @@ For above example, the output would be:
225225

226226
Same version number is passed to all attached Commands. So you can trigger version on any of the commands.
227227

228+
### Shell
229+
230+
Very thing shell wrapper that provides convenience methods around `proc_open()`.
231+
232+
#### Basic usage
233+
234+
```php
235+
$shell = new Ahc\Cli\Helper\Shell($command = 'php -v', $rawInput = null);
236+
237+
// Waits until proc finishes
238+
$shell->execute($async = false); // default false
239+
240+
echo $shell->getOutput(); // PHP version string (often with zend/opcache info)
241+
```
242+
243+
#### Advanced usage
244+
245+
```php
246+
$shell = new Ahc\Cli\Helper\Shell('php /some/long/running/scipt.php');
247+
248+
// With async flag, doesnt wait for proc to finish!
249+
$shell->setOptions($workDir = '/home', $envVars = [])
250+
->execute($async = true)
251+
->isRunning(); // true
252+
253+
// Force stop anytime (please check php.net/proc_close)
254+
$shell->stop(); // also closes pipes
255+
256+
// Force kill anytime (please check php.net/proc_terminate)
257+
$shell->kill();
258+
```
259+
260+
#### Timeout
261+
262+
```php
263+
$shell = new Ahc\Cli\Helper\Shell('php /some/long/running/scipt.php');
264+
265+
// Wait for at most 10.5 seconds for proc to finish!
266+
// If it doesnt complete by then, throws exception
267+
$shell->setOptions($workDir, $envVars, $timeout = 10.5)->execute();
268+
269+
// And if it completes within timeout, you can access the stdout/stderr
270+
echo $shell->getOutput();
271+
echo $shell->getErrorOutput();
272+
```
273+
228274
### Cli Interaction
229275

230276
You can perform user interaction like printing colored output, reading user input programatically and moving the cursors around with provided `Ahc\Cli\IO\Interactor`.
@@ -449,6 +495,11 @@ Whenever an exception is caught by `Application::handle()`, it will show a beaut
449495
- [adhocore/phint](https://github.com/adhocore/phint) PHP project scaffolding app using `adhocore/cli`
450496
- [adhocore/type-hinter](https://github.com/adhocore/php-type-hinter) Auto PHP7 typehinter tool using `adhocore/cli`
451497

452-
## LICENSE
498+
### Contributors
499+
500+
- [adhocore](https://github.com/adhocore)
501+
- [sushilgupta](https://github.com/sushilgupta)
502+
503+
## License
453504

454-
> © [MIT](./LICENSE) | 2018, Jitendra Adhikari
505+
> © 2018, [Jitendra Adhikari](https://github.com/adhocore) | [MIT](./LICENSE)

src/Helper/Shell.php

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the PHP-CLI package.
5+
*
6+
* (c) Jitendra Adhikari <[email protected]>
7+
* <https://github.com/adhocore>
8+
*
9+
* Licensed under MIT license.
10+
*/
11+
12+
namespace Ahc\Cli\Helper;
13+
14+
use Ahc\Cli\Exception\RuntimeException;
15+
16+
/**
17+
* A thin proc_open wrapper to execute shell commands.
18+
*
19+
* With some inspirations from symfony/process.
20+
*
21+
* @author Sushil Gupta <[email protected]>
22+
* @license MIT
23+
*
24+
* @link https://github.com/adhocore/cli
25+
*/
26+
class Shell
27+
{
28+
const STDIN_DESCRIPTOR_KEY = 0;
29+
const STDOUT_DESCRIPTOR_KEY = 1;
30+
const STDERR_DESCRIPTOR_KEY = 2;
31+
32+
const STATE_READY = 'ready';
33+
const STATE_STARTED = 'started';
34+
const STATE_CLOSED = 'closed';
35+
const STATE_TERMINATED = 'terminated';
36+
37+
/** @var bool Whether to wait for the process to finish or return instantly */
38+
protected $async = false;
39+
40+
/** @var string Command to be executed */
41+
protected $command;
42+
43+
/** @var string Current working directory */
44+
protected $cwd = null;
45+
46+
/** @var array Descriptor to be passed for proc_open */
47+
protected $descriptors;
48+
49+
/** @var array An array of environment variables */
50+
protected $env = null;
51+
52+
/** @var int Exit code of the process once it has been terminated */
53+
protected $exitCode = null;
54+
55+
/** @var string Input for stdin */
56+
protected $input;
57+
58+
/** @var array Other options to be passed for proc_open */
59+
protected $otherOptions = [];
60+
61+
/** @var array Pointers to stdin, stdout & stderr */
62+
protected $pipes = null;
63+
64+
/** @var resource The actual process resource returned from proc_open */
65+
protected $process = null;
66+
67+
/** @var array Status of the process as returned from proc_get_status */
68+
protected $processStatus = null;
69+
70+
/** @var int Process starting time in unix timestamp */
71+
protected $processStartTime;
72+
73+
/** @var string Current state of the shell execution */
74+
protected $state = self::STATE_READY;
75+
76+
/** @var float Default timeout for the process in seconds with microseconds */
77+
protected $processTimeout = null;
78+
79+
public function __construct(string $command, string $input = null)
80+
{
81+
// @codeCoverageIgnoreStart
82+
if (!\function_exists('proc_open')) {
83+
throw new RuntimeException('Required proc_open could not be found in your PHP setup');
84+
}
85+
// @codeCoverageIgnoreEnd
86+
87+
$this->command = $command;
88+
$this->input = $input;
89+
}
90+
91+
protected function getDescriptors(): array
92+
{
93+
$out = '\\' === \DIRECTORY_SEPARATOR ? ['file', 'NUL', 'w'] : ['pipe', 'w'];
94+
95+
return [
96+
self::STDIN_DESCRIPTOR_KEY => ['pipe', 'r'],
97+
self::STDOUT_DESCRIPTOR_KEY => $out,
98+
self::STDERR_DESCRIPTOR_KEY => $out,
99+
];
100+
}
101+
102+
protected function setInput()
103+
{
104+
\fwrite($this->pipes[self::STDIN_DESCRIPTOR_KEY], $this->input);
105+
}
106+
107+
protected function updateProcessStatus()
108+
{
109+
if ($this->state !== self::STATE_STARTED) {
110+
return;
111+
}
112+
113+
$this->processStatus = \proc_get_status($this->process);
114+
115+
if ($this->processStatus['running'] === false && $this->exitCode === null) {
116+
$this->exitCode = $this->processStatus['exitcode'];
117+
}
118+
}
119+
120+
protected function closePipes()
121+
{
122+
\fclose($this->pipes[self::STDIN_DESCRIPTOR_KEY]);
123+
\fclose($this->pipes[self::STDOUT_DESCRIPTOR_KEY]);
124+
\fclose($this->pipes[self::STDERR_DESCRIPTOR_KEY]);
125+
}
126+
127+
protected function wait()
128+
{
129+
while ($this->isRunning()) {
130+
usleep(5000);
131+
$this->checkTimeout();
132+
}
133+
134+
return $this->exitCode;
135+
}
136+
137+
protected function checkTimeout()
138+
{
139+
if ($this->processTimeout === null) {
140+
return;
141+
}
142+
143+
$executionDuration = \microtime(true) - $this->processStartTime;
144+
145+
if ($executionDuration > $this->processTimeout) {
146+
$this->kill();
147+
148+
throw new RuntimeException('Process timeout occurred, terminated');
149+
}
150+
151+
// @codeCoverageIgnoreStart
152+
153+
// @codeCoverageIgnoreEnd
154+
}
155+
156+
public function setOptions(string $cwd = null, array $env = null, float $timeout = null, array $otherOptions = []): self
157+
{
158+
$this->cwd = $cwd;
159+
$this->env = $env;
160+
$this->processTimeout = $timeout;
161+
$this->otherOptions = $otherOptions;
162+
163+
return $this;
164+
}
165+
166+
public function execute(bool $async = false): self
167+
{
168+
if ($this->isRunning()) {
169+
throw new RuntimeException('Process is already running');
170+
}
171+
172+
$this->descriptors = $this->getDescriptors();
173+
$this->processStartTime = \microtime(true);
174+
175+
$this->process = \proc_open($this->command, $this->descriptors, $this->pipes, $this->cwd, $this->env, $this->otherOptions);
176+
$this->setInput();
177+
178+
// @codeCoverageIgnoreStart
179+
if (!\is_resource($this->process)) {
180+
throw new RuntimeException('Bad program could not be started.');
181+
}
182+
// @codeCoverageIgnoreEnd
183+
184+
$this->state = self::STATE_STARTED;
185+
186+
$this->updateProcessStatus();
187+
188+
if ($this->async = $async) {
189+
$this->setOutputStreamNonBlocking();
190+
} else {
191+
$this->wait();
192+
}
193+
194+
return $this;
195+
}
196+
197+
private function setOutputStreamNonBlocking(): bool
198+
{
199+
return \stream_set_blocking($this->pipes[self::STDOUT_DESCRIPTOR_KEY], false);
200+
}
201+
202+
public function getState(): string
203+
{
204+
return $this->state;
205+
}
206+
207+
public function getOutput(): string
208+
{
209+
return \stream_get_contents($this->pipes[self::STDOUT_DESCRIPTOR_KEY]);
210+
}
211+
212+
public function getErrorOutput(): string
213+
{
214+
return \stream_get_contents($this->pipes[self::STDERR_DESCRIPTOR_KEY]);
215+
}
216+
217+
public function getExitCode()
218+
{
219+
$this->updateProcessStatus();
220+
221+
return $this->exitCode;
222+
}
223+
224+
public function isRunning(): bool
225+
{
226+
if (self::STATE_STARTED !== $this->state) {
227+
return false;
228+
}
229+
230+
$this->updateProcessStatus();
231+
232+
return $this->processStatus['running'];
233+
}
234+
235+
public function getProcessId()
236+
{
237+
return $this->isRunning() ? $this->processStatus['pid'] : null;
238+
}
239+
240+
public function stop()
241+
{
242+
$this->closePipes();
243+
244+
if (\is_resource($this->process)) {
245+
\proc_close($this->process);
246+
}
247+
248+
$this->state = self::STATE_CLOSED;
249+
250+
$this->exitCode = $this->processStatus['exitcode'];
251+
252+
return $this->exitCode;
253+
}
254+
255+
public function kill()
256+
{
257+
if (\is_resource($this->process)) {
258+
\proc_terminate($this->process);
259+
}
260+
261+
$this->state = self::STATE_TERMINATED;
262+
}
263+
264+
public function __destruct()
265+
{
266+
// If async (run in background) => we don't care if it ever closes
267+
// Otherwise, waited already till it runs or timeout occurs, in which case kill it
268+
}
269+
}

0 commit comments

Comments
 (0)