Skip to content

Commit 13d8259

Browse files
authored
Merge pull request #224 from clue-labs/eyeball-default
Make happy eyeballs algorithm (IPv6) the default, add new `happy_eyeballs` option to `Connector`
2 parents 66fc103 + e243955 commit 13d8259

8 files changed

+230
-70
lines changed

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,22 @@ also shares all of their features and implementation details.
927927
If you want to typehint in your higher-level protocol implementation, you SHOULD
928928
use the generic [`ConnectorInterface`](#connectorinterface) instead.
929929

930+
As of `v1.4.0`, the `Connector` class defaults to using the
931+
[happy eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to
932+
automatically connect over IPv4 or IPv6 when a hostname is given.
933+
This automatically attempts to connect using both IPv4 and IPv6 at the same time
934+
(preferring IPv6), thus avoiding the usual problems faced by users with imperfect
935+
IPv6 connections or setups.
936+
If you want to revert to the old behavior of only doing an IPv4 lookup and
937+
only attempt a single IPv4 connection, you can set up the `Connector` like this:
938+
939+
```php
940+
$connector = new React\Socket\Connector($loop, array(
941+
'happy_eyeballs' => false
942+
));
943+
```
944+
945+
Similarly, you can also affect the default DNS behavior as follows.
930946
The `Connector` class will try to detect your system DNS settings (and uses
931947
Google's public DNS server `8.8.8.8` as a fallback if unable to determine your
932948
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
977993
```
978994

979995
By default, the `tcp://` and `tls://` URI schemes will use timeout value that
980-
repects your `default_socket_timeout` ini setting (which defaults to 60s).
996+
respects your `default_socket_timeout` ini setting (which defaults to 60s).
981997
If you want a custom timeout value, you can simply pass this like this:
982998

983999
```php
@@ -1061,7 +1077,7 @@ pass an instance implementing the `ConnectorInterface` like this:
10611077
```php
10621078
$dnsResolverFactory = new React\Dns\Resolver\Factory();
10631079
$resolver = $dnsResolverFactory->createCached('127.0.1.1', $loop);
1064-
$tcp = new React\Socket\DnsConnector(new React\Socket\TcpConnector($loop), $resolver);
1080+
$tcp = new React\Socket\HappyEyeBallsConnector($loop, new React\Socket\TcpConnector($loop), $resolver);
10651081

10661082
$tls = new React\Socket\SecureConnector($tcp, $loop);
10671083

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
},
1515
"require-dev": {
1616
"clue/block-react": "^1.2",
17-
"phpunit/phpunit": "^7.5 || ^6.4 || ^5.7 || ^4.8.35"
17+
"phpunit/phpunit": "^7.5 || ^6.4 || ^5.7 || ^4.8.35",
18+
"react/promise-stream": "^1.2"
1819
},
1920
"autoload": {
2021
"psr-4": {

src/Connector.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public function __construct(LoopInterface $loop, array $options = array())
3636

3737
'dns' => true,
3838
'timeout' => true,
39+
'happy_eyeballs' => true,
3940
);
4041

4142
if ($options['timeout'] === true) {
@@ -70,7 +71,11 @@ public function __construct(LoopInterface $loop, array $options = array())
7071
);
7172
}
7273

73-
$tcp = new DnsConnector($tcp, $resolver);
74+
if ($options['happy_eyeballs'] === true) {
75+
$tcp = new HappyEyeBallsConnector($loop, $tcp, $resolver);
76+
} else {
77+
$tcp = new DnsConnector($tcp, $resolver);
78+
}
7479
}
7580

7681
if ($options['tcp'] !== false) {

src/HappyEyeBallsConnectionBuilder.php

Lines changed: 35 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -190,71 +190,49 @@ public function check($resolve, $reject)
190190
*/
191191
public function attemptConnection($ip)
192192
{
193-
$promise = null;
194-
$that = $this;
195-
196-
return new Promise\Promise(
197-
function ($resolve, $reject) use (&$promise, $that, $ip) {
198-
$uri = '';
193+
$uri = '';
199194

200-
// prepend original scheme if known
201-
if (isset($that->parts['scheme'])) {
202-
$uri .= $that->parts['scheme'] . '://';
203-
}
195+
// prepend original scheme if known
196+
if (isset($this->parts['scheme'])) {
197+
$uri .= $this->parts['scheme'] . '://';
198+
}
204199

205-
if (\strpos($ip, ':') !== false) {
206-
// enclose IPv6 addresses in square brackets before appending port
207-
$uri .= '[' . $ip . ']';
208-
} else {
209-
$uri .= $ip;
210-
}
200+
if (\strpos($ip, ':') !== false) {
201+
// enclose IPv6 addresses in square brackets before appending port
202+
$uri .= '[' . $ip . ']';
203+
} else {
204+
$uri .= $ip;
205+
}
211206

212-
// append original port if known
213-
if (isset($that->parts['port'])) {
214-
$uri .= ':' . $that->parts['port'];
215-
}
207+
// append original port if known
208+
if (isset($this->parts['port'])) {
209+
$uri .= ':' . $this->parts['port'];
210+
}
216211

217-
// append orignal path if known
218-
if (isset($that->parts['path'])) {
219-
$uri .= $that->parts['path'];
220-
}
212+
// append orignal path if known
213+
if (isset($this->parts['path'])) {
214+
$uri .= $this->parts['path'];
215+
}
221216

222-
// append original query if known
223-
if (isset($that->parts['query'])) {
224-
$uri .= '?' . $that->parts['query'];
225-
}
217+
// append original query if known
218+
if (isset($this->parts['query'])) {
219+
$uri .= '?' . $this->parts['query'];
220+
}
226221

227-
// append original hostname as query if resolved via DNS and if
228-
// destination URI does not contain "hostname" query param already
229-
$args = array();
230-
\parse_str(isset($that->parts['query']) ? $that->parts['query'] : '', $args);
231-
if ($that->host !== $ip && !isset($args['hostname'])) {
232-
$uri .= (isset($that->parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($that->host);
233-
}
222+
// append original hostname as query if resolved via DNS and if
223+
// destination URI does not contain "hostname" query param already
224+
$args = array();
225+
\parse_str(isset($this->parts['query']) ? $this->parts['query'] : '', $args);
226+
if ($this->host !== $ip && !isset($args['hostname'])) {
227+
$uri .= (isset($this->parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($this->host);
228+
}
234229

235-
// append original fragment if known
236-
if (isset($that->parts['fragment'])) {
237-
$uri .= '#' . $that->parts['fragment'];
238-
}
230+
// append original fragment if known
231+
if (isset($this->parts['fragment'])) {
232+
$uri .= '#' . $this->parts['fragment'];
233+
}
239234

240-
$promise = $that->connector->connect($uri);
241-
$promise->then($resolve, $reject);
242-
},
243-
function ($_, $reject) use (&$promise, $that) {
244-
// cancellation should reject connection attempt
245-
// (try to) cancel pending connection attempt
246-
$reject(new \RuntimeException('Connection to ' . $that->uri . ' cancelled during connection attempt'));
247-
248-
if ($promise instanceof CancellablePromiseInterface) {
249-
// overwrite callback arguments for PHP7+ only, so they do not show
250-
// up in the Exception trace and do not cause a possible cyclic reference.
251-
$_ = $reject = null;
252-
253-
$promise->cancel();
254-
$promise = null;
255-
}
256-
}
257-
);
235+
return $this->connector->connect($uri);
258236
}
259237

260238
/**

tests/ConnectorTest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ public function testConnectorUsesGivenResolverInstance()
100100
$resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn($promise);
101101

102102
$connector = new Connector($loop, array(
103-
'dns' => $resolver
103+
'dns' => $resolver,
104+
'happy_eyeballs' => false,
104105
));
105106

106107
$connector->connect('google.com:80');
@@ -120,7 +121,8 @@ public function testConnectorUsesResolvedHostnameIfDnsIsUsed()
120121

121122
$connector = new Connector($loop, array(
122123
'tcp' => $tcp,
123-
'dns' => $resolver
124+
'dns' => $resolver,
125+
'happy_eyeballs' => false,
124126
));
125127

126128
$connector->connect('tcp://google.com:80');

tests/FunctionalConnectorTest.php

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44

55
use Clue\React\Block;
66
use React\EventLoop\Factory;
7+
use React\Socket\ConnectionInterface;
78
use React\Socket\Connector;
9+
use React\Socket\ConnectorInterface;
810
use React\Socket\TcpServer;
911

1012
class FunctionalConnectorTest extends TestCase
1113
{
12-
const TIMEOUT = 1.0;
14+
const TIMEOUT = 30.0;
15+
16+
private $ipv4;
17+
private $ipv6;
1318

1419
/** @test */
1520
public function connectionToTcpServerShouldSucceedWithLocalhost()
@@ -29,4 +34,104 @@ public function connectionToTcpServerShouldSucceedWithLocalhost()
2934
$connection->close();
3035
$server->close();
3136
}
37+
38+
/**
39+
* @test
40+
* @group internet
41+
*/
42+
public function connectionToRemoteTCP4n6ServerShouldResultInOurIP()
43+
{
44+
$loop = Factory::create();
45+
46+
$connector = new Connector($loop, array('happy_eyeballs' => true));
47+
48+
$ip = Block\await($this->request('dual.tlund.se', $connector), $loop, self::TIMEOUT);
49+
50+
$this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6), $ip);
51+
}
52+
53+
/**
54+
* @test
55+
* @group internet
56+
*/
57+
public function connectionToRemoteTCP4ServerShouldResultInOurIP()
58+
{
59+
if ($this->ipv4() === false) {
60+
$this->markTestSkipped('IPv4 connection not supported on this system');
61+
}
62+
63+
$loop = Factory::create();
64+
65+
$connector = new Connector($loop, array('happy_eyeballs' => true));
66+
67+
$ip = Block\await($this->request('ipv4.tlund.se', $connector), $loop, self::TIMEOUT);
68+
69+
$this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip);
70+
$this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip);
71+
}
72+
73+
/**
74+
* @test
75+
* @group internet
76+
*/
77+
public function connectionToRemoteTCP6ServerShouldResultInOurIP()
78+
{
79+
if ($this->ipv6() === false) {
80+
$this->markTestSkipped('IPv6 connection not supported on this system');
81+
}
82+
83+
$loop = Factory::create();
84+
85+
$connector = new Connector($loop, array('happy_eyeballs' => true));
86+
87+
$ip = Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT);
88+
89+
$this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip);
90+
$this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip);
91+
}
92+
93+
/**
94+
* @internal
95+
*/
96+
public function parseIpFromPage($body)
97+
{
98+
$ex = explode('title="Look up on bgp.he.net">', $body);
99+
$ex = explode('<', $ex[1]);
100+
101+
return $ex[0];
102+
}
103+
104+
private function request($host, ConnectorInterface $connector)
105+
{
106+
$that = $this;
107+
return $connector->connect($host . ':80')->then(function (ConnectionInterface $connection) use ($host) {
108+
$connection->write("GET / HTTP/1.1\r\nHost: " . $host . "\r\n\r\n");
109+
110+
return \React\Promise\Stream\buffer($connection);
111+
})->then(function ($response) use ($that) {
112+
return $that->parseIpFromPage($response);
113+
});
114+
}
115+
116+
private function ipv4()
117+
{
118+
if ($this->ipv4 !== null) {
119+
return $this->ipv4;
120+
}
121+
122+
$this->ipv4 = !!@file_get_contents('http://ipv4.tlund.se/');
123+
124+
return $this->ipv4;
125+
}
126+
127+
private function ipv6()
128+
{
129+
if ($this->ipv6 !== null) {
130+
return $this->ipv6;
131+
}
132+
133+
$this->ipv6 = !!@file_get_contents('http://ipv6.tlund.se/');
134+
135+
return $this->ipv6;
136+
}
32137
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace React\Tests\Socket;
4+
5+
use React\Promise\Promise;
6+
use React\Socket\HappyEyeBallsConnectionBuilder;
7+
8+
class HappyEyeBallsConnectionBuilderTest extends TestCase
9+
{
10+
public function testAttemptConnectionWillConnectViaConnectorToGivenIpWithPortAndHostnameFromUriParts()
11+
{
12+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
13+
14+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
15+
$connector->expects($this->once())->method('connect')->with('tcp://10.1.1.1:80?hostname=reactphp.org')->willReturn(new Promise(function () { }));
16+
17+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
18+
$resolver->expects($this->never())->method('resolveAll');
19+
20+
$uri = 'tcp://reactphp.org:80';
21+
$host = 'reactphp.org';
22+
$parts = parse_url($uri);
23+
24+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
25+
26+
$builder->attemptConnection('10.1.1.1');
27+
}
28+
29+
public function testAttemptConnectionWillConnectViaConnectorToGivenIpv6WithAllUriParts()
30+
{
31+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
32+
33+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
34+
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80/path?test=yes&hostname=reactphp.org#start')->willReturn(new Promise(function () { }));
35+
36+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
37+
$resolver->expects($this->never())->method('resolveAll');
38+
39+
$uri = 'tcp://reactphp.org:80/path?test=yes#start';
40+
$host = 'reactphp.org';
41+
$parts = parse_url($uri);
42+
43+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
44+
45+
$builder->attemptConnection('::1');
46+
}
47+
}

0 commit comments

Comments
 (0)