diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 18ce04f..696db74 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -6,4 +6,5 @@ use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; return new Configuration() - ->ignoreErrorsOnExtension('ext-zlib', [ErrorType::SHADOW_DEPENDENCY]); + ->ignoreErrorsOnExtension('ext-zlib', [ErrorType::SHADOW_DEPENDENCY]) + ->ignoreErrorsOnExtension('ext-sockets', [ErrorType::SHADOW_DEPENDENCY]); diff --git a/composer.json b/composer.json index f3ac880..4f8b806 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,9 @@ "thesis/grpc-server": "*@dev", "thesis/protoregistry": "^0.1.2" }, + "suggest": { + "ext-sockets": "Required for TCP keepalive (Client\\Builder::withKeepalive())." + }, "repositories": [ { "type": "path", diff --git a/packages/client/composer.json b/packages/client/composer.json index 41f07f8..7065d92 100644 --- a/packages/client/composer.json +++ b/packages/client/composer.json @@ -34,5 +34,8 @@ "config": { "lock": false, "sort-packages": true + }, + "suggest": { + "ext-sockets": "Required for TCP keepalive (Client\\Builder::withKeepalive())." } } diff --git a/packages/client/src/Client/Builder.php b/packages/client/src/Client/Builder.php index 5898048..c02c416 100644 --- a/packages/client/src/Client/Builder.php +++ b/packages/client/src/Client/Builder.php @@ -57,6 +57,8 @@ final class Builder private ?SocketConnector $connector = null; + private ?Internal\KeepaliveSettings $keepalive = null; + private ?Encoder $encoder = null; /** @@ -211,6 +213,29 @@ public function withSocketConnector(SocketConnector $connector): self return $builder; } + /** + * Enables TCP keepalive on every connection, so a dead peer is detected even on an idle + * connection after roughly `idle + interval * count` seconds. Applies on top of a custom + * {@see self::withSocketConnector()} connector, but not to a custom + * {@see self::withHttpClient()} client. Requires the "sockets" extension. + * + * @param positive-int $idle seconds of idleness before the first probe + * @param positive-int $interval seconds between unanswered probes + * @param positive-int $count unanswered probes before the connection is dropped + * @throws KeepaliveUnavailable + */ + public function withKeepalive(int $idle = 10, int $interval = 10, int $count = 3): self + { + if (!\extension_loaded('sockets')) { + throw new KeepaliveUnavailable(); + } + + $builder = clone $this; + $builder->keepalive = new Internal\KeepaliveSettings($idle, $interval, $count); + + return $builder; + } + public function withLoadBalancer(LoadBalancerFactory $factory): self { $builder = clone $this; @@ -272,11 +297,21 @@ public function build(): Client $controlMetadata, ]); + $connector = $this->connector ?? new DnsSocketConnector(); + if ($this->keepalive !== null) { + $connector = new KeepaliveSocketConnector( + $connector, + $this->keepalive->idle, + $this->keepalive->interval, + $this->keepalive->count, + ); + } + $httpclient = $this->httpclient ?? new HttpClientBuilder() ->usingPool(ConnectionLimitingPool::byAuthority( $this->connectionLimit, new DefaultConnectionFactory( - $this->connector ?? new DnsSocketConnector(), + $connector, new ConnectContext() ->withConnectTimeout($this->connectTimeout) ->withTlsContext($tlsContext), diff --git a/packages/client/src/Client/Internal/KeepaliveSettings.php b/packages/client/src/Client/Internal/KeepaliveSettings.php new file mode 100644 index 0000000..01e6054 --- /dev/null +++ b/packages/client/src/Client/Internal/KeepaliveSettings.php @@ -0,0 +1,22 @@ +connector->connect($uri, $context, $cancellation); + + if ($socket instanceof ResourceSocket) { + $this->enable($socket); + } + + return $socket; + } + + /** + * @throws ConnectException + */ + private function enable(ResourceSocket $socket): void + { + $resource = $socket->getResource(); + if (!\is_resource($resource)) { + return; + } + + $raw = socket_import_stream($resource); + if ($raw === false) { + return; + } + + $options = [[\SOL_SOCKET, \SO_KEEPALIVE, 1]]; + + // Linux and most BSDs, macOS names the idle time TCP_KEEPALIVE. + if (\defined('TCP_KEEPIDLE')) { + $options[] = [\SOL_TCP, \TCP_KEEPIDLE, $this->idle]; + } elseif (\defined('TCP_KEEPALIVE') && \is_int($idle = \constant('TCP_KEEPALIVE'))) { + $options[] = [\SOL_TCP, $idle, $this->idle]; + } + + if (\defined('TCP_KEEPINTVL')) { + $options[] = [\SOL_TCP, \TCP_KEEPINTVL, $this->interval]; + } + + if (\defined('TCP_KEEPCNT')) { + $options[] = [\SOL_TCP, \TCP_KEEPCNT, $this->count]; + } + + foreach ($options as [$level, $option, $value]) { + if (!socket_set_option($raw, $level, $option, $value)) { + $socket->close(); + + throw new ConnectException(\sprintf( + 'Cannot enable TCP keepalive on the connection to "%s": %s', + $socket->getRemoteAddress()->toString(), + socket_strerror(socket_last_error($raw)), + )); + } + } + } +} diff --git a/packages/client/src/Client/KeepaliveUnavailable.php b/packages/client/src/Client/KeepaliveUnavailable.php new file mode 100644 index 0000000..43f3c37 --- /dev/null +++ b/packages/client/src/Client/KeepaliveUnavailable.php @@ -0,0 +1,18 @@ +getResource(); + TestCase::assertIsResource($resource); + $raw = socket_import_stream($resource); + TestCase::assertInstanceOf(\Socket::class, $raw); + + return [ + 'keepalive' => socket_get_option($raw, \SOL_SOCKET, \SO_KEEPALIVE), + 'idle' => socket_get_option($raw, \SOL_TCP, \TCP_KEEPIDLE), + 'interval' => socket_get_option($raw, \SOL_TCP, \TCP_KEEPINTVL), + 'count' => socket_get_option($raw, \SOL_TCP, \TCP_KEEPCNT), + ]; + } +} diff --git a/tests/Client/KeepaliveSocketConnectorTest.php b/tests/Client/KeepaliveSocketConnectorTest.php new file mode 100644 index 0000000..1a9a7f5 --- /dev/null +++ b/tests/Client/KeepaliveSocketConnectorTest.php @@ -0,0 +1,49 @@ +server = listen('127.0.0.1:0'); + } + + protected function tearDown(): void + { + $this->server->close(); + } + + public function testEnablesKeepaliveWithTheGivenTimings(): void + { + $socket = new KeepaliveSocketConnector(new DnsSocketConnector(), idle: 7, interval: 3, count: 4) + ->connect($this->server->getAddress()->toString()); + + self::assertSame( + ['keepalive' => 1, 'idle' => 7, 'interval' => 3, 'count' => 4], + KeepaliveOptions::of($socket), + ); + + $socket->close(); + } + + public function testPlainConnectionsHaveNoKeepalive(): void + { + $socket = new DnsSocketConnector()->connect($this->server->getAddress()->toString()); + + self::assertSame(0, KeepaliveOptions::of($socket)['keepalive']); + + $socket->close(); + } +} diff --git a/tests/KeepaliveTest.php b/tests/KeepaliveTest.php new file mode 100644 index 0000000..1fd26f6 --- /dev/null +++ b/tests/KeepaliveTest.php @@ -0,0 +1,104 @@ +server = new Server\Builder() + ->withAddresses(self::ADDRESS) + ->withServices(new EchoServiceServerRegistry(new KeepaliveEchoServer())) + ->build(); + + $this->server->start(); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + public function testClientConnectionsUseKeepalive(): void + { + $recorder = new RecordingSocketConnector(new DnsSocketConnector()); + $client = new Client\Builder() + ->withHost(self::ADDRESS) + ->withSocketConnector($recorder) + ->withKeepalive(idle: 7, interval: 3, count: 4) + ->build(); + + self::assertSame('ping', new EchoServiceClient($client)->echo(new EchoRequest('ping'))->sentence); + self::assertNotNull($recorder->socket); + self::assertSame( + ['keepalive' => 1, 'idle' => 7, 'interval' => 3, 'count' => 4], + KeepaliveOptions::of($recorder->socket), + ); + + $client->close(); + } + + public function testKeepaliveIsOffByDefault(): void + { + $recorder = new RecordingSocketConnector(new DnsSocketConnector()); + $client = new Client\Builder() + ->withHost(self::ADDRESS) + ->withSocketConnector($recorder) + ->build(); + + new EchoServiceClient($client)->echo(new EchoRequest('ping')); + + self::assertNotNull($recorder->socket); + self::assertSame(0, KeepaliveOptions::of($recorder->socket)['keepalive']); + + $client->close(); + } +} + +final class RecordingSocketConnector implements SocketConnector +{ + public ?Socket $socket = null; + + public function __construct( + private readonly SocketConnector $connector, + ) {} + + #[\Override] + public function connect(SocketAddress|string $uri, ?ConnectContext $context = null, ?Cancellation $cancellation = null): Socket + { + return $this->socket = $this->connector->connect($uri, $context, $cancellation); + } +} + +final readonly class KeepaliveEchoServer implements EchoServiceServer +{ + #[\Override] + public function echo(EchoRequest $request, Metadata $md, Cancellation $cancellation): EchoResponse + { + return new EchoResponse($request->sentence); + } +}