Skip to content

Commit

Permalink
Make happy eyeballs available in Connector with a flag
Browse files Browse the repository at this point in the history
  • Loading branch information
WyriHaximus committed Nov 17, 2019
1 parent ca817ad commit 4d9ea52
Show file tree
Hide file tree
Showing 5 changed files with 200 additions and 6 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
},
"require-dev": {
"clue/block-react": "^1.2",
"phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35"
"phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35",
"react/promise-stream": "^1.2"
},
"autoload": {
"psr-4": {
Expand Down
7 changes: 6 additions & 1 deletion src/Connector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions tests/ConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
177 changes: 176 additions & 1 deletion tests/FunctionalConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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;
}
}

0 comments on commit 4d9ea52

Please sign in to comment.