Skip to content

Commit ac92884

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

File tree

3 files changed

+766
-0
lines changed

3 files changed

+766
-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

src/HappyEyeBallsConnector.php

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

0 commit comments

Comments
 (0)