diff --git a/composer.json b/composer.json index 5782f56..a833a5a 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,6 @@ "react/event-loop": "^1.6", "react/http": "^1.11", "react/promise": "^3.3 || ^2.11 || ^1.3", - "react/promise-stream": "^1.6", "react/socket": "^1.17", "react/stream": "^1.4" }, diff --git a/src/Client.php b/src/Client.php index 4897b55..201f7ce 100644 --- a/src/Client.php +++ b/src/Client.php @@ -2,6 +2,7 @@ namespace Clue\React\Docker; +use Clue\React\Docker\Io\ReadableUnwrapStream; use Clue\React\Docker\Io\ResponseParser; use Clue\React\Docker\Io\StreamingParser; use React\EventLoop\LoopInterface; @@ -365,7 +366,7 @@ public function containerLogsStream($container, $follow = false, $stdout = true, )); // first inspect container to check TTY setting, then request logs with appropriate log parser - return \React\Promise\Stream\unwrapReadable($this->containerInspect($container)->then(function ($info) use ($url, $browser, $parser, $stderrEvent) { + return new ReadableUnwrapStream($this->containerInspect($container)->then(function ($info) use ($url, $browser, $parser, $stderrEvent) { $stream = $parser->parsePlainStream($browser->requestStreaming('GET', $url)); if (!$info['Config']['Tty']) { @@ -729,7 +730,7 @@ public function containerAttachStream($container, $logs = false, $stream = false )); // first inspect container to check TTY setting, then attach with appropriate log parser - return \React\Promise\Stream\unwrapReadable($this->containerInspect($container)->then(function ($info) use ($url, $browser, $parser, $stderrEvent) { + return new ReadableUnwrapStream($this->containerInspect($container)->then(function ($info) use ($url, $browser, $parser, $stderrEvent) { $stream = $parser->parsePlainStream($browser->requestStreaming('POST', $url)); if (!$info['Config']['Tty']) { diff --git a/src/Io/ReadableUnwrapStream.php b/src/Io/ReadableUnwrapStream.php new file mode 100644 index 0000000..e413f19 --- /dev/null +++ b/src/Io/ReadableUnwrapStream.php @@ -0,0 +1,127 @@ + */ + private $promise; + + /** @var ?ReadableStreamInterface */ + private $input; + + private $paused = false; + private $closed = false; + + /** + * @param PromiseInterface $promise + */ + public function __construct(PromiseInterface $promise) + { + $this->promise = $promise; + + $out = $this; + $input =& $this->input; + $paused =& $this->paused; + + $promise->then( + function (ReadableStreamInterface $stream) use ($out, &$input, &$paused) { + // output already closed while waiting or input already closed => close other side + if (!$out->isReadable() || !$stream->isReadable()) { + $stream->close(); + $out->close(); + return; + } + + $input = $stream; + if ($paused) { + $stream->pause(); + } + + // forward data event to output + $stream->on('data', function ($data) use ($out) { + $out->emit('data', array($data)); + }); + + // forward end event to output and close + $stream->on('end', function () use ($out) { + $out->emit('end'); + $out->close(); + }); + + // forward error event to output and close + $stream->on('error', function ($e) use ($out) { + $out->emit('error', array($e)); + $out->close(); + }); + + // close both streams once either side closes + $stream->on('close', array($out, 'close')); + $out->on('close', array($stream, 'close')); + }, + function ($e) use ($out) { + // ignore rejection after output is closed (cancellation) + if ($out->isReadable()) { + $out->emit('error', array($e)); + $out->close(); + } + } + ); + } + + public function isReadable() + { + return !$this->closed; + } + + public function pause() + { + $this->paused = true; + if ($this->input !== null) { + $this->input->pause(); + } + } + + public function resume() + { + $this->paused = false; + if ($this->input !== null) { + $this->input->resume(); + } + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + return Util::pipe($this, $dest, $options); + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + // try to cancel promise once the stream closes + if ($this->promise !== null && method_exists($this->promise, 'cancel')) { + $this->promise->cancel(); + } + $this->promise = null; + + $this->emit('close'); + $this->removeAllListeners(); + } +} diff --git a/src/Io/StreamingParser.php b/src/Io/StreamingParser.php index 28e2a9c..42e97f8 100644 --- a/src/Io/StreamingParser.php +++ b/src/Io/StreamingParser.php @@ -5,7 +5,6 @@ use Psr\Http\Message\ResponseInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; -use React\Promise\Stream; use React\Stream\ReadableStreamInterface; use RuntimeException; @@ -41,7 +40,7 @@ public function parsePlainStream(PromiseInterface $promise) { // text/plain - return Stream\unwrapReadable($promise->then(function (ResponseInterface $response) { + return new ReadableUnwrapStream($promise->then(function (ResponseInterface $response) { return $response->getBody(); })); } @@ -62,11 +61,44 @@ public function demultiplexStream(ReadableStreamInterface $input, $stderrEvent = * Returns a promise which resolves with the buffered stream contents of the given stream * * @param ReadableStreamInterface $stream - * @return PromiseInterface Promise + * @return PromiseInterface */ public function bufferedStream(ReadableStreamInterface $stream) { - return Stream\buffer($stream); + // stream already closed => resolve with empty buffer + if (!$stream->isReadable()) { + return \React\Promise\resolve(''); + } + + $buffer = ''; + $bufferer = function ($data) use (&$buffer) { + $buffer .= $data; + }; + $stream->on('data', $bufferer); + + // cancelling the deferred will stop buffering and clear buffered data + $deferred = new Deferred(function () use ($stream, $bufferer, &$buffer) { + $stream->removeListener('data', $bufferer); + $buffer = ''; + + throw new RuntimeException('Cancelled buffering'); + }); + + // error event rejects (stream closes afterwards) + $stream->on('error', function ($e) use ($deferred) { + $deferred->reject(new RuntimeException( + 'An error occured on the underlying stream while buffering: ' . $e->getMessage(), + $e->getCode(), + $e + )); + }); + + // close event resolves with buffered data (unless already rejected) + $stream->on('close', function () use ($deferred, &$buffer) { + $deferred->resolve($buffer); + }); + + return $deferred->promise(); } /** diff --git a/tests/FunctionalClientTest.php b/tests/FunctionalClientTest.php index c78256f..8a38f77 100644 --- a/tests/FunctionalClientTest.php +++ b/tests/FunctionalClientTest.php @@ -3,12 +3,13 @@ namespace Clue\Tests\React\Docker; use Clue\React\Docker\Client; +use Clue\React\Docker\Io\StreamingParser; use React\EventLoop\Loop; -use React\Promise\Stream; class FunctionalClientTest extends TestCase { private $client; + private $parser; /** * @before @@ -16,6 +17,7 @@ class FunctionalClientTest extends TestCase public function setUpClient() { $this->client = new Client(); + $this->parser = new StreamingParser(); $promise = $this->client->ping(); @@ -221,7 +223,7 @@ public function testExecStreamOutputInMultipleChunksWhileRunning($container) $stream->once('data', $this->expectCallableOnceWith('hello')); $stream->on('end', $this->expectCallableOnce()); - $output = \React\Async\await(Stream\buffer($stream)); + $output = \React\Async\await($this->parser->bufferedStream($stream)); $this->assertEquals('helloworld', $output); } @@ -278,7 +280,7 @@ public function testExecStreamCommandWithTtyAndStderrOutputWhileRunning($contain $stream->once('data', $this->expectCallableOnce('hello world')); $stream->on('end', $this->expectCallableOnce()); - $output = \React\Async\await(Stream\buffer($stream)); + $output = \React\Async\await($this->parser->bufferedStream($stream)); $this->assertEquals('hello world', $output); } @@ -301,7 +303,7 @@ public function testExecStreamStderrCustomEventWhileRunning($container) $stream->on('error', $this->expectCallableNever()); $stream->on('end', $this->expectCallableOnce()); - $output = \React\Async\await(Stream\buffer($stream)); + $output = \React\Async\await($this->parser->bufferedStream($stream)); $this->assertEquals('', $output); } @@ -321,7 +323,7 @@ public function testExecStreamEmptyOutputWhileRunning($container) $stream = $this->client->execStartStream($exec['Id'], true); $stream->on('end', $this->expectCallableOnce()); - $output = \React\Async\await(Stream\buffer($stream)); + $output = \React\Async\await($this->parser->bufferedStream($stream)); $this->assertEquals('', $output); } diff --git a/tests/Io/ReadableUnwrapStreamTest.php b/tests/Io/ReadableUnwrapStreamTest.php new file mode 100644 index 0000000..f6a058f --- /dev/null +++ b/tests/Io/ReadableUnwrapStreamTest.php @@ -0,0 +1,354 @@ +assertTrue($stream->isReadable()); + } + + public function testClosingStreamMakesItNotReadable() + { + $promise = new Promise\Promise(function () { }); + $stream = new ReadableUnwrapStream($promise); + + $stream->on('close', $this->expectCallableOnce()); + $stream->on('end', $this->expectCallableNever()); + $stream->on('error', $this->expectCallableNever()); + + $stream->close(); + + $this->assertFalse($stream->isReadable()); + } + + public function testClosingRejectingStreamMakesItNotReadable() + { + $deferred = new Deferred(); + + $stream = new ReadableUnwrapStream($deferred->promise()); + + $stream->on('close', $this->expectCallableOnce()); + $stream->on('end', $this->expectCallableNever()); + $stream->on('error', $this->expectCallableNever()); + + $stream->close(); + + $this->assertFalse($stream->isReadable()); + } + + public function testClosingStreamWillCancelInputPromiseAndMakeStreamNotReadable() + { + $promise = new Promise\Promise(function () { }, $this->expectCallableOnce()); + $stream = new ReadableUnwrapStream($promise); + + $stream->close(); + + $this->assertFalse($stream->isReadable()); + } + + public function testClosingStreamWillNotEmitErrorWhenCancellationRejectsInputPromise() + { + $promise = new Promise\Promise(function () { }, function () { + throw new \RuntimeException('Cancelled'); + }); + $stream = new ReadableUnwrapStream($promise); + + $stream->on('error', $this->expectCallableNever()); + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + } + + public function testEmitsErrorWhenPromiseRejects() + { + $deferred = new Deferred(); + + $stream = new ReadableUnwrapStream($deferred->promise()); + + $this->assertTrue($stream->isReadable()); + + $stream->on('error', $this->expectCallableOnce()); + $stream->on('end', $this->expectCallableNever()); + + $deferred->reject(new \RuntimeException()); + + $this->assertFalse($stream->isReadable()); + } + + public function testReturnsClosedStreamIfInputStreamIsClosed() + { + $input = new ThroughStream(); + $input->close(); + + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $this->assertFalse($stream->isReadable()); + } + + public function testReturnsStreamThatWillBeClosedWhenPromiseResolvesWithClosedInputStream() + { + $input = new ThroughStream(); + $input->close(); + + $deferred = new Deferred(); + + $stream = new ReadableUnwrapStream($deferred->promise()); + + $this->assertTrue($stream->isReadable()); + + $stream->on('close', $this->expectCallableOnce()); + + $deferred->resolve($input); + + $this->assertFalse($stream->isReadable()); + } + + public function testEmitsDataWhenInputEmitsData() + { + $input = new ThroughStream(); + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $stream->on('data', $this->expectCallableOnceWith('hello world')); + $input->emit('data', array('hello world')); + } + + public function testEmitsErrorAndClosesWhenInputEmitsError() + { + $input = new ThroughStream(); + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $stream->on('error', $this->expectCallableOnceWith(new \RuntimeException())); + $stream->on('close', $this->expectCallableOnce()); + $input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($stream->isReadable()); + } + + public function testEmitsEndAndClosesWhenInputEmitsEnd() + { + $input = new ThroughStream(); + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $stream->on('end', $this->expectCallableOnce()); + $stream->on('close', $this->expectCallableOnce()); + $input->emit('end', array()); + + $this->assertFalse($stream->isReadable()); + } + + public function testEmitsCloseOnlyOnceWhenClosingStreamMultipleTimes() + { + $promise = new Promise\Promise(function () { }); + $stream = new ReadableUnwrapStream($promise); + + $stream->on('end', $this->expectCallableNever()); + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + $stream->close(); + } + + public function testForwardsPauseToInputStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('isReadable')->willReturn(true); + $input->expects($this->once())->method('pause'); + + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $stream->pause(); + } + + public function testPauseBeforePromiseResolvesWillPauseInputStreamOnceResolved() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('isReadable')->willReturn(true); + $input->expects($this->once())->method('pause'); + + $deferred = new Deferred(); + $stream = new ReadableUnwrapStream($deferred->promise()); + + $stream->pause(); + $deferred->resolve($input); + } + + public function testPauseAndResumeBeforePromiseResolvesWillNotPauseInputStreamOnceResolved() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('isReadable')->willReturn(true); + $input->expects($this->never())->method('pause'); + + $deferred = new Deferred(); + $stream = new ReadableUnwrapStream($deferred->promise()); + + $stream->pause(); + $stream->resume(); + $deferred->resolve($input); + } + + /** + * @doesNotPerformAssertions + */ + public function testPauseAfterCloseHasNoEffect() + { + $promise = new Promise\Promise(function () { }); + $stream = new ReadableUnwrapStream($promise); + + $stream->close(); + $stream->pause(); + } + + /** + * @doesNotPerformAssertions + */ + public function testPauseAfterErrorDueToInvalidInputHasNoEffect() + { + $stream = new ReadableUnwrapStream(Promise\reject(new \RuntimeException())); + + $stream->pause(); + } + + public function testForwardsResumeToInputStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('isReadable')->willReturn(true); + $input->expects($this->once())->method('resume'); + + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $stream->resume(); + } + + /** + * @doesNotPerformAssertions + */ + public function testResumeAfterCloseHasNoEffect() + { + $promise = new Promise\Promise(function () { }); + $stream = new ReadableUnwrapStream($promise); + + $stream->close(); + $stream->resume(); + } + + public function testPipingStreamWillForwardDataEvents() + { + $input = new ThroughStream(); + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $output = new ThroughStream(); + $buffer = ''; + $output->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); + $output->on('end', $this->expectCallableOnce()); + $stream->pipe($output); + + $input->emit('data', array('hello')); + $input->emit('data', array('world')); + $input->end(); + + $this->assertEquals('helloworld', $buffer); + } + + public function testClosingStreamWillCloseInputStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('isReadable')->willReturn(true); + $input->expects($this->once())->method('close'); + + $stream = new ReadableUnwrapStream(Promise\resolve($input)); + + $stream->close(); + } + + public function testClosingStreamWillEmitCloseEventToExistingListenersBeforeClosingInputStream() + { + $input = new ThroughStream(); + + $deferred = new Deferred(); + $stream = new ReadableUnwrapStream($deferred->promise()); + + $readable = null; + $stream->on('close', function () use ($input, &$readable) { + $readable = $input->isReadable(); + }); + + $deferred->resolve($input); + $stream->close(); + + $this->assertTrue($readable); + $this->assertFalse($input->isReadable()); + } + + public function testClosingStreamWillCloseStreamIfItIgnoredCancellationAndResolvesLater() + { + $input = new ThroughStream(); + + $deferred = new Deferred(); + $stream = new ReadableUnwrapStream($deferred->promise()); + + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + + $deferred->resolve($input); + + $this->assertFalse($input->isReadable()); + } + + public function testClosingStreamWillCloseStreamFromCancellationHandler() + { + $input = new ThroughStream(); + + $promise = new Promise\Promise(function () { }, function ($resolve) use ($input) { + $resolve($input); + }); + + $stream = new ReadableUnwrapStream($promise); + + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + + $this->assertFalse($input->isReadable()); + } + + public function testCloseShouldRemoveAllListenersAfterCloseEvent() + { + $promise = new Promise\Promise(function () { }); + $stream = new ReadableUnwrapStream($promise); + + $stream->on('close', $this->expectCallableOnce()); + $this->assertCount(1, $stream->listeners('close')); + + $stream->close(); + + $this->assertCount(0, $stream->listeners('close')); + } + + public function testCloseShouldRemoveReferenceToPromiseToAvoidGarbageReferences() + { + $promise = new Promise\Promise(function () { }); + $stream = new ReadableUnwrapStream($promise); + + $stream->close(); + + $ref = new \ReflectionProperty($stream, 'promise'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $this->assertNull($ref->getValue($stream)); + } +} diff --git a/tests/Io/StreamingParserTest.php b/tests/Io/StreamingParserTest.php index 1906e19..3cd3f63 100644 --- a/tests/Io/StreamingParserTest.php +++ b/tests/Io/StreamingParserTest.php @@ -70,6 +70,75 @@ public function testPlainPassingRejectedPromiseResolvesWithClosedStream() $this->assertFalse($stream->isReadable()); } + public function testBufferedClosedStreamWillResolveWithEmptyString() + { + $stream = new ThroughStream(); + $stream->close(); + + $promise = $this->parser->bufferedStream($stream); + + $this->expectPromiseResolveWith('', $promise); + } + + public function testBufferedStreamDataEventsWillBeBufferedAndResolvedOnClose() + { + $stream = new ThroughStream(); + + $promise = $this->parser->bufferedStream($stream); + + $stream->write('hello'); + $stream->end('world'); + + $this->expectPromiseResolveWith('helloworld', $promise); + } + + public function testBufferedStreamErrorEventWillRejectPromise() + { + $stream = new ThroughStream(); + + $promise = $this->parser->bufferedStream($stream); + + $stream->write('hello'); + $stream->emit('error', array(new \RuntimeException('Boom', 42))); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException( + 'An error occured on the underlying stream while buffering: Boom', + 42, + new \RuntimeException('Boom', 42) + ))); + } + + public function testBufferedCancelingPromiseWillRejectPromiseAndStopBuffering() + { + $stream = new ThroughStream(); + + $promise = $this->parser->bufferedStream($stream); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Cancelled buffering'))); + $this->assertCount(0, $stream->listeners('data')); + } + + public function testBufferedCancelingPromiseWillClearBufferToAvoidGarbageReferences() + { + $stream = new ThroughStream(); + + $promise = $this->parser->bufferedStream($stream); + + $stream->write('hello'); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnce()); + $listeners = $stream->listeners('close'); + $ref = new \ReflectionFunction(end($listeners)); + $variables = $ref->getStaticVariables(); + $this->assertSame('', $variables['buffer']); + } + public function testDeferredClosedStreamWillReject() { $stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock();