Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hookup happy eyeballs #216

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,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
Expand Down 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/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test expects an exception and has assertions, can you check which is correct?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, resolved that 👍

*/
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:');
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check makes sense, but I don't link how this marks the whole test result as yellow in a properly configured system (this would match most common systems). This might be preference, but how about using return with a comment instead? I think this is also in one of the other tests?


$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:');
}
WyriHaximus marked this conversation as resolved.
Show resolved Hide resolved

$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;
}
}