diff --git a/examples/01-periodic.php b/examples/01-periodic.php index 508a1a3..4dfc085 100644 --- a/examples/01-periodic.php +++ b/examples/01-periodic.php @@ -29,4 +29,10 @@ $stdio->end(); }); +// input already closed on program start, exit immediately +if (!$stdio->isReadable()) { + $loop->cancelTimer($timer); + $stdio->end(); +} + $loop->run(); diff --git a/src/Stdin.php b/src/Stdin.php index 9850530..c6e5cf4 100644 --- a/src/Stdin.php +++ b/src/Stdin.php @@ -2,7 +2,6 @@ namespace Clue\React\Stdio; -use React\Stream\ReadableStream; use React\Stream\Stream; use React\EventLoop\LoopInterface; @@ -13,40 +12,77 @@ class Stdin extends Stream public function __construct(LoopInterface $loop) { + // STDIN not defined ("php -a") or already closed (`fclose(STDIN)`) + if (!defined('STDIN') || !is_resource(STDIN)) { + parent::__construct(fopen('php://memory', 'r'), $loop); + return $this->close(); + } + parent::__construct(STDIN, $loop); - } - public function resume() - { - if ($this->oldMode === null) { + // support starting program with closed STDIN ("example.php 0<&-") + // the stream is a valid resource and is not EOF, but fstat fails + if (fstat(STDIN) === false) { + return $this->close(); + } + + if ($this->isTty()) { $this->oldMode = shell_exec('stty -g'); // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead) shell_exec('stty -icanon -echo'); - - parent::resume(); } } - public function pause() + public function close() + { + $this->restore(); + parent::close(); + } + + public function __destruct() { - if ($this->oldMode !== null) { + $this->restore(); + } + + private function restore() + { + if ($this->oldMode !== null && $this->isTty()) { // Reset stty so it behaves normally again shell_exec(sprintf('stty %s', $this->oldMode)); - $this->oldMode = null; - parent::pause(); } } - public function close() + /** + * @return bool + * @codeCoverageIgnore + */ + private function isTty() { - $this->pause(); - parent::close(); - } + if (PHP_VERSION_ID >= 70200) { + // Prefer `stream_isatty()` (available as of PHP 7.2 only) + return stream_isatty(STDIN); + } elseif (function_exists('posix_isatty')) { + // Otherwise use `posix_isatty` if available (requires `ext-posix`) + return posix_isatty(STDIN); + } - public function __destruct() - { - $this->pause(); + // otherwise try to guess based on stat file mode and device major number + // Must be special character device: ($mode & S_IFMT) === S_IFCHR + // And device major number must be allocated to TTYs (2-5 and 128-143) + // For what it's worth, checking for device gid 5 (tty) is less reliable. + // @link http://man7.org/linux/man-pages/man7/inode.7.html + // @link https://www.kernel.org/doc/html/v4.11/admin-guide/devices.html#terminal-devices + if (is_resource(STDIN)) { + $stat = fstat(STDIN); + $mode = isset($stat['mode']) ? ($stat['mode'] & 0170000) : 0; + $major = isset($stat['dev']) ? (($stat['dev'] >> 8) & 0xff) : 0; + + if ($mode === 0020000 && $major >= 2 && $major <= 143 && ($major <=5 || $major >= 128)) { + return true; + } + } + return false; } } diff --git a/src/Stdio.php b/src/Stdio.php index cb17df4..5cd8acb 100644 --- a/src/Stdio.php +++ b/src/Stdio.php @@ -26,7 +26,7 @@ public function __construct(LoopInterface $loop, ReadableStreamInterface $input } if ($output === null) { - $output = new Stdout(STDOUT); + $output = new Stdout(); } if ($readline === null) { diff --git a/src/Stdout.php b/src/Stdout.php index 6544f05..17474b7 100644 --- a/src/Stdout.php +++ b/src/Stdout.php @@ -6,10 +6,21 @@ class Stdout extends WritableStream { + public function __construct() + { + // STDOUT not defined ("php -a") or already closed (`fclose(STDOUT)`) + if (!defined('STDOUT') || !is_resource(STDOUT)) { + return $this->close(); + } + } + public function write($data) { - // TODO: use non-blocking output instead + if ($this->closed) { + return false; + } + // TODO: use non-blocking output instead fwrite(STDOUT, $data); return true; diff --git a/tests/FunctionalExampleTest.php b/tests/FunctionalExampleTest.php new file mode 100644 index 0000000..c661b11 --- /dev/null +++ b/tests/FunctionalExampleTest.php @@ -0,0 +1,84 @@ +execExample('echo hello | php 01-periodic.php'); + + $this->assertContains('you just said: hello\n', $output); + } + + public function testPeriodicExampleWithNullInputQuitsImmediately() + { + $output = $this->execExample('php 01-periodic.php < /dev/null'); + + $this->assertNotContains('you just said:', $output); + } + + public function testPeriodicExampleWithNoInputQuitsImmediately() + { + $output = $this->execExample('true | php 01-periodic.php'); + + $this->assertNotContains('you just said:', $output); + } + + public function testPeriodicExampleWithSleepNoInputQuitsOnEnd() + { + $output = $this->execExample('sleep 0.1 | php 01-periodic.php'); + + $this->assertNotContains('you just said:', $output); + } + + public function testPeriodicExampleWithClosedInputQuitsImmediately() + { + $output = $this->execExample('php 01-periodic.php <&-'); + + if (strpos($output, 'said') !== false) { + $this->markTestIncomplete('Your platform exhibits a closed STDIN bug, this may need some further debugging'); + } + + $this->assertNotContains('you just said:', $output); + } + + public function testStubShowStdinIsReadableByDefault() + { + $output = $this->execExample('php ../tests/stub/01-check-stdin.php'); + + $this->assertContains('YES', $output); + } + + public function testStubCanCloseStdinAndIsNotReadable() + { + $output = $this->execExample('php ../tests/stub/02-close-stdin.php'); + + $this->assertContains('NO', $output); + } + + public function testStubCanCloseStdoutAndIsNotWritable() + { + $output = $this->execExample('php ../tests/stub/03-close-stdout.php 2>&1'); + + $this->assertEquals('', $output); + } + + public function testPeriodicExampleViaInteractiveModeQuitsImmediately() + { + if (defined('HHVM_VERSION')) { + $this->markTestSkipped('Skipped interactive mode on HHVM'); + } + + $output = $this->execExample('echo "require(\"01-periodic.php\");" | php -a'); + + // starts with either "Interactive mode enabled" or "Interactive shell" + $this->assertStringStartsWith('Interactive ', $output); + $this->assertNotContains('you just said:', $output); + } + + private function execExample($command) + { + chdir(__DIR__ . '/../examples/'); + + return shell_exec($command); + } +} diff --git a/tests/stub/01-check-stdin.php b/tests/stub/01-check-stdin.php new file mode 100644 index 0000000..d9b2210 --- /dev/null +++ b/tests/stub/01-check-stdin.php @@ -0,0 +1,12 @@ +end($stdio->isReadable() ? 'YES' : 'NO'); + +$loop->run(); diff --git a/tests/stub/02-close-stdin.php b/tests/stub/02-close-stdin.php new file mode 100644 index 0000000..7178d9c --- /dev/null +++ b/tests/stub/02-close-stdin.php @@ -0,0 +1,13 @@ +end($stdio->isReadable() ? 'YES' : 'NO'); + +$loop->run(); diff --git a/tests/stub/03-close-stdout.php b/tests/stub/03-close-stdout.php new file mode 100644 index 0000000..4a357b5 --- /dev/null +++ b/tests/stub/03-close-stdout.php @@ -0,0 +1,16 @@ +isWritable()) { + throw new \RuntimeException('Not writable'); +} +$stdio->close(); + +$loop->run();