bug #38351 [Console] clone stream on multiline questions so EOT doesn't close input (ramsey)

This PR was squashed before being merged into the 5.2-dev branch (closes #38351).

Discussion
----------

[Console] clone stream on multiline questions so EOT doesn't close input

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Tickets       | N/A
| License       | MIT
| Doc PR        | N/A

This fixes a bug in the multiline question feature that was introduced in #37683.

Today, @epitre commented on https://github.com/symfony/symfony/pull/37683#issuecomment-700666826:

> If I ask a question AFTER using the multiline option in a previous question, then it seems that the ctrl+d is kind of saved, and the command gets aborted.

I'm honestly not sure how I missed this while working on #37683, since I was testing it with multiple questions, but I think it might have resulted from some of the back-and-forth and the lack of ability to effectively test the EOT character from a unit test.

The solution was to _clone_ the input stream resource and use the clone to read the multiline input and capture the EOT byte. In this way, the primary input stream is not closed by the EOT.

This is similar to @epitre's solution in https://github.com/symfony/symfony/pull/38345, but I'm using the `uri` and `mode` from `stream_get_meta_data()` to create the new stream, and if the existing stream has any data and is seekable and writable (like the streams used in the tests), I add the data to the clone and seek to the same offset.

I've ensured that this solution works on a question that is in the middle of a series of other questions, and I've tested in on *nix and Windows. I've also improved the tests for multiline questions. While I'm unable to test (with a unit test) that an EOT character effectively stops reading from STDIN while continuing to the next question and prompt, I feel confident that the tests here provide sufficient coverage.

Commits
-------

ec688a361e [Console] clone stream on multiline questions so EOT doesn't close input
This commit is contained in:
Robin Chalas 2020-09-30 21:48:20 +02:00
commit e8dd14ac9e
2 changed files with 91 additions and 4 deletions

View File

@ -517,11 +517,53 @@ class QuestionHelper extends Helper
return fgets($inputStream, 4096);
}
$multiLineStreamReader = $this->cloneInputStream($inputStream);
if (null === $multiLineStreamReader) {
return false;
}
$ret = '';
while (false !== ($char = fgetc($inputStream))) {
while (false !== ($char = fgetc($multiLineStreamReader))) {
if (\PHP_EOL === "{$ret}{$char}") {
break;
}
$ret .= $char;
}
return $ret;
}
/**
* Clones an input stream in order to act on one instance of the same
* stream without affecting the other instance.
*
* @param resource $inputStream The handler resource
*
* @return resource|null The cloned resource, null in case it could not be cloned
*/
private function cloneInputStream($inputStream)
{
$streamMetaData = stream_get_meta_data($inputStream);
$seekable = $streamMetaData['seekable'] ?? false;
$mode = $streamMetaData['mode'] ?? 'rb';
$uri = $streamMetaData['uri'] ?? null;
if (null === $uri) {
return null;
}
$cloneStream = fopen($uri, $mode);
// For seekable and writable streams, add all the same data to the
// cloned stream and then seek to the same offset.
if (true === $seekable && !\in_array($mode, ['r', 'rb', 'rt'])) {
$offset = ftell($inputStream);
rewind($inputStream);
stream_copy_to_stream($inputStream, $cloneStream);
fseek($inputStream, $offset);
fseek($cloneStream, $offset);
}
return $cloneStream;
}
}

View File

@ -461,19 +461,64 @@ EOD;
$question = new Question('Write an essay');
$question->setMultiline(true);
$this->assertEquals($essay, $dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question));
$this->assertSame($essay, $dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question));
}
public function testAskMultilineResponseWithSingleNewline()
{
$response = $this->getInputStream("\n");
$response = $this->getInputStream(\PHP_EOL);
$dialog = new QuestionHelper();
$question = new Question('Write an essay');
$question->setMultiline(true);
$this->assertEquals('', $dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question));
$this->assertNull($dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question));
}
public function testAskMultilineResponseWithDataAfterNewline()
{
$response = $this->getInputStream(\PHP_EOL.'this is text');
$dialog = new QuestionHelper();
$question = new Question('Write an essay');
$question->setMultiline(true);
$this->assertNull($dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question));
}
public function testAskMultilineResponseWithMultipleNewlinesAtEnd()
{
$typedText = 'This is a body'.\PHP_EOL.\PHP_EOL;
$response = $this->getInputStream($typedText);
$dialog = new QuestionHelper();
$question = new Question('Write an essay');
$question->setMultiline(true);
$this->assertSame('This is a body', $dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question));
}
public function testAskMultilineResponseWithWithCursorInMiddleOfSeekableInputStream()
{
$input = <<<EOD
This
is
some
input
EOD;
$response = $this->getInputStream($input);
fseek($response, 8);
$dialog = new QuestionHelper();
$question = new Question('Write an essay');
$question->setMultiline(true);
$this->assertSame("some\ninput", $dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question));
$this->assertSame(8, ftell($response));
}
/**