Skip to content

Commit 3534ecc

Browse files
authored
Merge pull request #65 from clue-labs/custom-pipes
Support passing custom pipes and file descriptors to child process
2 parents 02c560e + 53f3920 commit 3534ecc

File tree

4 files changed

+180
-29
lines changed

4 files changed

+180
-29
lines changed

README.md

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ as [Streams](https://github.com/reactphp/stream).
1919
* [Stream Properties](#stream-properties)
2020
* [Command](#command)
2121
* [Termination](#termination)
22+
* [Custom pipes](#custom-pipes)
2223
* [Sigchild Compatibility](#sigchild-compatibility)
2324
* [Windows Compatibility](#windows-compatibility)
2425
* [Install](#install)
@@ -55,21 +56,31 @@ Once a process is started, its I/O streams will be constructed as instances of
5556
Before `start()` is called, these properties are not set. Once a process terminates,
5657
the streams will become closed but not unset.
5758

59+
Following common Unix conventions, this library will start each child process
60+
with the three pipes matching the standard I/O streams as given below by default.
61+
You can use the named references for common use cases or access these as an
62+
array with all three pipes.
63+
5864
* `$stdin` or `$pipes[0]` is a `WritableStreamInterface`
5965
* `$stdout` or `$pipes[1]` is a `ReadableStreamInterface`
6066
* `$stderr` or `$pipes[2]` is a `ReadableStreamInterface`
6167

62-
Following common Unix conventions, this library will always start each child
63-
process with the three pipes matching the standard I/O streams as given above.
64-
You can use the named references for common use cases or access these as an
65-
array with all three pipes.
68+
Note that this default configuration may be overridden by explicitly passing
69+
[custom pipes](#custom-pipes), in which case they may not be set or be assigned
70+
different values. The `$pipes` array will always contain references to all pipes
71+
as configured and the standard I/O references will always be set to reference
72+
the pipes matching the above conventions. See [custom pipes](#custom-pipes) for
73+
more details.
6674

6775
Because each of these implement the underlying
6876
[`ReadableStreamInterface`](https://github.com/reactphp/stream#readablestreaminterface) or
6977
[`WritableStreamInterface`](https://github.com/reactphp/stream#writablestreaminterface),
7078
you can use any of their events and methods as usual:
7179

7280
```php
81+
$process = new Process($command);
82+
$process->start($loop);
83+
7384
$process->stdout->on('data', function ($chunk) {
7485
echo $chunk;
7586
});
@@ -298,6 +309,54 @@ properties actually allow some fine grained control over process termination,
298309
such as first trying a soft-close and then applying a force-close after a
299310
timeout.
300311

312+
### Custom pipes
313+
314+
Following common Unix conventions, this library will start each child process
315+
with the three pipes matching the standard I/O streams by default. For more
316+
advanced use cases it may be useful to pass in custom pipes, such as explicitly
317+
passing additional file descriptors (FDs) or overriding default process pipes.
318+
319+
Note that passing custom pipes is considered advanced usage and requires a
320+
more in-depth understanding of Unix file descriptors and how they are inherited
321+
to child processes and shared in multi-processing applications.
322+
323+
If you do not want to use the default standard I/O pipes, you can explicitly
324+
pass an array containing the file descriptor specification to the constructor
325+
like this:
326+
327+
```php
328+
$fds = array(
329+
// standard I/O pipes for stdin/stdout/stderr
330+
0 => array('pipe', 'r'),
331+
1 => array('pipe', 'w'),
332+
2 => array('pipe', 'w'),
333+
334+
// example FDs for files or open resources
335+
4 => array('file', '/dev/null', 'r'),
336+
6 => fopen('log.txt','a'),
337+
8 => STDERR,
338+
339+
// example FDs for sockets
340+
10 => fsockopen('localhost', 8080),
341+
12 => stream_socket_server('tcp://0.0.0.0:4711')
342+
);
343+
344+
$process = new Process($cmd, null, null, $fds);
345+
$process->start($loop);
346+
```
347+
348+
Unless your use case has special requirements that demand otherwise, you're
349+
highly recommended to (at least) pass in the standard I/O pipes as given above.
350+
The file descriptor specification accepts arguments in the exact same format
351+
as the underlying [`proc_open()`](http://php.net/proc_open) function.
352+
353+
Once the process is started, the `$pipes` array will always contain references to
354+
all pipes as configured and the standard I/O references will always be set to
355+
reference the pipes matching common Unix conventions. This library supports any
356+
number of pipes and additional file descriptors, but many common applications
357+
being run as a child process will expect that the parent process properly
358+
assigns these file descriptors.
359+
301360
### Sigchild Compatibility
302361

303362
Internally, this project uses a work-around to improve compatibility when PHP

examples/21-fds.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
use React\EventLoop\Factory;
4+
use React\ChildProcess\Process;
5+
6+
require __DIR__ . '/../vendor/autoload.php';
7+
8+
$loop = Factory::create();
9+
10+
$process = new Process('exec 0>&- 2>&-;exec ls -la /proc/self/fd', null, null, array(
11+
1 => array('pipe', 'w')
12+
));
13+
$process->start($loop);
14+
15+
$process->stdout->on('data', function ($chunk) {
16+
echo $chunk;
17+
});
18+
19+
$process->on('exit', function ($code) {
20+
echo 'EXIT with code ' . $code . PHP_EOL;
21+
});
22+
23+
$loop->run();

src/Process.php

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,25 @@
2020
class Process extends EventEmitter
2121
{
2222
/**
23-
* @var ?WritableStreamInterface
23+
* @var WritableStreamInterface|null|ReadableStreamInterface
2424
*/
2525
public $stdin;
2626

2727
/**
28-
* @var ?ReadableStreamInterface
28+
* @var ReadableStreamInterface|null|WritableStreamInterface
2929
*/
3030
public $stdout;
3131

3232
/**
33-
* @var ?ReadableStreamInterface
33+
* @var ReadableStreamInterface|null|WritableStreamInterface
3434
*/
3535
public $stderr;
3636

3737
/**
3838
* Array with all process pipes (once started)
39+
*
40+
* Unless explicitly configured otherwise during construction, the following
41+
* standard I/O pipes will be assigned by default:
3942
* - 0: STDIN (`WritableStreamInterface`)
4043
* - 1: STDOUT (`ReadableStreamInterface`)
4144
* - 2: STDERR (`ReadableStreamInterface`)
@@ -47,6 +50,8 @@ class Process extends EventEmitter
4750
private $cmd;
4851
private $cwd;
4952
private $env;
53+
private $fds;
54+
5055
private $enhanceSigchildCompatibility;
5156
private $sigchildPipe;
5257

@@ -65,9 +70,10 @@ class Process extends EventEmitter
6570
* @param string $cmd Command line to run
6671
* @param null|string $cwd Current working directory or null to inherit
6772
* @param null|array $env Environment variables or null to inherit
73+
* @param null|array $fds File descriptors to allocate for this process (or null = default STDIO streams)
6874
* @throws \LogicException On windows or when proc_open() is not installed
6975
*/
70-
public function __construct($cmd, $cwd = null, array $env = null)
76+
public function __construct($cmd, $cwd = null, array $env = null, array $fds = null)
7177
{
7278
if (substr(strtolower(PHP_OS), 0, 3) === 'win') {
7379
throw new \LogicException('Windows isn\'t supported due to the blocking nature of STDIN/STDOUT/STDERR pipes.');
@@ -87,18 +93,27 @@ public function __construct($cmd, $cwd = null, array $env = null)
8793
}
8894
}
8995

96+
if ($fds === null) {
97+
$fds = array(
98+
array('pipe', 'r'), // stdin
99+
array('pipe', 'w'), // stdout
100+
array('pipe', 'w'), // stderr
101+
);
102+
}
103+
104+
$this->fds = $fds;
90105
$this->enhanceSigchildCompatibility = self::isSigchildEnabled();
91106
}
92107

93108
/**
94109
* Start the process.
95110
*
96-
* After the process is started, the standard IO streams will be constructed
97-
* and available via public properties. STDIN will be paused upon creation.
111+
* After the process is started, the standard I/O streams will be constructed
112+
* and available via public properties.
98113
*
99114
* @param LoopInterface $loop Loop interface for stream construction
100115
* @param float $interval Interval to periodically monitor process state (seconds)
101-
* @throws RuntimeException If the process is already running or fails to start
116+
* @throws \RuntimeException If the process is already running or fails to start
102117
*/
103118
public function start(LoopInterface $loop, $interval = 0.1)
104119
{
@@ -107,17 +122,22 @@ public function start(LoopInterface $loop, $interval = 0.1)
107122
}
108123

109124
$cmd = $this->cmd;
110-
$fdSpec = array(
111-
array('pipe', 'r'), // stdin
112-
array('pipe', 'w'), // stdout
113-
array('pipe', 'w'), // stderr
114-
);
115-
125+
$fdSpec = $this->fds;
116126
$sigchild = null;
127+
117128
// Read exit code through fourth pipe to work around --enable-sigchild
118129
if ($this->enhanceSigchildCompatibility) {
119130
$fdSpec[] = array('pipe', 'w');
120-
$sigchild = 3;
131+
\end($fdSpec);
132+
$sigchild = \key($fdSpec);
133+
134+
// make sure this is fourth or higher (do not mess with STDIO)
135+
if ($sigchild < 3) {
136+
$fdSpec[3] = $fdSpec[$sigchild];
137+
unset($fdSpec[$sigchild]);
138+
$sigchild = 3;
139+
}
140+
121141
$cmd = sprintf('(%s) ' . $sigchild . '>/dev/null; code=$?; echo $code >&' . $sigchild . '; exit $code', $cmd);
122142
}
123143

@@ -127,13 +147,13 @@ public function start(LoopInterface $loop, $interval = 0.1)
127147
throw new \RuntimeException('Unable to launch a new process.');
128148
}
129149

130-
$closeCount = 0;
131-
150+
// count open process pipes and await close event for each to drain buffers before detecting exit
132151
$that = $this;
152+
$closeCount = 0;
133153
$streamCloseHandler = function () use (&$closeCount, $loop, $interval, $that) {
134-
$closeCount++;
154+
$closeCount--;
135155

136-
if ($closeCount < 2) {
156+
if ($closeCount > 0) {
137157
return;
138158
}
139159

@@ -160,18 +180,25 @@ public function start(LoopInterface $loop, $interval = 0.1)
160180
}
161181

162182
foreach ($pipes as $n => $fd) {
163-
if ($n === 0) {
183+
$meta = \stream_get_meta_data($fd);
184+
if (\strpos($meta['mode'], 'w') !== false) {
164185
$stream = new WritableResourceStream($fd, $loop);
165186
} else {
166187
$stream = new ReadableResourceStream($fd, $loop);
167188
$stream->on('close', $streamCloseHandler);
189+
$closeCount++;
168190
}
169191
$this->pipes[$n] = $stream;
170192
}
171193

172-
$this->stdin = $this->pipes[0];
173-
$this->stdout = $this->pipes[1];
174-
$this->stderr = $this->pipes[2];
194+
$this->stdin = isset($this->pipes[0]) ? $this->pipes[0] : null;
195+
$this->stdout = isset($this->pipes[1]) ? $this->pipes[1] : null;
196+
$this->stderr = isset($this->pipes[2]) ? $this->pipes[2] : null;
197+
198+
// immediately start checking for process exit when started without any I/O pipes
199+
if (!$closeCount) {
200+
$streamCloseHandler();
201+
}
175202
}
176203

177204
/**
@@ -186,9 +213,9 @@ public function close()
186213
return;
187214
}
188215

189-
$this->stdin->close();
190-
$this->stdout->close();
191-
$this->stderr->close();
216+
foreach ($this->pipes as $pipe) {
217+
$pipe->close();
218+
}
192219

193220
if ($this->enhanceSigchildCompatibility) {
194221
$this->pollExitCodePipe();

tests/AbstractProcessTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ public function testStartWillAssignPipes()
4242
$this->assertSame($process->stderr, $process->pipes[2]);
4343
}
4444

45+
public function testStartWithoutAnyPipesWillNotAssignPipes()
46+
{
47+
$process = new Process('exit 0', null, null, array());
48+
$process->start($this->createLoop());
49+
50+
$this->assertNull($process->stdin);
51+
$this->assertNull($process->stdout);
52+
$this->assertNull($process->stderr);
53+
$this->assertEquals(array(), $process->pipes);
54+
}
55+
56+
public function testStartWithCustomPipesWillAssignPipes()
57+
{
58+
$process = new Process('exit 0', null, null, array(
59+
0 => array('pipe', 'w'),
60+
3 => array('pipe', 'r')
61+
));
62+
$process->start($this->createLoop());
63+
64+
$this->assertInstanceOf('React\Stream\ReadableStreamInterface', $process->stdin);
65+
$this->assertNull($process->stdout);
66+
$this->assertNull($process->stderr);
67+
$this->assertCount(2, $process->pipes);
68+
$this->assertSame($process->stdin, $process->pipes[0]);
69+
$this->assertInstanceOf('React\Stream\WritableStreamInterface', $process->pipes[3]);
70+
}
71+
4572
public function testIsRunning()
4673
{
4774
$process = new Process('sleep 1');
@@ -333,6 +360,21 @@ public function testDetectsClosingProcessEvenWhenAllStdioPipesHaveBeenClosed()
333360
$time = microtime(true) - $time;
334361

335362
$this->assertLessThan(0.1, $time);
363+
$this->assertSame(0, $process->getExitCode());
364+
}
365+
366+
public function testDetectsClosingProcessEvenWhenStartedWithoutPipes()
367+
{
368+
$loop = $this->createLoop();
369+
$process = new Process('exit 0', null, null, array());
370+
$process->start($loop, 0.001);
371+
372+
$time = microtime(true);
373+
$loop->run();
374+
$time = microtime(true) - $time;
375+
376+
$this->assertLessThan(0.1, $time);
377+
$this->assertSame(0, $process->getExitCode());
336378
}
337379

338380
public function testStartInvalidProcess()

0 commit comments

Comments
 (0)