Skip to content

Support running on non-TTY and closing STDIN and STDOUT streams #57

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 1, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/01-periodic.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@
$stdio->end();
});

// input already closed on program start, exit immediately
if (!$stdio->isReadable()) {
$loop->cancelTimer($timer);
$stdio->end();
}

$loop->run();
72 changes: 54 additions & 18 deletions src/Stdin.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Clue\React\Stdio;

use React\Stream\ReadableStream;
use React\Stream\Stream;
use React\EventLoop\LoopInterface;

Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion src/Stdio.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function __construct(LoopInterface $loop, ReadableStreamInterface $input
}

if ($output === null) {
$output = new Stdout(STDOUT);
$output = new Stdout();
}

if ($readline === null) {
Expand Down
13 changes: 12 additions & 1 deletion src/Stdout.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions tests/FunctionalExampleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

class FunctionalExampleTest extends TestCase
{
public function testPeriodicExampleWithPipedInputEndsBecauseInputEnds()
{
$output = $this->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);
}
}
12 changes: 12 additions & 0 deletions tests/stub/01-check-stdin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

use Clue\React\Stdio\Stdio;

require __DIR__ . '/../../vendor/autoload.php';

$loop = React\EventLoop\Factory::create();

$stdio = new Stdio($loop);
$stdio->end($stdio->isReadable() ? 'YES' : 'NO');

$loop->run();
13 changes: 13 additions & 0 deletions tests/stub/02-close-stdin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

use Clue\React\Stdio\Stdio;

require __DIR__ . '/../../vendor/autoload.php';

$loop = React\EventLoop\Factory::create();

fclose(STDIN);
$stdio = new Stdio($loop);
$stdio->end($stdio->isReadable() ? 'YES' : 'NO');

$loop->run();
16 changes: 16 additions & 0 deletions tests/stub/03-close-stdout.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

use Clue\React\Stdio\Stdio;

require __DIR__ . '/../../vendor/autoload.php';

$loop = React\EventLoop\Factory::create();

fclose(STDOUT);
$stdio = new Stdio($loop);
if ($stdio->isWritable()) {
throw new \RuntimeException('Not writable');
}
$stdio->close();

$loop->run();