Skip to content

Commit 32a5b99

Browse files
authored
Merge pull request #57 from clue-labs/no-tty
Support running on non-TTY and closing STDIN and STDOUT streams
2 parents 2e11cb9 + 6967e04 commit 32a5b99

8 files changed

+198
-20
lines changed

examples/01-periodic.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,10 @@
2929
$stdio->end();
3030
});
3131

32+
// input already closed on program start, exit immediately
33+
if (!$stdio->isReadable()) {
34+
$loop->cancelTimer($timer);
35+
$stdio->end();
36+
}
37+
3238
$loop->run();

src/Stdin.php

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Clue\React\Stdio;
44

5-
use React\Stream\ReadableStream;
65
use React\Stream\Stream;
76
use React\EventLoop\LoopInterface;
87

@@ -13,40 +12,77 @@ class Stdin extends Stream
1312

1413
public function __construct(LoopInterface $loop)
1514
{
15+
// STDIN not defined ("php -a") or already closed (`fclose(STDIN)`)
16+
if (!defined('STDIN') || !is_resource(STDIN)) {
17+
parent::__construct(fopen('php://memory', 'r'), $loop);
18+
return $this->close();
19+
}
20+
1621
parent::__construct(STDIN, $loop);
17-
}
1822

19-
public function resume()
20-
{
21-
if ($this->oldMode === null) {
23+
// support starting program with closed STDIN ("example.php 0<&-")
24+
// the stream is a valid resource and is not EOF, but fstat fails
25+
if (fstat(STDIN) === false) {
26+
return $this->close();
27+
}
28+
29+
if ($this->isTty()) {
2230
$this->oldMode = shell_exec('stty -g');
2331

2432
// Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
2533
shell_exec('stty -icanon -echo');
26-
27-
parent::resume();
2834
}
2935
}
3036

31-
public function pause()
37+
public function close()
38+
{
39+
$this->restore();
40+
parent::close();
41+
}
42+
43+
public function __destruct()
3244
{
33-
if ($this->oldMode !== null) {
45+
$this->restore();
46+
}
47+
48+
private function restore()
49+
{
50+
if ($this->oldMode !== null && $this->isTty()) {
3451
// Reset stty so it behaves normally again
3552
shell_exec(sprintf('stty %s', $this->oldMode));
36-
3753
$this->oldMode = null;
38-
parent::pause();
3954
}
4055
}
4156

42-
public function close()
57+
/**
58+
* @return bool
59+
* @codeCoverageIgnore
60+
*/
61+
private function isTty()
4362
{
44-
$this->pause();
45-
parent::close();
46-
}
63+
if (PHP_VERSION_ID >= 70200) {
64+
// Prefer `stream_isatty()` (available as of PHP 7.2 only)
65+
return stream_isatty(STDIN);
66+
} elseif (function_exists('posix_isatty')) {
67+
// Otherwise use `posix_isatty` if available (requires `ext-posix`)
68+
return posix_isatty(STDIN);
69+
}
4770

48-
public function __destruct()
49-
{
50-
$this->pause();
71+
// otherwise try to guess based on stat file mode and device major number
72+
// Must be special character device: ($mode & S_IFMT) === S_IFCHR
73+
// And device major number must be allocated to TTYs (2-5 and 128-143)
74+
// For what it's worth, checking for device gid 5 (tty) is less reliable.
75+
// @link http://man7.org/linux/man-pages/man7/inode.7.html
76+
// @link https://www.kernel.org/doc/html/v4.11/admin-guide/devices.html#terminal-devices
77+
if (is_resource(STDIN)) {
78+
$stat = fstat(STDIN);
79+
$mode = isset($stat['mode']) ? ($stat['mode'] & 0170000) : 0;
80+
$major = isset($stat['dev']) ? (($stat['dev'] >> 8) & 0xff) : 0;
81+
82+
if ($mode === 0020000 && $major >= 2 && $major <= 143 && ($major <=5 || $major >= 128)) {
83+
return true;
84+
}
85+
}
86+
return false;
5187
}
5288
}

src/Stdio.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public function __construct(LoopInterface $loop, ReadableStreamInterface $input
2626
}
2727

2828
if ($output === null) {
29-
$output = new Stdout(STDOUT);
29+
$output = new Stdout();
3030
}
3131

3232
if ($readline === null) {

src/Stdout.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,21 @@
66

77
class Stdout extends WritableStream
88
{
9+
public function __construct()
10+
{
11+
// STDOUT not defined ("php -a") or already closed (`fclose(STDOUT)`)
12+
if (!defined('STDOUT') || !is_resource(STDOUT)) {
13+
return $this->close();
14+
}
15+
}
16+
917
public function write($data)
1018
{
11-
// TODO: use non-blocking output instead
19+
if ($this->closed) {
20+
return false;
21+
}
1222

23+
// TODO: use non-blocking output instead
1324
fwrite(STDOUT, $data);
1425

1526
return true;

tests/FunctionalExampleTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
class FunctionalExampleTest extends TestCase
4+
{
5+
public function testPeriodicExampleWithPipedInputEndsBecauseInputEnds()
6+
{
7+
$output = $this->execExample('echo hello | php 01-periodic.php');
8+
9+
$this->assertContains('you just said: hello\n', $output);
10+
}
11+
12+
public function testPeriodicExampleWithNullInputQuitsImmediately()
13+
{
14+
$output = $this->execExample('php 01-periodic.php < /dev/null');
15+
16+
$this->assertNotContains('you just said:', $output);
17+
}
18+
19+
public function testPeriodicExampleWithNoInputQuitsImmediately()
20+
{
21+
$output = $this->execExample('true | php 01-periodic.php');
22+
23+
$this->assertNotContains('you just said:', $output);
24+
}
25+
26+
public function testPeriodicExampleWithSleepNoInputQuitsOnEnd()
27+
{
28+
$output = $this->execExample('sleep 0.1 | php 01-periodic.php');
29+
30+
$this->assertNotContains('you just said:', $output);
31+
}
32+
33+
public function testPeriodicExampleWithClosedInputQuitsImmediately()
34+
{
35+
$output = $this->execExample('php 01-periodic.php <&-');
36+
37+
if (strpos($output, 'said') !== false) {
38+
$this->markTestIncomplete('Your platform exhibits a closed STDIN bug, this may need some further debugging');
39+
}
40+
41+
$this->assertNotContains('you just said:', $output);
42+
}
43+
44+
public function testStubShowStdinIsReadableByDefault()
45+
{
46+
$output = $this->execExample('php ../tests/stub/01-check-stdin.php');
47+
48+
$this->assertContains('YES', $output);
49+
}
50+
51+
public function testStubCanCloseStdinAndIsNotReadable()
52+
{
53+
$output = $this->execExample('php ../tests/stub/02-close-stdin.php');
54+
55+
$this->assertContains('NO', $output);
56+
}
57+
58+
public function testStubCanCloseStdoutAndIsNotWritable()
59+
{
60+
$output = $this->execExample('php ../tests/stub/03-close-stdout.php 2>&1');
61+
62+
$this->assertEquals('', $output);
63+
}
64+
65+
public function testPeriodicExampleViaInteractiveModeQuitsImmediately()
66+
{
67+
if (defined('HHVM_VERSION')) {
68+
$this->markTestSkipped('Skipped interactive mode on HHVM');
69+
}
70+
71+
$output = $this->execExample('echo "require(\"01-periodic.php\");" | php -a');
72+
73+
// starts with either "Interactive mode enabled" or "Interactive shell"
74+
$this->assertStringStartsWith('Interactive ', $output);
75+
$this->assertNotContains('you just said:', $output);
76+
}
77+
78+
private function execExample($command)
79+
{
80+
chdir(__DIR__ . '/../examples/');
81+
82+
return shell_exec($command);
83+
}
84+
}

tests/stub/01-check-stdin.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
use Clue\React\Stdio\Stdio;
4+
5+
require __DIR__ . '/../../vendor/autoload.php';
6+
7+
$loop = React\EventLoop\Factory::create();
8+
9+
$stdio = new Stdio($loop);
10+
$stdio->end($stdio->isReadable() ? 'YES' : 'NO');
11+
12+
$loop->run();

tests/stub/02-close-stdin.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
use Clue\React\Stdio\Stdio;
4+
5+
require __DIR__ . '/../../vendor/autoload.php';
6+
7+
$loop = React\EventLoop\Factory::create();
8+
9+
fclose(STDIN);
10+
$stdio = new Stdio($loop);
11+
$stdio->end($stdio->isReadable() ? 'YES' : 'NO');
12+
13+
$loop->run();

tests/stub/03-close-stdout.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
use Clue\React\Stdio\Stdio;
4+
5+
require __DIR__ . '/../../vendor/autoload.php';
6+
7+
$loop = React\EventLoop\Factory::create();
8+
9+
fclose(STDOUT);
10+
$stdio = new Stdio($loop);
11+
if ($stdio->isWritable()) {
12+
throw new \RuntimeException('Not writable');
13+
}
14+
$stdio->close();
15+
16+
$loop->run();

0 commit comments

Comments
 (0)