Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 888941f

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 87e7633 commit 888941f

5 files changed

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

0 commit comments

Comments
 (0)