Skip to content

Commit 6eadca6

Browse files
committed
fix: Fixes QUIC concurrency
1 parent 8c466fd commit 6eadca6

6 files changed

Lines changed: 356 additions & 68 deletions

File tree

.envrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[ -f .env ] && dotenv

.gitignore

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,119 @@
1-
# The directory Mix will write compiled artifacts to.
2-
/_build/
1+
# Created by https://www.toptal.com/developers/gitignore/api/elixir,erlang,macos,visualstudiocode,vim
2+
# Edit at https://www.toptal.com/developers/gitignore?templates=elixir,erlang,macos,visualstudiocode,vim
33

4-
# If you run "mix test --cover", coverage assets end up here.
5-
/cover/
6-
7-
# The directory Mix downloads your dependencies sources to.
8-
/deps/
4+
### Elixir ###
5+
/_build
6+
/cover
7+
/deps
8+
/doc
9+
/.fetch
10+
erl_crash.dump
11+
*.ez
12+
*.beam
13+
/config/*.secret.exs
14+
.elixir_ls/
915

10-
# Where third-party dependencies like ExDoc output generated docs.
11-
/doc/
16+
### Elixir Patch ###
1217

13-
# Temporary files, for example, from tests.
14-
/tmp/
18+
### Erlang ###
19+
.eunit
20+
*.o
21+
*.plt
22+
.concrete/DEV_MODE
1523

16-
# If the VM crashes, it generates a dump, let's ignore it too.
17-
erl_crash.dump
24+
# rebar 2.x
25+
.rebar
26+
rel/example_project
27+
ebin/*.beam
28+
deps
1829

19-
# Also ignore archive artifacts (built via "mix archive.build").
20-
*.ez
30+
# rebar 3
31+
.rebar3
32+
_build/
33+
_checkouts/
2134

22-
# Ignore package tarball (built via "mix hex.build").
23-
quiver-*.tar
35+
### Erlang Patch ###
36+
rebar3.crashdump
2437

2538
### macOS ###
2639
# General
2740
.DS_Store
41+
.AppleDouble
42+
.LSOverride
43+
44+
# Icon must end with two \r
45+
Icon
46+
47+
48+
# Thumbnails
49+
._*
50+
51+
# Files that might appear in the root of a volume
52+
.DocumentRevisions-V100
53+
.fseventsd
54+
.Spotlight-V100
55+
.TemporaryItems
56+
.Trashes
57+
.VolumeIcon.icns
58+
.com.apple.timemachine.donotpresent
59+
60+
# Directories potentially created on remote AFP share
61+
.AppleDB
62+
.AppleDesktop
63+
Network Trash Folder
64+
Temporary Items
65+
.apdisk
66+
67+
### macOS Patch ###
68+
# iCloud generated files
69+
*.icloud
70+
71+
### Vim ###
72+
# Swap
73+
[._]*.s[a-v][a-z]
74+
!*.svg # comment out if you don't need vector files
75+
[._]*.sw[a-p]
76+
[._]s[a-rt-v][a-z]
77+
[._]ss[a-gi-z]
78+
[._]sw[a-p]
79+
80+
# Session
81+
Session.vim
82+
Sessionx.vim
83+
84+
# Temporary
85+
.netrwhist
86+
*~
87+
# Auto-generated tag files
88+
tags
89+
# Persistent undo
90+
[._]*.un~
91+
92+
### VisualStudioCode ###
93+
.vscode/*
94+
!.vscode/settings.json
95+
!.vscode/tasks.json
96+
!.vscode/launch.json
97+
!.vscode/extensions.json
98+
!.vscode/*.code-snippets
99+
100+
# Local History for Visual Studio Code
101+
.history/
102+
103+
# Built Visual Studio Code Extensions
104+
*.vsix
105+
106+
### VisualStudioCode Patch ###
107+
# Ignore all local history of files
108+
.history
109+
.ionide
28110
.env
29-
.envrc
30111

112+
tmp/*
31113
/bench/output/
32-
33114
/certs/
115+
docker/logs
116+
117+
# End of https://www.toptal.com/developers/gitignore/api/elixir,erlang,macos,visualstudiocode,vim
118+
119+

lib/quiver/conn/http3.ex

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule Quiver.Conn.HTTP3 do
1313

1414
@behaviour Quiver.Conn
1515

16-
@dialyzer {:nowarn_function, connect: 2, query_peer_max_streams: 2}
16+
@dialyzer {:nowarn_function, connect: 2, do_connect: 5, query_peer_max_streams: 2}
1717

1818
alias Quiver.Error.H3StreamError
1919
alias Quiver.Error.QUICHandshakeFailed
@@ -22,6 +22,11 @@ defmodule Quiver.Conn.HTTP3 do
2222

2323
@forbidden_headers ~w(connection keep-alive transfer-encoding upgrade proxy-connection)
2424

25+
# Probe budget for a non-final connect candidate, so an unreachable preferred
26+
# address fails over to the next family without burning the full connect timeout.
27+
# Shared with `Quiver.Pool.HTTP3.Connection` via `connect_probe_timeout/0`.
28+
@connect_probe_timeout 1_500
29+
2530
defstruct [
2631
:h3_conn,
2732
:host,
@@ -54,11 +59,10 @@ defmodule Quiver.Conn.HTTP3 do
5459

5560
h3_opts =
5661
opts
57-
|> build_h3_opts()
62+
|> build_h3_opts(host)
5863
|> Map.put(:sync, true)
59-
|> Map.put(:connect_timeout, timeout)
6064

61-
case :quic_h3.connect(host, port, h3_opts) do
65+
case do_connect(resolve_candidates(host), port, h3_opts, timeout, :nxdomain) do
6266
{:ok, h3_conn} ->
6367
{:ok,
6468
%__MODULE__{
@@ -88,6 +92,21 @@ defmodule Quiver.Conn.HTTP3 do
8892
)}
8993
end
9094

95+
# Dial resolved candidate addresses in turn (IPv6 first), forcing the QUIC direct
96+
# path. Non-final candidates get the short probe budget so an unreachable preferred
97+
# family fails over quickly; the final candidate gets the full connect timeout.
98+
defp do_connect([], _port, _h3_opts, _timeout, last_error), do: {:error, last_error}
99+
100+
defp do_connect([ip | rest], port, h3_opts, timeout, _last_error) do
101+
attempt_timeout = if rest == [], do: timeout, else: min(timeout, @connect_probe_timeout)
102+
h3_opts = Map.put(h3_opts, :connect_timeout, attempt_timeout)
103+
104+
case :quic_h3.connect(ip, port, h3_opts) do
105+
{:ok, h3_conn} -> {:ok, h3_conn}
106+
{:error, reason} -> do_connect(rest, port, h3_opts, timeout, reason)
107+
end
108+
end
109+
91110
@impl Quiver.Conn
92111
def open?(%__MODULE__{h3_conn: pid}) when is_pid(pid), do: Process.alive?(pid)
93112
def open?(_), do: false
@@ -239,16 +258,56 @@ defmodule Quiver.Conn.HTTP3 do
239258
end
240259
end
241260

242-
defp build_h3_opts(opts) do
261+
@doc false
262+
@spec connect_probe_timeout() :: pos_integer()
263+
def connect_probe_timeout, do: @connect_probe_timeout
264+
265+
@doc """
266+
Resolves a host to at most one address per family, IPv6 first (RFC 8305 §4
267+
preference), for the direct-connect path. A literal IP is returned as-is.
268+
Shared with `Quiver.Pool.HTTP3.Connection`.
269+
"""
270+
@spec resolve_candidates(String.t()) :: [:inet.ip_address()]
271+
def resolve_candidates(host) do
272+
hostc = to_charlist(host)
273+
274+
case :inet.parse_address(hostc) do
275+
{:ok, ip} -> [ip]
276+
{:error, _} -> first_addr(hostc, :inet6) ++ first_addr(hostc, :inet)
277+
end
278+
end
279+
280+
defp first_addr(hostc, family) do
281+
case :inet.getaddrs(hostc, family) do
282+
{:ok, [ip | _]} -> [ip]
283+
_ -> []
284+
end
285+
end
286+
287+
defp build_h3_opts(opts, host) do
243288
base = %{verify: Keyword.get(opts, :verify, :verify_peer)}
244289

245290
base
246291
|> maybe_put(:cacerts, Keyword.get(opts, :cacerts))
247292
|> maybe_put(:settings, Keyword.get(opts, :h3_settings))
248-
|> maybe_put(:quic_opts, Keyword.get(opts, :quic_opts))
293+
|> Map.put(:quic_opts, build_quic_opts(Keyword.get(opts, :quic_opts), host))
249294
|> maybe_put_datagram(Keyword.get(opts, :h3_datagram_enabled, true))
250295
end
251296

297+
# Disable Happy-Eyeballs and dial the resolved literal address: the QUIC race
298+
# coordinator transfers connection ownership only after the handshake completes,
299+
# dropping the server's SETTINGS streams. `server_name` carries the hostname for
300+
# SNI / `verify_peer` since the dialed host is an IP. See `do_connect/5`.
301+
defp build_quic_opts(quic_opts, host) do
302+
quic_opts
303+
|> normalize_quic_opts()
304+
|> Map.put(:happy_eyeballs, false)
305+
|> Map.put(:server_name, to_string(host))
306+
end
307+
308+
defp normalize_quic_opts(opts) when is_map(opts), do: opts
309+
defp normalize_quic_opts(_), do: %{}
310+
252311
defp maybe_put(map, _k, nil), do: map
253312
defp maybe_put(map, _k, :default), do: map
254313
defp maybe_put(map, _k, v) when is_map(v) and map_size(v) == 0, do: map

0 commit comments

Comments
 (0)