Skip to content

Commit 4d9ea52

Browse files
committed
Make happy eyeballs available in Connector with a flag
1 parent ca817ad commit 4d9ea52

File tree

5 files changed

+200
-6
lines changed

5 files changed

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

0 commit comments

Comments
 (0)