From 893e30dd533fffc589ee6d248d6714f18cdd4c9e Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Thu, 28 Nov 2019 21:07:17 +0100 Subject: [PATCH 01/23] Split HTTP/2 frame parsing and processing --- src/Connection/Http2Connection.php | 1677 +---------------- .../Internal/Http2FrameProcessor.php | 37 + src/Connection/Internal/Http2Parser.php | 591 ++++++ src/Connection/Internal/Http2Stream.php | 63 +- .../Internal/InternalHttp2Connection.php | 1384 ++++++++++++++ test/Connection/Http2ConnectionTest.php | 27 +- 6 files changed, 2074 insertions(+), 1705 deletions(-) create mode 100644 src/Connection/Internal/Http2FrameProcessor.php create mode 100644 src/Connection/Internal/Http2Parser.php create mode 100644 src/Connection/Internal/InternalHttp2Connection.php diff --git a/src/Connection/Http2Connection.php b/src/Connection/Http2Connection.php index 3f8d6926..7fa1ab75 100644 --- a/src/Connection/Http2Connection.php +++ b/src/Connection/Http2Connection.php @@ -1,39 +1,16 @@ - true, - ]; - - private const KNOWN_REQUEST_PSEUDO_HEADERS = [ - ":method" => true, - ":authority" => true, - ":path" => true, - ":scheme" => true, - ]; - - // Milliseconds to wait for pong (PING with ACK) frame before closing the connection. - private const PONG_TIMEOUT = 500; - - /** @var string 64-bit for ping. */ - private $counter = "aaaaaaaa"; - /** @var EncryptableSocket */ private $socket; - /** @var callable[]|null */ - private $onClose = []; - - /** @var Http2Stream[] */ - private $streams = []; - - /** @var int */ - private $serverWindow = self::DEFAULT_WINDOW_SIZE; - - /** @var int */ - private $clientWindow = self::DEFAULT_WINDOW_SIZE; - - /** @var int */ - private $initialWindowSize = self::DEFAULT_WINDOW_SIZE; - - /** @var int */ - private $maxFrameSize = self::DEFAULT_MAX_FRAME_SIZE; - - /** @var int Previous stream ID. */ - private $streamId = -1; - - /** @var Deferred[] */ - private $pendingRequests = []; - - /** @var Emitter[] */ - private $bodyEmitters = []; - - /** @var Deferred[] */ - private $trailerDeferreds = []; - - /** @var int Maximum number of streams that may be opened. Initially unlimited. */ - private $maxStreams = \PHP_INT_MAX; - - /** @var int Currently open or reserved streams. Initially unlimited. */ - private $remainingStreams = \PHP_INT_MAX; - - /** @var HPack */ - private $table; - - /** @var Deferred|null */ - private $settingsDeferred; - - /** @var bool */ - private $initialized = false; - - /** @var string|null */ - private $pongWatcher; - - /** @var Deferred|null */ - private $pongDeferred; - - /** @var string|null */ - private $idleWatcher; - - /** @var int */ - private $idlePings = 0; + /** @var InternalHttp2Connection */ + private $connection; /** @var int */ private $requestCount = 0; public function __construct(EncryptableSocket $socket) { - $this->table = new HPack; $this->socket = $socket; + $this->connection = new InternalHttp2Connection($socket); } - /** - * Returns a promise that is resolved once the connection has been initialized. A stream cannot be obtained from the - * connection until the promise returned by this method resolves. - * - * @return Promise - */ - public function initialize(): Promise + public function getProtocolVersions(): array { - if ($this->initialized) { - throw new \Error('Connection may only be initialized once'); - } - - $this->initialized = true; - - if ($this->socket->isClosed()) { - return new Failure(new UnprocessedRequestException( - new SocketException('The socket closed before the connection could be initialized') - )); - } - - $this->settingsDeferred = new Deferred; - $promise = $this->settingsDeferred->promise(); - - Promise\rethrow(new Coroutine($this->run())); - - return $promise; + return self::PROTOCOL_VERSIONS; } - public function getProtocolVersions(): array + public function initialize(): Promise { - return self::PROTOCOL_VERSIONS; + return $this->connection->initialize(); } public function getStream(Request $request): Promise { - if (!$this->initialized || $this->settingsDeferred !== null) { + if (!$this->connection->isInitialized()) { throw new \Error('The promise returned from ' . __CLASS__ . '::initialize() must resolve before using the connection'); } return call(function () { - if ($this->remainingStreams <= 0 || $this->onClose === null) { + if ($this->connection->isClosed() || $this->connection->getRemainingStreams() <= 0) { return null; } - --$this->remainingStreams; + $this->connection->reserveStream(); return HttpStream::fromConnection( $this, \Closure::fromCallable([$this, 'request']), - \Closure::fromCallable([$this, 'release']) + \Closure::fromCallable([$this->connection, 'unreserveStream']) ); }); } public function onClose(callable $onClose): void { - if ($this->onClose === null) { - asyncCall($onClose, $this); - return; - } - - $this->onClose[] = $onClose; + $this->connection->onClose($onClose); } public function close(): Promise { - return $this->shutdown(); + return $this->connection->close(); } public function getLocalAddress(): SocketAddress @@ -261,1483 +88,13 @@ public function getRemoteAddress(): SocketAddress public function getTlsInfo(): ?TlsInfo { - return $this->socket instanceof EncryptableSocket ? $this->socket->getTlsInfo() : null; - } - - /** - * @return Promise Fulfilled with true if a pong is received within the timeout, false if none is received. - */ - private function ping(): Promise - { - if ($this->onClose === null) { - return new Success(false); - } - - if ($this->pongDeferred !== null) { - return $this->pongDeferred->promise(); - } - - $this->pongDeferred = new Deferred; - $this->idlePings++; - - $this->writeFrame($this->counter++, self::PING, self::NOFLAG); - - $this->pongWatcher = Loop::delay(self::PONG_TIMEOUT, [$this, 'close']); - - return $this->pongDeferred->promise(); + return $this->socket->getTlsInfo(); } private function request(Request $request, CancellationToken $token, Stream $applicationStream): Promise { $this->requestCount++; - $this->idlePings = 0; - $this->cancelIdleWatcher(); - - // Remove defunct HTTP/1.x headers. - $request->removeHeader('host'); - $request->removeHeader('connection'); - $request->removeHeader('transfer-encoding'); - - $request->setProtocolVersions(['2']); - - $id = $this->streamId += 2; // Client streams should be odd-numbered, starting at 1. - - $this->streams[$id] = $stream = new Http2Stream( - self::DEFAULT_WINDOW_SIZE, - $this->initialWindowSize, - $request->getHeaderSizeLimit(), - $request->getBodySizeLimit() - ); - - $stream->applicationStream = $applicationStream; - $stream->request = $request; - $stream->cancellationToken = $token; - - if ($request->getTransferTimeout() > 0) { - // Cancellation token combined with timeout token should not be stored in $stream->cancellationToken, - // otherwise the timeout applies to the body transfer and pushes. - $token = new CombinedCancellationToken( - $token, - new TimeoutCancellationToken($request->getTransferTimeout()) - ); - } - - return call(function () use ($id, $request, $token, $applicationStream): \Generator { - $this->pendingRequests[$id] = $deferred = new Deferred; - - if ($this->socket->isClosed()) { - throw new UnprocessedRequestException( - new SocketException(\sprintf( - "Socket to '%s' closed before the request could be sent", - $this->socket->getRemoteAddress() - )) - ); - } - - $this->socket->reference(); - - $cancellationId = $token->subscribe(function (CancelledException $exception) use ($id): void { - if (!isset($this->streams[$id])) { - return; - } - - $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NOFLAG, $id); - $this->releaseStream($id, $exception); - }); - - try { - $uri = $request->getUri(); - - $path = $uri->getPath(); - if ($path === '') { - $path = '/'; - } - - $query = $uri->getQuery(); - if ($query !== '') { - $path .= '?' . $query; - } - - $body = $request->getBody(); - - $headers = yield $request->getBody()->getHeaders(); - foreach ($headers as $name => $header) { - if (!$request->hasHeader($name)) { - $request->setHeaders([$name => $header]); - } - } - - $authority = $uri->getHost(); - if ($port = $uri->getPort()) { - $authority .= ':' . $port; - } - - $headers = \array_merge([ - ":authority" => [$authority], - ":path" => [$path], - ":scheme" => [$uri->getScheme()], - ":method" => [$request->getMethod()], - ], $request->getHeaders()); - - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->startSendingRequest($request, $applicationStream); - } - - $headers = $this->table->encode($headers); - - $stream = $body->createBodyStream(); - - $chunk = yield $stream->read(); - - if (!isset($this->streams[$id]) || $token->isRequested()) { - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeSendingRequest($request, $applicationStream); - } - - return yield $deferred->promise(); - } - - $flag = self::END_HEADERS | ($chunk === null ? self::END_STREAM : "\0"); - - if (\strlen($headers) > $this->maxFrameSize) { - $split = \str_split($headers, $this->maxFrameSize); - $headers = \array_shift($split); - yield $this->writeFrame($headers, self::HEADERS, self::NOFLAG, $id); - - $headers = \array_pop($split); - foreach ($split as $msgPart) { - yield $this->writeFrame($msgPart, self::CONTINUATION, self::NOFLAG, $id); - } - yield $this->writeFrame($headers, self::CONTINUATION, $flag, $id); - } else { - yield $this->writeFrame($headers, self::HEADERS, $flag, $id); - } - - if ($chunk === null) { - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeSendingRequest($request, $applicationStream); - } - - return yield $deferred->promise(); - } - - $buffer = $chunk; - while (null !== $chunk = yield $stream->read()) { - if (!isset($this->streams[$id]) || $token->isRequested()) { - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeSendingRequest($request, $applicationStream); - } - - return yield $deferred->promise(); - } - - yield $this->writeData($buffer, $id); - $buffer = $chunk; - } - - if (!isset($this->streams[$id]) || $token->isRequested()) { - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeSendingRequest($request, $applicationStream); - } - - return yield $deferred->promise(); - } - - $this->streams[$id]->state |= Http2Stream::LOCAL_CLOSED; - - yield $this->writeData($buffer, $id); - - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeSendingRequest($request, $applicationStream); - } - - return yield $deferred->promise(); - } catch (\Throwable $exception) { - if (isset($this->streams[$id])) { - $this->releaseStream($id, $exception); - } - - if ($exception instanceof StreamException) { - $exception = new SocketException('Failed to write request to socket: ' . $exception->getMessage()); - } - - throw $exception; - } finally { - $token->unsubscribe($cancellationId); - } - }); - } - - private function release(): void - { - ++$this->remainingStreams; - } - - private function run(): \Generator - { - try { - // Write initial preface - yield $this->socket->write(self::PREFACE); - - yield $this->writeFrame( - \pack( - "nNnNnNnN", - self::ENABLE_PUSH, - 1, - self::MAX_CONCURRENT_STREAMS, - 256, - self::INITIAL_WINDOW_SIZE, - self::DEFAULT_WINDOW_SIZE, - self::MAX_FRAME_SIZE, - self::DEFAULT_MAX_FRAME_SIZE - ), - self::SETTINGS, - self::NOFLAG - ); - - $parser = $this->parser(); - - while (null !== $chunk = yield $this->socket->read()) { - $promise = $parser->send($chunk); - - \assert($promise === null || $promise instanceof Promise); - - while ($promise instanceof Promise) { - yield $promise; // Wait for promise to resolve before resuming parser and reading more data. - $promise = $parser->send(null); - \assert($promise === null || $promise instanceof Promise); - } - } - } catch (\Throwable $exception) { - if ($this->settingsDeferred !== null) { - $deferred = $this->settingsDeferred; - $this->settingsDeferred = null; - $deferred->fail($exception); - } - } finally { - $this->shutdown(null, $exception ?? null); - } - } - - private function writeFrame(string $data, string $type, string $flags, int $stream = 0): Promise - { - $data = \substr(\pack("N", \strlen($data)), 1, 3) . $type . $flags . \pack("N", $stream) . $data; - return $this->socket->write($data); - } - - private function writeData(string $data, int $stream): Promise - { - \assert(isset($this->streams[$stream]), "The stream was closed"); - - $this->streams[$stream]->buffer .= $data; - - return $this->writeBufferedData($stream); - } - - private function writeBufferedData(int $id): Promise - { - $stream = $this->streams[$id]; - $delta = \min($this->clientWindow, $stream->clientWindow); - $length = \strlen($stream->buffer); - - if ($delta >= $length) { - $this->clientWindow -= $length; - - if ($length > $this->maxFrameSize) { - $split = \str_split($stream->buffer, $this->maxFrameSize); - $stream->buffer = \array_pop($split); - foreach ($split as $part) { - $this->writeFrame($part, self::DATA, self::NOFLAG, $id); - } - } - - if ($stream->state & Http2Stream::LOCAL_CLOSED) { - $promise = $this->writeFrame($stream->buffer, self::DATA, self::END_STREAM, $id); - } else { - $promise = $this->writeFrame($stream->buffer, self::DATA, self::NOFLAG, $id); - } - - $stream->clientWindow -= $length; - $stream->buffer = ""; - - if ($stream->deferred) { - $deferred = $stream->deferred; - $stream->deferred = null; - $deferred->resolve(); - } - - return $promise; - } - - if ($delta > 0) { - $data = $stream->buffer; - $end = $delta - $this->maxFrameSize; - - $stream->clientWindow -= $delta; - $this->clientWindow -= $delta; - - for ($off = 0; $off < $end; $off += $this->maxFrameSize) { - $this->writeFrame(\substr($data, $off, $this->maxFrameSize), self::DATA, self::NOFLAG, $id); - } - - $this->writeFrame(\substr($data, $off, $delta - $off), self::DATA, self::NOFLAG, $id); - - $stream->buffer = \substr($data, $delta); - } - - if ($stream->deferred === null) { - $stream->deferred = new Deferred; - } - - return $stream->deferred->promise(); - } - - private function releaseStream(int $id, \Throwable $exception = null): void - { - \assert(isset($this->streams[$id]), "Tried to release a non-existent stream"); - - if (isset($this->bodyEmitters[$id])) { - $emitter = $this->bodyEmitters[$id]; - unset($this->bodyEmitters[$id]); - $emitter->fail($exception ?? new SocketException("Server disconnected", self::CANCEL)); - } - - if (isset($this->trailerDeferreds[$id])) { - $deferred = $this->trailerDeferreds[$id]; - unset($this->trailerDeferreds[$id]); - $deferred->fail($exception ?? new SocketException("Server disconnected", self::CANCEL)); - } - - if (isset($this->pendingRequests[$id])) { - $deferred = $this->pendingRequests[$id]; - unset($this->pendingRequests[$id]); - $deferred->fail($exception ?? new SocketException("Server disconnected", self::CANCEL)); - } - - unset($this->streams[$id]); - - if ($id & 1) { // Client-initiated stream. - $this->remainingStreams++; - } - - if (empty($this->pendingRequests) && empty($this->bodyEmitters) && empty($this->trailerDeferreds) && !$this->socket->isClosed()) { - $this->socket->unreference(); - } - } - - /** - * @return \Generator - */ - private function parser(): \Generator - { - $maxHeaderSize = self::DEFAULT_MAX_FRAME_SIZE; // Should be configurable? - - $frameCount = 0; - $bytesReceived = 0; - $continuation = false; - - $buffer = yield; - - while (true) { - while (\strlen($buffer) < 9) { - $buffer .= yield; - } - - $length = \unpack("N", "\0" . \substr($buffer, 0, 3))[1]; - $frameCount++; - $bytesReceived += $length; - - try { - if ($length > self::DEFAULT_MAX_FRAME_SIZE) { // Do we want to allow increasing max frame size? - throw new Http2ConnectionException("Max frame size exceeded", self::FRAME_SIZE_ERROR); - } - - $type = $buffer[3]; - $flags = $buffer[4]; - $id = \unpack("N", \substr($buffer, 5, 4))[1]; - - // If the highest bit is 1, ignore it. - if ($id & 0x80000000) { - $id &= 0x7fffffff; - } - - $buffer = \substr($buffer, 9); - - // Fail if expecting a continuation frame and anything else is received. - if ($continuation && $type !== self::CONTINUATION) { - throw new Http2ConnectionException("Expected continuation frame", self::PROTOCOL_ERROR); - } - - switch ($type) { - case self::DATA: - $padding = 0; - - if (($flags & self::PADDED) !== "\0") { - if ($buffer === "") { - $buffer = yield; - } - $padding = \ord($buffer); - $buffer = \substr($buffer, 1); - $length--; - - if ($padding > $length) { - throw new Http2ConnectionException("Padding greater than length", self::PROTOCOL_ERROR); - } - } - - if ($id === 0) { - throw new Http2ConnectionException("Invalid stream ID", self::PROTOCOL_ERROR); - } - - if (!isset($this->streams[$id])) { - throw new Http2StreamException("Stream ID not found", $id, self::CANCEL); - } - - $stream = $this->streams[$id]; - - if ($stream->headers !== null) { - throw new Http2StreamException("Stream headers not complete", $id, self::PROTOCOL_ERROR); - } - - if ($stream->state & Http2Stream::REMOTE_CLOSED) { - throw new Http2StreamException("Stream remote closed", $id, self::PROTOCOL_ERROR); - } - - $this->serverWindow -= $length; - $stream->serverWindow -= $length; - $stream->received += $length; - - if ($stream->received >= $stream->bodySizeLimit && ($flags & self::END_STREAM) === "\0") { - throw new Http2StreamException("Max body size exceeded", $id, self::CANCEL); - } - - while (\strlen($buffer) < $length) { - /* it is fine to just .= the $body as $length < 2^14 */ - $buffer .= yield; - } - - $body = \substr($buffer, 0, $length - $padding); - $buffer = \substr($buffer, $length); - - if ($this->serverWindow <= self::MINIMUM_WINDOW) { - $this->serverWindow += self::MAX_INCREMENT; - $this->writeFrame(\pack("N", self::MAX_INCREMENT), self::WINDOW_UPDATE, self::NOFLAG); - } - - // Stream may close while reading body chunk. - if (!isset($this->bodyEmitters[$id])) { - continue 2; - } - - if ($body !== "") { - if (\is_int($stream->expectedLength)) { - $stream->expectedLength -= \strlen($body); - } - - $promise = $this->bodyEmitters[$id]->emit($body); - - if ($stream->serverWindow <= self::MINIMUM_WINDOW) { - $promise->onResolve(function (?\Throwable $exception) use ($id): void { - if ($exception || !isset($this->streams[$id])) { - return; - } - - $stream = $this->streams[$id]; - - if ($stream->state & Http2Stream::REMOTE_CLOSED || $stream->serverWindow > self::MINIMUM_WINDOW) { - return; - } - - $increment = \min( - $stream->bodySizeLimit - $stream->received - $stream->serverWindow, - self::MAX_INCREMENT - ); - if ($increment <= 0) { - return; - } - $stream->serverWindow += $increment; - - $this->writeFrame(\pack("N", $increment), self::WINDOW_UPDATE, self::NOFLAG, $id); - }); - } - } - - if (($flags & self::END_STREAM) !== "\0") { - $stream->state |= Http2Stream::REMOTE_CLOSED; - - if ($stream->expectedLength) { - throw new Http2StreamException( - "Body length does not match content-length header", - $id, - self::PROTOCOL_ERROR - ); - } - - if (!isset($this->bodyEmitters[$id], $this->trailerDeferreds[$id])) { - continue 2; // Stream closed after emitting body fragment. - } - - $deferred = $this->trailerDeferreds[$id]; - $emitter = $this->bodyEmitters[$id]; - - unset($this->bodyEmitters[$id], $this->trailerDeferreds[$id]); - - foreach ($stream->request->getEventListeners() as $eventListener) { - yield $eventListener->completeReceivingResponse( - $stream->request, - $stream->applicationStream - ); - } - - $this->setupPingIfIdle(); - - $emitter->complete(); - $deferred->resolve(new Trailers([])); - - $this->releaseStream($id); - } - - continue 2; - - case self::PUSH_PROMISE: - if (!isset($this->streams[$id])) { - throw new Http2StreamException("Stream closed", $id, self::CANCEL); - } - - $parent = $this->streams[$id]; - - while (\strlen($buffer) < 4) { - $buffer .= yield; - } - - $pushedId = \unpack("N", $buffer)[1]; - - // If the highest bit is 1, ignore it. - if ($pushedId & 0x80000000) { - $pushedId &= 0x7fffffff; - } - - $buffer = \substr($buffer, 4); - $length -= 4; - - if ($parent->request->getPushHandler() === null || isset($this->streams[$pushedId])) { - throw new Http2StreamException("Push promise refused", $pushedId, self::CANCEL); - } - - $this->streams[$pushedId] = $stream = new Http2Stream( - self::DEFAULT_WINDOW_SIZE, - 0, - $parent->request->getHeaderSizeLimit(), - $parent->request->getBodySizeLimit() - ); - - $stream->parent = $parent; // Set parent stream on new stream. - $stream->dependency = $id; - $stream->weight = $parent->weight; - $stream->cancellationToken = $parent->cancellationToken; - $stream->applicationStream = HttpStream::fromStream( - $parent->applicationStream, - static function () { - throw new \Error('A stream may only be used for a single request'); - }, - static function () { - // nothing to do - } - ); - - $id = $pushedId; // Switch ID to pushed stream for parsing headers. - - // no break to fall through to parsing remainder of PUSH_PROMISE frame as HEADERS frame. - - case self::HEADERS: - if (!isset($this->streams[$id])) { - throw new Http2ConnectionException( - "Headers already started on stream {$id}", - self::PROTOCOL_ERROR - ); - } - - $stream = $this->streams[$id]; - - if ($stream->state & Http2Stream::REMOTE_CLOSED) { - throw new Http2StreamException("Stream {$id} remote closed", $id, self::STREAM_CLOSED); - } - - if (($flags & self::PADDED) !== "\0") { - if ($buffer === "") { - $buffer = yield; - } - $padding = \ord($buffer); - $buffer = \substr($buffer, 1); - $length--; - } else { - $padding = 0; - } - - if (($flags & self::PRIORITY_FLAG) !== "\0") { - while (\strlen($buffer) < 5) { - $buffer .= yield; - } - - $parent = \unpack("N", $buffer)[1]; - - if ($exclusive = $parent & 0x80000000) { - $parent &= 0x7fffffff; - } - - if ($id === 0) { - throw new Http2ConnectionException("Invalid dependency ID 0", self::PROTOCOL_ERROR); - } - - if ($parent === $id) { - throw new Http2ConnectionException( - "Invalid dependency ID {$id}: Must not match parent stream ID", - self::PROTOCOL_ERROR - ); - } - - $stream->dependency = $parent; - $stream->weight = \ord($buffer[4]); - - $buffer = \substr($buffer, 5); - $length -= 5; - } - - if ($padding >= $length) { - throw new Http2ConnectionException("Padding greater than length", self::PROTOCOL_ERROR); - } - - if ($length > $maxHeaderSize) { - throw new Http2StreamException( - "Headers exceed maximum configured size of {$maxHeaderSize} bytes", - $id, - self::ENHANCE_YOUR_CALM - ); - } - - while (\strlen($buffer) < $length) { - $buffer .= yield; - } - - $stream->headers = \substr($buffer, 0, $length - $padding); - $buffer = \substr($buffer, $length); - - if (($flags & self::END_STREAM) !== "\0") { - $stream->state |= Http2Stream::REMOTE_CLOSED; - } - - if (($flags & self::END_HEADERS) !== "\0") { - goto parse_headers; - } - - $continuation = true; - - continue 2; - - case self::PRIORITY: - if ($length !== 5) { - throw new Http2ConnectionException("Invalid frame size", self::PROTOCOL_ERROR); - } - - while (\strlen($buffer) < 5) { - $buffer .= yield; - } - - $parent = \unpack("N", $buffer)[1]; - if ($exclusive = $parent & 0x80000000) { - $parent &= 0x7fffffff; - } - - $weight = \ord($buffer[4]); - $buffer = \substr($buffer, 5); - - if ($id === 0) { - throw new Http2ConnectionException("Invalid dependency ID 0", self::PROTOCOL_ERROR); - } - - if ($parent === $id) { - throw new Http2ConnectionException( - "Invalid dependency ID {$id}: Must not match parent stream ID", - self::PROTOCOL_ERROR - ); - } - - if (!isset($this->streams[$id])) { - throw new Http2ConnectionException("Stream {$id} not found", self::PROTOCOL_ERROR); - } - - $stream = $this->streams[$id]; - - if ($stream->headers !== null) { - throw new Http2ConnectionException("Headers not complete for stream {$id}", self::PROTOCOL_ERROR); - } - - $stream->dependency = $parent; - $stream->weight = $weight; - - continue 2; - - case self::RST_STREAM: - if ($length !== 4) { - throw new Http2ConnectionException("Invalid frame size", self::PROTOCOL_ERROR); - } - - if ($id === 0) { - throw new Http2ConnectionException("Invalid stream ID 0", self::PROTOCOL_ERROR); - } - - while (\strlen($buffer) < 4) { - $buffer .= yield; - } - - $error = \unpack("N", $buffer)[1]; - - if (isset($this->streams[$id])) { - $exception = new Http2StreamException("Stream closed by server", $id, $error); - if ($error === self::REFUSED_STREAM) { - $exception = new UnprocessedRequestException($exception); - } - $this->releaseStream($id, $exception); - } - - $buffer = \substr($buffer, 4); - continue 2; - - case self::SETTINGS: - if ($id !== 0) { - throw new Http2ConnectionException( - "Non-zero stream ID with settings frame", - self::PROTOCOL_ERROR - ); - } - - if (($flags & self::ACK) !== "\0") { - if ($length) { - throw new Http2ConnectionException("Invalid frame size", self::PROTOCOL_ERROR); - } - - // Got ACK - continue 2; - } - - if ($length % 6 !== 0) { - throw new Http2ConnectionException("Invalid frame size", self::PROTOCOL_ERROR); - } - - if ($length > 60) { - // Even with room for a few future options, sending that a big SETTINGS frame is just about - // wasting our processing time. I hereby declare this a protocol error. - throw new Http2ConnectionException("Settings frame too big", self::PROTOCOL_ERROR); - } - - while (\strlen($buffer) < $length) { - $buffer .= yield; - } - - while ($length > 0) { - $this->updateSetting($buffer); - $buffer = \substr($buffer, 6); - $length -= 6; - } - - $this->writeFrame("", self::SETTINGS, self::ACK); - - if ($this->settingsDeferred) { - $deferred = $this->settingsDeferred; - $this->settingsDeferred = null; - $deferred->resolve($this->remainingStreams); - } - - continue 2; - - case self::PING: - if ($length !== 8) { - throw new Http2ConnectionException("Invalid frame size", self::PROTOCOL_ERROR); - } - - if ($id !== 0) { - throw new Http2ConnectionException( - "Non-zero stream ID with ping frame", - self::PROTOCOL_ERROR - ); - } - - while (\strlen($buffer) < 8) { - $buffer .= yield; - } - - $data = \substr($buffer, 0, 8); - - if (($flags & self::ACK) === "\0") { - $this->writeFrame($data, self::PING, self::ACK); - } elseif ($this->pongDeferred !== null) { - if ($this->pongWatcher !== null) { - Loop::cancel($this->pongWatcher); - $this->pongWatcher = null; - } - - $deferred = $this->pongDeferred; - $this->pongDeferred = null; - $deferred->resolve(true); - } - - $buffer = \substr($buffer, 8); - - continue 2; - - case self::GOAWAY: - if ($id !== 0) { - throw new Http2ConnectionException( - "Non-zero stream ID with goaway frame", - self::PROTOCOL_ERROR - ); - } - - $lastId = \unpack("N", $buffer)[1]; - // If the highest bit is 1, ignore it. - if ($lastId & 0x80000000) { - $lastId &= 0x7fffffff; - } - $error = \unpack("N", \substr($buffer, 4, 4))[1]; - - $buffer = \substr($buffer, 8); - $length -= 8; - - while (\strlen($buffer) < $length) { - $buffer .= yield; - } - - $message = \sprintf( - "Received GOAWAY frame from %s with error code %d", - $this->socket->getRemoteAddress(), - $error - ); - - $this->shutdown($lastId, new Http2ConnectionException($message, $error)); - - return; - - case self::WINDOW_UPDATE: - if ($length !== 4) { - throw new Http2ConnectionException("Invalid frame size", self::FRAME_SIZE_ERROR); - } - - while (\strlen($buffer) < 4) { - $buffer .= yield; - } - - if ($buffer === "\0\0\0\0") { - if ($id) { - throw new Http2StreamException( - "Invalid window update value", - $id, - self::PROTOCOL_ERROR - ); - } - throw new Http2ConnectionException("Invalid window update value", self::PROTOCOL_ERROR); - } - - $windowSize = \unpack("N", $buffer)[1]; - $buffer = \substr($buffer, 4); - - if ($id) { - if (!isset($this->streams[$id])) { - continue 2; - } - - $stream = $this->streams[$id]; - - if ($stream->clientWindow + $windowSize > (2 << 30) - 1) { - throw new Http2StreamException( - "Current window size plus new window exceeds maximum size", - $id, - self::FLOW_CONTROL_ERROR - ); - } - - $stream->clientWindow += $windowSize; - } else { - if ($this->clientWindow + $windowSize > (2 << 30) - 1) { - throw new Http2ConnectionException( - "Current window size plus new window exceeds maximum size", - self::FLOW_CONTROL_ERROR - ); - } - - $this->clientWindow += $windowSize; - } - - Loop::defer(\Closure::fromCallable([$this, 'sendBufferedData'])); - - continue 2; - - case self::CONTINUATION: - if (!isset($this->streams[$id])) { - throw new Http2ConnectionException("Invalid stream ID {$id}", self::PROTOCOL_ERROR); - } - - $continuation = true; - - $stream = $this->streams[$id]; - - if ($stream->headers === null) { - throw new Http2ConnectionException( - "No headers received before continuation frame on stream {$id}", - self::PROTOCOL_ERROR - ); - } - - if ($stream->state & Http2Stream::REMOTE_CLOSED) { - $continuation = false; - throw new Http2StreamException("Stream remote closed", $id, self::ENHANCE_YOUR_CALM); - } - - if ($length > $maxHeaderSize - \strlen($stream->headers)) { - $continuation = false; - throw new Http2StreamException( - "Headers exceed maximum configured length of {$maxHeaderSize} bytes", - $id, - self::ENHANCE_YOUR_CALM - ); - } - - while (\strlen($buffer) < $length) { - $buffer .= yield; - } - - $stream->headers .= \substr($buffer, 0, $length); - $buffer = \substr($buffer, $length); - - if (($flags & self::END_STREAM) !== "\0") { - $stream->state |= Http2Stream::REMOTE_CLOSED; - } - - if (($flags & self::END_HEADERS) !== "\0") { - $continuation = false; - goto parse_headers; - } - - continue 2; - - default: // Ignore and discard unknown frame per spec. - while (\strlen($buffer) < $length) { - $buffer .= yield; - } - - $buffer = \substr($buffer, $length); - - continue 2; - } - - parse_headers: { - $decoded = $this->table->decode($stream->headers, $maxHeaderSize); - $stream->headers = null; - - if ($decoded === null) { - throw new Http2ConnectionException("Compression error in headers", self::COMPRESSION_ERROR); - } - - $headers = []; - $pseudo = []; - $knownHeaders = $stream->request === null - ? self::KNOWN_REQUEST_PSEUDO_HEADERS - : self::KNOWN_RESPONSE_PSEUDO_HEADERS; - - foreach ($decoded as [$name, $value]) { - if (!\preg_match(self::HEADER_NAME_REGEX, $name)) { - throw new Http2StreamException("Invalid header field name", $id, self::PROTOCOL_ERROR); - } - - if ($name[0] === ':') { - if (!empty($headers) || !isset($knownHeaders[$name]) || isset($pseudo[$name])) { - throw new Http2ConnectionException( - "Unknown or invalid pseudo headers", - self::PROTOCOL_ERROR - ); - } - - $pseudo[$name] = $value; - continue; - } - - $headers[$name][] = $value; - } - - try { - if ($stream->request === null) { - // Request originated from push promise. - - if (!isset($pseudo[":method"], $pseudo[":path"], $pseudo[":scheme"], $pseudo[":authority"]) - || isset($headers["connection"]) - || $pseudo[":path"] === '' - || (isset($headers["te"]) && \implode($headers["te"]) !== "trailers") - ) { - throw new Http2StreamException("Invalid header values", $id, self::PROTOCOL_ERROR); - } - - $method = $pseudo[":method"]; - $target = $pseudo[":path"]; - $scheme = $pseudo[":scheme"]; - $host = $pseudo[":authority"]; - $query = null; - - if ($method !== 'GET' && $method !== 'HEAD') { - throw new Http2StreamException( - "Pushed request method must be a safe method", - $id, - self::PROTOCOL_ERROR - ); - } - - if (!\preg_match("#^([A-Z\d\.\-]+|\[[\d:]+\])(?::([1-9]\d*))?$#i", $host, $matches)) { - throw new Http2StreamException( - "Invalid pushed authority (host) name", - $id, - self::PROTOCOL_ERROR - ); - } - - $host = $matches[1]; - $port = isset($matches[2]) ? (int) $matches[2] : $this->socket->getRemoteAddress()->getPort(); - - if (\strcasecmp($host, $stream->parent->request->getUri()->getHost()) !== 0) { - throw new Http2StreamException( - "Authority does not match original request authority", - $id, - self::PROTOCOL_ERROR - ); - } - - if ($position = \strpos($target, "#")) { - $target = \substr($target, 0, $position); - } - - if ($position = \strpos($target, "?")) { - $query = \substr($target, $position + 1); - $target = \substr($target, 0, $position); - } - - try { - $uri = Uri\Http::createFromComponents([ - "scheme" => $scheme, - "host" => $host, - "port" => $port, - "path" => $target, - "query" => $query, - ]); - } catch (\Exception $exception) { - throw new Http2ConnectionException("Invalid push promise URI", self::PROTOCOL_ERROR); - } - - $stream->request = new Request($uri, $method); - $stream->request->setHeaders($headers); - $stream->request->setProtocolVersions(['2']); - $stream->request->setPushHandler($stream->parent->request->getPushHandler()); - - $this->pendingRequests[$id] = $deferred = new Deferred; - - asyncCall(function () use ($id, $stream, $deferred): \Generator { - $tokenSource = new CancellationTokenSource; - $cancellationToken = new CombinedCancellationToken( - $stream->cancellationToken, - $tokenSource->getToken() - ); - - $cancellationId = $cancellationToken->subscribe(function ( - CancelledException $exception - ) use ($id): void { - if (!isset($this->streams[$id])) { - return; - } - - $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NOFLAG, $id); - $this->releaseStream($id, $exception); - }); - - $onPush = $stream->request->getPushHandler(); - - try { - yield call($onPush, $stream->request, $deferred->promise()); - } catch (HttpException | StreamException | CancelledException $exception) { - $tokenSource->cancel($exception); - } catch (\Throwable $exception) { - $tokenSource->cancel($exception); - throw $exception; - } finally { - $cancellationToken->unsubscribe($cancellationId); - } - }); - - continue; - } - - if (isset($this->trailerDeferreds[$id]) && $stream->state & Http2Stream::RESERVED) { - if (($flags & self::END_STREAM) === "\0" || $stream->expectedLength) { - throw new Http2StreamException( - "Stream not ended before receiving trailers", - $id, - self::PROTOCOL_ERROR - ); - } - - // Trailers must not contain pseudo-headers. - if (!empty($pseudo)) { - throw new Http2StreamException( - "Trailers must not contain pseudo headers", - $id, - self::PROTOCOL_ERROR - ); - } - - try { - // Trailers constructor checks for any disallowed fields. - $headers = new Trailers($headers); - } catch (InvalidHeaderException $exception) { - throw new Http2StreamException( - "Disallowed trailer field name", - $id, - self::PROTOCOL_ERROR, - $exception - ); - } - - $deferred = $this->trailerDeferreds[$id]; - $emitter = $this->bodyEmitters[$id]; - - unset($this->bodyEmitters[$id], $this->trailerDeferreds[$id]); - - foreach ($stream->request->getEventListeners() as $eventListener) { - yield $eventListener->completeReceivingResponse( - $stream->request, - $stream->applicationStream - ); - } - - $this->setupPingIfIdle(); - - $emitter->complete(); - $deferred->resolve($headers); - - continue; - } - - if (!isset($pseudo[":status"])) { - throw new Http2ConnectionException( - "No status psuedo header in response", - self::PROTOCOL_ERROR - ); - } - - if (!\preg_match("/^[1-9]\d\d$/", $pseudo[":status"])) { - throw new Http2StreamException("Invalid response status code", $id, self::PROTOCOL_ERROR); - } - - $status = (int) $pseudo[":status"]; - - if ($stream->state & Http2Stream::RESERVED) { - throw new Http2StreamException("Stream already reserved", $id, self::PROTOCOL_ERROR); - } - - $stream->state |= Http2Stream::RESERVED; - - if (!isset($this->pendingRequests[$id])) { - throw new Http2StreamException("Stream already used", $id, self::INTERNAL_ERROR); - } - - if ($status === Status::SWITCHING_PROTOCOLS) { - throw new Http2ConnectionException( - "Switching Protocols (101) is not part of HTTP/2", - self::PROTOCOL_ERROR - ); - } - - if ($status < Status::OK) { - $stream->headers = ''; - $stream->state &= ~Http2Stream::RESERVED; - continue; - } - - $stream->state |= Http2Stream::RESERVED; - - $deferred = $this->pendingRequests[$id]; - unset($this->pendingRequests[$id]); - - foreach ($stream->request->getEventListeners() as $eventListener) { - yield $eventListener->startReceivingResponse($stream->request, $stream->applicationStream); - } - - if ($stream->state & Http2Stream::REMOTE_CLOSED) { - $response = new Response( - '2', - $status, - Status::getReason($status), - $headers, - new InMemoryStream, - $stream->request - ); - - $deferred->resolve($response); - - $this->releaseStream($id); // Response has no body, release stream immediately. - } else { - $this->trailerDeferreds[$id] = new Deferred; - $this->bodyEmitters[$id] = new Emitter; - - if ($this->serverWindow <= $stream->bodySizeLimit >> 1) { - $increment = $stream->bodySizeLimit - $this->serverWindow; - $this->serverWindow = $stream->bodySizeLimit; - $this->writeFrame(\pack("N", $increment), self::WINDOW_UPDATE, self::NOFLAG); - } - - if (isset($headers["content-length"])) { - $contentLength = \implode($headers["content-length"]); - if (!\preg_match('/^(0|[1-9][0-9]*)$/', $contentLength)) { - throw new Http2StreamException( - "Invalid content-length header value", - $id, - self::PROTOCOL_ERROR - ); - } - - $stream->expectedLength = (int) $contentLength; - } - - $tokenSource = new CancellationTokenSource; - $cancellationToken = new CombinedCancellationToken( - $stream->cancellationToken, - $tokenSource->getToken() - ); - - $response = new Response( - '2', - $status, - Status::getReason($status), - $headers, - new ResponseBodyStream( - new IteratorStream($this->bodyEmitters[$id]->iterate()), - $tokenSource - ), - $stream->request, - $this->trailerDeferreds[$id]->promise() - ); - - $deferred->resolve($response); - - $cancellationToken->subscribe(function (CancelledException $exception) use ($id): void { - if (!isset($this->streams[$id])) { - return; - } - - $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NOFLAG, $id); - $this->releaseStream($id, $exception); - }); - - $tokenSource = $cancellationToken = null; // Remove reference to cancellation token. - } - } finally { - $deferred = $response = null; // Remove reference to response from parser. - } - } - } catch (Http2StreamException $exception) { - $id = $exception->getStreamId(); - $code = $exception->getCode(); - - $this->writeFrame(\pack("N", $code), self::RST_STREAM, self::NOFLAG, $id); - - if (isset($this->streams[$id])) { - $this->releaseStream($id, $exception); - } - - // consume whole frame to be able to continue this connection - $length -= \strlen($buffer); - while ($length > 0) { - $buffer = yield; - $length -= \strlen($buffer); - } - $buffer = \substr($buffer, \strlen($buffer) + $length); - } catch (Http2ConnectionException $exception) { - $this->shutdown(null, $exception); - throw $exception; - } - } - } - - /** - * @param int|null $lastId ID of last processed frame. Null to use the last opened frame ID or 0 if no - * streams have been opened. - * @param \Throwable|null $reason - * - * @return Promise - */ - private function shutdown(?int $lastId = null, ?\Throwable $reason = null): Promise - { - if ($this->onClose === null) { - return new Success; - } - - return call(function () use ($lastId, $reason) { - $code = $reason ? $reason->getCode() : self::GRACEFUL_SHUTDOWN; - $lastId = $lastId ?? ($this->streamId > 0 ? $this->streamId : 0); - $promise = $this->writeFrame(\pack("NN", $lastId, $code), self::GOAWAY, self::NOFLAG); - - if (!empty($this->streams)) { - $reason = $exception = $reason ?? new SocketException("Connection closed"); - foreach ($this->streams as $id => $stream) { - $exception = $reason; - if ($id > $lastId) { - $exception = new UnprocessedRequestException($reason); - } - - $this->releaseStream($id, $exception); - } - } - - if ($this->pongDeferred !== null) { - $this->pongDeferred->resolve(false); - } - - if ($this->pongWatcher !== null) { - Loop::cancel($this->pongWatcher); - } - - $this->cancelIdleWatcher(); - - if ($this->onClose !== null) { - $onClose = $this->onClose; - $this->onClose = null; - - foreach ($onClose as $callback) { - asyncCall($callback, $this); - } - } - - yield $promise; - - $this->socket->close(); - }); - } - - - /** - * @param string $buffer Entire settings frame payload. Only the first 6 bytes are examined. - * - * @throws Http2ConnectionException Thrown if the setting is invalid. - */ - private function updateSetting(string $buffer): void - { - $unpacked = \unpack("nsetting/Nvalue", $buffer); - - if ($unpacked["value"] < 0) { - throw new Http2ConnectionException("Invalid settings value", self::PROTOCOL_ERROR); - } - - switch ($unpacked["setting"]) { - case self::INITIAL_WINDOW_SIZE: - if ($unpacked["value"] >= 1 << 31) { - throw new Http2ConnectionException("Invalid window size", self::FLOW_CONTROL_ERROR); - } - - $priorWindowSize = $this->initialWindowSize; - $this->initialWindowSize = $unpacked["value"]; - $difference = $this->initialWindowSize - $priorWindowSize; - - foreach ($this->streams as $stream) { - $stream->clientWindow += $difference; - } - - // Settings ACK should be sent before HEADER or DATA frames. - Loop::defer(\Closure::fromCallable([$this, 'sendBufferedData'])); - return; - - case self::ENABLE_PUSH: - return; // No action needed. - - case self::MAX_FRAME_SIZE: - if ($unpacked["value"] < 1 << 14 || $unpacked["value"] >= 1 << 24) { - throw new Http2ConnectionException("Invalid max frame size", self::PROTOCOL_ERROR); - } - - $this->maxFrameSize = $unpacked["value"]; - return; - - case self::MAX_CONCURRENT_STREAMS: - if ($unpacked["value"] >= 1 << 31) { - throw new Http2ConnectionException("Invalid concurrent streams value", self::PROTOCOL_ERROR); - } - - $priorUsedStreams = $this->maxStreams - $this->remainingStreams; - - $this->maxStreams = $unpacked["value"]; - $this->remainingStreams = $this->maxStreams - $priorUsedStreams; - return; - - case self::HEADER_TABLE_SIZE: - case self::MAX_HEADER_LIST_SIZE: - return; // @TODO Respect these settings from the server. - - default: - return; // Unknown setting, ignore (6.5.2). - } - } - - private function sendBufferedData(): void - { - foreach ($this->streams as $id => $stream) { - if ($this->clientWindow <= 0) { - return; - } - - if (!\strlen($stream->buffer) || $stream->clientWindow <= 0) { - continue; - } - - $this->writeBufferedData($id); - } - } - - private function setupPingIfIdle(): void - { - if ($this->idleWatcher !== null) { - return; - } - - $this->idleWatcher = Loop::defer(function ($watcher) { - \assert($this->idleWatcher === null || $this->idleWatcher === $watcher); - - $this->idleWatcher = null; - if (!empty($this->streams)) { - return; - } - - $this->idleWatcher = Loop::delay(300000, function ($watcher) { - \assert($this->idleWatcher === null || $this->idleWatcher === $watcher); - \assert(empty($this->streams)); - - $this->idleWatcher = null; - - // Connection idle for 10 minutes - if ($this->idlePings >= 1) { - $this->close(); - return; - } - - if (yield $this->ping()) { - $this->setupPingIfIdle(); - } - }); - - Loop::unreference($this->idleWatcher); - }); - - Loop::unreference($this->idleWatcher); - } - - private function cancelIdleWatcher(): void - { - if ($this->idleWatcher !== null) { - Loop::cancel($this->idleWatcher); - $this->idleWatcher = null; - } + return $this->connection->request($request, $token, $applicationStream); } } diff --git a/src/Connection/Internal/Http2FrameProcessor.php b/src/Connection/Internal/Http2FrameProcessor.php new file mode 100644 index 00000000..ceb3e8fe --- /dev/null +++ b/src/Connection/Internal/Http2FrameProcessor.php @@ -0,0 +1,37 @@ + true, + ]; + + private const KNOWN_REQUEST_PSEUDO_HEADERS = [ + ":method" => true, + ":authority" => true, + ":path" => true, + ":scheme" => true, + ]; + + // SETTINGS Flags - https://http2.github.io/http2-spec/#rfc.section.6.5 + public const ACK = 0x01; + + // HEADERS Flags - https://http2.github.io/http2-spec/#rfc.section.6.2 + public const END_STREAM = 0x01; + public const END_HEADERS = 0x04; + public const PADDED = 0x08; + public const PRIORITY_FLAG = 0x20; + + // Frame Types - https://http2.github.io/http2-spec/#rfc.section.11.2 + public const DATA = 0x00; + public const HEADERS = 0x01; + public const PRIORITY = 0x02; + public const RST_STREAM = 0x03; + public const SETTINGS = 0x04; + public const PUSH_PROMISE = 0x05; + public const PING = 0x06; + public const GOAWAY = 0x07; + public const WINDOW_UPDATE = 0x08; + public const CONTINUATION = 0x09; + + // Error codes + public const GRACEFUL_SHUTDOWN = 0x0; + public const PROTOCOL_ERROR = 0x1; + public const INTERNAL_ERROR = 0x2; + public const FLOW_CONTROL_ERROR = 0x3; + public const SETTINGS_TIMEOUT = 0x4; + public const STREAM_CLOSED = 0x5; + public const FRAME_SIZE_ERROR = 0x6; + public const REFUSED_STREAM = 0x7; + public const CANCEL = 0x8; + public const COMPRESSION_ERROR = 0x9; + public const CONNECT_ERROR = 0xa; + public const ENHANCE_YOUR_CALM = 0xb; + public const INADEQUATE_SECURITY = 0xc; + public const HTTP_1_1_REQUIRED = 0xd; + + /** @var string */ + private $buffer = ''; + + /** @var int */ + private $headerSizeLimit = self::DEFAULT_MAX_FRAME_SIZE; // Should be configurable? + + /** @var bool */ + private $continuationExpected = false; + + /** @var int */ + private $headerFrameType = 0; + + /** @var string */ + private $headerBuffer = ''; + + /** @var int */ + private $headerStream = 0; + + /** @var HPack */ + private $hpack; + + /** @var Http2FrameProcessor */ + private $handler; + + public function __construct(Http2FrameProcessor $handler) + { + $this->hpack = new HPack; + $this->handler = $handler; + } + + public function parse(): \Generator + { + $this->buffer = yield; + + while (true) { + $frameHeader = yield from $this->consume(9); + + [ + 'length' => $frameLength, + 'type' => $frameType, + 'flags' => $frameFlags, + 'id' => $streamId, + ] = \unpack('Nlength/ctype/cflags/Nid', "\0" . $frameHeader); + + $streamId &= 0x7fffffff; + + $frameBuffer = $frameLength === 0 ? '' : yield from $this->consume($frameLength); + + try { + // Do we want to allow increasing the maximum frame size? + if ($frameLength > self::DEFAULT_MAX_FRAME_SIZE) { + throw new Http2ConnectionException("Frame size limit exceeded", self::FRAME_SIZE_ERROR); + } + + if ($this->continuationExpected && $frameType !== self::CONTINUATION) { + throw new Http2ConnectionException("Expected continuation frame", self::PROTOCOL_ERROR); + } + + switch ($frameType) { + case self::DATA: + $this->parseDataFrame($frameBuffer, $frameLength, $frameFlags, $streamId); + break; + + case self::PUSH_PROMISE: + $this->parsePushPromise($frameBuffer, $frameLength, $frameFlags, $streamId); + break; + + case self::HEADERS: + $this->parseHeaders($frameBuffer, $frameLength, $frameFlags, $streamId); + break; + + case self::PRIORITY: + $this->parsePriorityFrame($frameBuffer, $frameLength, $streamId); + break; + + case self::RST_STREAM: + $this->parseStreamReset($frameBuffer, $frameLength, $streamId); + break; + + case self::SETTINGS: + $this->parseSettings($frameBuffer, $frameLength, $frameFlags, $streamId); + break; + + case self::PING: + $this->parsePing($frameBuffer, $frameLength, $frameFlags, $streamId); + break; + + case self::GOAWAY: + $this->parseGoAway($frameBuffer, $frameLength, $streamId); + return; + + case self::WINDOW_UPDATE: + $this->parseWindowUpdate($frameBuffer, $frameLength, $streamId); + break; + + case self::CONTINUATION: + $this->parseContinuation($frameBuffer, $frameFlags, $streamId); + break; + + default: // Ignore and discard unknown frame per spec + break; + } + } catch (Http2StreamException $exception) { + $this->handler->handleStreamException($exception); + } catch (Http2ConnectionException $exception) { + $this->handler->handleconnectionException($exception); + + throw $exception; + } + } + } + + private function consume(int $bytes): \Generator + { + while (\strlen($this->buffer) < 9) { + $this->buffer .= yield; + } + + $consumed = \substr($this->buffer, 0, $bytes); + $this->buffer = \substr($this->buffer, $bytes); + + return $consumed; + } + + private function parseDataFrame(string $frameBuffer, int $frameLength, int $frameFlags, int $streamId): void + { + $isPadded = $frameFlags & self::PADDED; + + $headerLength = $isPadded ? 1 : 0; + + if ($frameLength < $headerLength) { + $this->throwInvalidFrameSizeError(); + } + + $header = $headerLength === 0 ? '' : \substr($frameBuffer, 0, $headerLength); + + $padding = $isPadded ? \ord($header[0]) : 0; + + if ($streamId === 0) { + $this->throwInvalidZeroStreamIdError(); + } + + if ($frameLength - $headerLength - $padding < 0) { + $this->throwInvalidPaddingError(); + } + + $data = \substr($frameBuffer, $headerLength, $frameLength - $headerLength - $padding); + + $this->handler->handleData($streamId, $data); + + if ($frameFlags & self::END_STREAM) { + $this->handler->handleStreamEnd($streamId); + } + } + + /** @see https://http2.github.io/http2-spec/#rfc.section.6.6 */ + private function parsePushPromise(string $frameBuffer, int $frameLength, int $frameFlags, int $streamId): void + { + $isPadded = $frameFlags & self::PADDED; + + $headerLength = $isPadded ? 5 : 4; + + if ($frameLength < $headerLength) { + $this->throwInvalidFrameSizeError(); + } + + $header = \substr($frameBuffer, 0, $headerLength); + + $padding = $isPadded ? \ord($header[0]) : 0; + + $pushId = \unpack("N", $header)[1] & 0x7fffffff; + + if ($frameLength - $headerLength - $padding < 0) { + $this->throwInvalidPaddingError(); + } + + $this->headerFrameType = self::PUSH_PROMISE; + + $this->pushHeaderBlockFragment( + $pushId, + \substr($frameBuffer, $headerLength, $frameLength - $headerLength - $padding) + ); + + if ($frameFlags & self::END_HEADERS) { + $this->continuationExpected = false; + + [$pseudo, $headers] = $this->parseHeaderBuffer(self::KNOWN_REQUEST_PSEUDO_HEADERS); + + $this->handler->handlePushPromise($streamId, $pushId, $pseudo, $headers); + } else { + $this->continuationExpected = true; + } + } + + private function parseHeaderBuffer(array $knownHeaders): array + { + \assert($this->headerStream !== 0); + \assert($this->headerBuffer !== ''); + + $decoded = $this->hpack->decode($this->headerBuffer, $this->headerSizeLimit); + + if ($decoded === null) { + throw new Http2ConnectionException("Compression error in headers", self::COMPRESSION_ERROR); + } + + $headers = []; + $pseudo = []; + + foreach ($decoded as [$name, $value]) { + if (!\preg_match(self::HEADER_NAME_REGEX, $name)) { + throw new Http2StreamException("Invalid header field name", $this->headerStream, self::PROTOCOL_ERROR); + } + + if ($name[0] === ':') { + if (!empty($headers) || !isset($knownHeaders[$name]) || isset($pseudo[$name])) { + throw new Http2ConnectionException( + "Unknown or invalid pseudo header", + self::PROTOCOL_ERROR + ); + } + + $pseudo[$name] = $value; + continue; + } + + $headers[$name][] = $value; + } + + $this->headerBuffer = ''; + $this->headerStream = 0; + + return [$pseudo, $headers]; + } + + private function pushHeaderBlockFragment(int $streamId, string $buffer): void + { + if ($this->headerStream !== 0 && $this->headerStream !== $streamId) { + throw new Http2ConnectionException( + "Expected CONTINUATION frame for stream ID " . $this->headerStream, + self::PROTOCOL_ERROR + ); + } + + $this->headerStream = $streamId; + $this->headerBuffer .= $buffer; + } + + /** @see https://http2.github.io/http2-spec/#HEADERS */ + private function parseHeaders(string $frameBuffer, int $frameLength, int $frameFlags, int $streamId): void + { + $headerLength = 0; + $isPadded = $frameFlags & self::PADDED; + $isPriority = $frameFlags & self::PRIORITY_FLAG; + + if ($isPadded) { + $headerLength++; + } + + if ($isPriority) { + $headerLength += 5; + } + + if ($frameLength < $headerLength) { + $this->throwInvalidFrameSizeError(); + } + + $header = \substr($frameBuffer, 0, $headerLength); + + $padding = $isPadded ? \ord($header[0]) : 0; + + if ($isPriority) { + ['parent' => $parent, 'weight' => $weight] = \unpack("Nparent/cweight", $header, $isPadded ? 1 : 0); + + $parent &= 0x7fffffff; + + if ($parent === 0) { + $this->throwInvalidZeroStreamIdError(); + } + + if ($parent === $streamId) { + $this->throwInvalidRecursiveDependency($streamId); + } + + $this->handler->handlePriority($streamId, $parent, $weight); + } + + if ($frameLength - $headerLength - $padding < 0) { + $this->throwInvalidPaddingError(); + } + + $this->headerFrameType = self::HEADERS; + + $this->pushHeaderBlockFragment( + $streamId, + \substr($frameBuffer, $headerLength, $frameLength - $headerLength - $padding) + ); + + if ($frameFlags & self::END_HEADERS) { + $this->continuationExpected = false; + + $headersTooLarge = \strlen($this->headerBuffer) > $this->headerSizeLimit; + + [$pseudo, $headers] = $this->parseHeaderBuffer(self::KNOWN_RESPONSE_PSEUDO_HEADERS); + + // This must happen after the parsing, otherwise we loose the connection state and must close the whole + // connection, which is not what we want here… + if ($headersTooLarge) { + throw new Http2StreamException( + "Headers exceed maximum configured size of {$this->headerSizeLimit} bytes", + $streamId, + self::ENHANCE_YOUR_CALM + ); + } + + $this->handler->handleHeaders($streamId, $pseudo, $headers); + } else { + $this->continuationExpected = true; + } + + if ($frameFlags & self::END_STREAM) { + $this->handler->handleStreamEnd($streamId); + } + } + + private function parsePriorityFrame(string $frameBuffer, int $frameLength, int $streamId): void + { + if ($frameLength !== 5) { + $this->throwInvalidFrameSizeError(); + } + + ['parent' => $parent, 'weight' => $weight] = \unpack("Nparent/cweight", $frameBuffer); + + if ($exclusive = ($parent & 0x80000000)) { + $parent &= 0x7fffffff; + } + + if ($parent === 0) { + $this->throwInvalidZeroStreamIdError(); + } + + if ($parent === $streamId) { + $this->throwInvalidRecursiveDependency($streamId); + } + + $this->handler->handlePriority($streamId, $parent, $weight); + } + + private function parseStreamReset(string $frameBuffer, int $frameLength, int $streamId): void + { + if ($frameLength !== 4) { + $this->throwInvalidFrameSizeError(); + } + + if ($streamId === 0) { + $this->throwInvalidZeroStreamIdError(); + } + + $errorCode = \unpack('N', $frameBuffer)[1]; + + $this->handler->handleStreamReset($streamId, $errorCode); + } + + private function parseSettings(string $frameBuffer, int $frameLength, int $frameFlags, int $streamId): void + { + if ($streamId !== 0) { + $this->throwInvalidNonZeroStreamIdError(); + } + + if ($frameFlags & self::ACK) { + if ($frameLength) { + $this->throwInvalidFrameSizeError(); + } + + return; // Got ACK, nothing to do + } + + if ($frameLength % 6 !== 0) { + $this->throwInvalidFrameSizeError(); + } + + if ($frameLength > 60) { + // Even with room for a few future options, sending that a big SETTINGS frame is just about + // wasting our processing time. We declare this a protocol error. + throw new Http2ConnectionException("Excessive SETTINGS frame", self::PROTOCOL_ERROR); + } + + $settings = []; + + while ($frameLength > 0) { + ['key' => $key, 'value' => $value] = \unpack("nkey/Nvalue", $frameBuffer); + + if ($value < 0) { + throw new Http2ConnectionException( + "Invalid setting: {$value}", + self::PROTOCOL_ERROR + ); + } + + $settings[$key] = $value; + + $frameBuffer = \substr($frameBuffer, 6); + $frameLength -= 6; + } + + $this->handler->handleSettings($settings); + } + + /** @see https://http2.github.io/http2-spec/#rfc.section.6.7 */ + private function parsePing(string $frameBuffer, int $frameLength, int $frameFlags, int $streamId): void + { + if ($frameLength !== 8) { + $this->throwInvalidFrameSizeError(); + } + + if ($streamId !== 0) { + $this->throwInvalidNonZeroStreamIdError(); + } + + if ($frameFlags & self::ACK) { + $this->handler->handlePong($frameBuffer); + } else { + $this->handler->handlePing($frameBuffer); + } + } + + /** @see https://http2.github.io/http2-spec/#rfc.section.6.8 */ + private function parseGoAway(string $frameBuffer, int $frameLength, int $streamId): void + { + if ($frameLength < 8) { + $this->throwInvalidFrameSizeError(); + } + + if ($streamId !== 0) { + $this->throwInvalidNonZeroStreamIdError(); + } + + ['last' => $lastId, 'error' => $error] = \unpack("Nlast/Nerror", $frameBuffer); + + $this->handler->handleShutdown($lastId & 0x7fffffff, $error); + } + + /** @see https://http2.github.io/http2-spec/#rfc.section.6.9 */ + private function parseWindowUpdate(string $frameBuffer, int $frameLength, int $streamId): void + { + if ($frameLength !== 4) { + $this->throwInvalidFrameSizeError(); + } + + $windowSize = \unpack('N', $frameBuffer)[1]; + + if ($windowSize === 0) { + if ($streamId) { + throw new Http2StreamException( + "Invalid zero window update value", + $streamId, + self::PROTOCOL_ERROR + ); + } + + throw new Http2ConnectionException("Invalid zero window update value", self::PROTOCOL_ERROR); + } + + if ($streamId) { + $this->handler->handleStreamWindowIncrement($streamId, $windowSize); + } else { + $this->handler->handleConnectionWindowIncrement($windowSize); + } + } + + /** @see https://http2.github.io/http2-spec/#rfc.section.6.10 */ + private function parseContinuation(string $frameBuffer, int $frameFlags, int $streamId): void + { + $this->pushHeaderBlockFragment($streamId, $frameBuffer); + + if ($frameFlags & self::END_HEADERS) { + $this->continuationExpected = false; + + $isPush = $this->headerFrameType === self::PUSH_PROMISE; + $knownHeaders = $isPush ? self::KNOWN_REQUEST_PSEUDO_HEADERS : self::KNOWN_RESPONSE_PSEUDO_HEADERS; + + $pushId = $this->headerStream; + + [$pseudo, $headers] = $this->parseHeaderBuffer($knownHeaders); + + if ($isPush) { + $this->handler->handlePushPromise($streamId, $pushId, $pseudo, $headers); + } else { + $this->handler->handleHeaders($streamId, $pseudo, $headers); + } + } + } + + private function throwInvalidFrameSizeError(): void + { + throw new Http2ConnectionException("Invalid frame length", self::PROTOCOL_ERROR); + } + + private function throwInvalidRecursiveDependency(int $streamId): void + { + throw new Http2ConnectionException( + "Invalid recursive dependency for stream {$streamId}", + self::PROTOCOL_ERROR + ); + } + + private function throwInvalidPaddingError(): void + { + throw new Http2ConnectionException("Padding greater than length", self::PROTOCOL_ERROR); + } + + private function throwInvalidZeroStreamIdError(): void + { + throw new Http2ConnectionException("Invalid zero stream ID", self::PROTOCOL_ERROR); + } + + private function throwInvalidNonZeroStreamIdError(): void + { + throw new Http2ConnectionException("Invalid non-zero stream ID", self::PROTOCOL_ERROR); + } +} diff --git a/src/Connection/Internal/Http2Stream.php b/src/Connection/Internal/Http2Stream.php index dcae6f53..2b55ea53 100644 --- a/src/Connection/Internal/Http2Stream.php +++ b/src/Connection/Internal/Http2Stream.php @@ -4,10 +4,12 @@ use Amp\CancellationToken; use Amp\Deferred; +use Amp\Emitter; use Amp\Http\Client\Connection\Stream; use Amp\Http\Client\Internal\ForbidCloning; use Amp\Http\Client\Internal\ForbidSerialization; use Amp\Http\Client\Request; +use Amp\Http\Client\Response; use Amp\Struct; /** @@ -21,29 +23,26 @@ final class Http2Stream use ForbidSerialization; use ForbidCloning; - public const OPEN = 0; - public const RESERVED = 0b0001; - public const REMOTE_CLOSED = 0b0010; - public const LOCAL_CLOSED = 0b0100; - public const CLOSED = 0b0110; + /** @var int */ + public $id; - /** @var Request|null */ + /** @var Request */ public $request; - /** @var CancellationToken */ - public $cancellationToken; + /** @var Response|null */ + public $response; - /** @var self|null */ - public $parent; + /** @var Deferred|null */ + public $pendingResponse; - /** @var string|null Packed header string. */ - public $headers; + /** @var Emitter|null */ + public $body; - /** @var int Max header length. */ - public $headerSizeLimit; + /** @var Deferred|null */ + public $trailers; - /** @var int Max body length. */ - public $bodySizeLimit; + /** @var CancellationToken */ + public $cancellationToken; /** @var int Bytes received on the stream. */ public $received = 0; @@ -55,16 +54,13 @@ final class Http2Stream public $clientWindow; /** @var string */ - public $buffer = ""; + public $buffer = ''; - /** @var int */ - public $state; - - /** @var Deferred|null */ - public $deferred; + /** @var bool */ + public $bufferComplete = false; /** @var int Integer between 1 and 256 */ - public $weight = 0; + public $weight = 16; /** @var int */ public $dependency = 0; @@ -72,20 +68,23 @@ final class Http2Stream /** @var int|null */ public $expectedLength; - /** @var Stream|null */ - public $applicationStream; + /** @var Stream */ + public $stream; public function __construct( + int $id, + Request $request, + Stream $stream, + CancellationToken $cancellationToken, int $serverSize, - int $clientSize, - int $maxHeaderSize, - int $maxBodySize, - int $state = self::OPEN + int $clientSize ) { + $this->id = $id; + $this->request = $request; + $this->stream = $stream; + $this->cancellationToken = $cancellationToken; $this->serverWindow = $serverSize; - $this->headerSizeLimit = $maxHeaderSize; - $this->bodySizeLimit = $maxBodySize; $this->clientWindow = $clientSize; - $this->state = $state; + $this->pendingResponse = new Deferred; } } diff --git a/src/Connection/Internal/InternalHttp2Connection.php b/src/Connection/Internal/InternalHttp2Connection.php new file mode 100644 index 00000000..f735179b --- /dev/null +++ b/src/Connection/Internal/InternalHttp2Connection.php @@ -0,0 +1,1384 @@ +socket = $socket; + $this->hpack = new HPack; + } + + public function isInitialized(): bool + { + return $this->initialized; + } + + /** + * Returns a promise that is resolved once the connection has been initialized. A stream cannot be obtained from the + * connection until the promise returned by this method resolves. + * + * @return Promise + */ + public function initialize(): Promise + { + if ($this->initializeStarted) { + throw new \Error('Connection may only be initialized once'); + } + + $this->initializeStarted = true; + + if ($this->socket->isClosed()) { + return new Failure(new UnprocessedRequestException( + new SocketException('The socket closed before the connection could be initialized') + )); + } + + $this->settings = new Deferred; + $promise = $this->settings->promise(); + + Promise\rethrow(new Coroutine($this->run())); + + return $promise; + } + + public function onClose(callable $onClose): void + { + if ($this->onClose === null) { + asyncCall($onClose, $this); + return; + } + + $this->onClose[] = $onClose; + } + + public function close(): Promise + { + $this->socket->close(); + + if ($this->onClose !== null) { + $onClose = $this->onClose; + $this->onClose = null; + + foreach ($onClose as $callback) { + asyncCall($callback, $this); + } + } + + return new Success; + } + + public function handlePong(string $data): void + { + $this->writeFrame($data, self::PING, self::ACK); + } + + public function handlePing(string $data): void + { + if ($this->pongDeferred !== null) { + if ($this->pongWatcher !== null) { + Loop::cancel($this->pongWatcher); + $this->pongWatcher = null; + } + + $deferred = $this->pongDeferred; + $this->pongDeferred = null; + $deferred->resolve(true); + } + } + + public function handleShutdown(int $lastId, int $error): void + { + $message = \sprintf( + "Received GOAWAY frame from %s with error code %d", + $this->socket->getRemoteAddress(), + $error + ); + + $this->shutdown($lastId, new Http2ConnectionException($message, $error)); + } + + public function handleStreamWindowIncrement(int $streamId, int $windowSize): void + { + if (!isset($this->streams[$streamId])) { + return; + } + + $stream = $this->streams[$streamId]; + + if ($stream->clientWindow + $windowSize > (2 << 30) - 1) { + $this->handleStreamException(new Http2StreamException( + "Current window size plus new window exceeds maximum size", + $streamId, + self::FLOW_CONTROL_ERROR + )); + + return; + } + + $stream->clientWindow += $windowSize; + + $this->writeBufferedData($stream); + } + + public function handleConnectionWindowIncrement($windowSize): void + { + if ($this->clientWindow + $windowSize > (2 << 30) - 1) { + $this->handleConnectionException(new Http2ConnectionException( + "Current window size plus new window exceeds maximum size", + self::FLOW_CONTROL_ERROR + )); + + return; + } + + $this->clientWindow += $windowSize; + + foreach ($this->streams as $stream) { + if ($this->clientWindow <= 0) { + return; + } + + if ($stream->buffer === '' || $stream->clientWindow <= 0) { + continue; + } + + $this->writeBufferedData($stream); + } + } + + public function handleHeaders(int $streamId, array $pseudo, array $headers): void + { + if (!isset($this->streams[$streamId])) { + return; + } + + $stream = $this->streams[$streamId]; + + if ($stream->trailers) { + if ($stream->body || $stream->expectedLength) { + $this->handleStreamException(new Http2StreamException( + "Stream not ended before receiving trailers", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + if (!empty($pseudo)) { + $this->handleStreamException(new Http2StreamException( + "Trailers must not contain pseudo headers", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + try { + // Constructor checks for any disallowed fields + $parsedTrailers = new Trailers($headers); + } catch (InvalidHeaderException $exception) { + $this->handleStreamException(new Http2StreamException( + "Disallowed field names in trailer", + $streamId, + self::PROTOCOL_ERROR, + $exception + )); + + return; + } + + $trailers = $stream->trailers; + $stream->trailers = null; + $trailers->resolve($parsedTrailers); + + asyncCall(function () use ($stream, $streamId) { + try { + foreach ($stream->request->getEventListeners() as $eventListener) { + yield $eventListener->completeReceivingResponse($stream->request, $stream->stream); + } + } catch (\Throwable $e) { + $this->handleStreamException(new Http2StreamException( + "Event listener error", + $streamId, + self::CANCEL + )); + } + }); + + $this->setupPingIfIdle(); + + return; + } + + if (!isset($pseudo[":status"])) { + $this->handleConnectionException(new Http2ConnectionException( + "No status pseudo header in response", + self::PROTOCOL_ERROR + )); + + return; + } + + if (!\preg_match("/^[1-5]\d\d$/", $pseudo[":status"])) { + $this->handleStreamException(new Http2StreamException( + "Invalid response status code", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + if ($stream->response !== null) { + $this->handleStreamException(new Http2StreamException( + "Stream headers already received", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + $status = (int) $pseudo[":status"]; + + if ($status === Status::SWITCHING_PROTOCOLS) { + $this->handleConnectionException(new Http2ConnectionException( + "Switching Protocols (101) is not part of HTTP/2", + self::PROTOCOL_ERROR + )); + + return; + } + + if ($status < 200) { + return; // ignore 1xx responses + } + + asyncCall(function () use ($stream, $streamId) { + try { + foreach ($stream->request->getEventListeners() as $eventListener) { + yield $eventListener->startReceivingResponse($stream->request, $stream->stream); + } + } catch (\Throwable $e) { + $this->handleStreamException(new Http2StreamException("Event listener error", $streamId, self::CANCEL)); + } + }); + + $stream->body = new Emitter; + $stream->trailers = new Deferred; + + $bodyCancellation = new CancellationTokenSource; + $cancellationToken = new CombinedCancellationToken( + $stream->cancellationToken, + $bodyCancellation->getToken() + ); + + $response = new Response( + '2', + $status, + Status::getReason($status), + $headers, + new ResponseBodyStream( + new IteratorStream($stream->body->iterate()), + $bodyCancellation + ), + $stream->request, + $stream->trailers->promise() + ); + + $pendingResponse = $stream->pendingResponse; + $stream->pendingResponse = null; + $pendingResponse->resolve($response); + + if ($this->serverWindow <= $stream->request->getBodySizeLimit() >> 1) { + $increment = $stream->request->getBodySizeLimit() - $this->serverWindow; + $this->serverWindow = $stream->request->getBodySizeLimit(); + + $this->writeFrame(\pack("N", $increment), self::WINDOW_UPDATE); + } + + if (isset($headers["content-length"])) { + if (\count($headers['content-length']) !== 1) { + $this->handleStreamException(new Http2StreamException( + "Multiple content-length header values", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + $contentLength = $headers["content-length"][0]; + if (!\preg_match('/^(0|[1-9][0-9]*)$/', $contentLength)) { + $this->handleStreamException(new Http2StreamException( + "Invalid content-length header value", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + $stream->expectedLength = (int) $contentLength; + } + + $cancellationToken->subscribe(function (CancelledException $exception) use ($streamId): void { + if (!isset($this->streams[$streamId])) { + return; + } + + $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NO_FLAG, $streamId); + $this->releaseStream($streamId, $exception); + }); + + unset($bodyCancellation, $cancellationToken); // Remove reference to cancellation token. + } + + public function handlePushPromise(int $parentId, int $streamId, array $pseudo, array $headers): void + { + if (!isset($pseudo[":method"], $pseudo[":path"], $pseudo[":scheme"], $pseudo[":authority"]) + || isset($headers["connection"]) + || $pseudo[":path"] === '' + || (isset($headers["te"]) && \implode($headers["te"]) !== "trailers") + ) { + $this->handleStreamException(new Http2StreamException( + "Invalid header values", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + $method = $pseudo[":method"]; + $target = $pseudo[":path"]; + $scheme = $pseudo[":scheme"]; + $host = $pseudo[":authority"]; + $query = null; + + if ($method !== 'GET' && $method !== 'HEAD') { + $this->handleStreamException(new Http2StreamException( + "Pushed request method must be a safe method", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + if (!\preg_match("#^([A-Z\d.\-]+|\[[\d:]+])(?::([1-9]\d*))?$#i", $host, $matches)) { + $this->handleStreamException(new Http2StreamException( + "Invalid pushed authority (host) name", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + $host = $matches[1]; + $port = isset($matches[2]) ? (int) $matches[2] : $this->socket->getRemoteAddress()->getPort(); + + if (!isset($this->streams[$parentId])) { + $this->handleStreamException(new Http2StreamException( + "Parent stream {$parentId} is no longer open", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + /** @var Http2Stream $parentStream */ + $parentStream = $this->streams[$parentId]; + + if (\strcasecmp($host, $parentStream->request->getUri()->getHost()) !== 0) { + $this->handleStreamException(new Http2StreamException( + "Authority does not match original request authority", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + if ($position = \strpos($target, "#")) { + $target = \substr($target, 0, $position); + } + + if ($position = \strpos($target, "?")) { + $query = \substr($target, $position + 1); + $target = \substr($target, 0, $position); + } + + try { + $uri = Uri\Http::createFromComponents([ + "scheme" => $scheme, + "host" => $host, + "port" => $port, + "path" => $target, + "query" => $query, + ]); + } catch (\Exception $exception) { + $this->handleConnectionException(new Http2ConnectionException("Invalid push URI", self::PROTOCOL_ERROR)); + + return; + } + + $request = new Request($uri, $method); + $request->setHeaders($headers); + $request->setProtocolVersions(['2']); + $request->setPushHandler($parentStream->request->getPushHandler()); + $request->setHeaderSizeLimit($parentStream->request->getHeaderSizeLimit()); + $request->setBodySizeLimit($parentStream->request->getBodySizeLimit()); + + $stream = new Http2Stream( + $streamId, + $request, + HttpStream::fromStream( + $parentStream->stream, + static function () { + throw new \Error('Calling Stream::request() on a pushed request is forbidden'); + }, + static function () { + // nothing to do + } + ), + $parentStream->cancellationToken, + self::DEFAULT_WINDOW_SIZE, + 0 + ); + + $stream->dependency = $parentId; + + $this->streams[$streamId] = $stream; + + if ($parentStream->request->getPushHandler() === null) { + $this->handleStreamException(new Http2StreamException("Push promise refused", $streamId, self::CANCEL)); + + return; + } + + asyncCall(function () use ($streamId, $stream): \Generator { + $tokenSource = new CancellationTokenSource; + $cancellationToken = new CombinedCancellationToken( + $stream->cancellationToken, + $tokenSource->getToken() + ); + + $cancellationId = $cancellationToken->subscribe(function ( + CancelledException $exception + ) use ($streamId): void { + if (!isset($this->streams[$streamId])) { + return; + } + + $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NO_FLAG, $streamId); + $this->releaseStream($streamId, $exception); + }); + + $onPush = $stream->request->getPushHandler(); + + try { + yield call($onPush, $stream->request, $stream->pendingResponse->promise()); + } catch (HttpException | StreamException | CancelledException $exception) { + $tokenSource->cancel($exception); + } catch (\Throwable $exception) { + $tokenSource->cancel($exception); + throw $exception; + } finally { + $cancellationToken->unsubscribe($cancellationId); + } + }); + } + + public function handlePriority(int $streamId, int $parentId, int $weight): void + { + if (!isset($this->streams[$streamId])) { + return; + } + + $stream = $this->streams[$streamId]; + + $stream->dependency = $parentId; + $stream->weight = $weight; + } + + public function handleStreamReset(int $streamId, int $errorCode): void + { + if (!isset($this->streams[$streamId])) { + return; + } + + $this->handleStreamException(new Http2StreamException("Stream closed by server", $streamId, $errorCode)); + } + + public function handleStreamException(Http2StreamException $exception): void + { + $id = $exception->getStreamId(); + $code = $exception->getCode(); + + if ($code === self::REFUSED_STREAM) { + $exception = new UnprocessedRequestException($exception); + } + + $this->writeFrame(\pack("N", $code), self::RST_STREAM, self::NO_FLAG, $id); + + if (isset($this->streams[$id])) { + $this->releaseStream($id, $exception); + } + } + + public function handleConnectionException(Http2ConnectionException $exception): void + { + $this->shutdown(null, $exception); + } + + public function handleData(int $streamId, string $data): void + { + if (!isset($this->streams[$streamId])) { + return; + } + + $stream = $this->streams[$streamId]; + + if (!$stream->body) { + $this->handleStreamException(new Http2StreamException( + "Stream headers not complete or body already complete", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + $length = \strlen($data); + + // TODO Check window size + $this->serverWindow -= $length; + $stream->serverWindow -= $length; + $stream->received += $length; + + if ($stream->received >= $stream->request->getBodySizeLimit()) { + $this->handleStreamException(new Http2StreamException("Body size limit exceeded", $streamId, self::CANCEL)); + + return; + } + + if ($stream->expectedLength !== null && $stream->received > $stream->expectedLength) { + $this->handleStreamException(new Http2StreamException( + "Body size exceeded content-length in header", + $streamId, + self::CANCEL + )); + + return; + } + + if ($this->serverWindow <= self::MINIMUM_WINDOW) { + $this->serverWindow += self::MAX_INCREMENT; + $this->writeFrame(\pack("N", self::MAX_INCREMENT), self::WINDOW_UPDATE); + } + + $promise = $stream->body->emit($data); + + if ($stream->serverWindow <= self::MINIMUM_WINDOW) { + $promise->onResolve(function (?\Throwable $exception) use ($streamId): void { + if ($exception || !isset($this->streams[$streamId])) { + return; + } + + $stream = $this->streams[$streamId]; + + if ($stream->serverWindow > self::MINIMUM_WINDOW) { + return; + } + + $increment = \min( + $stream->request->getBodySizeLimit() - $stream->received - $stream->serverWindow, + self::MAX_INCREMENT + ); + + if ($increment <= 0) { + return; + } + + $stream->serverWindow += $increment; + + $this->writeFrame(\pack("N", $increment), self::WINDOW_UPDATE, self::NO_FLAG, $streamId); + }); + } + } + + public function handleSettings(array $settings): void + { + foreach ($settings as $setting => $value) { + $this->applySetting($setting, $value); + } + + $this->writeFrame('', self::SETTINGS, self::ACK); + + if ($this->settings) { + $deferred = $this->settings; + $this->settings = null; + $this->initialized = true; + $deferred->resolve($this->remainingStreams); + } + } + + public function handleStreamEnd(int $streamId): void + { + if (!isset($this->streams[$streamId])) { + return; + } + + $stream = $this->streams[$streamId]; + + if ($stream->expectedLength !== null && $stream->received !== $stream->expectedLength) { + $this->handleStreamException(new Http2StreamException( + "Body length does not match content-length header", + $streamId, + self::PROTOCOL_ERROR + )); + + return; + } + + $body = $stream->body; + $stream->body = null; + $body->complete(); + + asyncCall(function () use ($stream, $streamId) { + try { + foreach ($stream->request->getEventListeners() as $eventListener) { + yield $eventListener->completeReceivingResponse($stream->request, $stream->stream); + } + } catch (\Throwable $e) { + $this->handleStreamException(new Http2StreamException("Event listener error", $streamId, self::CANCEL)); + } + }); + + // TODO Trailers? + $this->setupPingIfIdle(); + + $this->releaseStream($streamId); + } + + public function reserveStream(): void + { + --$this->remainingStreams; + } + + public function unreserveStream(): void + { + ++$this->remainingStreams; + } + + public function getRemainingStreams(): int + { + return $this->remainingStreams; + } + + public function request(Request $request, CancellationToken $cancellationToken, Stream $stream): Promise + { + $this->idlePings = 0; + $this->cancelIdleWatcher(); + + // Remove defunct HTTP/1.x headers. + $request->removeHeader('host'); + $request->removeHeader('connection'); + $request->removeHeader('keep-alive'); + $request->removeHeader('transfer-encoding'); + $request->removeHeader('upgrade'); + + $request->setProtocolVersions(['2']); + + if ($this->socket->isClosed()) { + return new Failure(new UnprocessedRequestException( + new SocketException(\sprintf( + "Socket to '%s' closed before the request could be sent", + $this->socket->getRemoteAddress() + )) + )); + } + + $streamId = $this->streamId += 2; // Client streams should be odd-numbered, starting at 1. + + $this->streams[$streamId] = $http2stream = new Http2Stream( + $streamId, + $request, + $stream, + $cancellationToken, + self::DEFAULT_WINDOW_SIZE, + $this->initialWindowSize + ); + + if ($request->getTransferTimeout() > 0) { + // Cancellation token combined with timeout token should not be stored in $stream->cancellationToken, + // otherwise the timeout applies to the body transfer and pushes. + $cancellationToken = new CombinedCancellationToken( + $cancellationToken, + new TimeoutCancellationToken($request->getTransferTimeout()) + ); + } + + return call(function () use ($streamId, $request, $cancellationToken, $stream, $http2stream): \Generator { + $this->socket->reference(); + + $cancellationId = $cancellationToken->subscribe(function (CancelledException $exception) use ($streamId + ): void { + if (!isset($this->streams[$streamId])) { + return; + } + + $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NO_FLAG, $streamId); + $this->releaseStream($streamId, $exception); + }); + + try { + $headers = yield from $this->generateHeaders($request); + $headers = $this->hpack->encode($headers); + $body = $request->getBody()->createBodyStream(); + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->startSendingRequest($request, $stream); + } + + $chunk = yield $body->read(); + + if (!isset($this->streams[$streamId])) { + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeSendingRequest($request, $stream); + } + + return yield $http2stream->pendingResponse->promise(); + } + + $flag = self::END_HEADERS | ($chunk === null ? self::END_STREAM : self::NO_FLAG); + + if (\strlen($headers) > $this->frameSizeLimit) { + $split = \str_split($headers, $this->frameSizeLimit); + + $firstChunk = \array_shift($split); + $lastChunk = \array_pop($split); + + // no yield, because there must not be other frames in between + $this->writeFrame($firstChunk, self::HEADERS, self::NO_FLAG, $streamId); + + foreach ($split as $headerChunk) { + // no yield, because there must not be other frames in between + $this->writeFrame($headerChunk, self::CONTINUATION, self::NO_FLAG, $streamId); + } + + yield $this->writeFrame($lastChunk, self::CONTINUATION, $flag, $streamId); + } else { + yield $this->writeFrame($headers, self::HEADERS, $flag, $streamId); + } + + if ($chunk === null) { + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeSendingRequest($request, $stream); + } + + return yield $http2stream->pendingResponse->promise(); + } + + $buffer = $chunk; + while (null !== $chunk = yield $body->read()) { + if (!isset($this->streams[$streamId])) { + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeSendingRequest($request, $stream); + } + + return yield $http2stream->pendingResponse->promise(); + } + + yield $this->writeData($buffer, $streamId); + + $buffer = $chunk; + } + + if (!isset($this->streams[$streamId])) { + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeSendingRequest($request, $stream); + } + + return yield $http2stream->pendingResponse->promise(); + } + + $http2stream->bufferComplete = true; + + yield $this->writeData($buffer, $streamId); + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeSendingRequest($request, $stream); + } + + return yield $http2stream->pendingResponse->promise(); + } catch (\Throwable $exception) { + if (isset($this->streams[$streamId])) { + $this->releaseStream($streamId, $exception); + } + + if ($exception instanceof StreamException) { + $exception = new SocketException('Failed to write request to socket: ' . $exception->getMessage()); + } + + throw $exception; + } finally { + $cancellationToken->unsubscribe($cancellationId); + } + }); + } + + public function isClosed(): bool + { + return $this->onClose === null; + } + + private function run(): \Generator + { + try { + yield $this->socket->write(self::PREFACE); + + yield $this->writeFrame( + \pack( + "nNnNnNnN", + self::ENABLE_PUSH, + 1, + self::MAX_CONCURRENT_STREAMS, + 256, + self::INITIAL_WINDOW_SIZE, + self::DEFAULT_WINDOW_SIZE, + self::MAX_FRAME_SIZE, + self::DEFAULT_MAX_FRAME_SIZE + ), + self::SETTINGS + ); + + $parser = (new Http2Parser($this))->parse(); + + while (null !== $chunk = yield $this->socket->read()) { + $promise = $parser->send($chunk); + + \assert($promise === null || $promise instanceof Promise); + + while ($promise instanceof Promise) { + yield $promise; // Wait for promise to resolve before resuming parser and reading more data. + $promise = $parser->send(null); + + \assert($promise === null || $promise instanceof Promise); + } + } + + $this->shutdown(null); + } catch (\Throwable $exception) { + $this->shutdown(null, $exception); + } + } + + private function writeFrame(string $data, int $type, int $flags = self::NO_FLAG, int $stream = 0): Promise + { + /** @noinspection PhpUnhandledExceptionInspection */ + return $this->socket->write(\substr(\pack("NccN", \strlen($data), $type, $flags, $stream), 1) . $data); + } + + private function applySetting(int $setting, int $value): void + { + switch ($setting) { + case self::INITIAL_WINDOW_SIZE: + if ($value >= 1 << 31) { + $this->handleConnectionException(new Http2ConnectionException( + "Invalid window size: {$value}", + self::FLOW_CONTROL_ERROR + )); + + return; + } + + $priorWindowSize = $this->initialWindowSize; + $this->initialWindowSize = $value; + $difference = $this->initialWindowSize - $priorWindowSize; + + foreach ($this->streams as $stream) { + $stream->clientWindow += $difference; + } + + // Settings ACK should be sent before HEADER or DATA frames. + if ($difference > 0) { + Loop::defer(function () { + foreach ($this->streams as $stream) { + if ($this->clientWindow <= 0) { + return; + } + + if ($stream->buffer === '' || $stream->clientWindow <= 0) { + continue; + } + + $this->writeBufferedData($stream); + } + }); + } + + return; + + case self::MAX_FRAME_SIZE: + if ($value < 1 << 14 || $value >= 1 << 24) { + $this->handleConnectionException(new Http2ConnectionException( + "Invalid maximum frame size: {$value}", + self::PROTOCOL_ERROR + )); + + return; + } + + $this->frameSizeLimit = $value; + return; + + case self::MAX_CONCURRENT_STREAMS: + if ($value >= 1 << 31) { + $this->handleConnectionException(new Http2ConnectionException( + "Invalid concurrent streams value: {$value}", + self::PROTOCOL_ERROR + )); + + return; + } + + $priorUsedStreams = $this->concurrentStreamLimit - $this->remainingStreams; + + $this->concurrentStreamLimit = $value; + $this->remainingStreams = $this->concurrentStreamLimit - $priorUsedStreams; + return; + + case self::HEADER_TABLE_SIZE: // TODO Respect this setting from the server + case self::MAX_HEADER_LIST_SIZE: // TODO Respect this setting from the server + case self::ENABLE_PUSH: // No action needed. + default: // Unknown setting, ignore (6.5.2). + return; + } + } + + private function writeBufferedData(Http2Stream $stream): Promise + { + $windowSize = \min($this->clientWindow, $stream->clientWindow); + $length = \strlen($stream->buffer); + + if ($length <= $windowSize) { + $this->clientWindow -= $length; + $stream->clientWindow -= $length; + + if ($length > $this->frameSizeLimit) { + $chunks = \str_split($stream->buffer, $this->frameSizeLimit); + $stream->buffer = \array_pop($chunks); + + foreach ($chunks as $chunk) { + $this->writeFrame($chunk, self::DATA, self::NO_FLAG, $stream->id); + } + } + + if ($stream->bufferComplete) { + $promise = $this->writeFrame($stream->buffer, self::DATA, self::END_STREAM, $stream->id); + } else { + $promise = $this->writeFrame($stream->buffer, self::DATA, self::NO_FLAG, $stream->id); + } + + $stream->buffer = ""; + + // TODO Read next request body chunk + + return $promise; + } + + if ($windowSize > 0) { + $data = $stream->buffer; + $end = $windowSize - $this->frameSizeLimit; + + $stream->clientWindow -= $windowSize; + $this->clientWindow -= $windowSize; + + for ($off = 0; $off < $end; $off += $this->frameSizeLimit) { + $this->writeFrame(\substr($data, $off, $this->frameSizeLimit), self::DATA, self::NO_FLAG, $stream->id); + } + + $promise = $this->writeFrame( + \substr($data, $off, $windowSize - $off), + self::DATA, + self::NO_FLAG, + $stream->id + ); + + $stream->buffer = \substr($data, $windowSize); + + return $promise; + } + + return new Success; + } + + private function releaseStream(int $streamId, ?\Throwable $exception = null): void + { + \assert(isset($this->streams[$streamId])); + + $stream = $this->streams[$streamId]; + + if ($stream->pendingResponse) { + $pendingResponse = $stream->pendingResponse; + $stream->pendingResponse = null; + $pendingResponse->fail($exception ?? new Http2StreamException( + "Stream closed unexpectedly", + $streamId, + self::INTERNAL_ERROR + )); + } + + if ($stream->body) { + $body = $stream->body; + $stream->body = null; + $body->fail($exception ?? new Http2StreamException( + "Stream closed unexpectedly", + $streamId, + self::INTERNAL_ERROR + )); + } + + if ($stream->trailers) { + $trailers = $stream->trailers; + $stream->trailers = null; + $trailers->fail($exception ?? new Http2StreamException( + "Stream closed unexpectedly", + $streamId, + self::INTERNAL_ERROR + )); + } + + unset($this->streams[$streamId]); + + if ($streamId & 1) { // Client-initiated stream. + $this->remainingStreams++; + } + + if (!$this->streams && !$this->socket->isClosed()) { + $this->socket->unreference(); + } + } + + private function setupPingIfIdle(): void + { + if ($this->idleWatcher !== null) { + return; + } + + $this->idleWatcher = Loop::defer(function ($watcher) { + \assert($this->idleWatcher === null || $this->idleWatcher === $watcher); + + $this->idleWatcher = null; + if (!empty($this->streams)) { + return; + } + + $this->idleWatcher = Loop::delay(300000, function ($watcher) { + \assert($this->idleWatcher === null || $this->idleWatcher === $watcher); + \assert(empty($this->streams)); + + $this->idleWatcher = null; + + // Connection idle for 10 minutes + if ($this->idlePings >= 1) { + $this->shutdown(); + return; + } + + if (yield $this->ping()) { + $this->setupPingIfIdle(); + } + }); + + Loop::unreference($this->idleWatcher); + }); + + Loop::unreference($this->idleWatcher); + } + + private function cancelIdleWatcher(): void + { + if ($this->idleWatcher !== null) { + Loop::cancel($this->idleWatcher); + $this->idleWatcher = null; + } + } + + /** + * @return Promise Fulfilled with true if a pong is received within the timeout, false if none is received. + */ + private function ping(): Promise + { + if ($this->onClose === null) { + return new Success(false); + } + + if ($this->pongDeferred !== null) { + return $this->pongDeferred->promise(); + } + + $this->pongDeferred = new Deferred; + $this->idlePings++; + + $this->writeFrame($this->counter++, self::PING); + + $this->pongWatcher = Loop::delay(self::PONG_TIMEOUT, [$this, 'close']); + + return $this->pongDeferred->promise(); + } + + /** + * @param int|null $lastId ID of last processed frame. Null to use the last opened frame ID or 0 if no + * streams have been opened. + * @param \Throwable|null $reason + * + * @return Promise + */ + private function shutdown(?int $lastId = null, ?\Throwable $reason = null): Promise + { + if ($this->onClose === null) { + return new Success; + } + + return call(function () use ($lastId, $reason) { + $code = $reason ? $reason->getCode() : self::GRACEFUL_SHUTDOWN; + $lastId = $lastId ?? ($this->streamId > 0 ? $this->streamId : 0); + $goawayPromise = $this->writeFrame(\pack("NN", $lastId, $code), self::GOAWAY, self::NO_FLAG); + + if ($this->settings !== null) { + $settings = $this->settings; + $this->settings = null; + $settings->fail($reason ?? new UnprocessedRequestException(new SocketException("Connection closed"))); + } + + if ($this->streams) { + $reason = $reason ?? new SocketException("Connection closed"); + foreach ($this->streams as $id => $stream) { + $this->releaseStream($id, $id > $lastId ? new UnprocessedRequestException($reason) : $reason); + } + } + + if ($this->pongDeferred !== null) { + $this->pongDeferred->resolve(false); + } + + if ($this->pongWatcher !== null) { + Loop::cancel($this->pongWatcher); + } + + $this->cancelIdleWatcher(); + + if ($this->onClose !== null) { + $onClose = $this->onClose; + $this->onClose = null; + + foreach ($onClose as $callback) { + asyncCall($callback, $this); + } + } + + yield $goawayPromise; + + $this->socket->close(); + }); + } + + private function generateHeaders(Request $request): \Generator + { + $uri = $request->getUri(); + + $path = $uri->getPath(); + if ($path === '') { + $path = '/'; + } + + $query = $uri->getQuery(); + if ($query !== '') { + $path .= '?' . $query; + } + + $headers = yield $request->getBody()->getHeaders(); + foreach ($headers as $name => $header) { + if (!$request->hasHeader($name)) { + $request->setHeaders([$name => $header]); + } + } + + $authority = $uri->getHost(); + if ($port = $uri->getPort()) { + $authority .= ':' . $port; + } + + $headers = \array_merge([ + ":authority" => [$authority], + ":path" => [$path], + ":scheme" => [$uri->getScheme()], + ":method" => [$request->getMethod()], + ], $request->getHeaders()); + + return $headers; + } + + private function writeData(Http2Stream $stream, string $data): Promise + { + $stream->buffer .= $data; + + return $this->writeBufferedData($stream); + } +} diff --git a/test/Connection/Http2ConnectionTest.php b/test/Connection/Http2ConnectionTest.php index 7c30e6d7..c779c044 100644 --- a/test/Connection/Http2ConnectionTest.php +++ b/test/Connection/Http2ConnectionTest.php @@ -2,6 +2,7 @@ namespace Amp\Http\Client\Connection; +use Amp\Http\Client\Connection\Internal\Http2Parser; use Amp\Http\Client\Request; use Amp\Http\Client\Response; use Amp\Http\HPack; @@ -13,9 +14,9 @@ class Http2ConnectionTest extends AsyncTestCase { - public static function packFrame(string $data, string $type, string $flags, int $stream = 0): string + public static function packFrame(string $data, int $type, int $flags, int $stream = 0): string { - return \substr(\pack("N", \strlen($data)), 1, 3) . $type . $flags . \pack("N", $stream) . $data; + return \substr(\pack("NccN", \strlen($data), $type, $flags, $stream), 1) . $data; } public static function packHeader( @@ -29,23 +30,23 @@ public static function packHeader( $headers = $hpack->encode($headers); $all = \str_split($headers, $split); if ($split !== PHP_INT_MAX) { - $flag = Http2Connection::PADDED; + $flag = Http2Parser::PADDED; $len = 1; $all[0] = \chr($len) . $all[0] . \str_repeat("\0", $len); } else { - $flag = Http2Connection::NOFLAG; + $flag = 0; } $end = \array_pop($all); - $type = Http2Connection::HEADERS; + $type = Http2Parser::HEADERS; foreach ($all as $frame) { $data .= self::packFrame($frame, $type, $flag, $stream); - $type = Http2Connection::CONTINUATION; - $flag = Http2Connection::NOFLAG; + $type = Http2Parser::CONTINUATION; + $flag = 0; } - $flags = ($continue ? $flag : Http2Connection::END_STREAM | $flag) | Http2Connection::END_HEADERS; + $flags = ($continue ? $flag : Http2Parser::END_STREAM | $flag) | Http2Parser::END_HEADERS; return $data . self::packFrame($end, $type, $flags, $stream); } @@ -58,7 +59,7 @@ public function test100Continue(): \Generator $connection = new Http2Connection($client); - $server->write(self::packFrame('', Http2Connection::SETTINGS, Http2Connection::NOFLAG, 0)); + $server->write(self::packFrame('', Http2Parser::SETTINGS, 0, 0)); yield $connection->initialize(); @@ -70,12 +71,12 @@ public function test100Continue(): \Generator $server->write(self::packFrame($hpack->encode([ ":status" => Status::CONTINUE, "date" => [formatDateHeader()], - ]), Http2Connection::HEADERS, Http2Connection::END_HEADERS, 1)); + ]), Http2Parser::HEADERS, Http2Parser::END_HEADERS, 1)); $server->write(self::packFrame($hpack->encode([ ":status" => Status::NO_CONTENT, "date" => [formatDateHeader()], - ]), Http2Connection::HEADERS, Http2Connection::END_HEADERS | Http2Connection::END_STREAM, 1)); + ]), Http2Parser::HEADERS, Http2Parser::END_HEADERS | Http2Parser::END_STREAM, 1)); /** @var Response $response */ $response = yield $stream->request($request, new NullCancellationToken); @@ -91,7 +92,7 @@ public function testSwitchingProtocols(): \Generator $connection = new Http2Connection($client); - $server->write(self::packFrame('', Http2Connection::SETTINGS, Http2Connection::NOFLAG, 0)); + $server->write(self::packFrame('', Http2Parser::SETTINGS, 0, 0)); yield $connection->initialize(); @@ -103,7 +104,7 @@ public function testSwitchingProtocols(): \Generator $server->write(self::packFrame($hpack->encode([ ":status" => Status::SWITCHING_PROTOCOLS, "date" => [formatDateHeader()], - ]), Http2Connection::HEADERS, Http2Connection::END_HEADERS, 1)); + ]), Http2Parser::HEADERS, Http2Parser::END_HEADERS, 1)); $this->expectException(Http2ConnectionException::class); $this->expectExceptionMessage('Switching Protocols (101) is not part of HTTP/2'); From e7e225fcd36d8f106cab2b409b12bceed6b2dbec Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Thu, 28 Nov 2019 22:00:52 +0100 Subject: [PATCH 02/23] Fix Http2Parser::consume --- src/Connection/Internal/Http2Parser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Connection/Internal/Http2Parser.php b/src/Connection/Internal/Http2Parser.php index 279ef58e..f610edfb 100644 --- a/src/Connection/Internal/Http2Parser.php +++ b/src/Connection/Internal/Http2Parser.php @@ -183,7 +183,7 @@ public function parse(): \Generator private function consume(int $bytes): \Generator { - while (\strlen($this->buffer) < 9) { + while (\strlen($this->buffer) < $bytes) { $this->buffer .= yield; } From 2e1e9b27f437aa022e9fc128239084d21fe2519b Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Thu, 28 Nov 2019 22:11:12 +0100 Subject: [PATCH 03/23] Add HTTP/2 parser benchmark --- examples/bench-http2.php | 93 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 examples/bench-http2.php diff --git a/examples/bench-http2.php b/examples/bench-http2.php new file mode 100644 index 00000000..435db037 --- /dev/null +++ b/examples/bench-http2.php @@ -0,0 +1,93 @@ +parse(); + $parser->send($data); +} + +print 'Runtime: ' . (getCurrentTime() - $start) . ' milliseconds' . "\r\n"; From 83704b7026bb67509a12b17a8be47fbf0d00a49e Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Fri, 29 Nov 2019 18:53:06 +0100 Subject: [PATCH 04/23] Improve HTTP/2 parsing performance --- examples/bench-http2.php | 3 +-- src/Connection/Internal/Http2Parser.php | 17 ++++++++++--- .../Internal/InternalHttp2Connection.php | 24 +++++++++---------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/examples/bench-http2.php b/examples/bench-http2.php index 435db037..3e83b2a1 100644 --- a/examples/bench-http2.php +++ b/examples/bench-http2.php @@ -10,8 +10,7 @@ $data = \file_get_contents(__DIR__ . '/../test/fixture/h2.log'); -$processor = new class implements Http2FrameProcessor -{ +$processor = new class implements Http2FrameProcessor { public function handlePong(string $data): void { // empty stub diff --git a/src/Connection/Internal/Http2Parser.php b/src/Connection/Internal/Http2Parser.php index f610edfb..be68d250 100644 --- a/src/Connection/Internal/Http2Parser.php +++ b/src/Connection/Internal/Http2Parser.php @@ -72,6 +72,9 @@ final class Http2Parser /** @var string */ private $buffer = ''; + /** @var int */ + private $bufferOffset = 0; + /** @var int */ private $headerSizeLimit = self::DEFAULT_MAX_FRAME_SIZE; // Should be configurable? @@ -183,12 +186,20 @@ public function parse(): \Generator private function consume(int $bytes): \Generator { - while (\strlen($this->buffer) < $bytes) { + $bufferEnd = $this->bufferOffset + $bytes; + + while (\strlen($this->buffer) < $bufferEnd) { $this->buffer .= yield; } - $consumed = \substr($this->buffer, 0, $bytes); - $this->buffer = \substr($this->buffer, $bytes); + $consumed = \substr($this->buffer, $this->bufferOffset, $bytes); + + if ($bufferEnd > 2048) { + $this->buffer = \substr($this->buffer, $bufferEnd); + $this->bufferOffset = 0; + } else { + $this->bufferOffset += $bytes; + } return $consumed; } diff --git a/src/Connection/Internal/InternalHttp2Connection.php b/src/Connection/Internal/InternalHttp2Connection.php index f735179b..0b8d7b06 100644 --- a/src/Connection/Internal/InternalHttp2Connection.php +++ b/src/Connection/Internal/InternalHttp2Connection.php @@ -1178,30 +1178,30 @@ private function releaseStream(int $streamId, ?\Throwable $exception = null): vo $pendingResponse = $stream->pendingResponse; $stream->pendingResponse = null; $pendingResponse->fail($exception ?? new Http2StreamException( - "Stream closed unexpectedly", - $streamId, - self::INTERNAL_ERROR - )); + "Stream closed unexpectedly", + $streamId, + self::INTERNAL_ERROR + )); } if ($stream->body) { $body = $stream->body; $stream->body = null; $body->fail($exception ?? new Http2StreamException( - "Stream closed unexpectedly", - $streamId, - self::INTERNAL_ERROR - )); + "Stream closed unexpectedly", + $streamId, + self::INTERNAL_ERROR + )); } if ($stream->trailers) { $trailers = $stream->trailers; $stream->trailers = null; $trailers->fail($exception ?? new Http2StreamException( - "Stream closed unexpectedly", - $streamId, - self::INTERNAL_ERROR - )); + "Stream closed unexpectedly", + $streamId, + self::INTERNAL_ERROR + )); } unset($this->streams[$streamId]); From e486025b2b8d5d8f8274357a851c4ccc985b8715 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Fri, 29 Nov 2019 19:12:17 +0100 Subject: [PATCH 05/23] Fix HTTP/2 trailers --- .../Internal/InternalHttp2Connection.php | 12 ++- test/Connection/Http2ConnectionTest.php | 88 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/Connection/Internal/InternalHttp2Connection.php b/src/Connection/Internal/InternalHttp2Connection.php index 0b8d7b06..7bb7d1b2 100644 --- a/src/Connection/Internal/InternalHttp2Connection.php +++ b/src/Connection/Internal/InternalHttp2Connection.php @@ -304,9 +304,10 @@ public function handleHeaders(int $streamId, array $pseudo, array $headers): voi $stream = $this->streams[$streamId]; if ($stream->trailers) { - if ($stream->body || $stream->expectedLength) { + if ($stream->expectedLength && $stream->received !== $stream->expectedLength) { + $diff = $stream->expectedLength - $stream->received; $this->handleStreamException(new Http2StreamException( - "Stream not ended before receiving trailers", + "Content length mismatch: " . \abs($diff) . ' bytes ' . ($diff > 0 ? ' missing' : 'too much'), $streamId, self::PROTOCOL_ERROR )); @@ -705,7 +706,6 @@ public function handleData(int $streamId, string $data): void $length = \strlen($data); - // TODO Check window size $this->serverWindow -= $length; $stream->serverWindow -= $length; $stream->received += $length; @@ -799,6 +799,11 @@ public function handleStreamEnd(int $streamId): void $stream->body = null; $body->complete(); + $trailers = $stream->trailers; + $stream->trailers = null; + /** @noinspection PhpUnhandledExceptionInspection */ + $trailers->resolve(new Trailers([])); + asyncCall(function () use ($stream, $streamId) { try { foreach ($stream->request->getEventListeners() as $eventListener) { @@ -809,7 +814,6 @@ public function handleStreamEnd(int $streamId): void } }); - // TODO Trailers? $this->setupPingIfIdle(); $this->releaseStream($streamId); diff --git a/test/Connection/Http2ConnectionTest.php b/test/Connection/Http2ConnectionTest.php index c779c044..2deedb27 100644 --- a/test/Connection/Http2ConnectionTest.php +++ b/test/Connection/Http2ConnectionTest.php @@ -5,11 +5,14 @@ use Amp\Http\Client\Connection\Internal\Http2Parser; use Amp\Http\Client\Request; use Amp\Http\Client\Response; +use Amp\Http\Client\Trailers; use Amp\Http\HPack; use Amp\Http\Status; use Amp\NullCancellationToken; use Amp\PHPUnit\AsyncTestCase; use Amp\Socket; +use function Amp\asyncCall; +use function Amp\delay; use function Amp\Http\formatDateHeader; class Http2ConnectionTest extends AsyncTestCase @@ -111,4 +114,89 @@ public function testSwitchingProtocols(): \Generator yield $stream->request($request, new NullCancellationToken); } + + public function testTrailers(): \Generator + { + $hpack = new HPack; + + [$server, $client] = Socket\createPair(); + + $connection = new Http2Connection($client); + + $server->write(self::packFrame('', Http2Parser::SETTINGS, 0, 0)); + + yield $connection->initialize(); + + $request = new Request('http://localhost/'); + + /** @var Stream $stream */ + $stream = yield $connection->getStream($request); + + asyncCall(static function () use ($server, $hpack) { + yield delay(100); + + $server->write(self::packFrame($hpack->encode([ + ":status" => Status::OK, + "content-length" => ["4"], + "trailers" => ["Foo"], + "date" => [formatDateHeader()], + ]), Http2Parser::HEADERS, Http2Parser::END_HEADERS, 1)); + + yield delay(100); + + $server->write(self::packFrame('test', Http2Parser::DATA, 0, 1)); + + yield delay(100); + + $server->write(self::packFrame($hpack->encode([ + "foo" => ['bar'], + ]), Http2Parser::HEADERS, Http2Parser::END_HEADERS | Http2Parser::END_STREAM, 1)); + }); + + /** @var Response $response */ + $response = yield $stream->request($request, new NullCancellationToken); + + $this->assertSame(200, $response->getStatus()); + + /** @var Trailers $trailers */ + $trailers = yield $response->getTrailers(); + + $this->assertSame('bar', $trailers->getHeader('foo')); + } + + public function testTrailersWithoutTrailers(): \Generator + { + $hpack = new HPack; + + [$server, $client] = Socket\createPair(); + + $connection = new Http2Connection($client); + + $server->write(self::packFrame('', Http2Parser::SETTINGS, 0, 0)); + + yield $connection->initialize(); + + $request = new Request('http://localhost/'); + + /** @var Stream $stream */ + $stream = yield $connection->getStream($request); + + $server->write(self::packFrame($hpack->encode([ + ":status" => Status::OK, + "content-length" => ["4"], + "date" => [formatDateHeader()], + ]), Http2Parser::HEADERS, Http2Parser::END_HEADERS, 1)); + + $server->write(self::packFrame('test', Http2Parser::DATA, Http2Parser::END_STREAM, 1)); + + /** @var Response $response */ + $response = yield $stream->request($request, new NullCancellationToken); + + $this->assertSame(200, $response->getStatus()); + + /** @var Trailers $trailers */ + $trailers = yield $response->getTrailers(); + + $this->assertSame([], $trailers->getHeaders()); + } } From 1c5922641bce70a06fcf89eb4a195a149b51af4a Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Fri, 29 Nov 2019 19:38:41 +0100 Subject: [PATCH 06/23] Fix backpressure for HTTP/2 request bodies --- src/Connection/Internal/Http2Stream.php | 3 ++ .../Internal/InternalHttp2Connection.php | 28 +++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/Connection/Internal/Http2Stream.php b/src/Connection/Internal/Http2Stream.php index 2b55ea53..6b027aac 100644 --- a/src/Connection/Internal/Http2Stream.php +++ b/src/Connection/Internal/Http2Stream.php @@ -71,6 +71,9 @@ final class Http2Stream /** @var Stream */ public $stream; + /**@var Deferred|null */ + public $windowSizeIncrease; + public function __construct( int $id, Request $request, diff --git a/src/Connection/Internal/InternalHttp2Connection.php b/src/Connection/Internal/InternalHttp2Connection.php index 7bb7d1b2..cfb8c16c 100644 --- a/src/Connection/Internal/InternalHttp2Connection.php +++ b/src/Connection/Internal/InternalHttp2Connection.php @@ -880,15 +880,16 @@ public function request(Request $request, CancellationToken $cancellationToken, return call(function () use ($streamId, $request, $cancellationToken, $stream, $http2stream): \Generator { $this->socket->reference(); - $cancellationId = $cancellationToken->subscribe(function (CancelledException $exception) use ($streamId - ): void { + $onCancel = function (CancelledException $exception) use ($streamId): void { if (!isset($this->streams[$streamId])) { return; } $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NO_FLAG, $streamId); $this->releaseStream($streamId, $exception); - }); + }; + + $cancellationId = $cancellationToken->subscribe($onCancel); try { $headers = yield from $this->generateHeaders($request); @@ -1121,6 +1122,12 @@ private function writeBufferedData(Http2Stream $stream): Promise $length = \strlen($stream->buffer); if ($length <= $windowSize) { + if ($stream->windowSizeIncrease) { + $deferred = $stream->windowSizeIncrease; + $stream->windowSizeIncrease = null; + $deferred->resolve(); + } + $this->clientWindow -= $length; $stream->clientWindow -= $length; @@ -1141,12 +1148,17 @@ private function writeBufferedData(Http2Stream $stream): Promise $stream->buffer = ""; - // TODO Read next request body chunk - return $promise; } if ($windowSize > 0) { + // Read next body chunk if less than 8192 bytes will remain in the buffer + if ($length - 8192 < $windowSize && $stream->windowSizeIncrease) { + $deferred = $stream->windowSizeIncrease; + $stream->windowSizeIncrease = null; + $deferred->resolve(); + } + $data = $stream->buffer; $end = $windowSize - $this->frameSizeLimit; @@ -1169,7 +1181,11 @@ private function writeBufferedData(Http2Stream $stream): Promise return $promise; } - return new Success; + if ($stream->windowSizeIncrease === null) { + $stream->windowSizeIncrease = new Deferred; + } + + return $stream->windowSizeIncrease->promise(); } private function releaseStream(int $streamId, ?\Throwable $exception = null): void From d1120d57bd9da8f37c34e8d238e66d29de59129c Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Fri, 29 Nov 2019 20:04:45 +0100 Subject: [PATCH 07/23] Check for amphp/file to be installed in LogHttpArchive --- src/Interceptor/LogHttpArchive.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Interceptor/LogHttpArchive.php b/src/Interceptor/LogHttpArchive.php index 811408f5..1dcb1f78 100644 --- a/src/Interceptor/LogHttpArchive.php +++ b/src/Interceptor/LogHttpArchive.php @@ -4,6 +4,7 @@ use Amp\CancellationToken; use Amp\File; +use Amp\File\Driver; use Amp\Http\Client\ApplicationInterceptor; use Amp\Http\Client\DelegateHttpClient; use Amp\Http\Client\EventListener; @@ -148,6 +149,10 @@ public function __construct(string $filePath) $this->filePath = $filePath; $this->fileMutex = new LocalMutex; $this->eventListener = new RecordHarAttributes; + + if (!\interface_exists(Driver::class)) { + throw new \Error(__CLASS__ . ' requires amphp/file to be installed'); + } } public function request( From 832cb14fb1b44a02ac83ac3e8f4fb6d31fb474d1 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 21:06:14 +0100 Subject: [PATCH 08/23] Remove Http2Exception There's no use for this exception that isn't covered by HttpException. --- composer.json | 8 ++++---- src/Connection/Http2ConnectionException.php | 4 +++- src/Connection/Http2Exception.php | 9 --------- src/Connection/Http2StreamException.php | 4 +++- 4 files changed, 10 insertions(+), 15 deletions(-) delete mode 100644 src/Connection/Http2Exception.php diff --git a/composer.json b/composer.json index add287bf..7c1029b3 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,12 @@ { "name": "amphp/http-client", "homepage": "https://github.com/amphp/http-client", - "description": "Asynchronous parallel HTTP/2 and HTTP/1.1 client built on the Amp concurrency framework", + "description": "Asynchronous concurrent HTTP/2 and HTTP/1.1 client built on the Amp concurrency framework", "keywords": [ "http", "rest", "client", - "parallel", + "concurrent", "async", "non-blocking" ], @@ -30,7 +30,7 @@ "amphp/amp": "^2.4", "amphp/byte-stream": "^1.6", "amphp/hpack": "^2", - "amphp/http": "^1.3", + "amphp/http": "^1.5", "amphp/socket": "^1", "amphp/sync": "^1.3", "league/uri": "^6", @@ -47,7 +47,7 @@ "suggest": { "ext-zlib": "*", "ext-json": "*", - "amphp/file": "Required for file request bodies" + "amphp/file": "Required for file request bodies and HTTP archive logging" }, "autoload": { "psr-4": { diff --git a/src/Connection/Http2ConnectionException.php b/src/Connection/Http2ConnectionException.php index 58f4e17c..f2bcb01a 100644 --- a/src/Connection/Http2ConnectionException.php +++ b/src/Connection/Http2ConnectionException.php @@ -2,7 +2,9 @@ namespace Amp\Http\Client\Connection; -final class Http2ConnectionException extends Http2Exception +use Amp\Http\Client\HttpException; + +final class Http2ConnectionException extends HttpException { public function __construct(string $message, int $code, ?\Throwable $previous = null) { diff --git a/src/Connection/Http2Exception.php b/src/Connection/Http2Exception.php deleted file mode 100644 index 3488f5ab..00000000 --- a/src/Connection/Http2Exception.php +++ /dev/null @@ -1,9 +0,0 @@ - Date: Sat, 30 Nov 2019 21:34:25 +0100 Subject: [PATCH 09/23] Fix documentation and make some more classes final --- src/Connection/Http1Connection.php | 4 ++-- src/Connection/Internal/Http2FrameProcessor.php | 1 + src/Connection/Internal/Http2Parser.php | 4 +--- src/Connection/Internal/InternalHttp2Connection.php | 1 + src/DelegateHttpClient.php | 10 ++-------- src/HttpClient.php | 10 ++-------- src/NetworkInterceptor.php | 4 ++-- src/SocketException.php | 2 +- src/TimeoutException.php | 2 +- 9 files changed, 13 insertions(+), 25 deletions(-) diff --git a/src/Connection/Http1Connection.php b/src/Connection/Http1Connection.php index 2351bc00..a98d5b52 100644 --- a/src/Connection/Http1Connection.php +++ b/src/Connection/Http1Connection.php @@ -448,9 +448,9 @@ private function handleUpgradeResponse(Request $request, Response $response, str $socket = new UpgradedSocket($this->socket, $buffer); $this->free(); // Mark this connection as unusable without closing socket. - asyncCall(function () use ($onUpgrade, $socket, $request, $response): \Generator { + asyncCall(static function () use ($onUpgrade, $socket, $request, $response): \Generator { try { - yield call($onUpgrade, $socket, clone $request, $response); + yield call($onUpgrade, $socket, $request, $response); } catch (\Throwable $exception) { throw new HttpException('Upgrade handler threw an exception', 0, $exception); } finally { diff --git a/src/Connection/Internal/Http2FrameProcessor.php b/src/Connection/Internal/Http2FrameProcessor.php index ceb3e8fe..1f65e2e6 100644 --- a/src/Connection/Internal/Http2FrameProcessor.php +++ b/src/Connection/Internal/Http2FrameProcessor.php @@ -5,6 +5,7 @@ use Amp\Http\Client\Connection\Http2ConnectionException; use Amp\Http\Client\Connection\Http2StreamException; +/** @internal */ interface Http2FrameProcessor { public function handlePong(string $data): void; diff --git a/src/Connection/Internal/Http2Parser.php b/src/Connection/Internal/Http2Parser.php index be68d250..ed9f98bc 100644 --- a/src/Connection/Internal/Http2Parser.php +++ b/src/Connection/Internal/Http2Parser.php @@ -12,9 +12,7 @@ use Amp\Http\Client\Connection\Http2StreamException; use Amp\Http\HPack; -/** - * @internal - */ +/** @internal */ final class Http2Parser { private const DEFAULT_MAX_FRAME_SIZE = 1 << 14; diff --git a/src/Connection/Internal/InternalHttp2Connection.php b/src/Connection/Internal/InternalHttp2Connection.php index cfb8c16c..73050a83 100644 --- a/src/Connection/Internal/InternalHttp2Connection.php +++ b/src/Connection/Internal/InternalHttp2Connection.php @@ -35,6 +35,7 @@ use function Amp\asyncCall; use function Amp\call; +/** @internal */ final class InternalHttp2Connection implements Http2FrameProcessor { private const PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; diff --git a/src/DelegateHttpClient.php b/src/DelegateHttpClient.php index 8aff1315..42a68eb9 100644 --- a/src/DelegateHttpClient.php +++ b/src/DelegateHttpClient.php @@ -9,10 +9,8 @@ * Base HTTP client interface for use in {@see ApplicationInterceptor}. * * Applications and implementations should depend on {@see HttpClient} instead. The intent of this interface is to - * allow - * static analysis tools to find interceptors that forget to pass the cancellation token down. This situation is - * created - * because of the cancellation token being optional. + * allow static analysis tools to find interceptors that forget to pass the cancellation token down. This situation is + * created because of the cancellation token being optional. * * Before executing or delegating the request, any client implementation must call {@see EventListener::startRequest()} * on all event listeners registered on the given request in the order defined by {@see Request::getEventListeners()}. @@ -25,10 +23,6 @@ interface DelegateHttpClient /** * Request a specific resource from an HTTP server. * - * Note: Each client implementation MUST clone the given request before any modification or before passing the - * request to another object. This ensures that interceptors don't have to care about cloning and work reliably - * even if requests are retried. - * * @param Request $request * @param CancellationToken $cancellation * diff --git a/src/HttpClient.php b/src/HttpClient.php index 3de37c52..9490d02e 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -7,10 +7,8 @@ use Amp\Promise; /** - * Less strict HTTP client for use in applications and libraries. - * - * This class makes the cancellation token optional, so applications and libraries using an HttpClient don't have - * to pass a token if they don't need cancellation support. + * Convenient HTTP client for use in applications and libraries, providing a default for the cancellation token and + * automatically cloning the passed request, so future application requests can re-use the same object again. */ final class HttpClient implements DelegateHttpClient { @@ -24,10 +22,6 @@ public function __construct(DelegateHttpClient $httpClient) /** * Request a specific resource from an HTTP server. * - * Note: Each client implementation MUST clone the given request before any modification or before passing the - * request to another object. This ensures that interceptors don't have to care about cloning and work reliably - * even if requests are retried. - * * @param Request $request * @param CancellationToken $cancellation * diff --git a/src/NetworkInterceptor.php b/src/NetworkInterceptor.php index 33a0303f..8655bb2b 100644 --- a/src/NetworkInterceptor.php +++ b/src/NetworkInterceptor.php @@ -17,8 +17,8 @@ interface NetworkInterceptor * The implementation might modify the request and/or modify the response after the promise returned from * `$stream->request(...)` resolved. * - * A NetworkInterceptor MUST NOT short-circuit and MUST delegate to the `$stream` passed as third argument exactly - * once. The only exception to this rule is throwing an exception, e.g. because the TLS settings used are + * A NetworkInterceptor SHOULD NOT short-circuit and SHOULD delegate to the `$stream` passed as third argument + * exactly once. The only exception to this is throwing an exception, e.g. because the TLS settings used are * unacceptable. If you need short circuits, use an {@see ApplicationInterceptor} instead. * * @param Request $request diff --git a/src/SocketException.php b/src/SocketException.php index e34e428d..580a0e83 100755 --- a/src/SocketException.php +++ b/src/SocketException.php @@ -2,6 +2,6 @@ namespace Amp\Http\Client; -class SocketException extends HttpException +final class SocketException extends HttpException { } diff --git a/src/TimeoutException.php b/src/TimeoutException.php index dd7c9725..cd991a9a 100755 --- a/src/TimeoutException.php +++ b/src/TimeoutException.php @@ -2,6 +2,6 @@ namespace Amp\Http\Client; -class TimeoutException extends HttpException +final class TimeoutException extends HttpException { } From a5e2088126d72e951391dcb4a0152d0285e47022 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 21:36:00 +0100 Subject: [PATCH 10/23] Add note about @internal not being public API --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6a524ccc..0667a9ca 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ More extensive code examples reside in the [`examples`](./examples) directory. `amphp/http-client` follows the [semver](http://semver.org/) semantic versioning specification like all other `amphp` packages. +Everything in an `Internal` namespace or marked as `@internal` is not public API and therefore not covered by BC guarantees. + ##### 4.x Under development. From b6420868a414e34f749659b0037d76cf2d617368 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 21:46:30 +0100 Subject: [PATCH 11/23] Fix weight --- src/Connection/Internal/Http2Parser.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Connection/Internal/Http2Parser.php b/src/Connection/Internal/Http2Parser.php index ed9f98bc..77e12abb 100644 --- a/src/Connection/Internal/Http2Parser.php +++ b/src/Connection/Internal/Http2Parser.php @@ -361,7 +361,7 @@ private function parseHeaders(string $frameBuffer, int $frameLength, int $frameF $this->throwInvalidRecursiveDependency($streamId); } - $this->handler->handlePriority($streamId, $parent, $weight); + $this->handler->handlePriority($streamId, $parent, $weight + 1); } if ($frameLength - $headerLength - $padding < 0) { @@ -422,7 +422,7 @@ private function parsePriorityFrame(string $frameBuffer, int $frameLength, int $ $this->throwInvalidRecursiveDependency($streamId); } - $this->handler->handlePriority($streamId, $parent, $weight); + $this->handler->handlePriority($streamId, $parent, $weight + 1); } private function parseStreamReset(string $frameBuffer, int $frameLength, int $streamId): void From f230499c8bed5543f9cbc9a6f2130006fb0ce0f4 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 21:53:57 +0100 Subject: [PATCH 12/23] Improve internal HTTP/2 class names --- examples/bench-http2.php | 4 +-- src/Connection/Http2Connection.php | 25 ++++++++++--------- ...ction.php => Http2ConnectionProcessor.php} | 2 +- src/Connection/Internal/Http2Parser.php | 4 +-- ...2FrameProcessor.php => Http2Processor.php} | 2 +- 5 files changed, 19 insertions(+), 18 deletions(-) rename src/Connection/Internal/{InternalHttp2Connection.php => Http2ConnectionProcessor.php} (99%) rename src/Connection/Internal/{Http2FrameProcessor.php => Http2Processor.php} (97%) diff --git a/examples/bench-http2.php b/examples/bench-http2.php index 3e83b2a1..fa926b75 100644 --- a/examples/bench-http2.php +++ b/examples/bench-http2.php @@ -2,7 +2,7 @@ use Amp\Http\Client\Connection\Http2ConnectionException; use Amp\Http\Client\Connection\Http2StreamException; -use Amp\Http\Client\Connection\Internal\Http2FrameProcessor; +use Amp\Http\Client\Connection\Internal\Http2Processor; use Amp\Http\Client\Connection\Internal\Http2Parser; use function Amp\getCurrentTime; @@ -10,7 +10,7 @@ $data = \file_get_contents(__DIR__ . '/../test/fixture/h2.log'); -$processor = new class implements Http2FrameProcessor { +$processor = new class implements Http2Processor { public function handlePong(string $data): void { // empty stub diff --git a/src/Connection/Http2Connection.php b/src/Connection/Http2Connection.php index 7fa1ab75..9cc5d94f 100644 --- a/src/Connection/Http2Connection.php +++ b/src/Connection/Http2Connection.php @@ -3,7 +3,8 @@ namespace Amp\Http\Client\Connection; use Amp\CancellationToken; -use Amp\Http\Client\Connection\Internal\InternalHttp2Connection; +use Amp\Http\Client\Connection\Internal\Http2ConnectionProcessor; +use Amp\Http\Client\Connection\Internal\Http2Processor; use Amp\Http\Client\Internal\ForbidCloning; use Amp\Http\Client\Internal\ForbidSerialization; use Amp\Http\Client\Request; @@ -23,8 +24,8 @@ final class Http2Connection implements Connection /** @var EncryptableSocket */ private $socket; - /** @var InternalHttp2Connection */ - private $connection; + /** @var Http2Processor */ + private $processor; /** @var int */ private $requestCount = 0; @@ -32,7 +33,7 @@ final class Http2Connection implements Connection public function __construct(EncryptableSocket $socket) { $this->socket = $socket; - $this->connection = new InternalHttp2Connection($socket); + $this->processor = new Http2ConnectionProcessor($socket); } public function getProtocolVersions(): array @@ -42,38 +43,38 @@ public function getProtocolVersions(): array public function initialize(): Promise { - return $this->connection->initialize(); + return $this->processor->initialize(); } public function getStream(Request $request): Promise { - if (!$this->connection->isInitialized()) { + if (!$this->processor->isInitialized()) { throw new \Error('The promise returned from ' . __CLASS__ . '::initialize() must resolve before using the connection'); } return call(function () { - if ($this->connection->isClosed() || $this->connection->getRemainingStreams() <= 0) { + if ($this->processor->isClosed() || $this->processor->getRemainingStreams() <= 0) { return null; } - $this->connection->reserveStream(); + $this->processor->reserveStream(); return HttpStream::fromConnection( $this, \Closure::fromCallable([$this, 'request']), - \Closure::fromCallable([$this->connection, 'unreserveStream']) + \Closure::fromCallable([$this->processor, 'unreserveStream']) ); }); } public function onClose(callable $onClose): void { - $this->connection->onClose($onClose); + $this->processor->onClose($onClose); } public function close(): Promise { - return $this->connection->close(); + return $this->processor->close(); } public function getLocalAddress(): SocketAddress @@ -95,6 +96,6 @@ private function request(Request $request, CancellationToken $token, Stream $app { $this->requestCount++; - return $this->connection->request($request, $token, $applicationStream); + return $this->processor->request($request, $token, $applicationStream); } } diff --git a/src/Connection/Internal/InternalHttp2Connection.php b/src/Connection/Internal/Http2ConnectionProcessor.php similarity index 99% rename from src/Connection/Internal/InternalHttp2Connection.php rename to src/Connection/Internal/Http2ConnectionProcessor.php index 73050a83..b2840c70 100644 --- a/src/Connection/Internal/InternalHttp2Connection.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -36,7 +36,7 @@ use function Amp\call; /** @internal */ -final class InternalHttp2Connection implements Http2FrameProcessor +final class Http2ConnectionProcessor implements Http2Processor { private const PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; private const DEFAULT_MAX_FRAME_SIZE = 1 << 14; diff --git a/src/Connection/Internal/Http2Parser.php b/src/Connection/Internal/Http2Parser.php index 77e12abb..e304a947 100644 --- a/src/Connection/Internal/Http2Parser.php +++ b/src/Connection/Internal/Http2Parser.php @@ -91,10 +91,10 @@ final class Http2Parser /** @var HPack */ private $hpack; - /** @var Http2FrameProcessor */ + /** @var Http2Processor */ private $handler; - public function __construct(Http2FrameProcessor $handler) + public function __construct(Http2Processor $handler) { $this->hpack = new HPack; $this->handler = $handler; diff --git a/src/Connection/Internal/Http2FrameProcessor.php b/src/Connection/Internal/Http2Processor.php similarity index 97% rename from src/Connection/Internal/Http2FrameProcessor.php rename to src/Connection/Internal/Http2Processor.php index 1f65e2e6..37428993 100644 --- a/src/Connection/Internal/Http2FrameProcessor.php +++ b/src/Connection/Internal/Http2Processor.php @@ -6,7 +6,7 @@ use Amp\Http\Client\Connection\Http2StreamException; /** @internal */ -interface Http2FrameProcessor +interface Http2Processor { public function handlePong(string $data): void; From 07e7112536f07cc8d38260af9ba62abc31e13772 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 21:57:57 +0100 Subject: [PATCH 13/23] Move bench script to appropriate directory --- {examples => test}/bench-http2.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename {examples => test}/bench-http2.php (95%) diff --git a/examples/bench-http2.php b/test/bench-http2.php similarity index 95% rename from examples/bench-http2.php rename to test/bench-http2.php index fa926b75..094685a1 100644 --- a/examples/bench-http2.php +++ b/test/bench-http2.php @@ -6,9 +6,9 @@ use Amp\Http\Client\Connection\Internal\Http2Parser; use function Amp\getCurrentTime; -require __DIR__ . '/.helper/functions.php'; +require __DIR__ . '/../vendor/autoload.php'; -$data = \file_get_contents(__DIR__ . '/../test/fixture/h2.log'); +$data = \file_get_contents(__DIR__ . '/fixture/h2.log'); $processor = new class implements Http2Processor { public function handlePong(string $data): void From b7adb27be79411027b0f76ecf88fe9e2d7b18239 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 22:07:27 +0100 Subject: [PATCH 14/23] Forbid RetryRequests and FollowRedirects to be directly added via intercept() --- src/HttpClientBuilder.php | 8 ++++++++ test/ClientHttpBinIntegrationTest.php | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/HttpClientBuilder.php b/src/HttpClientBuilder.php index 9be2e4ec..20e26ad8 100644 --- a/src/HttpClientBuilder.php +++ b/src/HttpClientBuilder.php @@ -103,6 +103,14 @@ public function usingPool(ConnectionPool $pool): self */ public function intercept(ApplicationInterceptor $interceptor): self { + if ($this->followRedirectsInterceptor !== null && $interceptor instanceof FollowRedirects) { + throw new \Error('Disable automatic redirect following or use HttpClientBuilder::followRedirects() to customize redirects'); + } + + if ($this->retryInterceptor !== null && $interceptor instanceof RetryRequests) { + throw new \Error('Disable automatic retries or use HttpClientBuilder::retry() to customize retries'); + } + $builder = clone $this; $builder->applicationInterceptors[] = $interceptor; diff --git a/test/ClientHttpBinIntegrationTest.php b/test/ClientHttpBinIntegrationTest.php index fb3c6199..4d8cb566 100644 --- a/test/ClientHttpBinIntegrationTest.php +++ b/test/ClientHttpBinIntegrationTest.php @@ -478,7 +478,7 @@ public function testDeflateResponse(): \Generator public function testInfiniteRedirect(): \Generator { - $this->givenApplicationInterceptor(new FollowRedirects(10)); + $this->builder->followRedirects(10); $this->expectException(TooManyRedirectsException::class); From 6425fca38aa9375c08032c5eee103af5ba7632e4 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 22:11:14 +0100 Subject: [PATCH 15/23] Fix code style --- test/ClientHttpBinIntegrationTest.php | 1 - test/bench-http2.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/ClientHttpBinIntegrationTest.php b/test/ClientHttpBinIntegrationTest.php index 4d8cb566..f0b83b5f 100644 --- a/test/ClientHttpBinIntegrationTest.php +++ b/test/ClientHttpBinIntegrationTest.php @@ -11,7 +11,6 @@ use Amp\Http\Client\Body\FileBody; use Amp\Http\Client\Body\FormBody; use Amp\Http\Client\Interceptor\DecompressResponse; -use Amp\Http\Client\Interceptor\FollowRedirects; use Amp\Http\Client\Interceptor\ModifyRequest; use Amp\Http\Client\Interceptor\SetRequestHeaderIfUnset; use Amp\Http\Client\Interceptor\TooManyRedirectsException; diff --git a/test/bench-http2.php b/test/bench-http2.php index 094685a1..1d7290cd 100644 --- a/test/bench-http2.php +++ b/test/bench-http2.php @@ -2,8 +2,8 @@ use Amp\Http\Client\Connection\Http2ConnectionException; use Amp\Http\Client\Connection\Http2StreamException; -use Amp\Http\Client\Connection\Internal\Http2Processor; use Amp\Http\Client\Connection\Internal\Http2Parser; +use Amp\Http\Client\Connection\Internal\Http2Processor; use function Amp\getCurrentTime; require __DIR__ . '/../vendor/autoload.php'; From e15c1477d4283f9a3329fee1e0296b7edbcbe768 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 22:20:51 +0100 Subject: [PATCH 16/23] Error if a connection has already enabled TLS on creation --- src/Connection/UnlimitedConnectionPool.php | 33 ++++++++++++---------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/Connection/UnlimitedConnectionPool.php b/src/Connection/UnlimitedConnectionPool.php index 464ae88b..910346ed 100644 --- a/src/Connection/UnlimitedConnectionPool.php +++ b/src/Connection/UnlimitedConnectionPool.php @@ -253,27 +253,30 @@ private function createConnection( try { $tlsState = $socket->getTlsState(); - if ($tlsState === EncryptableSocket::TLS_STATE_DISABLED) { - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->startTlsNegotiation($request); - } - - $tlsCancellationToken = new CombinedCancellationToken( - $cancellation, - new TimeoutCancellationToken($request->getTlsHandshakeTimeout()) - ); - - yield $socket->setupTls($tlsCancellationToken); - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeTlsNegotiation($request); - } - } elseif ($tlsState !== EncryptableSocket::TLS_STATE_ENABLED) { + // Error if anything enabled TLS on a new connection before we can do it + if ($tlsState !== EncryptableSocket::TLS_STATE_DISABLED) { $socket->close(); + throw new UnprocessedRequestException( new SocketException('Failed to setup TLS connection, connection was in an unexpected TLS state (' . $tlsState . ')') ); } + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->startTlsNegotiation($request); + } + + $tlsCancellationToken = new CombinedCancellationToken( + $cancellation, + new TimeoutCancellationToken($request->getTlsHandshakeTimeout()) + ); + + yield $socket->setupTls($tlsCancellationToken); + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeTlsNegotiation($request); + } } catch (StreamException $exception) { $socket->close(); throw new UnprocessedRequestException(new SocketException(\sprintf( From 0f6dbd67ba460ee8904257bfac7cfc15202a9703 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 22:37:34 +0100 Subject: [PATCH 17/23] Improve writeFrame signature --- .../Internal/Http2ConnectionProcessor.php | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/Connection/Internal/Http2ConnectionProcessor.php b/src/Connection/Internal/Http2ConnectionProcessor.php index b2840c70..a8ebc3aa 100644 --- a/src/Connection/Internal/Http2ConnectionProcessor.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -219,7 +219,7 @@ public function close(): Promise public function handlePong(string $data): void { - $this->writeFrame($data, self::PING, self::ACK); + $this->writeFrame(self::PING, self::ACK, 0, $data); } public function handlePing(string $data): void @@ -447,7 +447,7 @@ public function handleHeaders(int $streamId, array $pseudo, array $headers): voi $increment = $stream->request->getBodySizeLimit() - $this->serverWindow; $this->serverWindow = $stream->request->getBodySizeLimit(); - $this->writeFrame(\pack("N", $increment), self::WINDOW_UPDATE); + $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", $increment)); } if (isset($headers["content-length"])) { @@ -480,7 +480,7 @@ public function handleHeaders(int $streamId, array $pseudo, array $headers): voi return; } - $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NO_FLAG, $streamId); + $this->writeFrame(self::RST_STREAM, self::NO_FLAG, $streamId, \pack("N", self::CANCEL)); $this->releaseStream($streamId, $exception); }); @@ -626,7 +626,7 @@ static function () { return; } - $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NO_FLAG, $streamId); + $this->writeFrame(self::RST_STREAM, self::NO_FLAG, $streamId, \pack("N", self::CANCEL)); $this->releaseStream($streamId, $exception); }); @@ -675,7 +675,7 @@ public function handleStreamException(Http2StreamException $exception): void $exception = new UnprocessedRequestException($exception); } - $this->writeFrame(\pack("N", $code), self::RST_STREAM, self::NO_FLAG, $id); + $this->writeFrame(self::RST_STREAM, self::NO_FLAG, $id, \pack("N", $code)); if (isset($this->streams[$id])) { $this->releaseStream($id, $exception); @@ -729,7 +729,7 @@ public function handleData(int $streamId, string $data): void if ($this->serverWindow <= self::MINIMUM_WINDOW) { $this->serverWindow += self::MAX_INCREMENT; - $this->writeFrame(\pack("N", self::MAX_INCREMENT), self::WINDOW_UPDATE); + $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::MAX_INCREMENT)); } $promise = $stream->body->emit($data); @@ -757,7 +757,7 @@ public function handleData(int $streamId, string $data): void $stream->serverWindow += $increment; - $this->writeFrame(\pack("N", $increment), self::WINDOW_UPDATE, self::NO_FLAG, $streamId); + $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", $increment)); }); } } @@ -768,7 +768,7 @@ public function handleSettings(array $settings): void $this->applySetting($setting, $value); } - $this->writeFrame('', self::SETTINGS, self::ACK); + $this->writeFrame(self::SETTINGS, self::ACK); if ($this->settings) { $deferred = $this->settings; @@ -886,7 +886,7 @@ public function request(Request $request, CancellationToken $cancellationToken, return; } - $this->writeFrame(\pack("N", self::CANCEL), self::RST_STREAM, self::NO_FLAG, $streamId); + $this->writeFrame(self::RST_STREAM, self::NO_FLAG, $streamId, \pack("N", self::CANCEL)); $this->releaseStream($streamId, $exception); }; @@ -920,16 +920,16 @@ public function request(Request $request, CancellationToken $cancellationToken, $lastChunk = \array_pop($split); // no yield, because there must not be other frames in between - $this->writeFrame($firstChunk, self::HEADERS, self::NO_FLAG, $streamId); + $this->writeFrame(self::HEADERS, self::NO_FLAG, $streamId, $firstChunk); foreach ($split as $headerChunk) { // no yield, because there must not be other frames in between - $this->writeFrame($headerChunk, self::CONTINUATION, self::NO_FLAG, $streamId); + $this->writeFrame(self::CONTINUATION, self::NO_FLAG, $streamId, $headerChunk); } - yield $this->writeFrame($lastChunk, self::CONTINUATION, $flag, $streamId); + yield $this->writeFrame(self::CONTINUATION, $flag, $streamId, $lastChunk); } else { - yield $this->writeFrame($headers, self::HEADERS, $flag, $streamId); + yield $this->writeFrame(self::HEADERS, $flag, $streamId, $headers); } if ($chunk === null) { @@ -999,6 +999,9 @@ private function run(): \Generator yield $this->socket->write(self::PREFACE); yield $this->writeFrame( + self::SETTINGS, + 0, + 0, \pack( "nNnNnNnN", self::ENABLE_PUSH, @@ -1009,8 +1012,7 @@ private function run(): \Generator self::DEFAULT_WINDOW_SIZE, self::MAX_FRAME_SIZE, self::DEFAULT_MAX_FRAME_SIZE - ), - self::SETTINGS + ) ); $parser = (new Http2Parser($this))->parse(); @@ -1034,7 +1036,7 @@ private function run(): \Generator } } - private function writeFrame(string $data, int $type, int $flags = self::NO_FLAG, int $stream = 0): Promise + private function writeFrame(int $type, int $flags = self::NO_FLAG, int $stream = 0, string $data = ''): Promise { /** @noinspection PhpUnhandledExceptionInspection */ return $this->socket->write(\substr(\pack("NccN", \strlen($data), $type, $flags, $stream), 1) . $data); @@ -1137,14 +1139,14 @@ private function writeBufferedData(Http2Stream $stream): Promise $stream->buffer = \array_pop($chunks); foreach ($chunks as $chunk) { - $this->writeFrame($chunk, self::DATA, self::NO_FLAG, $stream->id); + $this->writeFrame(self::DATA, self::NO_FLAG, $stream->id, $chunk); } } if ($stream->bufferComplete) { - $promise = $this->writeFrame($stream->buffer, self::DATA, self::END_STREAM, $stream->id); + $promise = $this->writeFrame(self::DATA, self::END_STREAM, $stream->id, $stream->buffer); } else { - $promise = $this->writeFrame($stream->buffer, self::DATA, self::NO_FLAG, $stream->id); + $promise = $this->writeFrame(self::DATA, self::NO_FLAG, $stream->id, $stream->buffer); } $stream->buffer = ""; @@ -1167,14 +1169,14 @@ private function writeBufferedData(Http2Stream $stream): Promise $this->clientWindow -= $windowSize; for ($off = 0; $off < $end; $off += $this->frameSizeLimit) { - $this->writeFrame(\substr($data, $off, $this->frameSizeLimit), self::DATA, self::NO_FLAG, $stream->id); + $this->writeFrame(self::DATA, self::NO_FLAG, $stream->id, \substr($data, $off, $this->frameSizeLimit)); } $promise = $this->writeFrame( - \substr($data, $off, $windowSize - $off), self::DATA, self::NO_FLAG, - $stream->id + $stream->id, + \substr($data, $off, $windowSize - $off) ); $stream->buffer = \substr($data, $windowSize); @@ -1297,7 +1299,7 @@ private function ping(): Promise $this->pongDeferred = new Deferred; $this->idlePings++; - $this->writeFrame($this->counter++, self::PING); + $this->writeFrame(self::PING, 0, 0, $this->counter++); $this->pongWatcher = Loop::delay(self::PONG_TIMEOUT, [$this, 'close']); @@ -1320,7 +1322,7 @@ private function shutdown(?int $lastId = null, ?\Throwable $reason = null): Prom return call(function () use ($lastId, $reason) { $code = $reason ? $reason->getCode() : self::GRACEFUL_SHUTDOWN; $lastId = $lastId ?? ($this->streamId > 0 ? $this->streamId : 0); - $goawayPromise = $this->writeFrame(\pack("NN", $lastId, $code), self::GOAWAY, self::NO_FLAG); + $goawayPromise = $this->writeFrame(self::GOAWAY, self::NO_FLAG, 0, \pack("NN", $lastId, $code)); if ($this->settings !== null) { $settings = $this->settings; From 37a5985e23cee009e825bcc9593d125cbcdf528b Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 22:44:43 +0100 Subject: [PATCH 18/23] Fix sending request bodies over HTTP/2 --- .../Internal/Http2ConnectionProcessor.php | 4 ++-- test/ClientHttpBinIntegrationTest.php | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Connection/Internal/Http2ConnectionProcessor.php b/src/Connection/Internal/Http2ConnectionProcessor.php index a8ebc3aa..88e85ee2 100644 --- a/src/Connection/Internal/Http2ConnectionProcessor.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -950,7 +950,7 @@ public function request(Request $request, CancellationToken $cancellationToken, return yield $http2stream->pendingResponse->promise(); } - yield $this->writeData($buffer, $streamId); + yield $this->writeData($http2stream, $buffer); $buffer = $chunk; } @@ -965,7 +965,7 @@ public function request(Request $request, CancellationToken $cancellationToken, $http2stream->bufferComplete = true; - yield $this->writeData($buffer, $streamId); + yield $this->writeData($http2stream, $buffer); foreach ($request->getEventListeners() as $eventListener) { yield $eventListener->completeSendingRequest($request, $stream); diff --git a/test/ClientHttpBinIntegrationTest.php b/test/ClientHttpBinIntegrationTest.php index f0b83b5f..957fb680 100644 --- a/test/ClientHttpBinIntegrationTest.php +++ b/test/ClientHttpBinIntegrationTest.php @@ -592,6 +592,22 @@ public function testHttp2Support(): \Generator $this->assertSame('2', $response->getProtocolVersion()); } + public function testHttp2SupportBody(): \Generator + { + $request = new Request('https://http2.pro/api/v1', 'POST'); + $request->setBody('foobar'); + + /** @var Response $response */ + $response = yield $this->client->request($request); + $body = yield $response->getBody()->buffer(); + $json = \json_decode($body, true); + + $this->assertSame(1, $json['http2']); + $this->assertSame('HTTP/2.0', $json['protocol']); + $this->assertSame(1, $json['push']); + $this->assertSame('2', $response->getProtocolVersion()); + } + public function testConcurrentSlowNetworkInterceptor(): \Generator { $this->givenNetworkInterceptor(new ModifyRequest(static function (Request $request) { From 4e41f6583180635064fa8bb2fbe519b722b8828c Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sat, 30 Nov 2019 23:49:34 +0100 Subject: [PATCH 19/23] Await request to be fully written before returning response --- .../Internal/Http2ConnectionProcessor.php | 82 +++++++++---------- src/Connection/Internal/Http2Stream.php | 13 ++- test/ClientHttpBinIntegrationTest.php | 29 +++++++ 3 files changed, 78 insertions(+), 46 deletions(-) diff --git a/src/Connection/Internal/Http2ConnectionProcessor.php b/src/Connection/Internal/Http2ConnectionProcessor.php index 88e85ee2..e76b6631 100644 --- a/src/Connection/Internal/Http2ConnectionProcessor.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -288,7 +288,7 @@ public function handleConnectionWindowIncrement($windowSize): void return; } - if ($stream->buffer === '' || $stream->clientWindow <= 0) { + if ($stream->requestBodyBuffer === '' || $stream->clientWindow <= 0) { continue; } @@ -439,9 +439,13 @@ public function handleHeaders(int $streamId, array $pseudo, array $headers): voi $stream->trailers->promise() ); - $pendingResponse = $stream->pendingResponse; - $stream->pendingResponse = null; - $pendingResponse->resolve($response); + $requestCompletion = $stream->requestBodyCompletion; + $stream->responsePending = false; + $stream->pendingResponse->resolve(call(static function () use ($response, $requestCompletion) { + yield $requestCompletion->promise(); + + return $response; + })); if ($this->serverWindow <= $stream->request->getBodySizeLimit() >> 1) { $increment = $stream->request->getBodySizeLimit() - $this->serverWindow; @@ -734,32 +738,19 @@ public function handleData(int $streamId, string $data): void $promise = $stream->body->emit($data); - if ($stream->serverWindow <= self::MINIMUM_WINDOW) { - $promise->onResolve(function (?\Throwable $exception) use ($streamId): void { - if ($exception || !isset($this->streams[$streamId])) { - return; - } - - $stream = $this->streams[$streamId]; - - if ($stream->serverWindow > self::MINIMUM_WINDOW) { - return; - } - - $increment = \min( - $stream->request->getBodySizeLimit() - $stream->received - $stream->serverWindow, - self::MAX_INCREMENT - ); + $promise->onResolve(function (?\Throwable $exception) use ($streamId): void { + if ($exception || !isset($this->streams[$streamId])) { + return; + } - if ($increment <= 0) { - return; - } + $stream = $this->streams[$streamId]; - $stream->serverWindow += $increment; + if ($stream->serverWindow <= self::MINIMUM_WINDOW) { + $stream->serverWindow += self::MAX_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", $increment)); - }); - } + $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::MAX_INCREMENT)); + } + }); } public function handleSettings(array $settings): void @@ -937,6 +928,9 @@ public function request(Request $request, CancellationToken $cancellationToken, yield $eventListener->completeSendingRequest($request, $stream); } + $http2stream->requestBodyComplete = true; + $http2stream->requestBodyCompletion->resolve(); + return yield $http2stream->pendingResponse->promise(); } @@ -963,7 +957,8 @@ public function request(Request $request, CancellationToken $cancellationToken, return yield $http2stream->pendingResponse->promise(); } - $http2stream->bufferComplete = true; + $http2stream->requestBodyComplete = true; + $http2stream->requestBodyCompletion->resolve(); yield $this->writeData($http2stream, $buffer); @@ -974,6 +969,8 @@ public function request(Request $request, CancellationToken $cancellationToken, return yield $http2stream->pendingResponse->promise(); } catch (\Throwable $exception) { if (isset($this->streams[$streamId])) { + $http2stream->requestBodyCompletion->fail($exception); + $this->releaseStream($streamId, $exception); } @@ -1071,7 +1068,7 @@ private function applySetting(int $setting, int $value): void return; } - if ($stream->buffer === '' || $stream->clientWindow <= 0) { + if ($stream->requestBodyBuffer === '' || $stream->clientWindow <= 0) { continue; } @@ -1122,7 +1119,7 @@ private function applySetting(int $setting, int $value): void private function writeBufferedData(Http2Stream $stream): Promise { $windowSize = \min($this->clientWindow, $stream->clientWindow); - $length = \strlen($stream->buffer); + $length = \strlen($stream->requestBodyBuffer); if ($length <= $windowSize) { if ($stream->windowSizeIncrease) { @@ -1135,21 +1132,21 @@ private function writeBufferedData(Http2Stream $stream): Promise $stream->clientWindow -= $length; if ($length > $this->frameSizeLimit) { - $chunks = \str_split($stream->buffer, $this->frameSizeLimit); - $stream->buffer = \array_pop($chunks); + $chunks = \str_split($stream->requestBodyBuffer, $this->frameSizeLimit); + $stream->requestBodyBuffer = \array_pop($chunks); foreach ($chunks as $chunk) { $this->writeFrame(self::DATA, self::NO_FLAG, $stream->id, $chunk); } } - if ($stream->bufferComplete) { - $promise = $this->writeFrame(self::DATA, self::END_STREAM, $stream->id, $stream->buffer); + if ($stream->requestBodyComplete) { + $promise = $this->writeFrame(self::DATA, self::END_STREAM, $stream->id, $stream->requestBodyBuffer); } else { - $promise = $this->writeFrame(self::DATA, self::NO_FLAG, $stream->id, $stream->buffer); + $promise = $this->writeFrame(self::DATA, self::NO_FLAG, $stream->id, $stream->requestBodyBuffer); } - $stream->buffer = ""; + $stream->requestBodyBuffer = ""; return $promise; } @@ -1162,7 +1159,7 @@ private function writeBufferedData(Http2Stream $stream): Promise $deferred->resolve(); } - $data = $stream->buffer; + $data = $stream->requestBodyBuffer; $end = $windowSize - $this->frameSizeLimit; $stream->clientWindow -= $windowSize; @@ -1179,7 +1176,7 @@ private function writeBufferedData(Http2Stream $stream): Promise \substr($data, $off, $windowSize - $off) ); - $stream->buffer = \substr($data, $windowSize); + $stream->requestBodyBuffer = \substr($data, $windowSize); return $promise; } @@ -1197,10 +1194,9 @@ private function releaseStream(int $streamId, ?\Throwable $exception = null): vo $stream = $this->streams[$streamId]; - if ($stream->pendingResponse) { - $pendingResponse = $stream->pendingResponse; - $stream->pendingResponse = null; - $pendingResponse->fail($exception ?? new Http2StreamException( + if ($stream->responsePending) { + $stream->responsePending = false; + $stream->pendingResponse->fail($exception ?? new Http2StreamException( "Stream closed unexpectedly", $streamId, self::INTERNAL_ERROR @@ -1400,7 +1396,7 @@ private function generateHeaders(Request $request): \Generator private function writeData(Http2Stream $stream, string $data): Promise { - $stream->buffer .= $data; + $stream->requestBodyBuffer .= $data; return $this->writeBufferedData($stream); } diff --git a/src/Connection/Internal/Http2Stream.php b/src/Connection/Internal/Http2Stream.php index 6b027aac..f3021be1 100644 --- a/src/Connection/Internal/Http2Stream.php +++ b/src/Connection/Internal/Http2Stream.php @@ -32,9 +32,12 @@ final class Http2Stream /** @var Response|null */ public $response; - /** @var Deferred|null */ + /** @var Deferred */ public $pendingResponse; + /** @var bool */ + public $responsePending = true; + /** @var Emitter|null */ public $body; @@ -54,10 +57,13 @@ final class Http2Stream public $clientWindow; /** @var string */ - public $buffer = ''; + public $requestBodyBuffer = ''; /** @var bool */ - public $bufferComplete = false; + public $requestBodyComplete = false; + + /** @var Deferred */ + public $requestBodyCompletion; /** @var int Integer between 1 and 256 */ public $weight = 16; @@ -89,5 +95,6 @@ public function __construct( $this->serverWindow = $serverSize; $this->clientWindow = $clientSize; $this->pendingResponse = new Deferred; + $this->requestBodyCompletion = new Deferred; } } diff --git a/test/ClientHttpBinIntegrationTest.php b/test/ClientHttpBinIntegrationTest.php index 957fb680..d1372ab1 100644 --- a/test/ClientHttpBinIntegrationTest.php +++ b/test/ClientHttpBinIntegrationTest.php @@ -608,6 +608,35 @@ public function testHttp2SupportBody(): \Generator $this->assertSame('2', $response->getProtocolVersion()); } + public function testHttp2SupportLargeBody(): \Generator + { + $request = new Request('https://http2.pro/api/v1', 'POST'); + $request->setBody(\str_repeat(',', 256 * 1024)); // larger than initial stream window + + /** @var Response $response */ + $response = yield $this->client->request($request); + $body = yield $response->getBody()->buffer(); + $json = \json_decode($body, true); + + $this->assertSame(1, $json['http2']); + $this->assertSame('HTTP/2.0', $json['protocol']); + $this->assertSame(1, $json['push']); + $this->assertSame('2', $response->getProtocolVersion()); + } + + public function testHttp2SupportLargeResponseBody(): \Generator + { + $request = new Request('https://1906714720.rsc.cdn77.org/img/cdn77-test-3mb.jpg', 'GET'); + $request->setTransferTimeout(100000); + $request->setBodySizeLimit(10000000000); + + /** @var Response $response */ + $response = yield $this->client->request($request); + yield $response->getBody()->buffer(); + + $this->assertSame(200, $response->getStatus()); + } + public function testConcurrentSlowNetworkInterceptor(): \Generator { $this->givenNetworkInterceptor(new ModifyRequest(static function (Request $request) { From 727126d3eafb94444e341db1b719f976f08253e0 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Mon, 2 Dec 2019 18:52:24 +0100 Subject: [PATCH 20/23] Fix response control flow --- .../Internal/Http2ConnectionProcessor.php | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/Connection/Internal/Http2ConnectionProcessor.php b/src/Connection/Internal/Http2ConnectionProcessor.php index e76b6631..8ab79629 100644 --- a/src/Connection/Internal/Http2ConnectionProcessor.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -439,19 +439,24 @@ public function handleHeaders(int $streamId, array $pseudo, array $headers): voi $stream->trailers->promise() ); - $requestCompletion = $stream->requestBodyCompletion; $stream->responsePending = false; - $stream->pendingResponse->resolve(call(static function () use ($response, $requestCompletion) { - yield $requestCompletion->promise(); + $stream->pendingResponse->resolve(call(static function () use ($response, $stream) { + yield $stream->requestBodyCompletion->promise(); + + $stream->pendingResponse = null; return $response; })); - if ($this->serverWindow <= $stream->request->getBodySizeLimit() >> 1) { - $increment = $stream->request->getBodySizeLimit() - $this->serverWindow; - $this->serverWindow = $stream->request->getBodySizeLimit(); + if ($this->serverWindow <= self::MINIMUM_WINDOW) { + $this->serverWindow += self::MAX_INCREMENT; + $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::MAX_INCREMENT)); + } + + if ($stream->serverWindow <= self::MINIMUM_WINDOW) { + $stream->serverWindow += self::MAX_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", $increment)); + $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::MAX_INCREMENT)); } if (isset($headers["content-length"])) { @@ -610,6 +615,9 @@ static function () { $this->streams[$streamId] = $stream; + $stream->requestBodyComplete = true; + $stream->requestBodyCompletion->resolve(); + if ($parentStream->request->getPushHandler() === null) { $this->handleStreamException(new Http2StreamException("Push promise refused", $streamId, self::CANCEL)); @@ -957,6 +965,8 @@ public function request(Request $request, CancellationToken $cancellationToken, return yield $http2stream->pendingResponse->promise(); } + $responsePromise = $http2stream->pendingResponse->promise(); + $http2stream->requestBodyComplete = true; $http2stream->requestBodyCompletion->resolve(); @@ -966,10 +976,12 @@ public function request(Request $request, CancellationToken $cancellationToken, yield $eventListener->completeSendingRequest($request, $stream); } - return yield $http2stream->pendingResponse->promise(); + return yield $responsePromise; } catch (\Throwable $exception) { if (isset($this->streams[$streamId])) { - $http2stream->requestBodyCompletion->fail($exception); + if (!$http2stream->requestBodyComplete) { + $http2stream->requestBodyCompletion->fail($exception); + } $this->releaseStream($streamId, $exception); } @@ -1015,16 +1027,9 @@ private function run(): \Generator $parser = (new Http2Parser($this))->parse(); while (null !== $chunk = yield $this->socket->read()) { - $promise = $parser->send($chunk); - - \assert($promise === null || $promise instanceof Promise); - - while ($promise instanceof Promise) { - yield $promise; // Wait for promise to resolve before resuming parser and reading more data. - $promise = $parser->send(null); + $return = $parser->send($chunk); - \assert($promise === null || $promise instanceof Promise); - } + \assert($return === null); } $this->shutdown(null); @@ -1194,33 +1199,29 @@ private function releaseStream(int $streamId, ?\Throwable $exception = null): vo $stream = $this->streams[$streamId]; + $exception = $exception ?? new Http2StreamException( + "Stream closed unexpectedly", + $streamId, + self::INTERNAL_ERROR + ); + if ($stream->responsePending) { $stream->responsePending = false; - $stream->pendingResponse->fail($exception ?? new Http2StreamException( - "Stream closed unexpectedly", - $streamId, - self::INTERNAL_ERROR - )); + $pendingResponse = $stream->pendingResponse; + $stream->pendingResponse = null; + $pendingResponse->fail($exception); } if ($stream->body) { $body = $stream->body; $stream->body = null; - $body->fail($exception ?? new Http2StreamException( - "Stream closed unexpectedly", - $streamId, - self::INTERNAL_ERROR - )); + $body->fail($exception); } if ($stream->trailers) { $trailers = $stream->trailers; $stream->trailers = null; - $trailers->fail($exception ?? new Http2StreamException( - "Stream closed unexpectedly", - $streamId, - self::INTERNAL_ERROR - )); + $trailers->fail($exception); } unset($this->streams[$streamId]); From 1a89ba3176412cacb1424fd00981d1207d247bb1 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Mon, 2 Dec 2019 19:45:04 +0100 Subject: [PATCH 21/23] Improve HTTP/2 flow control parameters --- examples/streaming/2-http1-http2.php | 79 +++++++++++++++++++ .../Internal/Http2ConnectionProcessor.php | 30 ++++--- 2 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 examples/streaming/2-http1-http2.php diff --git a/examples/streaming/2-http1-http2.php b/examples/streaming/2-http1-http2.php new file mode 100644 index 00000000..40a0eb72 --- /dev/null +++ b/examples/streaming/2-http1-http2.php @@ -0,0 +1,79 @@ +setProtocolVersions($protocolVersions); + $request->setBodySizeLimit(16 * 1024 * 1024); // 128 MB + $request->setTransferTimeout(120 * 1000); // 120 seconds + + /** @var Response $response */ + $response = yield $client->request($request); + + print "\n"; + + $path = \tempnam(\sys_get_temp_dir(), "artax-streaming-"); + + /** @var File $file */ + $file = yield Amp\File\open($path, "w"); + + $bytes = 0; + + while (null !== $chunk = yield $response->getBody()->read()) { + yield $file->write($chunk); + $bytes += \strlen($chunk); + + print "\r" . formatBytes($bytes) . ' '; // blanks to remove previous output + } + + yield $file->close(); + + print \sprintf( + "\rDone in %.2f seconds with peak memory usage of %.2fMB.\n", + (getCurrentTime() - $start) / 1000, + (float) \memory_get_peak_usage(true) / 1024 / 1024 + ); + + // We need to clear the stat cache, as we have just written to the file + StatCache::clear($path); + $size = yield Amp\File\size($path); + + print \sprintf("%s has a size of %.2fMB\r\n", $path, (float) $size / 1024 / 1024); + } catch (HttpException $error) { + // If something goes wrong Amp will throw the exception where the promise was yielded. + // The HttpClient::request() method itself will never throw directly, but returns a promise. + echo $error; + } +} + +Loop::run(static function () { + yield from fetch('http://1153288396.rsc.cdn77.org//img/cdn77-test-14mb.jpg', ['1.1']); + yield from fetch('https://1906714720.rsc.cdn77.org/img/cdn77-test-14mb.jpg', ['2']); +}); diff --git a/src/Connection/Internal/Http2ConnectionProcessor.php b/src/Connection/Internal/Http2ConnectionProcessor.php index 8ab79629..20a3c883 100644 --- a/src/Connection/Internal/Http2ConnectionProcessor.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -42,8 +42,8 @@ final class Http2ConnectionProcessor implements Http2Processor private const DEFAULT_MAX_FRAME_SIZE = 1 << 14; private const DEFAULT_WINDOW_SIZE = (1 << 16) - 1; - private const MINIMUM_WINDOW = (1 << 15) - 1; - private const MAX_INCREMENT = (1 << 16) - 1; + private const MINIMUM_WINDOW = 128 * 1024; + private const WINDOW_INCREMENT = 128 * 1024; // Milliseconds to wait for pong (PING with ACK) frame before closing the connection. private const PONG_TIMEOUT = 500; @@ -448,15 +448,14 @@ public function handleHeaders(int $streamId, array $pseudo, array $headers): voi return $response; })); - if ($this->serverWindow <= self::MINIMUM_WINDOW) { - $this->serverWindow += self::MAX_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::MAX_INCREMENT)); + while ($this->serverWindow <= self::MINIMUM_WINDOW) { + $this->serverWindow += self::WINDOW_INCREMENT; + $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::WINDOW_INCREMENT)); } - if ($stream->serverWindow <= self::MINIMUM_WINDOW) { - $stream->serverWindow += self::MAX_INCREMENT; - - $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::MAX_INCREMENT)); + while ($stream->serverWindow <= self::MINIMUM_WINDOW) { + $stream->serverWindow += self::WINDOW_INCREMENT; + $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::WINDOW_INCREMENT)); } if (isset($headers["content-length"])) { @@ -739,9 +738,9 @@ public function handleData(int $streamId, string $data): void return; } - if ($this->serverWindow <= self::MINIMUM_WINDOW) { - $this->serverWindow += self::MAX_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::MAX_INCREMENT)); + while ($this->serverWindow <= self::MINIMUM_WINDOW) { + $this->serverWindow += self::WINDOW_INCREMENT; + $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::WINDOW_INCREMENT)); } $promise = $stream->body->emit($data); @@ -753,10 +752,9 @@ public function handleData(int $streamId, string $data): void $stream = $this->streams[$streamId]; - if ($stream->serverWindow <= self::MINIMUM_WINDOW) { - $stream->serverWindow += self::MAX_INCREMENT; - - $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::MAX_INCREMENT)); + while ($stream->serverWindow <= self::MINIMUM_WINDOW) { + $stream->serverWindow += self::WINDOW_INCREMENT; + $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::WINDOW_INCREMENT)); } }); } From c4b16d702ed5bfa24ec1b4e000fefe0673c54d51 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Mon, 2 Dec 2019 20:24:52 +0100 Subject: [PATCH 22/23] Improve HTTP/2 flow control --- .../Internal/Http2ConnectionProcessor.php | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/src/Connection/Internal/Http2ConnectionProcessor.php b/src/Connection/Internal/Http2ConnectionProcessor.php index 20a3c883..ebfcb866 100644 --- a/src/Connection/Internal/Http2ConnectionProcessor.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -42,8 +42,8 @@ final class Http2ConnectionProcessor implements Http2Processor private const DEFAULT_MAX_FRAME_SIZE = 1 << 14; private const DEFAULT_WINDOW_SIZE = (1 << 16) - 1; - private const MINIMUM_WINDOW = 128 * 1024; - private const WINDOW_INCREMENT = 128 * 1024; + private const MINIMUM_WINDOW = 512 * 1024; + private const WINDOW_INCREMENT = 1024 * 1024; // Milliseconds to wait for pong (PING with ACK) frame before closing the connection. private const PONG_TIMEOUT = 500; @@ -448,15 +448,8 @@ public function handleHeaders(int $streamId, array $pseudo, array $headers): voi return $response; })); - while ($this->serverWindow <= self::MINIMUM_WINDOW) { - $this->serverWindow += self::WINDOW_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::WINDOW_INCREMENT)); - } - - while ($stream->serverWindow <= self::MINIMUM_WINDOW) { - $stream->serverWindow += self::WINDOW_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::WINDOW_INCREMENT)); - } + $this->increaseConnectionWindow(); + $this->increaseStreamWindow($stream); if (isset($headers["content-length"])) { if (\count($headers['content-length']) !== 1) { @@ -738,24 +731,15 @@ public function handleData(int $streamId, string $data): void return; } - while ($this->serverWindow <= self::MINIMUM_WINDOW) { - $this->serverWindow += self::WINDOW_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::WINDOW_INCREMENT)); - } + $this->increaseConnectionWindow(); $promise = $stream->body->emit($data); - $promise->onResolve(function (?\Throwable $exception) use ($streamId): void { if ($exception || !isset($this->streams[$streamId])) { return; } - $stream = $this->streams[$streamId]; - - while ($stream->serverWindow <= self::MINIMUM_WINDOW) { - $stream->serverWindow += self::WINDOW_INCREMENT; - $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $streamId, \pack("N", self::WINDOW_INCREMENT)); - } + $this->increaseStreamWindow($this->streams[$streamId]); }); } @@ -1399,4 +1383,33 @@ private function writeData(Http2Stream $stream, string $data): Promise return $this->writeBufferedData($stream); } + + private function increaseConnectionWindow(): void + { + $increase = 0; + + while ($this->serverWindow <= self::MINIMUM_WINDOW) { + $this->serverWindow += self::WINDOW_INCREMENT; + $increase += self::WINDOW_INCREMENT; + } + + if ($increase > 0) { + $this->writeFrame(self::WINDOW_UPDATE, 0, 0, \pack("N", self::WINDOW_INCREMENT)); + } + } + + private function increaseStreamWindow(Http2Stream $stream): void + { + $minWindow = \min($stream->request->getBodySizeLimit(), self::MINIMUM_WINDOW); + $increase = 0; + + while ($stream->serverWindow <= $minWindow) { + $stream->serverWindow += self::WINDOW_INCREMENT; + $increase += self::WINDOW_INCREMENT; + } + + if ($increase > 0) { + $this->writeFrame(self::WINDOW_UPDATE, self::NO_FLAG, $stream->id, \pack("N", self::WINDOW_INCREMENT)); + } + } } From 110d87fe90cef0d488b355b0e7803260f02f2f29 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Tue, 3 Dec 2019 21:22:41 +0100 Subject: [PATCH 23/23] Decouple UnlimitedConnectionPool from HTTP versions New connections are now created by an injected ConnectionFactory, which allows dropping all connection specific options from the pool. --- examples/basic/5-unix-sockets.php | 3 +- examples/concurrency/3-benchmark.php | 3 +- src/Connection/ConnectionFactory.php | 27 +++ src/Connection/ConnectionPool.php | 10 - src/Connection/DefaultConnectionFactory.php | 228 ++++++++++++++++++++ src/Connection/Http1Connection.php | 2 +- src/Connection/UnlimitedConnectionPool.php | 224 ++----------------- test/Interceptor/InterceptorTest.php | 3 +- test/TimeoutTest.php | 22 +- 9 files changed, 297 insertions(+), 225 deletions(-) create mode 100644 src/Connection/ConnectionFactory.php create mode 100644 src/Connection/DefaultConnectionFactory.php diff --git a/examples/basic/5-unix-sockets.php b/examples/basic/5-unix-sockets.php index 29984992..4c287727 100644 --- a/examples/basic/5-unix-sockets.php +++ b/examples/basic/5-unix-sockets.php @@ -1,5 +1,6 @@ usingPool(new UnlimitedConnectionPool($connector)) + ->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector))) ->build(); // amphp/http-client requires a host, so just use a dummy one. diff --git a/examples/concurrency/3-benchmark.php b/examples/concurrency/3-benchmark.php index 8b89c4fa..3f9e7d1a 100644 --- a/examples/concurrency/3-benchmark.php +++ b/examples/concurrency/3-benchmark.php @@ -5,6 +5,7 @@ // Infinite (10 x 100 requests): php examples/concurrency/3-benchmark.php 0 // Custom (10 x $count requests): php examples/concurrency/3-benchmark.php $count +use Amp\Http\Client\Connection\DefaultConnectionFactory; use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; @@ -28,7 +29,7 @@ ->withTlsContext($tlsContext); $client = (new HttpClientBuilder) - ->usingPool(new UnlimitedConnectionPool(null, $connectContext)) + ->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory(null, $connectContext))) ->build(); $handler = coroutine(static function (int $count) use ($client, $argv) { diff --git a/src/Connection/ConnectionFactory.php b/src/Connection/ConnectionFactory.php new file mode 100644 index 00000000..9148c3d2 --- /dev/null +++ b/src/Connection/ConnectionFactory.php @@ -0,0 +1,27 @@ +connector = $connector; + $this->connectContext = $connectContext; + } + + public function create( + Request $request, + CancellationToken $cancellationToken + ): Promise { + return call(function () use ($request, $cancellationToken) { + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->startConnectionCreation($request); + } + + $connector = $this->connector ?? connector(); + $connectContext = $this->connectContext ?? new ConnectContext; + + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + + if (!\in_array($scheme, ['http', 'https'], true)) { + throw new InvalidRequestException($request, 'Invalid scheme provided in the request URI: ' . $uri); + } + + $isHttps = $scheme === 'https'; + $defaultPort = $isHttps ? 443 : 80; + + $host = $uri->getHost(); + $port = $uri->getPort() ?? $defaultPort; + + if ($host === '') { + throw new InvalidRequestException($request, 'A host must be provided in the request URI: ' . $uri); + } + + $authority = $host . ':' . $port; + $protocolVersions = $request->getProtocolVersions(); + + if (!\array_intersect($protocolVersions, ['1.0', '1.1', '2'])) { + throw new InvalidRequestException( + $request, + \sprintf( + "None of the requested protocol versions (%s) are supported by %s", + \implode(', ', $protocolVersions), + self::class + ) + ); + } + + if (!$isHttps && !\array_intersect($protocolVersions, ['1.0', '1.1'])) { + throw new InvalidRequestException( + $request, + \sprintf( + "None of the requested protocol versions (%s) are supported by %s (HTTP/2 is only supported on HTTPS)", + \implode(', ', $protocolVersions), + self::class + ) + ); + } + + if ($isHttps) { + $protocols = []; + + if (\in_array('2', $protocolVersions, true)) { + $protocols[] = 'h2'; + } + + if (\in_array('1.1', $protocolVersions, true) || \in_array('1.0', $protocolVersions, true)) { + $protocols[] = 'http/1.1'; + } + + $tlsContext = ($connectContext->getTlsContext() ?? new ClientTlsContext('')) + ->withApplicationLayerProtocols($protocols) + ->withPeerCapturing(); + + if ($tlsContext->getPeerName() === '') { + $tlsContext = $tlsContext->withPeerName($host); + } + + $connectContext = $connectContext->withTlsContext($tlsContext); + } + + try { + /** @var EncryptableSocket $socket */ + $socket = yield $connector->connect( + 'tcp://' . $authority, + $connectContext->withConnectTimeout($request->getTcpConnectTimeout()), + $cancellationToken + ); + } catch (Socket\ConnectException $e) { + throw new UnprocessedRequestException( + new SocketException(\sprintf("Connection to '%s' failed", $authority), 0, $e) + ); + } catch (CancelledException $e) { + // In case of a user cancellation request, throw the expected exception + $cancellationToken->throwIfRequested(); + + // Otherwise we ran into a timeout of our TimeoutCancellationToken + throw new UnprocessedRequestException(new TimeoutException(\sprintf( + "Connection to '%s' timed out, took longer than " . $request->getTcpConnectTimeout() . ' ms', + $authority + ))); // don't pass $e + } + + if ($isHttps) { + try { + $tlsState = $socket->getTlsState(); + + // Error if anything enabled TLS on a new connection before we can do it + if ($tlsState !== EncryptableSocket::TLS_STATE_DISABLED) { + $socket->close(); + + throw new UnprocessedRequestException( + new SocketException('Failed to setup TLS connection, connection was in an unexpected TLS state (' . $tlsState . ')') + ); + } + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->startTlsNegotiation($request); + } + + $tlsCancellationToken = new CombinedCancellationToken( + $cancellationToken, + new TimeoutCancellationToken($request->getTlsHandshakeTimeout()) + ); + + yield $socket->setupTls($tlsCancellationToken); + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeTlsNegotiation($request); + } + } catch (StreamException $exception) { + $socket->close(); + + throw new UnprocessedRequestException(new SocketException(\sprintf( + "Connection to '%s' @ '%s' closed during TLS handshake", + $authority, + $socket->getRemoteAddress()->toString() + ), 0, $exception)); + } catch (CancelledException $e) { + $socket->close(); + + // In case of a user cancellation request, throw the expected exception + $cancellationToken->throwIfRequested(); + + // Otherwise we ran into a timeout of our TimeoutCancellationToken + throw new UnprocessedRequestException(new TimeoutException(\sprintf( + "TLS handshake with '%s' @ '%s' timed out, took longer than " . $request->getTlsHandshakeTimeout() . ' ms', + $authority, + $socket->getRemoteAddress()->toString() + ))); // don't pass $e + } + + $tlsInfo = $socket->getTlsInfo(); + if ($tlsInfo === null) { + throw new UnprocessedRequestException( + new SocketException(\sprintf( + "Socket closed after TLS handshake with '%s' @ '%s'", + $authority, + $socket->getRemoteAddress()->toString() + )) + ); + } + + if ($tlsInfo->getApplicationLayerProtocol() === 'h2') { + $connection = new Http2Connection($socket); + yield $connection->initialize(); + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeConnectionCreation($request); + } + + return $connection; + } + } + + if (!\array_intersect($request->getProtocolVersions(), ['1.0', '1.1'])) { + $socket->close(); + + throw new InvalidRequestException( + $request, + \sprintf( + "None of the requested protocol versions (%s) are supported by '%s' @ '%s'", + \implode(', ', $protocolVersions), + $authority, + $socket->getRemoteAddress()->toString() + ) + ); + } + + foreach ($request->getEventListeners() as $eventListener) { + yield $eventListener->completeConnectionCreation($request); + } + + return new Http1Connection($socket); + }); + } +} diff --git a/src/Connection/Http1Connection.php b/src/Connection/Http1Connection.php index a98d5b52..0e73db08 100644 --- a/src/Connection/Http1Connection.php +++ b/src/Connection/Http1Connection.php @@ -85,7 +85,7 @@ final class Http1Connection implements Connection /** @var TlsInfo|null */ private $tlsInfo; - public function __construct(EncryptableSocket $socket, int $timeoutGracePeriod) + public function __construct(EncryptableSocket $socket, int $timeoutGracePeriod = 2000) { $this->socket = $socket; $this->localAddress = $socket->getLocalAddress(); diff --git a/src/Connection/UnlimitedConnectionPool.php b/src/Connection/UnlimitedConnectionPool.php index 910346ed..5b840d99 100644 --- a/src/Connection/UnlimitedConnectionPool.php +++ b/src/Connection/UnlimitedConnectionPool.php @@ -2,44 +2,24 @@ namespace Amp\Http\Client\Connection; -use Amp\ByteStream\StreamException; use Amp\CancellationToken; -use Amp\CancelledException; -use Amp\CombinedCancellationToken; -use Amp\Coroutine; use Amp\Http\Client\Internal\ForbidSerialization; use Amp\Http\Client\InvalidRequestException; use Amp\Http\Client\Request; -use Amp\Http\Client\SocketException; -use Amp\Http\Client\TimeoutException; use Amp\Promise; -use Amp\Socket; -use Amp\Socket\ClientTlsContext; -use Amp\Socket\ConnectContext; -use Amp\Socket\Connector; -use Amp\Socket\EncryptableSocket; use Amp\Success; -use Amp\TimeoutCancellationToken; use function Amp\call; final class UnlimitedConnectionPool implements ConnectionPool { use ForbidSerialization; - private const PROTOCOL_VERSIONS = ['1.0', '1.1', '2']; - - /** @var Connector */ - private $connector; - - /** @var ConnectContext */ - private $connectContext; + /** @var ConnectionFactory */ + private $connectionFactory; /** @var Promise[][] */ private $connections = []; - /** @var int */ - private $timeoutGracePeriod = 2000; - /** @var int */ private $totalConnectionAttempts = 0; @@ -49,10 +29,9 @@ final class UnlimitedConnectionPool implements ConnectionPool /** @var int */ private $openConnectionCount = 0; - public function __construct(?Connector $connector = null, ?ConnectContext $connectContext = null) + public function __construct(?ConnectionFactory $connectionFactory = null) { - $this->connector = $connector ?? Socket\connector(); - $this->connectContext = $connectContext ?? new ConnectContext; + $this->connectionFactory = $connectionFactory ?? new DefaultConnectionFactory; } public function __clone() @@ -84,11 +63,12 @@ public function getStream(Request $request, CancellationToken $cancellation): Pr $this->totalStreamRequests++; $uri = $request->getUri(); - $scheme = \strtolower($uri->getScheme()); + $scheme = $uri->getScheme(); + $isHttps = $scheme === 'https'; $defaultPort = $isHttps ? 443 : 80; - $host = \strtolower($uri->getHost()); + $host = $uri->getHost(); $port = $uri->getPort() ?? $defaultPort; if ($host === '') { @@ -98,32 +78,17 @@ public function getStream(Request $request, CancellationToken $cancellation): Pr $authority = $host . ':' . $port; $key = $scheme . '://' . $authority; - if (!\array_intersect($request->getProtocolVersions(), self::PROTOCOL_VERSIONS)) { - throw new InvalidRequestException( - $request, - 'None of the requested protocol versions are supported; Supported versions: ' - . \implode(', ', self::PROTOCOL_VERSIONS) - ); - } - - if (!$isHttps && !\array_intersect($request->getProtocolVersions(), ['1.0', '1.1'])) { - throw new InvalidRequestException( - $request, - 'HTTP/1.x forbidden, but a secure connection (HTTPS) is required for HTTP/2' - ); - } - $connections = $this->connections[$key] ?? []; - foreach ($connections as $promise) { - \assert($promise instanceof Promise); + foreach ($connections as $connectionPromise) { + \assert($connectionPromise instanceof Promise); try { if ($isHttps && \count($connections) === 1) { // Wait for first successful connection if using a secure connection (maybe we can use HTTP/2). - $connection = yield $promise; + $connection = yield $connectionPromise; } else { - $connection = yield Promise\first([$promise, new Success]); + $connection = yield Promise\first([$connectionPromise, new Success]); if ($connection === null) { continue; } @@ -147,23 +112,22 @@ public function getStream(Request $request, CancellationToken $cancellation): Pr return $stream; } - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->startConnectionCreation($request); - } + $this->totalConnectionAttempts++; - $promise = new Coroutine($this->createConnection($request, $cancellation, $authority, $isHttps)); + $connectionPromise = $this->connectionFactory->create($request, $cancellation); - $hash = \spl_object_hash($promise); + $hash = \spl_object_hash($connectionPromise); $this->connections[$key] = $this->connections[$key] ?? []; - $this->connections[$key][$hash] = $promise; + $this->connections[$key][$hash] = $connectionPromise; try { - $connection = yield $promise; + $connection = yield $connectionPromise; $this->openConnectionCount++; + \assert($connection instanceof Connection); } catch (\Throwable $exception) { - // Connection failed, remove from list of connections. $this->dropConnection($key, $hash); + throw $exception; } @@ -173,163 +137,13 @@ public function getStream(Request $request, CancellationToken $cancellation): Pr }); $stream = yield $connection->getStream($request); + \assert($stream instanceof Stream); // New connection must always resolve with a Stream instance. return $stream; }); } - /** - * @param int $timeout Number of milliseconds before the estimated connection timeout that a non-idempotent - * request should will not be sent on an existing HTTP/1.x connection, instead opening a - * new connection for the request. Default is 2000 ms. - * - * @return self - */ - public function withTimeoutGracePeriod(int $timeout): self - { - $pool = clone $this; - $pool->timeoutGracePeriod = $timeout; - return $pool; - } - - private function createConnection( - Request $request, - CancellationToken $cancellation, - string $authority, - bool $isHttps - ): \Generator { - $this->totalConnectionAttempts++; - - $connectContext = $this->connectContext; - - if ($isHttps) { - if (\in_array('2', $request->getProtocolVersions(), true)) { - $protocols = ['h2', 'http/1.1']; - } else { - $protocols = ['http/1.1']; - } - - $tlsContext = ($connectContext->getTlsContext() ?? new ClientTlsContext($request->getUri()->getHost())) - ->withApplicationLayerProtocols($protocols) - ->withPeerCapturing(); - - if ($tlsContext->getPeerName() === '') { - $tlsContext = $tlsContext->withPeerName($request->getUri()->getHost()); - } - - $connectContext = $connectContext->withTlsContext($tlsContext); - } - - try { - /** @var EncryptableSocket $socket */ - $socket = yield $this->connector->connect( - 'tcp://' . $authority, - $connectContext->withConnectTimeout($request->getTcpConnectTimeout()), - $cancellation - ); - } catch (Socket\ConnectException $e) { - throw new UnprocessedRequestException( - new SocketException(\sprintf("Connection to '%s' failed", $authority), 0, $e) - ); - } catch (CancelledException $e) { - // In case of a user cancellation request, throw the expected exception - $cancellation->throwIfRequested(); - - // Otherwise we ran into a timeout of our TimeoutCancellationToken - throw new TimeoutException(\sprintf( - "Connection to '%s' timed out, took longer than " . $request->getTcpConnectTimeout() . ' ms', - $authority - )); // don't pass $e - } - - if (!$isHttps) { - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeConnectionCreation($request); - } - - return new Http1Connection($socket, $this->timeoutGracePeriod); - } - - try { - $tlsState = $socket->getTlsState(); - - // Error if anything enabled TLS on a new connection before we can do it - if ($tlsState !== EncryptableSocket::TLS_STATE_DISABLED) { - $socket->close(); - - throw new UnprocessedRequestException( - new SocketException('Failed to setup TLS connection, connection was in an unexpected TLS state (' . $tlsState . ')') - ); - } - - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->startTlsNegotiation($request); - } - - $tlsCancellationToken = new CombinedCancellationToken( - $cancellation, - new TimeoutCancellationToken($request->getTlsHandshakeTimeout()) - ); - - yield $socket->setupTls($tlsCancellationToken); - - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeTlsNegotiation($request); - } - } catch (StreamException $exception) { - $socket->close(); - throw new UnprocessedRequestException(new SocketException(\sprintf( - "Connection to '%s' closed during TLS handshake", - $authority - ), 0, $exception)); - } catch (CancelledException $e) { - $socket->close(); - - // In case of a user cancellation request, throw the expected exception - $cancellation->throwIfRequested(); - - // Otherwise we ran into a timeout of our TimeoutCancellationToken - throw new TimeoutException(\sprintf( - "TLS handshake with '%s' @ '%s' timed out, took longer than " . $request->getTlsHandshakeTimeout() . ' ms', - $authority, - $socket->getRemoteAddress()->toString() - )); // don't pass $e - } - - $tlsInfo = $socket->getTlsInfo(); - if ($tlsInfo === null) { - throw new UnprocessedRequestException( - new SocketException('Socket disconnected immediately after enabling TLS') - ); - } - - if ($tlsInfo->getApplicationLayerProtocol() === 'h2') { - $connection = new Http2Connection($socket); - yield $connection->initialize(); - - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeConnectionCreation($request); - } - - return $connection; - } - - if (!\array_intersect($request->getProtocolVersions(), ['1.0', '1.1'])) { - $socket->close(); - throw new InvalidRequestException( - $request, - 'Downgrade to HTTP/1.x forbidden, but server does not support HTTP/2' - ); - } - - foreach ($request->getEventListeners() as $eventListener) { - yield $eventListener->completeConnectionCreation($request); - } - - return new Http1Connection($socket, $this->timeoutGracePeriod); - } - private function dropConnection(string $uri, string $connectionHash): void { unset($this->connections[$uri][$connectionHash]); diff --git a/test/Interceptor/InterceptorTest.php b/test/Interceptor/InterceptorTest.php index c9af1b19..d1ac5d7b 100644 --- a/test/Interceptor/InterceptorTest.php +++ b/test/Interceptor/InterceptorTest.php @@ -3,6 +3,7 @@ namespace Amp\Http\Client\Interceptor; use Amp\Http\Client\ApplicationInterceptor; +use Amp\Http\Client\Connection\DefaultConnectionFactory; use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\NetworkInterceptor; @@ -96,7 +97,7 @@ protected function setUp(): void ); $staticConnector = new StaticConnector($this->serverSocket->getAddress()->toString(), connector()); - $this->builder = (new HttpClientBuilder)->usingPool(new UnlimitedConnectionPool($staticConnector)); + $this->builder = (new HttpClientBuilder)->usingPool(new UnlimitedConnectionPool(new DefaultConnectionFactory($staticConnector))); $this->client = $this->builder->build(); } diff --git a/test/TimeoutTest.php b/test/TimeoutTest.php index e65ebbe8..fc694b95 100644 --- a/test/TimeoutTest.php +++ b/test/TimeoutTest.php @@ -4,7 +4,9 @@ use Amp\CancellationToken; use Amp\Failure; +use Amp\Http\Client\Connection\DefaultConnectionFactory; use Amp\Http\Client\Connection\UnlimitedConnectionPool; +use Amp\Http\Client\Connection\UnprocessedRequestException; use Amp\Http\Client\Interceptor\SetRequestTimeout; use Amp\Loop; use Amp\NullCancellationToken; @@ -75,7 +77,7 @@ public function testTimeoutDuringConnect(): \Generator return new Failure(new TimeoutException); }); - $this->client = new PooledHttpClient(new UnlimitedConnectionPool($connector)); + $this->client = new PooledHttpClient(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector))); $this->expectException(TimeoutException::class); @@ -112,7 +114,11 @@ public function testTimeoutDuringTlsEnable(): \Generator $request = new Request($uri); $request->setTlsHandshakeTimeout(100); - yield $this->client->request($request); + try { + yield $this->client->request($request); + } catch (UnprocessedRequestException $e) { + throw $e->getPrevious(); + } } finally { $server->close(); } @@ -145,8 +151,8 @@ public function testTimeoutDuringTlsEnableCatchable(): \Generator yield $this->client->request($request); $this->fail('No exception thrown'); - } catch (TimeoutException $e) { - $this->assertStringStartsWith('TLS handshake with \'127.0.0.1:', $e->getMessage()); + } catch (UnprocessedRequestException $e) { + $this->assertStringStartsWith('TLS handshake with \'127.0.0.1:', $e->getPrevious()->getMessage()); } finally { $server->close(); } @@ -202,7 +208,7 @@ public function testTimeoutDuringConnectInterceptor(): \Generator return new Failure(new TimeoutException); }); - $client = new PooledHttpClient(new UnlimitedConnectionPool($connector)); + $client = new PooledHttpClient(new UnlimitedConnectionPool(new DefaultConnectionFactory($connector))); $client = new InterceptedHttpClient($client, new SetRequestTimeout(1)); $this->expectException(TimeoutException::class); @@ -242,7 +248,11 @@ public function testTimeoutDuringTlsEnableInterceptor(): \Generator $client = new PooledHttpClient(); $client = new InterceptedHttpClient($client, new SetRequestTimeout(10000, 100)); - yield $client->request($request, new NullCancellationToken); + try { + yield $client->request($request, new NullCancellationToken); + } catch (UnprocessedRequestException $e) { + throw $e->getPrevious(); + } } finally { $server->close(); }