Skip to content

Commit 2eaea98

Browse files
committed
Make happy eyeballs available in Connector with a flag
We'll set it to off until version 2.0.0 to ensure backwards compatibility.
1 parent ca817ad commit 2eaea98

File tree

5 files changed

+198
-6
lines changed

5 files changed

+198
-6
lines changed

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,7 +1061,7 @@ pass an instance implementing the `ConnectorInterface` like this:
10611061
```php
10621062
$dnsResolverFactory = new React\Dns\Resolver\Factory();
10631063
$resolver = $dnsResolverFactory->createCached('127.0.1.1', $loop);
1064-
$tcp = new React\Socket\DnsConnector(new React\Socket\TcpConnector($loop), $resolver);
1064+
$tcp = new React\Socket\HappyEyeBallsConnector($loop, new React\Socket\TcpConnector($loop), $resolver);
10651065

10661066
$tls = new React\Socket\SecureConnector($tcp, $loop);
10671067

@@ -1094,6 +1094,17 @@ $connector->connect('google.com:80')->then(function (React\Socket\ConnectionInte
10941094
Internally, the `tcp://` and `tls://` connectors will always be wrapped by
10951095
`TimeoutConnector`, unless you disable timeouts like in the above example.
10961096

1097+
> Internally the `HappyEyeBallsConnector` has replaced the `DnsConnector` as default
1098+
resolving connector. It is still available as `Connector` has a new option, namely
1099+
`happy_eyeballs`, to control which of the two will be used. By default it's `true`
1100+
and will use `HappyEyeBallsConnector`, when set to `false` `DnsConnector` is used.
1101+
We only recommend doing so when there are any backwards compatible issues on older
1102+
systems only supporting IPv4. The `HappyEyeBallsConnector` implements most of
1103+
RFC6555 and RFC8305 and will use concurrency to connect to the remote host by
1104+
attempting to connect over both IPv4 and IPv6 with a priority for IPv6 when
1105+
available. Which ever connection attempt succeeds first will be used, the rest
1106+
connection attempts will be canceled.
1107+
10971108
### Advanced client usage
10981109

10991110
#### TcpConnector

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": "^6.4 || ^5.7 || ^4.8.35"
17+
"phpunit/phpunit": "^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' => \PHP_VERSION_ID < 70000 ? false : 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) {

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: 174 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,172 @@ 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+
// IPv4 not supported on this system
61+
return;
62+
}
63+
64+
$loop = Factory::create();
65+
66+
$connector = new Connector($loop, array('happy_eyeballs' => true));
67+
68+
$ip = Block\await($this->request('ipv4.tlund.se', $connector), $loop, self::TIMEOUT);
69+
70+
$this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip);
71+
$this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip);
72+
}
73+
74+
/**
75+
* @test
76+
* @group internet
77+
*/
78+
public function connectionToRemoteTCP6ServerShouldResultInOurIP()
79+
{
80+
if ($this->ipv6() === false) {
81+
// IPv6 not supported on this system
82+
return;
83+
}
84+
85+
$loop = Factory::create();
86+
87+
$connector = new Connector($loop, array('happy_eyeballs' => true));
88+
89+
$ip = Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT);
90+
91+
$this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip);
92+
$this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip);
93+
}
94+
95+
/**
96+
* @test
97+
* @group internet
98+
*
99+
* @expectedException \RuntimeException
100+
* @expectedExceptionMessageRegExp /Connection to ipv6.tlund.se:80 failed/
101+
*/
102+
public function tryingToConnectToAnIPv6OnlyHostWithOutHappyEyeBallsShouldResultInFailure()
103+
{
104+
$loop = Factory::create();
105+
106+
$connector = new Connector($loop, array('happy_eyeballs' => false));
107+
108+
Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT);
109+
}
110+
111+
/**
112+
* @test
113+
* @group internet
114+
*
115+
* @expectedException \RuntimeException
116+
* @expectedExceptionMessageRegExp /Connection to tcp:\/\/193.15.228.195:80 failed:/
117+
*/
118+
public function connectingDirectlyToAnIPv4AddressShouldFailWhenIPv4IsntAvailable()
119+
{
120+
if ($this->ipv4() === true) {
121+
// IPv4 supported on this system
122+
throw new \RuntimeException('Connection to tcp://193.15.228.195:80 failed:');
123+
}
124+
125+
$loop = Factory::create();
126+
127+
$connector = new Connector($loop);
128+
129+
$host = current(dns_get_record('ipv4.tlund.se', DNS_A));
130+
$host = $host['ip'];
131+
Block\await($this->request($host, $connector), $loop, self::TIMEOUT);
132+
}
133+
134+
/**
135+
* @test
136+
* @group internet
137+
*
138+
* @expectedException \RuntimeException
139+
* @expectedExceptionMessageRegExp /Connection to tcp:\/\/\[2a00:801:f::195\]:80 failed:/
140+
*/
141+
public function connectingDirectlyToAnIPv6AddressShouldFailWhenIPv6IsntAvailable()
142+
{
143+
if ($this->ipv6() === true) {
144+
// IPv6 supported on this system
145+
throw new \RuntimeException('Connection to tcp://[2a00:801:f::195]:80 failed:');
146+
}
147+
148+
$loop = Factory::create();
149+
150+
$connector = new Connector($loop);
151+
152+
$host = current(dns_get_record('ipv6.tlund.se', DNS_AAAA));
153+
$host = $host['ipv6'];
154+
$host = '[' . $host . ']';
155+
$ip = Block\await($this->request($host, $connector), $loop, self::TIMEOUT);
156+
157+
$this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip);
158+
$this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip);
159+
}
160+
161+
/**
162+
* @internal
163+
*/
164+
public function parseIpFromPage($body)
165+
{
166+
$ex = explode('title="Look up on bgp.he.net">', $body);
167+
$ex = explode('<', $ex[1]);
168+
169+
return $ex[0];
170+
}
171+
172+
private function request($host, ConnectorInterface $connector)
173+
{
174+
$that = $this;
175+
return $connector->connect($host . ':80')->then(function (ConnectionInterface $connection) use ($host) {
176+
$connection->write("GET / HTTP/1.1\r\nHost: " . $host . "\r\n\r\n");
177+
178+
return \React\Promise\Stream\buffer($connection);
179+
})->then(function ($response) use ($that) {
180+
return $that->parseIpFromPage($response);
181+
});
182+
}
183+
184+
private function ipv4()
185+
{
186+
if ($this->ipv4 !== null) {
187+
return $this->ipv4;
188+
}
189+
190+
$this->ipv4 = !!@file_get_contents('http://ipv4.tlund.se/');
191+
192+
return $this->ipv4;
193+
}
194+
195+
private function ipv6()
196+
{
197+
if ($this->ipv6 !== null) {
198+
return $this->ipv6;
199+
}
200+
201+
$this->ipv6 = !!@file_get_contents('http://ipv6.tlund.se/');
202+
203+
return $this->ipv6;
204+
}
32205
}

0 commit comments

Comments
 (0)