File Coverage

blib/lib/IO/Socket/HappyEyeballs.pm
Criterion Covered Total %
statement 216 250 86.4
branch 69 112 61.6
condition 44 83 53.0
subroutine 29 29 100.0
pod 5 5 100.0
total 363 479 75.7


line stmt bran cond sub pod time code
1             package IO::Socket::HappyEyeballs;
2             # ABSTRACT: RFC 8305 Happy Eyeballs v2 connection algorithm
3              
4 11     11   2177044 use strict;
  11         25  
  11         469  
5 11     11   54 use warnings;
  11         26  
  11         883  
6 11     11   68 use Carp;
  11         25  
  11         981  
7 11     11   2815 use Errno qw( EINPROGRESS ECONNREFUSED EISCONN );
  11         14193  
  11         5391  
8 11     11   6528 use IO::Socket::IP;
  11         388771  
  11         62  
9 11         1811 use Socket qw(
10             getaddrinfo getnameinfo pack_sockaddr_in6 unpack_sockaddr_in6
11             AF_INET AF_INET6 AF_UNSPEC
12             SOCK_STREAM IPPROTO_TCP
13             NI_NUMERICHOST NI_NUMERICSERV
14             SOL_SOCKET SO_ERROR
15             AI_ADDRCONFIG
16             inet_pton inet_ntop
17 11     11   8912 );
  11         24  
18 11     11   6623 use IO::Select;
  11         21273  
  11         745  
19              
20 11     11   4518 use parent qw(IO::Socket::IP);
  11         3190  
  11         87  
21              
22             our $VERSION = '0.002';
23              
24             # Class-level cache: { "host:port" => { family => AF_INET6|AF_INET, expires => time } }
25             my %_cache;
26             my $CACHE_TTL = 600; # 10 minutes
27              
28             # Default connection attempt delay per RFC 8305 §5: 250ms
29             my $DEFAULT_DELAY = 0.250;
30              
31             # Last Resort Local Synthesis Delay per RFC 8305 §7.2: 2 seconds
32             my $LAST_RESORT_DELAY = 2;
33              
34             my $_override_active;
35              
36             sub import {
37 11     11   168 my ($class, @args) = @_;
38 11         847407 for my $arg (@args) {
39 1 50       5 if ($arg eq '-override') {
40 1 50       6 $class->_install_override unless $_override_active;
41             }
42             }
43             }
44              
45             sub _install_override {
46 1     1   3 my ($class) = @_;
47 1         16 my $original_new = IO::Socket::IP->can('new');
48 1         4 my $original_configure = IO::Socket::IP->can('configure');
49 11     11   2505 no warnings 'redefine';
  11         28  
  11         33530  
50              
51             # Override configure to skip connect on already-connected sockets.
52             # When a subclass (e.g. Net::HTTP) calls configure after Happy Eyeballs
53             # has already connected, IO::Socket::IP::configure must not reconnect.
54             *IO::Socket::IP::configure = sub {
55 3     3   11940 my ($self, $cnf) = @_;
56 3 100       33 if (delete ${*$self}{io_socket_happyeyeballs_connected}) {
  3         26  
57 1         14 return $self;
58             }
59 2         10 return $original_configure->($self, $cnf);
60 1         22 };
61              
62             *IO::Socket::IP::new = sub {
63 11     11   1243119 my ($ip_class, %args) = @_;
64             # Only intercept TCP connections with a peer
65 11 50 100     269 if (($args{PeerHost} || $args{PeerAddr}) && $args{PeerPort}) {
      66        
66 4   100     91 my $proto = $args{Proto} || '';
67 4   50     23 my $type = $args{Type} || 0;
68 4 0 66     29 if (!$proto || $proto eq 'tcp' || $type == SOCK_STREAM || !$type) {
      33        
      33        
69 4         135 my $sock = IO::Socket::HappyEyeballs->_happy_connect(\%args);
70 4 100       18 if ($sock) {
71             # For subclasses (e.g. Net::HTTP): rebless and let their
72             # configure run for protocol-specific setup, while our
73             # IO::Socket::IP::configure override skips reconnecting.
74 3 50 66     112 if ($ip_class ne 'IO::Socket::IP'
      66        
75             && $ip_class ne 'IO::Socket::HappyEyeballs'
76             && $ip_class->isa('IO::Socket::IP')) {
77 1         53 bless $sock, $ip_class;
78 1         7 ${*$sock}{io_socket_happyeyeballs_connected} = 1;
  1         27  
79 1 50       21 $sock->configure(\%args) if $sock->can('configure');
80             }
81 3         135 return $sock;
82             }
83 1         6 return;
84             }
85             }
86 7         73 return $original_new->($ip_class, %args);
87 1         8 };
88 1         116109 $_override_active = 1;
89             }
90              
91             sub _happy_connect {
92 4     4   22 my ($class, $args) = @_;
93             # Use our Happy Eyeballs algorithm
94 4   66     26 my $peer_host = $args->{PeerHost} || $args->{PeerAddr};
95 4         24 my $peer_port = $args->{PeerPort};
96              
97 4   33     48 my $delay = delete $args->{ConnectionAttemptDelay} // $DEFAULT_DELAY;
98 4   100     15 my $timeout = $args->{Timeout} // 30;
99              
100 4         44 my @addresses = _resolve($peer_host, $peer_port, $args);
101 4 50       11 unless (@addresses) {
102 0         0 $@ = "Cannot resolve host '$peer_host': no addresses found";
103 0         0 return;
104             }
105              
106 4         17 @addresses = _sort_addresses(\@addresses, $peer_host, $peer_port);
107              
108 4         17 my $sock = _attempt_connections(\@addresses, $delay, $timeout, $args,
109             $peer_host, $peer_port);
110              
111 4 100       16 if ($sock) {
112 3         139 _cache_result($peer_host, $peer_port, $sock->sockdomain);
113 3         33 return $sock;
114             }
115              
116 1         3 $@ = "Cannot connect to $peer_host:$peer_port: all attempts failed";
117 1         5 return;
118             }
119              
120             sub new {
121 6     6 1 3112934 my ($class, %args) = @_;
122              
123             my $peer_host = $args{PeerHost} || $args{PeerAddr}
124 6 50 33     257 or croak "PeerHost or PeerAddr is required";
125             my $peer_port = $args{PeerPort}
126 6 50       129 or croak "PeerPort is required";
127              
128 6   33     245 my $delay = delete $args{ConnectionAttemptDelay} // $DEFAULT_DELAY;
129 6   50     165 my $timeout = $args{Timeout} // 30;
130              
131             # Resolve addresses
132 6         280 my @addresses = _resolve($peer_host, $peer_port, \%args);
133 6 100       47 unless (@addresses) {
134 1         5 $@ = "Cannot resolve host '$peer_host': no addresses found";
135 1         16 return;
136             }
137              
138             # Sort with interleaving per RFC 8305 §4
139 5         45 @addresses = _sort_addresses(\@addresses, $peer_host, $peer_port);
140              
141             # Attempt connections with Happy Eyeballs algorithm
142 5         82 my $sock = _attempt_connections(\@addresses, $delay, $timeout, \%args,
143             $peer_host, $peer_port);
144              
145 5 100       23 if ($sock) {
146 4         134 _cache_result($peer_host, $peer_port, $sock->sockdomain);
147 4         39 return $sock;
148             }
149              
150 1         3 $@ = "Cannot connect to $peer_host:$peer_port: all attempts failed";
151 1         7 return;
152             }
153              
154             sub _resolve {
155 10     10   91 my ($host, $port, $args) = @_;
156              
157             # Respect GetAddrInfoFlags from IO::Socket::IP compatibility,
158             # default to AI_ADDRCONFIG per RFC 8305 recommendation.
159             my $flags = exists $args->{GetAddrInfoFlags}
160             ? $args->{GetAddrInfoFlags}
161 10 50       120 : AI_ADDRCONFIG;
162              
163 10 50       326 my %hints = (
164             socktype => SOCK_STREAM,
165             protocol => IPPROTO_TCP,
166             family => AF_UNSPEC,
167             ($flags ? (flags => $flags) : ()),
168             );
169              
170 10         36435 my ($err, @results) = getaddrinfo($host, $port, \%hints);
171              
172             # If AI_ADDRCONFIG returned nothing (e.g. loopback-only interfaces),
173             # retry without it to avoid filtering out valid addresses.
174 10 100 66     343 if (($err || !@results) && $flags) {
      66        
175 1         8 delete $hints{flags};
176 1         88239 ($err, @results) = getaddrinfo($host, $port, \%hints);
177             }
178              
179 10 100       76 if ($err) {
180 1         4 $@ = "getaddrinfo failed: $err";
181 1         12 return;
182             }
183              
184 9         62 return @results;
185             }
186              
187             sub _sort_addresses {
188 17     17   4179 my ($addresses, $host, $port) = @_;
189              
190             # Check cache for preferred family
191 17         71 my $cache_key = "$host:$port";
192 17         35 my $preferred_family;
193 17 100       106 if (my $cached = $_cache{$cache_key}) {
194 8 100       68 if ($cached->{expires} > time()) {
195 7         27 $preferred_family = $cached->{family};
196             } else {
197 1         14 delete $_cache{$cache_key};
198             }
199             }
200              
201             # Separate by address family
202 17         40 my (@ipv6, @ipv4);
203 17         95 for my $addr (@$addresses) {
204 29 100       102 if ($addr->{family} == AF_INET6) {
205 10         15 push @ipv6, $addr;
206             } else {
207 19         60 push @ipv4, $addr;
208             }
209             }
210              
211             # Interleave: preferred family first, then alternate
212             # Per RFC 8305 §4
213 17         74 my @sorted;
214 17         26 my ($primary, $secondary);
215 17 100 100     125 if ($preferred_family && $preferred_family == AF_INET) {
216 6         13 $primary = \@ipv4;
217 6         24 $secondary = \@ipv6;
218             } else {
219 11 100       30 $primary = @ipv6 ? \@ipv6 : \@ipv4;
220 11 100       64 $secondary = @ipv6 ? \@ipv4 : \@ipv6;
221             }
222              
223 17   66     110 while (@$primary || @$secondary) {
224 21 50       76 push @sorted, shift @$primary if @$primary;
225 21 100       166 push @sorted, shift @$secondary if @$secondary;
226             }
227              
228 17         81 return @sorted;
229             }
230              
231             sub _attempt_connections {
232 9     9   47 my ($addresses, $delay, $timeout, $args, $host, $port) = @_;
233              
234 9         30 my @pending; # [ socket, addrinfo ] pairs
235 9         227 my $select = IO::Select->new;
236 9         218 my $deadline = time() + $timeout;
237 9         26 my $next_attempt_time = 0; # start first attempt immediately
238              
239 9         26 my $addr_idx = 0;
240 9         15 my $last_attempt_time;
241 9         19 my $last_resort_done = 0;
242              
243 9   66     52 while ($addr_idx < @$addresses || @pending) {
244 9         54 my $now = time();
245 9 50       32 last if $now >= $deadline;
246              
247             # RFC 8305 §7.2: Last Resort Local Synthesis
248             # When all initial addresses are exhausted and all pending have failed,
249             # wait until $LAST_RESORT_DELAY seconds after last attempt, then try
250             # A-record-only resolution with NAT64 synthesis.
251 9 0 33     122 if (!$last_resort_done && $addr_idx >= @$addresses && !@pending
      33        
      33        
252             && $last_attempt_time) {
253 0         0 my $synth_addresses = _last_resort_synthesis(
254             $host, $port, $args, $last_attempt_time, $deadline);
255 0 0 0     0 if ($synth_addresses && @$synth_addresses) {
256 0         0 push @$addresses, @$synth_addresses;
257 0         0 $last_resort_done = 1;
258 0         0 next; # re-enter loop to process new addresses
259             }
260 0         0 $last_resort_done = 1;
261             }
262              
263             # Start a new connection attempt if it's time
264 9 50 33     127 if ($addr_idx < @$addresses && $now >= $next_attempt_time) {
265 9         30 my $addr = $addresses->[$addr_idx++];
266 9         72 my $sock = _start_connect($addr, $args);
267              
268 9 50       54 if ($sock) {
269             # Check if already connected (localhost etc.)
270 9 100       114 if ($sock->connected) {
271 7         336 _cleanup_pending(\@pending);
272 7         57 _restore_blocking($sock, $args);
273 7         255 return $sock;
274             }
275 2         67 push @pending, [ $sock, $addr ];
276 2         13 $select->add($sock);
277             }
278              
279 2         141 $last_attempt_time = time();
280 2         7 $next_attempt_time = $last_attempt_time + $delay;
281             }
282              
283 2 50       8 next unless @pending;
284              
285             # Calculate how long to wait
286 2         5 my $wait_time;
287 2 50       7 if ($addr_idx < @$addresses) {
288             # Wait until either a connection succeeds or it's time for the next attempt
289 0         0 $wait_time = $next_attempt_time - time();
290 0 0       0 $wait_time = 0 if $wait_time < 0;
291             } else {
292             # No more addresses to try, wait for remaining connections
293 2         6 $wait_time = $deadline - time();
294 2 50       7 $wait_time = 0 if $wait_time < 0;
295             }
296              
297             # select() for writable (connected) sockets
298 2         18 my @ready = IO::Select->select(undef, $select, undef, $wait_time);
299              
300 2 50 33     189 if (@ready && $ready[1]) {
301 2         5 for my $sock (@{$ready[1]}) {
  2         7  
302             # Check if the connection actually succeeded
303 2         20 my $err = $sock->sockopt(SO_ERROR);
304 2 50       61 if ($err == 0) {
305             # Success! Clean up all other pending sockets
306 0         0 my @others = grep { $_->[0] != $sock } @pending;
  0         0  
307 0         0 _cleanup_pending(\@others);
308 0         0 _restore_blocking($sock, $args);
309              
310 0         0 return $sock;
311             } else {
312             # This connection failed, remove it
313 2         21 $select->remove($sock);
314 2         121 $sock->close;
315 2         79 @pending = grep { $_->[0] != $sock } @pending;
  2         59  
316             }
317             }
318             }
319             }
320              
321             # All failed
322 2         15 _cleanup_pending(\@pending);
323 2         19 return;
324             }
325              
326             sub _start_connect {
327 9     9   31 my ($addr, $args) = @_;
328              
329 9         212 my $sock = IO::Socket::IP->new;
330             $sock->socket($addr->{family}, $addr->{socktype}, $addr->{protocol})
331 9 50       1958 or return;
332              
333 9         1093 $sock->blocking(0);
334              
335 9         2002 my $rv = CORE::connect($sock, $addr->{addr});
336 9 50       124 if ($rv) {
337             # Connected immediately
338 0         0 return $sock;
339             }
340 9 50       263 if ($! == EINPROGRESS) {
341             # Connection in progress — this is the normal non-blocking case
342 9         49 return $sock;
343             }
344              
345             # Immediate failure
346 0         0 $sock->close;
347 0         0 return;
348             }
349              
350             sub _restore_blocking {
351 7     7   30 my ($sock, $args) = @_;
352 7 100 66     118 if (exists $args->{Blocking} && !$args->{Blocking}) {
353 1         6 $sock->blocking(0);
354             } else {
355 6         64 $sock->blocking(1);
356             }
357             }
358              
359             sub _cleanup_pending {
360 9     9   30 my ($pending) = @_;
361 9         87 for my $p (@$pending) {
362 0 0       0 $p->[0]->close if $p->[0];
363             }
364             }
365              
366             # RFC 8305 §7.2: Last Resort Local Synthesis
367             # After all AAAA-based attempts fail, wait for the synthesis delay,
368             # then query A records and synthesize IPv6 via NAT64 if available.
369             sub _last_resort_synthesis {
370 2     2   19 my ($host, $port, $args, $last_attempt_time, $deadline) = @_;
371              
372 2 50 33     17 return unless defined $host && defined $port;
373              
374             # Wait until $LAST_RESORT_DELAY after last attempt fired
375 2         7 my $synthesis_time = $last_attempt_time + $LAST_RESORT_DELAY;
376 2         6 my $now = time();
377 2 100       9 if ($now < $synthesis_time) {
378 1         5 my $wait = $synthesis_time - $now;
379 1 50       7 return if $now + $wait > $deadline;
380 0         0 select(undef, undef, undef, $wait);
381             }
382              
383             # Query A records only (IPv4)
384 1         82954 my ($err, @ipv4_results) = getaddrinfo($host, $port, {
385             socktype => SOCK_STREAM,
386             protocol => IPPROTO_TCP,
387             family => AF_INET,
388             });
389 1 50       22 return unless @ipv4_results;
390              
391             # Detect NAT64 prefix via RFC 7050 (ipv4only.arpa)
392 0         0 my $nat64_prefix = _detect_nat64_prefix();
393              
394 0 0       0 if ($nat64_prefix) {
395             # Synthesize IPv6 addresses from IPv4 using NAT64 prefix
396 0         0 my @synthesized;
397 0         0 for my $r (@ipv4_results) {
398 0         0 my $synth = _synthesize_nat64_addr($r, $nat64_prefix, $port);
399 0 0       0 push @synthesized, $synth if $synth;
400             }
401 0 0       0 return \@synthesized if @synthesized;
402             }
403              
404             # No NAT64: return IPv4 addresses directly as fallback
405 0         0 return \@ipv4_results;
406             }
407              
408             # RFC 7050: Discovery of the IPv6 Prefix Used for IPv6 Address Synthesis
409             # Resolve ipv4only.arpa AAAA — if we get results, NAT64 is present
410             # and the prefix can be extracted from the response.
411             my $_nat64_prefix_cache;
412             my $_nat64_prefix_expires = 0;
413              
414             sub _detect_nat64_prefix {
415 1     1   5 my $now = time();
416 1 50       4 if ($now < $_nat64_prefix_expires) {
417 0         0 return $_nat64_prefix_cache;
418             }
419              
420             # The well-known IPv4 addresses for ipv4only.arpa are
421             # 192.0.0.170 and 192.0.0.171. If AAAA resolution returns
422             # results, the NAT64 prefix is the first 96 bits.
423 1         72155 my ($err, @results) = getaddrinfo('ipv4only.arpa', '443', {
424             socktype => SOCK_STREAM,
425             family => AF_INET6,
426             });
427              
428 1 50 33     20 if (!$err && @results) {
429 0         0 my ($synth_port, $synth_addr) = unpack_sockaddr_in6($results[0]{addr});
430             # The well-known address 192.0.0.170 = 0xC0000AA in the last 32 bits
431             # Extract the first 12 bytes as the NAT64 prefix
432 0         0 my $prefix = substr($synth_addr, 0, 12);
433 0         0 $_nat64_prefix_cache = $prefix;
434 0         0 $_nat64_prefix_expires = $now + 600; # cache for 10 minutes
435 0         0 return $prefix;
436             }
437              
438 1         3 $_nat64_prefix_cache = undef;
439 1         3 $_nat64_prefix_expires = $now + 60; # negative cache for 1 minute
440 1         9 return;
441             }
442              
443             # Synthesize an IPv6 address by combining NAT64 prefix with IPv4 address
444             sub _synthesize_nat64_addr {
445 2     2   2451 my ($ipv4_addrinfo, $nat64_prefix, $port) = @_;
446              
447             # Extract the IPv4 address from the sockaddr
448 2         5 my $family = $ipv4_addrinfo->{family};
449 2 100       6 return unless $family == AF_INET;
450              
451 1         5 my ($ipv4_port, $ipv4_packed) = Socket::unpack_sockaddr_in($ipv4_addrinfo->{addr});
452              
453             # Build synthesized IPv6 address: 96-bit prefix + 32-bit IPv4
454 1         2 my $synth_ipv6 = $nat64_prefix . $ipv4_packed;
455 1         4 my $synth_sockaddr = pack_sockaddr_in6($ipv4_port, $synth_ipv6);
456              
457             return {
458 1         9 family => AF_INET6,
459             socktype => SOCK_STREAM,
460             protocol => IPPROTO_TCP,
461             addr => $synth_sockaddr,
462             };
463             }
464              
465             sub _cache_result {
466 11     11   1954 my ($host, $port, $family) = @_;
467 11         205 $_cache{"$host:$port"} = {
468             family => $family,
469             expires => time() + $CACHE_TTL,
470             };
471             }
472              
473             sub clear_cache {
474 6     6 1 263452 %_cache = ();
475 6         12 $_nat64_prefix_cache = undef;
476 6         14 $_nat64_prefix_expires = 0;
477             }
478              
479             sub last_resort_delay {
480 8     8 1 248571 my ($class, $new_delay) = @_;
481 8 100       22 if (defined $new_delay) {
482 6         15 $LAST_RESORT_DELAY = $new_delay;
483             }
484 8         21 return $LAST_RESORT_DELAY;
485             }
486              
487             sub connection_attempt_delay {
488 4     4 1 294530 my ($class, $new_delay) = @_;
489 4 100       13 if (defined $new_delay) {
490 2         6 $DEFAULT_DELAY = $new_delay;
491             }
492 4         13 return $DEFAULT_DELAY;
493             }
494              
495             sub cache_ttl {
496 4     4 1 1895 my ($class, $new_ttl) = @_;
497 4 100       11 if (defined $new_ttl) {
498 2         3 $CACHE_TTL = $new_ttl;
499             }
500 4         10 return $CACHE_TTL;
501             }
502              
503             1;
504              
505             __END__