Files
wp-multisite-waas/dependencies/amphp/http-client/src/Connection/Http1Connection.php
2024-11-30 18:24:12 -07:00

547 lines
25 KiB
PHP

<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\ByteStream\IteratorStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\StreamException;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\CancellationTokenSource;
use WP_Ultimo\Dependencies\Amp\CancelledException;
use WP_Ultimo\Dependencies\Amp\CombinedCancellationToken;
use WP_Ultimo\Dependencies\Amp\Deferred;
use WP_Ultimo\Dependencies\Amp\Emitter;
use WP_Ultimo\Dependencies\Amp\Http;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal\Http1Parser;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal\RequestNormalizer;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ResponseBodyStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\InvalidRequestException;
use WP_Ultimo\Dependencies\Amp\Http\Client\ParseException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Http\Client\SocketException;
use WP_Ultimo\Dependencies\Amp\Http\Client\TimeoutException;
use WP_Ultimo\Dependencies\Amp\Http\InvalidHeaderException;
use WP_Ultimo\Dependencies\Amp\Http\Rfc7230;
use WP_Ultimo\Dependencies\Amp\Loop;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\EncryptableSocket;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
use WP_Ultimo\Dependencies\Amp\Success;
use WP_Ultimo\Dependencies\Amp\TimeoutCancellationToken;
use WP_Ultimo\Dependencies\Amp\TimeoutException as PromiseTimeoutException;
use function WP_Ultimo\Dependencies\Amp\asyncCall;
use function WP_Ultimo\Dependencies\Amp\call;
use function WP_Ultimo\Dependencies\Amp\getCurrentTime;
use function WP_Ultimo\Dependencies\Amp\Http\Client\Internal\normalizeRequestPathWithQuery;
/**
* Socket client implementation.
*
* @see Client
*/
final class Http1Connection implements Connection
{
use ForbidSerialization;
use ForbidCloning;
private const MAX_KEEP_ALIVE_TIMEOUT = 60;
private const PROTOCOL_VERSIONS = ['1.0', '1.1'];
/** @var EncryptableSocket|null */
private $socket;
/** @var bool */
private $busy = \false;
/** @var int Number of requests made on this connection. */
private $requestCounter = 0;
/** @var string|null Keep alive timeout watcher ID. */
private $timeoutWatcher;
/** @var int Keep-Alive timeout from last response. */
private $priorTimeout = self::MAX_KEEP_ALIVE_TIMEOUT;
/** @var callable[]|null */
private $onClose = [];
/** @var int */
private $timeoutGracePeriod;
/** @var int */
private $lastUsedAt;
/** @var bool */
private $explicitTimeout = \false;
/** @var SocketAddress */
private $localAddress;
/** @var SocketAddress */
private $remoteAddress;
/** @var TlsInfo|null */
private $tlsInfo;
/** @var Promise|null */
private $idleRead;
public function __construct(EncryptableSocket $socket, int $timeoutGracePeriod = 2000)
{
$this->socket = $socket;
$this->localAddress = $socket->getLocalAddress();
$this->remoteAddress = $socket->getRemoteAddress();
$this->tlsInfo = $socket->getTlsInfo();
$this->timeoutGracePeriod = $timeoutGracePeriod;
$this->lastUsedAt = getCurrentTime();
$this->watchIdleConnection();
}
public function __destruct()
{
$this->close();
}
public function onClose(callable $onClose) : void
{
if (!$this->socket || $this->socket->isClosed()) {
Promise\rethrow(call($onClose, $this));
return;
}
$this->onClose[] = $onClose;
}
public function close() : Promise
{
if ($this->socket) {
$this->socket->close();
}
return $this->free();
}
public function getLocalAddress() : SocketAddress
{
return $this->localAddress;
}
public function getRemoteAddress() : SocketAddress
{
return $this->remoteAddress;
}
public function getTlsInfo() : ?TlsInfo
{
return $this->tlsInfo;
}
public function getProtocolVersions() : array
{
return self::PROTOCOL_VERSIONS;
}
public function getStream(Request $request) : Promise
{
if ($this->busy || $this->requestCounter && !$this->hasStreamFor($request)) {
return new Success();
}
$this->busy = \true;
return new Success(HttpStream::fromConnection($this, \Closure::fromCallable([$this, 'request']), \Closure::fromCallable([$this, 'release'])));
}
private function free() : Promise
{
$this->socket = null;
$this->idleRead = null;
$this->lastUsedAt = 0;
if ($this->timeoutWatcher !== null) {
Loop::cancel($this->timeoutWatcher);
}
if ($this->onClose !== null) {
$onClose = $this->onClose;
$this->onClose = null;
foreach ($onClose as $callback) {
asyncCall($callback, $this);
}
}
return new Success();
}
private function hasStreamFor(Request $request) : bool
{
return !$this->busy && $this->socket && !$this->socket->isClosed() && ($this->getRemainingTime() > 0 || $request->isIdempotent());
}
/** @inheritdoc */
private function request(Request $request, CancellationToken $cancellation, Stream $stream) : Promise
{
return call(function () use($request, $cancellation, $stream) {
++$this->requestCounter;
if ($this->socket !== null && !$this->socket->isClosed()) {
$this->socket->reference();
}
if ($this->timeoutWatcher !== null) {
Loop::cancel($this->timeoutWatcher);
$this->timeoutWatcher = null;
}
(yield RequestNormalizer::normalizeRequest($request));
$protocolVersion = $this->determineProtocolVersion($request);
$request->setProtocolVersions([$protocolVersion]);
if ($request->getTransferTimeout() > 0) {
$timeoutToken = new TimeoutCancellationToken($request->getTransferTimeout());
$combinedCancellation = new CombinedCancellationToken($cancellation, $timeoutToken);
} else {
$combinedCancellation = $cancellation;
}
$id = $combinedCancellation->subscribe([$this, 'close']);
try {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startSendingRequest($request, $stream));
}
yield from $this->writeRequest($request, $protocolVersion, $combinedCancellation);
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeSendingRequest($request, $stream));
}
return yield from $this->readResponse($request, $cancellation, $combinedCancellation, $stream);
} catch (\Throwable $e) {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->abort($request, $e));
}
if ($this->socket !== null) {
$this->socket->close();
}
throw $e;
} finally {
$combinedCancellation->unsubscribe($id);
$cancellation->throwIfRequested();
}
});
}
private function release() : void
{
$this->busy = \false;
}
/**
* @param Request $request
* @param CancellationToken $originalCancellation
* @param CancellationToken $readingCancellation
*
* @param Stream $stream
*
* @return \Generator
* @throws CancelledException
* @throws HttpException
* @throws ParseException
* @throws SocketException
*/
private function readResponse(Request $request, CancellationToken $originalCancellation, CancellationToken $readingCancellation, Stream $stream) : \Generator
{
$bodyEmitter = new Emitter();
$backpressure = new Success();
$bodyCallback = static function ($data) use($bodyEmitter, &$backpressure) : void {
$backpressure = $bodyEmitter->emit($data);
};
$trailersDeferred = new Deferred();
$trailers = [];
$trailersCallback = static function (array $headers) use(&$trailers) : void {
$trailers = $headers;
};
$parser = new Http1Parser($request, $bodyCallback, $trailersCallback);
$start = getCurrentTime();
$timeout = $request->getInactivityTimeout();
try {
if ($this->socket === null) {
throw new SocketException('Socket closed prior to response completion');
}
while (null !== ($chunk = (yield $timeout > 0 ? Promise\timeout($this->idleRead ?: $this->socket->read(), $timeout) : ($this->idleRead ?: $this->socket->read())))) {
$this->idleRead = null;
parseChunk:
$response = $parser->parse($chunk);
if ($response === null) {
if ($this->socket === null) {
throw new SocketException('Socket closed prior to response completion');
}
continue;
}
$this->lastUsedAt = getCurrentTime();
$status = $response->getStatus();
if ($status === Http\Status::SWITCHING_PROTOCOLS) {
$connection = Http\createFieldValueComponentMap(Http\parseFieldValueComponents($response, 'connection'));
if (!isset($connection['upgrade'])) {
throw new HttpException('Switching protocols response missing "Connection: upgrade" header');
}
if (!$response->hasHeader('upgrade')) {
throw new HttpException('Switching protocols response missing "Upgrade" header');
}
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeReceivingResponse($request, $stream));
}
$trailersDeferred->resolve($trailers);
return $this->handleUpgradeResponse($request, $response, $parser->getBuffer());
}
if ($status < 200) {
// 1XX responses (excluding 101, handled above)
$onInformationalResponse = $request->getInformationalResponseHandler();
if ($onInformationalResponse !== null) {
(yield call($onInformationalResponse, $response));
}
$chunk = $parser->getBuffer();
$parser = new Http1Parser($request, $bodyCallback, $trailersCallback);
goto parseChunk;
}
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startReceivingResponse($request, $stream));
}
if ($status >= 200 && $status < 300 && $request->getMethod() === 'CONNECT') {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeReceivingResponse($request, $stream));
}
$trailersDeferred->resolve($trailers);
return $this->handleUpgradeResponse($request, $response, $parser->getBuffer());
}
$bodyCancellationSource = new CancellationTokenSource();
$bodyCancellationToken = new CombinedCancellationToken($readingCancellation, $bodyCancellationSource->getToken());
$response->setTrailers($trailersDeferred->promise());
$response->setBody(new ResponseBodyStream(new IteratorStream($bodyEmitter->iterate()), $bodyCancellationSource));
// Read body async
asyncCall(function () use($parser, $request, $response, $bodyEmitter, $trailersDeferred, $originalCancellation, $readingCancellation, $bodyCancellationToken, $stream, $timeout, &$backpressure, &$trailers) {
$id = $bodyCancellationToken->subscribe([$this, 'close']);
try {
// Required, otherwise responses without body hang
if (!$parser->isComplete()) {
// Directly parse again in case we already have the full body but aborted parsing
// to resolve promise with headers.
$chunk = null;
try {
/** @psalm-suppress PossiblyNullReference */
do {
/** @noinspection CallableParameterUseCaseInTypeContextInspection */
$parser->parse($chunk);
/**
* @noinspection NotOptimalIfConditionsInspection
* @psalm-suppress TypeDoesNotContainType
*/
if ($parser->isComplete()) {
break;
}
if (!$backpressure instanceof Success) {
(yield $this->withCancellation($backpressure, $bodyCancellationToken));
}
/** @psalm-suppress TypeDoesNotContainNull */
if ($this->socket === null) {
throw new SocketException('Socket closed prior to response completion');
}
} while (null !== ($chunk = (yield $timeout > 0 ? Promise\timeout($this->socket->read(), $timeout) : $this->socket->read())));
} catch (PromiseTimeoutException $e) {
$this->close();
throw new TimeoutException('Inactivity timeout exceeded, more than ' . $timeout . ' ms elapsed from last data received', 0, $e);
}
$originalCancellation->throwIfRequested();
if ($readingCancellation->isRequested()) {
throw new TimeoutException('Allowed transfer timeout exceeded, took longer than ' . $request->getTransferTimeout() . ' ms');
}
$bodyCancellationToken->throwIfRequested();
// Ignore check if neither content-length nor chunked encoding are given.
/** @psalm-suppress RedundantCondition */
if (!$parser->isComplete() && $parser->getState() !== Http1Parser::BODY_IDENTITY_EOF) {
throw new SocketException('Socket disconnected prior to response completion');
}
}
$timeout = $this->determineKeepAliveTimeout($response);
if ($timeout > 0 && $parser->getState() !== Http1Parser::BODY_IDENTITY_EOF) {
$this->timeoutWatcher = Loop::delay($timeout * 1000, [$this, 'close']);
Loop::unreference($this->timeoutWatcher);
$this->watchIdleConnection();
} else {
$this->close();
}
$this->busy = \false;
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeReceivingResponse($request, $stream));
}
$bodyEmitter->complete();
$trailersDeferred->resolve($trailers);
} catch (\Throwable $e) {
$this->close();
try {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->abort($request, $e));
}
} finally {
$bodyEmitter->fail($e);
$trailersDeferred->fail($e);
}
} finally {
$bodyCancellationToken->unsubscribe($id);
}
});
return $response;
}
$originalCancellation->throwIfRequested();
throw new SocketException(\sprintf("Receiving the response headers for '%s' failed, because the socket to '%s' @ '%s' closed early with %d bytes received within %d milliseconds", (string) $request->getUri()->withUserInfo(''), $request->getUri()->withUserInfo('')->getAuthority(), $this->socket === null ? '???' : (string) $this->socket->getRemoteAddress(), \strlen($parser->getBuffer()), getCurrentTime() - $start));
} catch (HttpException $e) {
$this->close();
throw $e;
} catch (PromiseTimeoutException $e) {
$this->close();
throw new TimeoutException('Inactivity timeout exceeded, more than ' . $timeout . ' ms elapsed from last data received', 0, $e);
} catch (\Throwable $e) {
$this->close();
throw new SocketException('Receiving the response headers failed: ' . $e->getMessage(), 0, $e);
}
}
private function handleUpgradeResponse(Request $request, Response $response, string $buffer) : Response
{
if ($this->socket === null) {
throw new SocketException('Socket closed while upgrading');
}
$socket = new UpgradedSocket($this->socket, $buffer);
$this->free();
// Mark this connection as unusable without closing socket.
if (($onUpgrade = $request->getUpgradeHandler()) === null) {
$socket->close();
throw new HttpException('CONNECT or upgrade request made without upgrade handler callback');
}
asyncCall(static function () use($onUpgrade, $socket, $request, $response) : \Generator {
try {
(yield call($onUpgrade, $socket, $request, $response));
} catch (\Throwable $exception) {
$socket->close();
throw new HttpException('Upgrade handler threw an exception', 0, $exception);
}
});
return $response;
}
/**
* @return int Approximate number of milliseconds remaining until the connection is closed.
*/
private function getRemainingTime() : int
{
$timestamp = $this->lastUsedAt + ($this->explicitTimeout ? $this->priorTimeout * 1000 : $this->timeoutGracePeriod);
return \max(0, $timestamp - getCurrentTime());
}
private function withCancellation(Promise $promise, CancellationToken $cancellationToken) : Promise
{
$deferred = new Deferred();
$newPromise = $deferred->promise();
$promise->onResolve(static function ($error, $value) use(&$deferred) : void {
if ($deferred) {
$temp = $deferred;
$deferred = null;
if ($error) {
$temp->fail($error);
} else {
$temp->resolve($value);
}
}
});
$cancellationSubscription = $cancellationToken->subscribe(static function ($e) use(&$deferred) : void {
if ($deferred) {
$temp = $deferred;
$deferred = null;
$temp->fail($e);
}
});
$newPromise->onResolve(static function () use($cancellationToken, $cancellationSubscription) : void {
$cancellationToken->unsubscribe($cancellationSubscription);
});
return $newPromise;
}
private function determineKeepAliveTimeout(Response $response) : int
{
$request = $response->getRequest();
$requestConnHeader = $request->getHeader('connection') ?? '';
$responseConnHeader = $response->getHeader('connection') ?? '';
if (!\strcasecmp($requestConnHeader, 'close')) {
return 0;
}
if ($response->getProtocolVersion() === '1.0') {
return 0;
}
if (!\strcasecmp($responseConnHeader, 'close')) {
return 0;
}
$params = Http\createFieldValueComponentMap(Http\parseFieldValueComponents($response, 'keep-alive'));
$timeout = (int) ($params['timeout'] ?? $this->priorTimeout);
if (isset($params['timeout'])) {
$this->explicitTimeout = \true;
}
return $this->priorTimeout = \min(\max(0, $timeout), self::MAX_KEEP_ALIVE_TIMEOUT);
}
private function determineProtocolVersion(Request $request) : string
{
$protocolVersions = $request->getProtocolVersions();
if (\in_array("1.1", $protocolVersions, \true)) {
return "1.1";
}
if (\in_array("1.0", $protocolVersions, \true)) {
return "1.0";
}
throw new InvalidRequestException($request, "None of the requested protocol versions is supported: " . \implode(", ", $protocolVersions));
}
private function writeRequest(Request $request, string $protocolVersion, CancellationToken $cancellation) : \Generator
{
try {
$rawHeaders = $this->generateRawHeader($request, $protocolVersion);
if ($this->socket === null) {
throw new UnprocessedRequestException(new SocketException('Socket closed before request started'));
}
(yield $this->socket->write($rawHeaders));
if ($request->getMethod() === 'CONNECT') {
return;
}
$body = $request->getBody()->createBodyStream();
$chunking = $request->getHeader("transfer-encoding") === "chunked";
$remainingBytes = $request->getHeader("content-length");
if ($remainingBytes !== null) {
$remainingBytes = (int) $remainingBytes;
}
if ($chunking && $protocolVersion === "1.0") {
throw new InvalidRequestException($request, "Can't send chunked bodies over HTTP/1.0");
}
// We always buffer the last chunk to make sure we don't write $contentLength bytes if the body is too long.
$buffer = "";
while (null !== ($chunk = (yield $body->read()))) {
$cancellation->throwIfRequested();
if ($chunk === "") {
continue;
}
if ($chunking) {
$chunk = \dechex(\strlen($chunk)) . "\r\n" . $chunk . "\r\n";
} elseif ($remainingBytes !== null) {
$remainingBytes -= \strlen($chunk);
if ($remainingBytes < 0) {
throw new InvalidRequestException($request, "Body contained more bytes than specified in Content-Length, aborting request");
}
}
(yield $this->socket->write($buffer));
$buffer = $chunk;
}
$cancellation->throwIfRequested();
// Flush last buffered chunk.
(yield $this->socket->write($buffer));
if ($chunking) {
(yield $this->socket->write("0\r\n\r\n"));
} elseif ($remainingBytes !== null && $remainingBytes > 0) {
throw new InvalidRequestException($request, "Body contained fewer bytes than specified in Content-Length, aborting request");
}
} catch (StreamException $exception) {
throw new SocketException('Socket disconnected prior to response completion');
}
}
/**
* @param Request $request
* @param string $protocolVersion
*
* @return string
*
* @throws HttpException
*/
private function generateRawHeader(Request $request, string $protocolVersion) : string
{
$uri = $request->getUri();
$requestUri = normalizeRequestPathWithQuery($request);
$method = $request->getMethod();
if ($method === 'CONNECT') {
$defaultPort = $uri->getScheme() === 'https' ? 443 : 80;
$requestUri = $uri->getHost() . ':' . ($uri->getPort() ?? $defaultPort);
}
$header = $method . ' ' . $requestUri . ' HTTP/' . $protocolVersion . "\r\n";
try {
$header .= Rfc7230::formatRawHeaders($request->getRawHeaders());
} catch (InvalidHeaderException $e) {
throw new HttpException($e->getMessage());
}
return $header . "\r\n";
}
private function watchIdleConnection() : void
{
if ($this->socket === null || $this->socket->isClosed()) {
return;
}
$this->socket->unreference();
$this->idleRead = $this->socket->read();
$this->idleRead->onResolve(function ($error, $chunk) {
if ($error || $chunk === null) {
$this->close();
}
});
}
}