Skip to content

Commit 82ce847

Browse files
committed
Implement the Happy Eye Balls RFC's
By using the happy eye balls algorithm as described in RFC6555 and RFC8305 it will connect to the quickest responding server with a preference for IPv6.
1 parent 2cf8dfa commit 82ce847

File tree

4 files changed

+729
-0
lines changed

4 files changed

+729
-0
lines changed

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ handle multiple concurrent connections without blocking.
4343
* [Connector](#connector)
4444
* [Advanced client usage](#advanced-client-usage)
4545
* [TcpConnector](#tcpconnector)
46+
* [HappyEyeBallsConnector](#happyeyeballsconnector)
4647
* [DnsConnector](#dnsconnector)
4748
* [SecureConnector](#secureconnector)
4849
* [TimeoutConnector](#timeoutconnector)
@@ -1154,6 +1155,60 @@ be used to set up the TLS peer name.
11541155
This is used by the `SecureConnector` and `DnsConnector` to verify the peer
11551156
name and can also be used if you want a custom TLS peer name.
11561157

1158+
#### HappyEyeBallsConnector
1159+
1160+
The `HappyEyeBallsConnector` class implements the
1161+
[`ConnectorInterface`](#connectorinterface) and allows you to create plaintext
1162+
TCP/IP connections to any hostname-port-combination. Internally it implements the
1163+
happy eyeballs algorythm from [`RFC6555`](https://tools.ietf.org/html/rfc6555) and
1164+
[`RFC8305`](https://tools.ietf.org/html/rfc8305) to support IPv6 and IPv4 hostnames.
1165+
1166+
It does so by decorating a given `TcpConnector` instance so that it first
1167+
looks up the given domain name via DNS (if applicable) and then establishes the
1168+
underlying TCP/IP connection to the resolved target IP address.
1169+
1170+
Make sure to set up your DNS resolver and underlying TCP connector like this:
1171+
1172+
```php
1173+
$dnsResolverFactory = new React\Dns\Resolver\Factory();
1174+
$dns = $dnsResolverFactory->createCached('8.8.8.8', $loop);
1175+
1176+
$dnsConnector = new React\Socket\HappyEyeBallsConnector($loop, $tcpConnector, $dns);
1177+
1178+
$dnsConnector->connect('www.google.com:80')->then(function (React\Socket\ConnectionInterface $connection) {
1179+
$connection->write('...');
1180+
$connection->end();
1181+
});
1182+
1183+
$loop->run();
1184+
```
1185+
1186+
See also the [examples](examples).
1187+
1188+
Pending connection attempts can be cancelled by cancelling its pending promise like so:
1189+
1190+
```php
1191+
$promise = $dnsConnector->connect('www.google.com:80');
1192+
1193+
$promise->cancel();
1194+
```
1195+
1196+
Calling `cancel()` on a pending promise will cancel the underlying DNS lookups
1197+
and/or the underlying TCP/IP connection(s) and reject the resulting promise.
1198+
1199+
1200+
> Advanced usage: Internally, the `HappyEyeBallsConnector` relies on a `Resolver` to
1201+
look up the IP addresses for the given hostname.
1202+
It will then replace the hostname in the destination URI with this IP's and
1203+
append a `hostname` query parameter and pass this updated URI to the underlying
1204+
connector.
1205+
The Happy Eye Balls algorythm describes looking the IPv6 and IPv4 address for
1206+
the given hostname so this connector sends out two DNS lookups for the A and
1207+
AAAA records. It then uses all IP addresses (both v6 and v4) and tries to
1208+
connect to all of them with a 50ms interval in between. Alterating between IPv6
1209+
and IPv4 addresses. When a connection is established all the other DNS lookups
1210+
and connection attempts are cancelled.
1211+
11571212
#### DnsConnector
11581213

11591214
The `DnsConnector` class implements the
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
<?php
2+
3+
namespace React\Socket;
4+
5+
use React\Dns\Model\Message;
6+
use React\Dns\Resolver\Resolver;
7+
use React\EventLoop\LoopInterface;
8+
use React\EventLoop\TimerInterface;
9+
use React\Promise;
10+
use React\Promise\CancellablePromiseInterface;
11+
use InvalidArgumentException;
12+
use RuntimeException;
13+
14+
/**
15+
* @internal
16+
*/
17+
final class HappyEyeBallsConnectionBuilder
18+
{
19+
public $loop;
20+
public $connector;
21+
public $resolver;
22+
public $uri;
23+
public $host;
24+
public $resolved = array();
25+
public $resolverPromises = array();
26+
public $connectionPromises = array();
27+
public $connectQueue = array();
28+
public $timer;
29+
public $parts;
30+
public $ipsCount = 0;
31+
public $failureCount = 0;
32+
public $resolve;
33+
public $reject;
34+
35+
public function __construct(LoopInterface $loop, ConnectorInterface $connector, Resolver $resolver, $uri, $host, $parts)
36+
{
37+
$this->loop = $loop;
38+
$this->connector = $connector;
39+
$this->resolver = $resolver;
40+
$this->uri = $uri;
41+
$this->host = $host;
42+
$this->parts = $parts;
43+
}
44+
45+
public function connect()
46+
{
47+
$that = $this;
48+
return new Promise\Promise(function ($resolve, $reject) use ($that) {
49+
/**
50+
* Delay A lookup by 50ms as per RFC.
51+
*
52+
* @link https://tools.ietf.org/html/rfc8305#section-3
53+
*/
54+
$that->resolverPromises[Message::TYPE_AAAA] = $that->resolve(Message::TYPE_AAAA, $resolve, $reject);
55+
$that->resolverPromises[Message::TYPE_A] = Promise\Timer\resolve(0.05, $that->loop)->then(function () use ($that, $resolve, $reject) {
56+
$that->resolverPromises[Message::TYPE_A] = $that->resolve(Message::TYPE_A, $resolve, $reject);
57+
});
58+
}, function ($_, $reject) use ($that) {
59+
$that->cleanUp();
60+
61+
$reject(new \RuntimeException('Connection to ' . $that->uri . ' cancelled during DNS lookup'));
62+
63+
$_ = $reject = null;
64+
});
65+
}
66+
67+
public function resolve($type, $resolve, $reject)
68+
{
69+
$that = $this;
70+
$that->resolved[$type] = false;
71+
return $that->resolver->resolveAll($that->host, $type)->then(function (array $ips) use ($that, $type, $resolve, $reject) {
72+
unset($that->resolverPromises[$type]);
73+
$that->resolved[$type] = true;
74+
75+
$that->mixIpsIntoConnectQueue($ips);
76+
77+
$that->check($resolve, $reject);
78+
}, function () use ($type, $reject, $that) {
79+
unset($that->resolverPromises[$type]);
80+
$that->resolved[$type] = true;
81+
82+
if ($that->hasBeenResolved() === false) {
83+
return;
84+
}
85+
86+
if ($that->ipsCount === 0) {
87+
$that->resolved = null;
88+
$that->resolverPromises = null;
89+
$reject(new \RuntimeException('Connection to ' . $that->uri . ' failed during DNS lookup: DNS error'));
90+
}
91+
});
92+
}
93+
94+
/**
95+
* @internal
96+
*/
97+
public function check($resolve, $reject)
98+
{
99+
$hasBeenResolved = $this->hasBeenResolved();
100+
101+
if ($hasBeenResolved === true && \count($this->connectQueue) === 0 && $this->timer instanceof TimerInterface) {
102+
$this->loop->cancelTimer($this->timer);
103+
$this->timer = null;
104+
return;
105+
}
106+
107+
if (\count($this->connectQueue) === 0 && $this->timer instanceof TimerInterface) {
108+
$this->loop->cancelTimer($this->timer);
109+
$this->timer = null;
110+
return;
111+
}
112+
113+
$ip = \array_shift($this->connectQueue);
114+
115+
if (\count($this->connectQueue) === 0 && $this->timer instanceof TimerInterface) {
116+
$this->loop->cancelTimer($this->timer);
117+
$this->timer = null;
118+
}
119+
120+
$that = $this;
121+
$that->connectionPromises[$ip] = $this->attemptConnection($ip)->then(function ($connection) use ($that, $ip, $resolve) {
122+
unset($that->connectionPromises[$ip]);
123+
124+
$that->cleanUp();
125+
126+
$resolve($connection);
127+
}, function () use ($that, $ip, $resolve, $reject) {
128+
unset($that->connectionPromises[$ip]);
129+
130+
$that->failureCount++;
131+
132+
if ($that->hasBeenResolved() === false) {
133+
return;
134+
}
135+
136+
if ($that->ipsCount === $that->failureCount) {
137+
$that->cleanUp();
138+
139+
$reject(new \RuntimeException('All attempts to connect to "' . $that->host . '" have failed'));
140+
}
141+
});
142+
143+
/**
144+
* As long as we haven't connected yet keep popping an IP address of the connect queue until one of them
145+
* succeeds or they all fail. We will wait 100ms between connection attempts as per RFC.
146+
*
147+
* @link https://tools.ietf.org/html/rfc8305#section-5
148+
*/
149+
if (\count($this->connectQueue) > 0 && $this->timer === null) {
150+
$this->timer = $this->loop->addPeriodicTimer(0.1, function () use ($that, $resolve, $reject) {
151+
$that->check($resolve, $reject);
152+
});
153+
}
154+
}
155+
156+
/**
157+
* @internal
158+
*/
159+
public function attemptConnection($ip)
160+
{
161+
$resolved = null;
162+
$promise = null;
163+
$that = $this;
164+
165+
return new Promise\Promise(
166+
function ($resolve, $reject) use (&$promise, &$resolved, $that, $ip) {
167+
$resolved = $ip;
168+
$uri = '';
169+
170+
// prepend original scheme if known
171+
if (isset($that->parts['scheme'])) {
172+
$uri .= $that->parts['scheme'] . '://';
173+
}
174+
175+
if (\strpos($ip, ':') !== false) {
176+
// enclose IPv6 addresses in square brackets before appending port
177+
$uri .= '[' . $ip . ']';
178+
} else {
179+
$uri .= $ip;
180+
}
181+
182+
// append original port if known
183+
if (isset($that->parts['port'])) {
184+
$uri .= ':' . $that->parts['port'];
185+
}
186+
187+
// append orignal path if known
188+
if (isset($that->parts['path'])) {
189+
$uri .= $that->parts['path'];
190+
}
191+
192+
// append original query if known
193+
if (isset($that->parts['query'])) {
194+
$uri .= '?' . $that->parts['query'];
195+
}
196+
197+
// append original hostname as query if resolved via DNS and if
198+
// destination URI does not contain "hostname" query param already
199+
$args = array();
200+
\parse_str(isset($that->parts['query']) ? $that->parts['query'] : '', $args);
201+
if ($that->host !== $ip && !isset($args['hostname'])) {
202+
$uri .= (isset($that->parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($that->host);
203+
}
204+
205+
// append original fragment if known
206+
if (isset($that->parts['fragment'])) {
207+
$uri .= '#' . $that->parts['fragment'];
208+
}
209+
210+
$promise = $that->connector->connect($uri);
211+
$promise->then($resolve, $reject);
212+
},
213+
function ($_, $reject) use (&$promise, &$resolved, $that) {
214+
// cancellation should reject connection attempt
215+
// (try to) cancel pending connection attempt
216+
if ($resolved === null) {
217+
$reject(new \RuntimeException('Connection to ' . $that->uri . ' cancelled during connection attempt'));
218+
}
219+
220+
if ($promise instanceof CancellablePromiseInterface) {
221+
// overwrite callback arguments for PHP7+ only, so they do not show
222+
// up in the Exception trace and do not cause a possible cyclic reference.
223+
$_ = $reject = null;
224+
225+
$promise->cancel();
226+
$promise = null;
227+
}
228+
}
229+
);
230+
}
231+
232+
/**
233+
* @internal
234+
*/
235+
public function cleanUp()
236+
{
237+
/** @var CancellablePromiseInterface $promise */
238+
foreach ($this->connectionPromises as $index => $connectionPromise) {
239+
if ($connectionPromise instanceof CancellablePromiseInterface) {
240+
$connectionPromise->cancel();
241+
}
242+
}
243+
244+
/** @var CancellablePromiseInterface $promise */
245+
foreach ($this->resolverPromises as $index => $resolverPromise) {
246+
if ($resolverPromise instanceof CancellablePromiseInterface) {
247+
$resolverPromise->cancel();
248+
}
249+
}
250+
251+
if ($this->timer instanceof TimerInterface) {
252+
$this->loop->cancelTimer($this->timer);
253+
$this->timer = null;
254+
}
255+
}
256+
257+
/**
258+
* @internal
259+
*/
260+
public function hasBeenResolved()
261+
{
262+
foreach ($this->resolved as $typeHasBeenResolved) {
263+
if ($typeHasBeenResolved === false) {
264+
return false;
265+
}
266+
}
267+
268+
return true;
269+
}
270+
271+
/**
272+
* Mixes an array of IP addresses into the connect queue in such a way they alternate when attempting to connect.
273+
* The goal behind it is first attempt to connect to IPv6, then to IPv4, then to IPv6 again until one of those
274+
* attempts succeeds.
275+
*
276+
* @link https://tools.ietf.org/html/rfc8305#section-4
277+
*
278+
* @internal
279+
*/
280+
public function mixIpsIntoConnectQueue(array $ips)
281+
{
282+
$this->ipsCount += \count($ips);
283+
$connectQueueStash = $this->connectQueue;
284+
$this->connectQueue = array();
285+
while (\count($connectQueueStash) > 0 || \count($ips) > 0) {
286+
if (\count($ips) > 0) {
287+
$this->connectQueue[] = \array_shift($ips);
288+
}
289+
if (\count($connectQueueStash) > 0) {
290+
$this->connectQueue[] = \array_shift($connectQueueStash);
291+
}
292+
}
293+
}
294+
}

0 commit comments

Comments
 (0)