friendship ended with social-app. php is my new best friend
1<?php
2
3namespace React\Socket;
4
5use React\Dns\Model\Message;
6use React\Dns\Resolver\ResolverInterface;
7use React\EventLoop\LoopInterface;
8use React\EventLoop\TimerInterface;
9use React\Promise;
10use React\Promise\PromiseInterface;
11
12/**
13 * @internal
14 */
15final class HappyEyeBallsConnectionBuilder
16{
17 /**
18 * As long as we haven't connected yet keep popping an IP address of the connect queue until one of them
19 * succeeds or they all fail. We will wait 100ms between connection attempts as per RFC.
20 *
21 * @link https://tools.ietf.org/html/rfc8305#section-5
22 */
23 const CONNECTION_ATTEMPT_DELAY = 0.1;
24
25 /**
26 * Delay `A` lookup by 50ms sending out connection to IPv4 addresses when IPv6 records haven't
27 * resolved yet as per RFC.
28 *
29 * @link https://tools.ietf.org/html/rfc8305#section-3
30 */
31 const RESOLUTION_DELAY = 0.05;
32
33 public $loop;
34 public $connector;
35 public $resolver;
36 public $uri;
37 public $host;
38 public $resolved = array(
39 Message::TYPE_A => false,
40 Message::TYPE_AAAA => false,
41 );
42 public $resolverPromises = array();
43 public $connectionPromises = array();
44 public $connectQueue = array();
45 public $nextAttemptTimer;
46 public $parts;
47 public $ipsCount = 0;
48 public $failureCount = 0;
49 public $resolve;
50 public $reject;
51
52 public $lastErrorFamily;
53 public $lastError6;
54 public $lastError4;
55
56 public function __construct(LoopInterface $loop, ConnectorInterface $connector, ResolverInterface $resolver, $uri, $host, $parts)
57 {
58 $this->loop = $loop;
59 $this->connector = $connector;
60 $this->resolver = $resolver;
61 $this->uri = $uri;
62 $this->host = $host;
63 $this->parts = $parts;
64 }
65
66 public function connect()
67 {
68 $that = $this;
69 return new Promise\Promise(function ($resolve, $reject) use ($that) {
70 $lookupResolve = function ($type) use ($that, $resolve, $reject) {
71 return 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 // start next connection attempt if not already awaiting next
78 if ($that->nextAttemptTimer === null && $that->connectQueue) {
79 $that->check($resolve, $reject);
80 }
81 };
82 };
83
84 $that->resolverPromises[Message::TYPE_AAAA] = $that->resolve(Message::TYPE_AAAA, $reject)->then($lookupResolve(Message::TYPE_AAAA));
85 $that->resolverPromises[Message::TYPE_A] = $that->resolve(Message::TYPE_A, $reject)->then(function (array $ips) use ($that) {
86 // happy path: IPv6 has resolved already (or could not resolve), continue with IPv4 addresses
87 if ($that->resolved[Message::TYPE_AAAA] === true || !$ips) {
88 return $ips;
89 }
90
91 // Otherwise delay processing IPv4 lookup until short timer passes or IPv6 resolves in the meantime
92 $deferred = new Promise\Deferred(function () use (&$ips) {
93 // discard all IPv4 addresses if cancelled
94 $ips = array();
95 });
96 $timer = $that->loop->addTimer($that::RESOLUTION_DELAY, function () use ($deferred, $ips) {
97 $deferred->resolve($ips);
98 });
99
100 $that->resolverPromises[Message::TYPE_AAAA]->then(function () use ($that, $timer, $deferred, &$ips) {
101 $that->loop->cancelTimer($timer);
102 $deferred->resolve($ips);
103 });
104
105 return $deferred->promise();
106 })->then($lookupResolve(Message::TYPE_A));
107 }, function ($_, $reject) use ($that) {
108 $reject(new \RuntimeException(
109 'Connection to ' . $that->uri . ' cancelled' . (!$that->connectionPromises ? ' during DNS lookup' : '') . ' (ECONNABORTED)',
110 \defined('SOCKET_ECONNABORTED') ? \SOCKET_ECONNABORTED : 103
111 ));
112 $_ = $reject = null;
113
114 $that->cleanUp();
115 });
116 }
117
118 /**
119 * @internal
120 * @param int $type DNS query type
121 * @param callable $reject
122 * @return \React\Promise\PromiseInterface<string[]> Returns a promise that
123 * always resolves with a list of IP addresses on success or an empty
124 * list on error.
125 */
126 public function resolve($type, $reject)
127 {
128 $that = $this;
129 return $that->resolver->resolveAll($that->host, $type)->then(null, function (\Exception $e) use ($type, $reject, $that) {
130 unset($that->resolverPromises[$type]);
131 $that->resolved[$type] = true;
132
133 if ($type === Message::TYPE_A) {
134 $that->lastError4 = $e->getMessage();
135 $that->lastErrorFamily = 4;
136 } else {
137 $that->lastError6 = $e->getMessage();
138 $that->lastErrorFamily = 6;
139 }
140
141 // cancel next attempt timer when there are no more IPs to connect to anymore
142 if ($that->nextAttemptTimer !== null && !$that->connectQueue) {
143 $that->loop->cancelTimer($that->nextAttemptTimer);
144 $that->nextAttemptTimer = null;
145 }
146
147 if ($that->hasBeenResolved() && $that->ipsCount === 0) {
148 $reject(new \RuntimeException(
149 $that->error(),
150 0,
151 $e
152 ));
153 }
154
155 // Exception already handled above, so don't throw an unhandled rejection here
156 return array();
157 });
158 }
159
160 /**
161 * @internal
162 */
163 public function check($resolve, $reject)
164 {
165 $ip = \array_shift($this->connectQueue);
166
167 // start connection attempt and remember array position to later unset again
168 $this->connectionPromises[] = $this->attemptConnection($ip);
169 \end($this->connectionPromises);
170 $index = \key($this->connectionPromises);
171
172 $that = $this;
173 $that->connectionPromises[$index]->then(function ($connection) use ($that, $index, $resolve) {
174 unset($that->connectionPromises[$index]);
175
176 $that->cleanUp();
177
178 $resolve($connection);
179 }, function (\Exception $e) use ($that, $index, $ip, $resolve, $reject) {
180 unset($that->connectionPromises[$index]);
181
182 $that->failureCount++;
183
184 $message = \preg_replace('/^(Connection to [^ ]+)[&?]hostname=[^ &]+/', '$1', $e->getMessage());
185 if (\strpos($ip, ':') === false) {
186 $that->lastError4 = $message;
187 $that->lastErrorFamily = 4;
188 } else {
189 $that->lastError6 = $message;
190 $that->lastErrorFamily = 6;
191 }
192
193 // start next connection attempt immediately on error
194 if ($that->connectQueue) {
195 if ($that->nextAttemptTimer !== null) {
196 $that->loop->cancelTimer($that->nextAttemptTimer);
197 $that->nextAttemptTimer = null;
198 }
199
200 $that->check($resolve, $reject);
201 }
202
203 if ($that->hasBeenResolved() === false) {
204 return;
205 }
206
207 if ($that->ipsCount === $that->failureCount) {
208 $that->cleanUp();
209
210 $reject(new \RuntimeException(
211 $that->error(),
212 $e->getCode(),
213 $e
214 ));
215 }
216 });
217
218 // Allow next connection attempt in 100ms: https://tools.ietf.org/html/rfc8305#section-5
219 // Only start timer when more IPs are queued or when DNS query is still pending (might add more IPs)
220 if ($this->nextAttemptTimer === null && (\count($this->connectQueue) > 0 || $this->resolved[Message::TYPE_A] === false || $this->resolved[Message::TYPE_AAAA] === false)) {
221 $this->nextAttemptTimer = $this->loop->addTimer(self::CONNECTION_ATTEMPT_DELAY, function () use ($that, $resolve, $reject) {
222 $that->nextAttemptTimer = null;
223
224 if ($that->connectQueue) {
225 $that->check($resolve, $reject);
226 }
227 });
228 }
229 }
230
231 /**
232 * @internal
233 */
234 public function attemptConnection($ip)
235 {
236 $uri = Connector::uri($this->parts, $this->host, $ip);
237
238 return $this->connector->connect($uri);
239 }
240
241 /**
242 * @internal
243 */
244 public function cleanUp()
245 {
246 // clear list of outstanding IPs to avoid creating new connections
247 $this->connectQueue = array();
248
249 // cancel pending connection attempts
250 foreach ($this->connectionPromises as $connectionPromise) {
251 if ($connectionPromise instanceof PromiseInterface && \method_exists($connectionPromise, 'cancel')) {
252 $connectionPromise->cancel();
253 }
254 }
255
256 // cancel pending DNS resolution (cancel IPv4 first in case it is awaiting IPv6 resolution delay)
257 foreach (\array_reverse($this->resolverPromises) as $resolverPromise) {
258 if ($resolverPromise instanceof PromiseInterface && \method_exists($resolverPromise, 'cancel')) {
259 $resolverPromise->cancel();
260 }
261 }
262
263 if ($this->nextAttemptTimer instanceof TimerInterface) {
264 $this->loop->cancelTimer($this->nextAttemptTimer);
265 $this->nextAttemptTimer = null;
266 }
267 }
268
269 /**
270 * @internal
271 */
272 public function hasBeenResolved()
273 {
274 foreach ($this->resolved as $typeHasBeenResolved) {
275 if ($typeHasBeenResolved === false) {
276 return false;
277 }
278 }
279
280 return true;
281 }
282
283 /**
284 * Mixes an array of IP addresses into the connect queue in such a way they alternate when attempting to connect.
285 * The goal behind it is first attempt to connect to IPv6, then to IPv4, then to IPv6 again until one of those
286 * attempts succeeds.
287 *
288 * @link https://tools.ietf.org/html/rfc8305#section-4
289 *
290 * @internal
291 */
292 public function mixIpsIntoConnectQueue(array $ips)
293 {
294 \shuffle($ips);
295 $this->ipsCount += \count($ips);
296 $connectQueueStash = $this->connectQueue;
297 $this->connectQueue = array();
298 while (\count($connectQueueStash) > 0 || \count($ips) > 0) {
299 if (\count($ips) > 0) {
300 $this->connectQueue[] = \array_shift($ips);
301 }
302 if (\count($connectQueueStash) > 0) {
303 $this->connectQueue[] = \array_shift($connectQueueStash);
304 }
305 }
306 }
307
308 /**
309 * @internal
310 * @return string
311 */
312 public function error()
313 {
314 if ($this->lastError4 === $this->lastError6) {
315 $message = $this->lastError6;
316 } elseif ($this->lastErrorFamily === 6) {
317 $message = 'Last error for IPv6: ' . $this->lastError6 . '. Previous error for IPv4: ' . $this->lastError4;
318 } else {
319 $message = 'Last error for IPv4: ' . $this->lastError4 . '. Previous error for IPv6: ' . $this->lastError6;
320 }
321
322 if ($this->hasBeenResolved() && $this->ipsCount === 0) {
323 if ($this->lastError6 === $this->lastError4) {
324 $message = ' during DNS lookup: ' . $this->lastError6;
325 } else {
326 $message = ' during DNS lookup. ' . $message;
327 }
328 } else {
329 $message = ': ' . $message;
330 }
331
332 return 'Connection to ' . $this->uri . ' failed' . $message;
333 }
334}