I ran into an edge case with child-process that I suspect may also affect stream. First of all, here is a reproduce case:
<?php use React\ChildProcess\Process; use React\EventLoop\Factory; use React\EventLoop\LoopInterface; require 'vendor/autoload.php'; function run(LoopInterface $loop, $cmd, callable $callback) { $process = new Process($cmd); $stdOut = ''; $stdErr = ''; $process->on( 'exit', function ($exitCode) use ($callback, $cmd, &$stdOut, &$stdErr) { if ($exitCode) { throw new \Exception('Error running command ' . $cmd . ': ' . $exitCode . PHP_EOL . $stdOut . PHP_EOL . $stdErr); } $callback($stdOut); } ); $process->start($loop); $process->stdout->on( 'data', function ($output) use (&$stdOut) { $stdOut .= $output; } ); $process->stderr->on( 'data', function ($output) use (&$stdErr) { $stdErr .= $output; } ); } function ssh($host, $cmd) { return 'ssh -o "PasswordAuthentication no" -A ' . escapeshellarg($host) . ' ' . escapeshellarg($cmd); } // list of 17 hosts to SSH into $hosts = [...]; $attempts = 0; while (true) { $attempts++; echo '.'; if ($attempts % 60 === 0) { echo "\n"; } // StreamSelectLoop $loop = Factory::create(); foreach ($hosts as $host) { $loop->addTimer( 0.001, function () use ($host, $loop, $attempts) { run($loop, ssh($host, 'echo foo'), function ($value) use ($attempts) { if ($value !== "foo\n") { throw new \Exception('Found bad value ' . json_encode($value) . ' after ' . $attempts . ' attempts'); } }); } ); } $loop->run(); }
The bad value returned here is an empty string.
I have been able to reproduce the problem in some cases without the timer and without the ssh command. But this is the most reliable reproduce case I have been able to come up with. On my local machine (running OSX) the problem is reproduced in 1-20 attempts. When running in a server environment it generally takes 20-200 attempts.
I managed to figure out that the process stdin's Stream::handleClose() is being called without the PHP stream's buffer being consumed.
A good way of seeing that is by replacing Stream::handleClose() with:
public function handleClose() { if (is_resource($this->stream)) { $rest = stream_get_contents($this->stream); if ($rest !== '') { throw new \Exception($rest); } fclose($this->stream); } }
You will see that the string "foo\n" was still in the buffer. And also get a backtrace to where the close() call came from.
Stream already waits for buffered writes to go out before closing. But it does not process buffered reads. I believe that is the source of the bug.
Enjoy!