From cc4737dfa828a77080b6b1b0bdac340325f4a807 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Thu, 24 Oct 2019 17:16:42 +0200 Subject: [PATCH 1/3] Make happy eyeballs available in Connector with a flag --- README.md | 13 ++- composer.json | 3 +- src/Connector.php | 7 +- tests/ConnectorTest.php | 6 +- tests/FunctionalConnectorTest.php | 177 +++++++++++++++++++++++++++++- 5 files changed, 200 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dcabda8a..0e04cfa1 100644 --- a/README.md +++ b/README.md @@ -1061,7 +1061,7 @@ pass an instance implementing the `ConnectorInterface` like this: ```php $dnsResolverFactory = new React\Dns\Resolver\Factory(); $resolver = $dnsResolverFactory->createCached('127.0.1.1', $loop); -$tcp = new React\Socket\DnsConnector(new React\Socket\TcpConnector($loop), $resolver); +$tcp = new React\Socket\HappyEyeBallsConnector($loop, new React\Socket\TcpConnector($loop), $resolver); $tls = new React\Socket\SecureConnector($tcp, $loop); @@ -1094,6 +1094,17 @@ $connector->connect('google.com:80')->then(function (React\Socket\ConnectionInte Internally, the `tcp://` and `tls://` connectors will always be wrapped by `TimeoutConnector`, unless you disable timeouts like in the above example. +> Internally the `HappyEyeBallsConnector` has replaced the `DnsConnector` as default + resolving connector. It is still available as `Connector` has a new option, namely + `happy_eyeballs`, to control which of the two will be used. By default it's `true` + and will use `HappyEyeBallsConnector`, when set to `false` `DnsConnector` is used. + We only recommend doing so when there are any backwards compatible issues on older + systems only supporting IPv4. The `HappyEyeBallsConnector` implements most of + RFC6555 and RFC8305 and will use concurrency to connect to the remote host by + attempting to connect over both IPv4 and IPv6 with a priority for IPv6 when + available. Which ever connection attempt succeeds first will be used, the rest + connection attempts will be canceled. + ### Advanced client usage #### TcpConnector diff --git a/composer.json b/composer.json index baa683d5..355c62e1 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ }, "require-dev": { "clue/block-react": "^1.2", - "phpunit/phpunit": "^7.5 || ^6.4 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^7.5 || ^6.4 || ^5.7 || ^4.8.35", + "react/promise-stream": "^1.2" }, "autoload": { "psr-4": { diff --git a/src/Connector.php b/src/Connector.php index 2a4f19a2..06b6c4ef 100644 --- a/src/Connector.php +++ b/src/Connector.php @@ -36,6 +36,7 @@ public function __construct(LoopInterface $loop, array $options = array()) 'dns' => true, 'timeout' => true, + 'happy_eyeballs' => \PHP_VERSION_ID < 70000 ? false : true, ); if ($options['timeout'] === true) { @@ -70,7 +71,11 @@ public function __construct(LoopInterface $loop, array $options = array()) ); } - $tcp = new DnsConnector($tcp, $resolver); + if ($options['happy_eyeballs'] === true) { + $tcp = new HappyEyeBallsConnector($loop, $tcp, $resolver); + } else { + $tcp = new DnsConnector($tcp, $resolver); + } } if ($options['tcp'] !== false) { diff --git a/tests/ConnectorTest.php b/tests/ConnectorTest.php index 14bba5e5..a8ffc0be 100644 --- a/tests/ConnectorTest.php +++ b/tests/ConnectorTest.php @@ -100,7 +100,8 @@ public function testConnectorUsesGivenResolverInstance() $resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn($promise); $connector = new Connector($loop, array( - 'dns' => $resolver + 'dns' => $resolver, + 'happy_eyeballs' => false, )); $connector->connect('google.com:80'); @@ -120,7 +121,8 @@ public function testConnectorUsesResolvedHostnameIfDnsIsUsed() $connector = new Connector($loop, array( 'tcp' => $tcp, - 'dns' => $resolver + 'dns' => $resolver, + 'happy_eyeballs' => false, )); $connector->connect('tcp://google.com:80'); diff --git a/tests/FunctionalConnectorTest.php b/tests/FunctionalConnectorTest.php index 6611352a..3e45c150 100644 --- a/tests/FunctionalConnectorTest.php +++ b/tests/FunctionalConnectorTest.php @@ -4,12 +4,17 @@ use Clue\React\Block; use React\EventLoop\Factory; +use React\Socket\ConnectionInterface; use React\Socket\Connector; +use React\Socket\ConnectorInterface; use React\Socket\TcpServer; class FunctionalConnectorTest extends TestCase { - const TIMEOUT = 1.0; + const TIMEOUT = 30.0; + + private $ipv4; + private $ipv6; /** @test */ public function connectionToTcpServerShouldSucceedWithLocalhost() @@ -29,4 +34,174 @@ public function connectionToTcpServerShouldSucceedWithLocalhost() $connection->close(); $server->close(); } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP4n6ServerShouldResultInOurIP() + { + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => true)); + + $ip = Block\await($this->request('dual.tlund.se', $connector), $loop, self::TIMEOUT); + + $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6), $ip); + } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP4ServerShouldResultInOurIP() + { + if ($this->ipv4() === false) { + // IPv4 not supported on this system + $this->assertFalse($this->ipv4()); + return; + } + + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => true)); + + $ip = Block\await($this->request('ipv4.tlund.se', $connector), $loop, self::TIMEOUT); + + $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip); + $this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip); + } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP6ServerShouldResultInOurIP() + { + if ($this->ipv6() === false) { + // IPv6 not supported on this system + $this->assertFalse($this->ipv6()); + return; + } + + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => true)); + + $ip = Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT); + + $this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip); + $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip); + } + + /** + * @test + * @group internet + * + * @expectedException \RuntimeException + * @expectedExceptionMessageRegExp /Connection to ipv6.tlund.se:80 failed/ + */ + public function tryingToConnectToAnIPv6OnlyHostWithOutHappyEyeBallsShouldResultInFailure() + { + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => false)); + + Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT); + } + + /** + * @test + * @group internet + * + * @expectedException \RuntimeException + * @expectedExceptionMessageRegExp /Connection to tcp:\/\/193.15.228.195:80 failed:/ + */ + public function connectingDirectlyToAnIPv4AddressShouldFailWhenIPv4IsntAvailable() + { + if ($this->ipv4() === true) { + // IPv4 supported on this system + throw new \RuntimeException('Connection to tcp://193.15.228.195:80 failed:'); + } + + $loop = Factory::create(); + + $connector = new Connector($loop); + + $host = current(dns_get_record('ipv4.tlund.se', DNS_A)); + $host = $host['ip']; + Block\await($this->request($host, $connector), $loop, self::TIMEOUT); + } + + /** + * @test + * @group internet + * + * @expectedException \RuntimeException + * @expectedExceptionMessageRegExp /Connection to tcp:\/\/\[2a00:801:f::195\]:80 failed:/ + */ + public function connectingDirectlyToAnIPv6AddressShouldFailWhenIPv6IsntAvailable() + { + if ($this->ipv6() === true) { + // IPv6 supported on this system + throw new \RuntimeException('Connection to tcp://[2a00:801:f::195]:80 failed:'); + } + + $loop = Factory::create(); + + $connector = new Connector($loop); + + $host = current(dns_get_record('ipv6.tlund.se', DNS_AAAA)); + $host = $host['ipv6']; + $host = '[' . $host . ']'; + $ip = Block\await($this->request($host, $connector), $loop, self::TIMEOUT); + + $this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip); + $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip); + } + + /** + * @internal + */ + public function parseIpFromPage($body) + { + $ex = explode('title="Look up on bgp.he.net">', $body); + $ex = explode('<', $ex[1]); + + return $ex[0]; + } + + private function request($host, ConnectorInterface $connector) + { + $that = $this; + return $connector->connect($host . ':80')->then(function (ConnectionInterface $connection) use ($host) { + $connection->write("GET / HTTP/1.1\r\nHost: " . $host . "\r\n\r\n"); + + return \React\Promise\Stream\buffer($connection); + })->then(function ($response) use ($that) { + return $that->parseIpFromPage($response); + }); + } + + private function ipv4() + { + if ($this->ipv4 !== null) { + return $this->ipv4; + } + + $this->ipv4 = !!@file_get_contents('http://ipv4.tlund.se/'); + + return $this->ipv4; + } + + private function ipv6() + { + if ($this->ipv6 !== null) { + return $this->ipv6; + } + + $this->ipv6 = !!@file_get_contents('http://ipv6.tlund.se/'); + + return $this->ipv6; + } } From 5b51fb9cfb9d5c02ccc0ec019cbe061a70d36d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 6 Mar 2020 17:19:35 +0100 Subject: [PATCH 2/3] Avoid unneeded promise wrapping & avoid garbage references on legacy PHP --- README.md | 29 +++--- src/Connector.php | 2 +- src/HappyEyeBallsConnectionBuilder.php | 92 ++++++++------------ tests/HappyEyeBallsConnectionBuilderTest.php | 47 ++++++++++ 4 files changed, 100 insertions(+), 70 deletions(-) create mode 100644 tests/HappyEyeBallsConnectionBuilderTest.php diff --git a/README.md b/README.md index 0e04cfa1..1e7016c1 100644 --- a/README.md +++ b/README.md @@ -927,6 +927,22 @@ also shares all of their features and implementation details. If you want to typehint in your higher-level protocol implementation, you SHOULD use the generic [`ConnectorInterface`](#connectorinterface) instead. +As of `v1.4.0`, the `Connector` class defaults to using the +[happy eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to +automatically connect over IPv4 or IPv6 when a hostname is given. +This automatically attempts to connect using both IPv4 and IPv6 at the same time +(preferring IPv6), thus avoiding the usual problems faced by users with imperfect +IPv6 connections or setups. +If you want to revert to the old behavior of only doing an IPv4 lookup and +only attempt a single IPv4 connection, you can set up the `Connector` like this: + +```php +$connector = new React\Socket\Connector($loop, array( + 'happy_eyeballs' => false +)); +``` + +Similarly, you can also affect the default DNS behavior as follows. The `Connector` class will try to detect your system DNS settings (and uses Google's public DNS server `8.8.8.8` as a fallback if unable to determine your system settings) to resolve all public hostnames into underlying IP addresses by @@ -977,7 +993,7 @@ $connector->connect('localhost:80')->then(function (React\Socket\ConnectionInter ``` By default, the `tcp://` and `tls://` URI schemes will use timeout value that -repects your `default_socket_timeout` ini setting (which defaults to 60s). +respects your `default_socket_timeout` ini setting (which defaults to 60s). If you want a custom timeout value, you can simply pass this like this: ```php @@ -1094,17 +1110,6 @@ $connector->connect('google.com:80')->then(function (React\Socket\ConnectionInte Internally, the `tcp://` and `tls://` connectors will always be wrapped by `TimeoutConnector`, unless you disable timeouts like in the above example. -> Internally the `HappyEyeBallsConnector` has replaced the `DnsConnector` as default - resolving connector. It is still available as `Connector` has a new option, namely - `happy_eyeballs`, to control which of the two will be used. By default it's `true` - and will use `HappyEyeBallsConnector`, when set to `false` `DnsConnector` is used. - We only recommend doing so when there are any backwards compatible issues on older - systems only supporting IPv4. The `HappyEyeBallsConnector` implements most of - RFC6555 and RFC8305 and will use concurrency to connect to the remote host by - attempting to connect over both IPv4 and IPv6 with a priority for IPv6 when - available. Which ever connection attempt succeeds first will be used, the rest - connection attempts will be canceled. - ### Advanced client usage #### TcpConnector diff --git a/src/Connector.php b/src/Connector.php index 06b6c4ef..578d10b8 100644 --- a/src/Connector.php +++ b/src/Connector.php @@ -36,7 +36,7 @@ public function __construct(LoopInterface $loop, array $options = array()) 'dns' => true, 'timeout' => true, - 'happy_eyeballs' => \PHP_VERSION_ID < 70000 ? false : true, + 'happy_eyeballs' => true, ); if ($options['timeout'] === true) { diff --git a/src/HappyEyeBallsConnectionBuilder.php b/src/HappyEyeBallsConnectionBuilder.php index a8553a58..fa10224c 100644 --- a/src/HappyEyeBallsConnectionBuilder.php +++ b/src/HappyEyeBallsConnectionBuilder.php @@ -190,71 +190,49 @@ public function check($resolve, $reject) */ public function attemptConnection($ip) { - $promise = null; - $that = $this; - - return new Promise\Promise( - function ($resolve, $reject) use (&$promise, $that, $ip) { - $uri = ''; + $uri = ''; - // prepend original scheme if known - if (isset($that->parts['scheme'])) { - $uri .= $that->parts['scheme'] . '://'; - } + // prepend original scheme if known + if (isset($this->parts['scheme'])) { + $uri .= $this->parts['scheme'] . '://'; + } - if (\strpos($ip, ':') !== false) { - // enclose IPv6 addresses in square brackets before appending port - $uri .= '[' . $ip . ']'; - } else { - $uri .= $ip; - } + if (\strpos($ip, ':') !== false) { + // enclose IPv6 addresses in square brackets before appending port + $uri .= '[' . $ip . ']'; + } else { + $uri .= $ip; + } - // append original port if known - if (isset($that->parts['port'])) { - $uri .= ':' . $that->parts['port']; - } + // append original port if known + if (isset($this->parts['port'])) { + $uri .= ':' . $this->parts['port']; + } - // append orignal path if known - if (isset($that->parts['path'])) { - $uri .= $that->parts['path']; - } + // append orignal path if known + if (isset($this->parts['path'])) { + $uri .= $this->parts['path']; + } - // append original query if known - if (isset($that->parts['query'])) { - $uri .= '?' . $that->parts['query']; - } + // append original query if known + if (isset($this->parts['query'])) { + $uri .= '?' . $this->parts['query']; + } - // append original hostname as query if resolved via DNS and if - // destination URI does not contain "hostname" query param already - $args = array(); - \parse_str(isset($that->parts['query']) ? $that->parts['query'] : '', $args); - if ($that->host !== $ip && !isset($args['hostname'])) { - $uri .= (isset($that->parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($that->host); - } + // append original hostname as query if resolved via DNS and if + // destination URI does not contain "hostname" query param already + $args = array(); + \parse_str(isset($this->parts['query']) ? $this->parts['query'] : '', $args); + if ($this->host !== $ip && !isset($args['hostname'])) { + $uri .= (isset($this->parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($this->host); + } - // append original fragment if known - if (isset($that->parts['fragment'])) { - $uri .= '#' . $that->parts['fragment']; - } + // append original fragment if known + if (isset($this->parts['fragment'])) { + $uri .= '#' . $this->parts['fragment']; + } - $promise = $that->connector->connect($uri); - $promise->then($resolve, $reject); - }, - function ($_, $reject) use (&$promise, $that) { - // cancellation should reject connection attempt - // (try to) cancel pending connection attempt - $reject(new \RuntimeException('Connection to ' . $that->uri . ' cancelled during connection attempt')); - - if ($promise instanceof CancellablePromiseInterface) { - // overwrite callback arguments for PHP7+ only, so they do not show - // up in the Exception trace and do not cause a possible cyclic reference. - $_ = $reject = null; - - $promise->cancel(); - $promise = null; - } - } - ); + return $this->connector->connect($uri); } /** diff --git a/tests/HappyEyeBallsConnectionBuilderTest.php b/tests/HappyEyeBallsConnectionBuilderTest.php new file mode 100644 index 00000000..4be5e7b5 --- /dev/null +++ b/tests/HappyEyeBallsConnectionBuilderTest.php @@ -0,0 +1,47 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tcp://10.1.1.1:80?hostname=reactphp.org')->willReturn(new Promise(function () { })); + + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->attemptConnection('10.1.1.1'); + } + + public function testAttemptConnectionWillConnectViaConnectorToGivenIpv6WithAllUriParts() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80/path?test=yes&hostname=reactphp.org#start')->willReturn(new Promise(function () { })); + + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->attemptConnection('::1'); + } +} From e243955ea795cf2fcf2e4e2c64c9147ae01ffd8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 7 Mar 2020 17:01:21 +0100 Subject: [PATCH 3/3] Make tests less fragile by improving test timeouts and skip unsupported --- tests/FunctionalConnectorTest.php | 74 +------------------------------ tests/IntegrationTest.php | 18 +++++--- 2 files changed, 14 insertions(+), 78 deletions(-) diff --git a/tests/FunctionalConnectorTest.php b/tests/FunctionalConnectorTest.php index 3e45c150..57ef8d5b 100644 --- a/tests/FunctionalConnectorTest.php +++ b/tests/FunctionalConnectorTest.php @@ -57,9 +57,7 @@ public function connectionToRemoteTCP4n6ServerShouldResultInOurIP() public function connectionToRemoteTCP4ServerShouldResultInOurIP() { if ($this->ipv4() === false) { - // IPv4 not supported on this system - $this->assertFalse($this->ipv4()); - return; + $this->markTestSkipped('IPv4 connection not supported on this system'); } $loop = Factory::create(); @@ -79,9 +77,7 @@ public function connectionToRemoteTCP4ServerShouldResultInOurIP() public function connectionToRemoteTCP6ServerShouldResultInOurIP() { if ($this->ipv6() === false) { - // IPv6 not supported on this system - $this->assertFalse($this->ipv6()); - return; + $this->markTestSkipped('IPv6 connection not supported on this system'); } $loop = Factory::create(); @@ -94,72 +90,6 @@ public function connectionToRemoteTCP6ServerShouldResultInOurIP() $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip); } - /** - * @test - * @group internet - * - * @expectedException \RuntimeException - * @expectedExceptionMessageRegExp /Connection to ipv6.tlund.se:80 failed/ - */ - public function tryingToConnectToAnIPv6OnlyHostWithOutHappyEyeBallsShouldResultInFailure() - { - $loop = Factory::create(); - - $connector = new Connector($loop, array('happy_eyeballs' => false)); - - Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT); - } - - /** - * @test - * @group internet - * - * @expectedException \RuntimeException - * @expectedExceptionMessageRegExp /Connection to tcp:\/\/193.15.228.195:80 failed:/ - */ - public function connectingDirectlyToAnIPv4AddressShouldFailWhenIPv4IsntAvailable() - { - if ($this->ipv4() === true) { - // IPv4 supported on this system - throw new \RuntimeException('Connection to tcp://193.15.228.195:80 failed:'); - } - - $loop = Factory::create(); - - $connector = new Connector($loop); - - $host = current(dns_get_record('ipv4.tlund.se', DNS_A)); - $host = $host['ip']; - Block\await($this->request($host, $connector), $loop, self::TIMEOUT); - } - - /** - * @test - * @group internet - * - * @expectedException \RuntimeException - * @expectedExceptionMessageRegExp /Connection to tcp:\/\/\[2a00:801:f::195\]:80 failed:/ - */ - public function connectingDirectlyToAnIPv6AddressShouldFailWhenIPv6IsntAvailable() - { - if ($this->ipv6() === true) { - // IPv6 supported on this system - throw new \RuntimeException('Connection to tcp://[2a00:801:f::195]:80 failed:'); - } - - $loop = Factory::create(); - - $connector = new Connector($loop); - - $host = current(dns_get_record('ipv6.tlund.se', DNS_AAAA)); - $host = $host['ipv6']; - $host = '[' . $host . ']'; - $ip = Block\await($this->request($host, $connector), $loop, self::TIMEOUT); - - $this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip); - $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip); - } - /** * @internal */ diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index d376f3dc..f2913809 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -203,7 +203,7 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect connection timeout error + // run loop for short period to ensure we detect a connection timeout error Block\sleep(0.01, $loop); if ($wait) { Block\sleep(0.2, $loop); @@ -236,7 +236,7 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect connection timeout error + // run loop for short period to ensure we detect a connection timeout error Block\sleep(0.01, $loop); if ($wait) { Block\sleep(0.2, $loop); @@ -269,12 +269,15 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect DNS error + // run loop for short period to ensure we detect a DNS error Block\sleep(0.01, $loop); if ($wait) { Block\sleep(0.2, $loop); if ($wait) { - $this->fail('Connection attempt did not fail'); + Block\sleep(2.0, $loop); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } } } unset($promise); @@ -309,12 +312,15 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect DNS error + // run loop for short period to ensure we detect a TLS error Block\sleep(0.1, $loop); if ($wait) { Block\sleep(0.4, $loop); if ($wait) { - $this->fail('Connection attempt did not fail'); + Block\sleep(self::TIMEOUT - 0.5, $loop); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } } } unset($promise);