diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index fc0823373..52006ee31 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -22,6 +22,13 @@ class HttpClient implements HttpClientInterface */ protected $sdkVersion; + /** + * Either a persistent share handle or a regular share handle, or null if no share handle can be obtained. + * + * @var object|resource|null + */ + private $shareHandle; + public function __construct(string $sdkIdentifier, string $sdkVersion) { $this->sdkIdentifier = $sdkIdentifier; @@ -72,6 +79,12 @@ public function sendRequest(Request $request, Options $options): Response curl_setopt($curlHandle, \CURLOPT_RETURNTRANSFER, true); curl_setopt($curlHandle, \CURLOPT_HEADERFUNCTION, $responseHeaderCallback); curl_setopt($curlHandle, \CURLOPT_HTTP_VERSION, \CURL_HTTP_VERSION_1_1); + if ($options->isShareHandleEnabled()) { + $shareHandle = $this->getShareHandle(); + if ($shareHandle !== null) { + curl_setopt($curlHandle, \CURLOPT_SHARE, $shareHandle); + } + } $httpSslVerifyPeer = $options->getHttpSslVerifyPeer(); if (!$httpSslVerifyPeer) { @@ -125,4 +138,51 @@ public function sendRequest(Request $request, Options $options): Response return new Response($statusCode, $responseHeaders, $error); } + + /** + * Initializes a share handle for CURL requests. If available, it will always try to use a persistent + * share handle first and fall back to a regular share handle in case it's unavailable. + * + * @return object|resource|null a share handle or null if none could be created + */ + private function getShareHandle() + { + if ($this->shareHandle !== null) { + return $this->shareHandle; + } + if (\function_exists('curl_share_init_persistent')) { + $shareOptions = [\CURL_LOCK_DATA_DNS]; + if (\defined('CURL_LOCK_DATA_CONNECT')) { + $shareOptions[] = \CURL_LOCK_DATA_CONNECT; + } + if (\defined('CURL_LOCK_DATA_SSL_SESSION')) { + $shareOptions[] = \CURL_LOCK_DATA_SSL_SESSION; + } + try { + $this->shareHandle = curl_share_init_persistent($shareOptions); + } catch (\Throwable $throwable) { + // don't crash if the share handle cannot be created + $this->shareHandle = null; + } + } + + // If the persistent share handle cannot be created or doesn't exist + if ($this->shareHandle === null) { + try { + $this->shareHandle = curl_share_init(); + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS); + if (\defined('CURL_LOCK_DATA_CONNECT')) { + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT); + } + if (\defined('CURL_LOCK_DATA_SSL_SESSION')) { + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION); + } + } catch (\Throwable $throwable) { + // don't crash if the share handle cannot be created + $this->shareHandle = null; + } + } + + return $this->shareHandle; + } } diff --git a/src/Options.php b/src/Options.php index 39c3d92e4..3f75ad897 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1142,6 +1142,37 @@ public function setEnableHttpCompression(bool $enabled): self return $this; } + /** + * Returns whether a shared curl handle should be used or not. + * + * For PHP 8.5 and above, this will use the persistent curl handle. For previous PHP versions, it will use the + * regular share handle. + */ + public function isShareHandleEnabled(): bool + { + /** + * @var bool $shareHandleEnabled + */ + $shareHandleEnabled = $this->options['http_enable_curl_share_handle']; + + return $shareHandleEnabled; + } + + /** + * Sets whether the persistent curl handle should be used or not. + * + * For PHP 8.5 and above, this will use the persistent curl handle. For previous PHP versions, it will use the + * regular share handle. + */ + public function setEnableShareHandle(bool $enabled): self + { + $options = array_merge($this->options, ['http_enable_curl_share_handle' => $enabled]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Gets whether the silenced errors should be captured or not. * @@ -1341,6 +1372,7 @@ private function configureOptions(OptionsResolver $resolver): void 'http_ssl_verify_peer' => true, 'http_ssl_native_ca' => false, 'http_compression' => true, + 'http_enable_curl_share_handle' => true, 'capture_silenced_errors' => false, 'max_request_body_size' => 'medium', 'class_serializers' => [], @@ -1392,6 +1424,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('http_ssl_verify_peer', 'bool'); $resolver->setAllowedTypes('http_ssl_native_ca', 'bool'); $resolver->setAllowedTypes('http_compression', 'bool'); + $resolver->setAllowedTypes('http_enable_curl_share_handle', 'bool'); $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); $resolver->setAllowedTypes('max_request_body_size', 'string'); $resolver->setAllowedTypes('class_serializers', 'array'); diff --git a/src/functions.php b/src/functions.php index 0836648d1..8d4581487 100644 --- a/src/functions.php +++ b/src/functions.php @@ -42,6 +42,7 @@ * http_proxy_authentication?: string|null, * http_ssl_verify_peer?: bool, * http_timeout?: int|float, + * http_enable_curl_share_handle?: bool, * ignore_exceptions?: array, * ignore_transactions?: array, * in_app_exclude?: array, diff --git a/tests/HttpClient/HttpClientTest.php b/tests/HttpClient/HttpClientTest.php index 583ba7eff..45dd466e8 100644 --- a/tests/HttpClient/HttpClientTest.php +++ b/tests/HttpClient/HttpClientTest.php @@ -75,6 +75,59 @@ public function testClientMakesUncompressedRequestWhenCompressionDisabled(): voi $this->assertEquals(\strlen($request->getStringBody()), $serverOutput['headers']['Content-Length']); } + public function testClientMakesRequestWhenShareHandleDisabled(): void + { + $testServer = $this->startTestServer(); + + $options = new Options([ + 'dsn' => "http://publicKey@{$testServer}/200", + 'http_enable_curl_share_handle' => false, + ]); + + $request = new Request(); + $request->setStringBody('test'); + + $client = new HttpClient('sentry.php', 'testing'); + $response = $client->sendRequest($request, $options); + + $serverOutput = $this->stopTestServer(); + + $this->assertTrue($response->isSuccess()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($response->getStatusCode(), $serverOutput['status']); + $this->assertEquals($request->getStringBody(), $serverOutput['body']); + $this->assertNull($this->getShareHandleFromClient($client)); + } + + public function testShareHandleIsInitializedOnlyOncePerHttpClientInstance(): void + { + $testServer = $this->startTestServer(); + + $options = new Options([ + 'dsn' => "http://publicKey@{$testServer}/200", + 'http_enable_curl_share_handle' => true, + ]); + + $request = new Request(); + $request->setStringBody('test'); + + $client = new HttpClient('sentry.php', 'testing'); + + $firstResponse = $client->sendRequest($request, $options); + $firstShareHandle = $this->getShareHandleFromClient($client); + + $secondResponse = $client->sendRequest($request, $options); + $secondShareHandle = $this->getShareHandleFromClient($client); + + $this->stopTestServer(); + + $this->assertTrue($firstResponse->isSuccess()); + $this->assertTrue($secondResponse->isSuccess()); + $this->assertNotNull($firstShareHandle); + $this->assertShareHandleHasExpectedType($firstShareHandle); + $this->assertSame($firstShareHandle, $secondShareHandle); + } + public function testClientReturnsBodyAsErrorOnNonSuccessStatusCode(): void { $testServer = $this->startTestServer(); @@ -118,4 +171,38 @@ public function testThrowsExceptionIfRequestDataIsEmpty(): void $client = new HttpClient('sentry.php', 'testing'); $client->sendRequest(new Request(), $options); } + + /** + * @return object|resource|null + */ + private function getShareHandleFromClient(HttpClient $client) + { + $reflectionProperty = new \ReflectionProperty(HttpClient::class, 'shareHandle'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + + return $reflectionProperty->getValue($client); + } + + /** + * @param object|resource $shareHandle + */ + private function assertShareHandleHasExpectedType($shareHandle): void + { + if (\PHP_VERSION_ID < 80000) { + $this->assertTrue(\is_resource($shareHandle)); + + return; + } + + if (\PHP_VERSION_ID >= 80500) { + $this->assertTrue(class_exists('CurlSharePersistentHandle')); + $this->assertInstanceOf(\CurlSharePersistentHandle::class, $shareHandle); + + return; + } + + $this->assertInstanceOf(\CurlShareHandle::class, $shareHandle); + } } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index d229dad8b..860b88d5a 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -415,6 +415,13 @@ static function (): void {}, 'setEnableHttpCompression', ]; + yield [ + 'http_enable_curl_share_handle', + false, + 'isShareHandleEnabled', + 'setEnableShareHandle', + ]; + yield [ 'capture_silenced_errors', true,